@lmk1010/speech-streaming-aliyun 0.0.0-stage → 0.2.2-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/lib/index.js +289 -0
- package/lib/types/index.d.ts +41 -0
- package/lib/types/index.js +205 -0
- package/package.json +39 -3
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DeepSeek
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import z from "@deepseek-ai/schemastery";
|
|
4
|
+
import { Channel } from "@lmk1010/speech-streaming";
|
|
5
|
+
//#region lib/types/index.js
|
|
6
|
+
/**
|
|
7
|
+
* Aliyun Model Studio (DashScope) realtime recognition. One WebSocket is opened ahead of use and reused for every
|
|
8
|
+
* turn (`run-task` … `finish-task`), so a press only pays for the task start, not the TLS and WebSocket handshake.
|
|
9
|
+
*/
|
|
10
|
+
const name = "speech-streaming-aliyun";
|
|
11
|
+
const inject = ["speechStreaming"];
|
|
12
|
+
const Config = z.object({
|
|
13
|
+
providerId: z.string().min(1).default("aliyun-realtime"),
|
|
14
|
+
model: z.string().min(1).default("paraformer-realtime-v2"),
|
|
15
|
+
host: z.string().default(""),
|
|
16
|
+
apiKey: z.string().default(""),
|
|
17
|
+
idleCloseMs: z.natural().default(12e4),
|
|
18
|
+
startTimeoutMs: z.natural().min(1e3).default(8e3)
|
|
19
|
+
});
|
|
20
|
+
function keychain(service) {
|
|
21
|
+
if (process.platform !== "darwin") return Promise.resolve("");
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
execFile("security", [
|
|
24
|
+
"find-generic-password",
|
|
25
|
+
"-a",
|
|
26
|
+
"dshub",
|
|
27
|
+
"-s",
|
|
28
|
+
service,
|
|
29
|
+
"-w"
|
|
30
|
+
], (error, stdout) => {
|
|
31
|
+
resolve(error ? "" : stdout.trim());
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
const SocketWithHeaders = WebSocket;
|
|
36
|
+
/** Reused WebSocket with one task at a time. */
|
|
37
|
+
var Connection = class {
|
|
38
|
+
config;
|
|
39
|
+
credentials;
|
|
40
|
+
socket;
|
|
41
|
+
opening;
|
|
42
|
+
idle;
|
|
43
|
+
handler;
|
|
44
|
+
constructor(config, credentials) {
|
|
45
|
+
this.config = config;
|
|
46
|
+
this.credentials = credentials;
|
|
47
|
+
}
|
|
48
|
+
async get() {
|
|
49
|
+
clearTimeout(this.idle);
|
|
50
|
+
if (this.socket?.readyState === WebSocket.OPEN) return this.socket;
|
|
51
|
+
this.opening ??= (async () => {
|
|
52
|
+
const { host, key } = await this.credentials();
|
|
53
|
+
if (key === "") throw new Error("Aliyun API key is not configured");
|
|
54
|
+
const socket = new SocketWithHeaders(`wss://${host}/api-ws/v1/inference`, { headers: { Authorization: `bearer ${key}` } });
|
|
55
|
+
socket.binaryType = "arraybuffer";
|
|
56
|
+
await new Promise((resolve, reject) => {
|
|
57
|
+
const timer = setTimeout(() => {
|
|
58
|
+
reject(/* @__PURE__ */ new Error("Aliyun connection timed out"));
|
|
59
|
+
socket.close();
|
|
60
|
+
}, this.config.startTimeoutMs);
|
|
61
|
+
socket.onopen = () => {
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
resolve();
|
|
64
|
+
};
|
|
65
|
+
socket.onerror = () => {
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
reject(/* @__PURE__ */ new Error("Aliyun connection failed"));
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
socket.onmessage = (event) => {
|
|
71
|
+
const text = typeof event.data === "string" ? event.data : Buffer.from(event.data).toString();
|
|
72
|
+
try {
|
|
73
|
+
this.handler?.(JSON.parse(text));
|
|
74
|
+
} catch (error) {
|
|
75
|
+
this.handler?.({ header: {
|
|
76
|
+
event: "task-failed",
|
|
77
|
+
error_message: String(error)
|
|
78
|
+
} });
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
socket.onclose = () => {
|
|
82
|
+
if (this.socket === socket) this.socket = void 0;
|
|
83
|
+
this.handler?.({ header: {
|
|
84
|
+
event: "task-failed",
|
|
85
|
+
error_message: "connection closed"
|
|
86
|
+
} });
|
|
87
|
+
};
|
|
88
|
+
this.socket = socket;
|
|
89
|
+
return socket;
|
|
90
|
+
})().finally(() => {
|
|
91
|
+
this.opening = void 0;
|
|
92
|
+
});
|
|
93
|
+
return await this.opening;
|
|
94
|
+
}
|
|
95
|
+
listen(handler) {
|
|
96
|
+
this.handler = handler;
|
|
97
|
+
}
|
|
98
|
+
release() {
|
|
99
|
+
clearTimeout(this.idle);
|
|
100
|
+
this.idle = setTimeout(() => {
|
|
101
|
+
this.close();
|
|
102
|
+
}, this.config.idleCloseMs);
|
|
103
|
+
}
|
|
104
|
+
close() {
|
|
105
|
+
clearTimeout(this.idle);
|
|
106
|
+
this.socket?.close();
|
|
107
|
+
this.socket = void 0;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
var AliyunRealtime = class {
|
|
111
|
+
config;
|
|
112
|
+
info;
|
|
113
|
+
connection;
|
|
114
|
+
busy = Promise.resolve();
|
|
115
|
+
constructor(config) {
|
|
116
|
+
this.config = config;
|
|
117
|
+
this.info = {
|
|
118
|
+
id: config.providerId,
|
|
119
|
+
name: `Aliyun ${config.model}`,
|
|
120
|
+
location: "cloud",
|
|
121
|
+
languages: [
|
|
122
|
+
"auto",
|
|
123
|
+
"zh",
|
|
124
|
+
"en"
|
|
125
|
+
]
|
|
126
|
+
};
|
|
127
|
+
this.connection = new Connection(config, async () => {
|
|
128
|
+
return {
|
|
129
|
+
host: config.host || await keychain("dshub.aliyun.host") || "dashscope.aliyuncs.com",
|
|
130
|
+
key: config.apiKey || process.env.DASHSCOPE_API_KEY || await keychain("dshub.aliyun.dashscope")
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
warm() {
|
|
135
|
+
this.connection.get().then(() => {
|
|
136
|
+
this.connection.release();
|
|
137
|
+
}, () => {});
|
|
138
|
+
}
|
|
139
|
+
async open(_options, signal) {
|
|
140
|
+
const previous = this.busy;
|
|
141
|
+
let done;
|
|
142
|
+
this.busy = new Promise((resolve) => {
|
|
143
|
+
done = resolve;
|
|
144
|
+
});
|
|
145
|
+
await previous;
|
|
146
|
+
const events = new Channel();
|
|
147
|
+
const taskId = randomUUID().replaceAll("-", "");
|
|
148
|
+
const finals = [];
|
|
149
|
+
let segment = 0, ended = false;
|
|
150
|
+
const finish = (error) => {
|
|
151
|
+
if (ended) return;
|
|
152
|
+
ended = true;
|
|
153
|
+
this.connection.listen(void 0);
|
|
154
|
+
this.connection.release();
|
|
155
|
+
if (error) events.fail(error);
|
|
156
|
+
else {
|
|
157
|
+
events.push({
|
|
158
|
+
type: "end",
|
|
159
|
+
text: finals.join(""),
|
|
160
|
+
audioSeconds: 0
|
|
161
|
+
});
|
|
162
|
+
events.close();
|
|
163
|
+
}
|
|
164
|
+
done();
|
|
165
|
+
};
|
|
166
|
+
let socket;
|
|
167
|
+
try {
|
|
168
|
+
socket = await this.connection.get();
|
|
169
|
+
} catch (error) {
|
|
170
|
+
finish(error);
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
const started = Promise.withResolvers();
|
|
174
|
+
const timer = setTimeout(() => {
|
|
175
|
+
started.reject(/* @__PURE__ */ new Error("Aliyun task did not start"));
|
|
176
|
+
}, this.config.startTimeoutMs);
|
|
177
|
+
this.connection.listen((message) => {
|
|
178
|
+
switch (message.header?.event) {
|
|
179
|
+
case "task-started":
|
|
180
|
+
clearTimeout(timer);
|
|
181
|
+
started.resolve();
|
|
182
|
+
break;
|
|
183
|
+
case "result-generated": {
|
|
184
|
+
const sentence = message.payload?.output?.sentence;
|
|
185
|
+
if (!sentence?.text) break;
|
|
186
|
+
if (sentence.sentence_end) {
|
|
187
|
+
finals.push(sentence.text);
|
|
188
|
+
events.push({
|
|
189
|
+
type: "final",
|
|
190
|
+
segment,
|
|
191
|
+
text: sentence.text
|
|
192
|
+
});
|
|
193
|
+
segment++;
|
|
194
|
+
} else events.push({
|
|
195
|
+
type: "partial",
|
|
196
|
+
segment,
|
|
197
|
+
text: sentence.text
|
|
198
|
+
});
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
case "task-finished":
|
|
202
|
+
finish();
|
|
203
|
+
break;
|
|
204
|
+
case "task-failed":
|
|
205
|
+
clearTimeout(timer);
|
|
206
|
+
started.reject(new Error(message.header.error_message ?? "Aliyun task failed"));
|
|
207
|
+
finish(new Error(message.header.error_message ?? "Aliyun task failed"));
|
|
208
|
+
break;
|
|
209
|
+
default: break;
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
socket.send(JSON.stringify({
|
|
213
|
+
header: {
|
|
214
|
+
action: "run-task",
|
|
215
|
+
task_id: taskId,
|
|
216
|
+
streaming: "duplex"
|
|
217
|
+
},
|
|
218
|
+
payload: {
|
|
219
|
+
task_group: "audio",
|
|
220
|
+
task: "asr",
|
|
221
|
+
function: "recognition",
|
|
222
|
+
model: this.config.model,
|
|
223
|
+
parameters: {
|
|
224
|
+
format: "pcm",
|
|
225
|
+
sample_rate: 16e3
|
|
226
|
+
},
|
|
227
|
+
input: {}
|
|
228
|
+
}
|
|
229
|
+
}));
|
|
230
|
+
const bytes = (pcm) => Uint8Array.from(new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength));
|
|
231
|
+
const pending = [];
|
|
232
|
+
let ready = false, finishing = false;
|
|
233
|
+
const sendFinish = () => {
|
|
234
|
+
socket.send(JSON.stringify({
|
|
235
|
+
header: {
|
|
236
|
+
action: "finish-task",
|
|
237
|
+
task_id: taskId,
|
|
238
|
+
streaming: "duplex"
|
|
239
|
+
},
|
|
240
|
+
payload: { input: {} }
|
|
241
|
+
}));
|
|
242
|
+
};
|
|
243
|
+
started.promise.then(() => {
|
|
244
|
+
ready = true;
|
|
245
|
+
for (const chunk of pending) socket.send(chunk);
|
|
246
|
+
pending.length = 0;
|
|
247
|
+
if (finishing) sendFinish();
|
|
248
|
+
}, (error) => {
|
|
249
|
+
finish(error);
|
|
250
|
+
});
|
|
251
|
+
signal.addEventListener("abort", () => {
|
|
252
|
+
if (!ended) {
|
|
253
|
+
if (ready) sendFinish();
|
|
254
|
+
finish(/* @__PURE__ */ new Error("cancelled"));
|
|
255
|
+
}
|
|
256
|
+
}, { once: true });
|
|
257
|
+
return {
|
|
258
|
+
write: (pcm) => {
|
|
259
|
+
if (ready) socket.send(bytes(pcm));
|
|
260
|
+
else pending.push(bytes(pcm));
|
|
261
|
+
},
|
|
262
|
+
finish: () => {
|
|
263
|
+
finishing = true;
|
|
264
|
+
if (ready) sendFinish();
|
|
265
|
+
},
|
|
266
|
+
events
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
dispose() {
|
|
270
|
+
this.connection.close();
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
/**
|
|
274
|
+
* Register the Aliyun realtime provider.
|
|
275
|
+
* @param ctx - streaming registry owner.
|
|
276
|
+
* @param config - model, host and credentials.
|
|
277
|
+
*/
|
|
278
|
+
function apply(ctx, config) {
|
|
279
|
+
const provider = new AliyunRealtime(config);
|
|
280
|
+
ctx.effect(() => {
|
|
281
|
+
const unregister = ctx.speechStreaming.register(provider);
|
|
282
|
+
return async () => {
|
|
283
|
+
provider.dispose();
|
|
284
|
+
await unregister();
|
|
285
|
+
};
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
//#endregion
|
|
289
|
+
export { AliyunRealtime, Config, apply, inject, name };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
import z from '@deepseek-ai/schemastery';
|
|
3
|
+
import { type SpeechProviderId, type StreamingProvider, type StreamingSession, type StreamOptions } from '@lmk1010/speech-streaming';
|
|
4
|
+
export declare const name = "speech-streaming-aliyun";
|
|
5
|
+
export declare const inject: string[];
|
|
6
|
+
export interface Config {
|
|
7
|
+
providerId: string;
|
|
8
|
+
/** Realtime model: `paraformer-realtime-v2` or `fun-asr-realtime`. */
|
|
9
|
+
model: string;
|
|
10
|
+
/** API host; empty reads the Keychain item `dshub.aliyun.host`, then falls back to `dashscope.aliyuncs.com`. */
|
|
11
|
+
host: string;
|
|
12
|
+
/** API key; empty reads `DASHSCOPE_API_KEY`, then the macOS Keychain item `dshub.aliyun.dashscope`. */
|
|
13
|
+
apiKey: string;
|
|
14
|
+
/** Close the idle connection after this long. */
|
|
15
|
+
idleCloseMs: number;
|
|
16
|
+
/** Deadline for the connection and the task start. */
|
|
17
|
+
startTimeoutMs: number;
|
|
18
|
+
}
|
|
19
|
+
export declare const Config: z<Partial<Config>, Config>;
|
|
20
|
+
export declare class AliyunRealtime implements StreamingProvider {
|
|
21
|
+
private readonly config;
|
|
22
|
+
readonly info: {
|
|
23
|
+
id: SpeechProviderId;
|
|
24
|
+
name: string;
|
|
25
|
+
location: "cloud";
|
|
26
|
+
languages: string[];
|
|
27
|
+
};
|
|
28
|
+
private readonly connection;
|
|
29
|
+
private busy;
|
|
30
|
+
constructor(config: Config);
|
|
31
|
+
warm(): void;
|
|
32
|
+
open(_options: StreamOptions, signal: AbortSignal): Promise<StreamingSession>;
|
|
33
|
+
dispose(): void;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Register the Aliyun realtime provider.
|
|
37
|
+
* @param ctx - streaming registry owner.
|
|
38
|
+
* @param config - model, host and credentials.
|
|
39
|
+
*/
|
|
40
|
+
export declare function apply(ctx: Context, config: Config): void;
|
|
41
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aliyun Model Studio (DashScope) realtime recognition. One WebSocket is opened ahead of use and reused for every
|
|
3
|
+
* turn (`run-task` … `finish-task`), so a press only pays for the task start, not the TLS and WebSocket handshake.
|
|
4
|
+
*/
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import z from '@deepseek-ai/schemastery';
|
|
8
|
+
import { Channel } from '@lmk1010/speech-streaming';
|
|
9
|
+
export const name = 'speech-streaming-aliyun';
|
|
10
|
+
export const inject = ['speechStreaming'];
|
|
11
|
+
export const Config = z.object({
|
|
12
|
+
providerId: z.string().min(1).default('aliyun-realtime'),
|
|
13
|
+
model: z.string().min(1).default('paraformer-realtime-v2'),
|
|
14
|
+
host: z.string().default(''),
|
|
15
|
+
apiKey: z.string().default(''),
|
|
16
|
+
idleCloseMs: z.natural().default(120_000),
|
|
17
|
+
startTimeoutMs: z.natural().min(1000).default(8000),
|
|
18
|
+
});
|
|
19
|
+
function keychain(service) {
|
|
20
|
+
if (process.platform !== 'darwin')
|
|
21
|
+
return Promise.resolve('');
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
execFile('security', ['find-generic-password', '-a', 'dshub', '-s', service, '-w'], (error, stdout) => { resolve(error ? '' : stdout.trim()); });
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
const SocketWithHeaders = WebSocket;
|
|
27
|
+
/** Reused WebSocket with one task at a time. */
|
|
28
|
+
class Connection {
|
|
29
|
+
config;
|
|
30
|
+
credentials;
|
|
31
|
+
socket;
|
|
32
|
+
opening;
|
|
33
|
+
idle;
|
|
34
|
+
handler;
|
|
35
|
+
constructor(config, credentials) {
|
|
36
|
+
this.config = config;
|
|
37
|
+
this.credentials = credentials;
|
|
38
|
+
}
|
|
39
|
+
async get() {
|
|
40
|
+
clearTimeout(this.idle);
|
|
41
|
+
if (this.socket?.readyState === WebSocket.OPEN)
|
|
42
|
+
return this.socket;
|
|
43
|
+
this.opening ??= (async () => {
|
|
44
|
+
const { host, key } = await this.credentials();
|
|
45
|
+
if (key === '')
|
|
46
|
+
throw new Error('Aliyun API key is not configured');
|
|
47
|
+
const socket = new SocketWithHeaders(`wss://${host}/api-ws/v1/inference`, { headers: { Authorization: `bearer ${key}` } });
|
|
48
|
+
socket.binaryType = 'arraybuffer';
|
|
49
|
+
await new Promise((resolve, reject) => {
|
|
50
|
+
const timer = setTimeout(() => { reject(new Error('Aliyun connection timed out')); socket.close(); }, this.config.startTimeoutMs);
|
|
51
|
+
socket.onopen = () => { clearTimeout(timer); resolve(); };
|
|
52
|
+
socket.onerror = () => { clearTimeout(timer); reject(new Error('Aliyun connection failed')); };
|
|
53
|
+
});
|
|
54
|
+
socket.onmessage = (event) => {
|
|
55
|
+
const text = typeof event.data === 'string' ? event.data : Buffer.from(event.data).toString();
|
|
56
|
+
try {
|
|
57
|
+
this.handler?.(JSON.parse(text));
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
this.handler?.({ header: { event: 'task-failed', error_message: String(error) } });
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
socket.onclose = () => { if (this.socket === socket)
|
|
64
|
+
this.socket = undefined; this.handler?.({ header: { event: 'task-failed', error_message: 'connection closed' } }); };
|
|
65
|
+
this.socket = socket;
|
|
66
|
+
return socket;
|
|
67
|
+
})().finally(() => { this.opening = undefined; });
|
|
68
|
+
return await this.opening;
|
|
69
|
+
}
|
|
70
|
+
listen(handler) { this.handler = handler; }
|
|
71
|
+
release() {
|
|
72
|
+
clearTimeout(this.idle);
|
|
73
|
+
this.idle = setTimeout(() => { this.close(); }, this.config.idleCloseMs);
|
|
74
|
+
}
|
|
75
|
+
close() { clearTimeout(this.idle); this.socket?.close(); this.socket = undefined; }
|
|
76
|
+
}
|
|
77
|
+
export class AliyunRealtime {
|
|
78
|
+
config;
|
|
79
|
+
info;
|
|
80
|
+
connection;
|
|
81
|
+
busy = Promise.resolve();
|
|
82
|
+
constructor(config) {
|
|
83
|
+
this.config = config;
|
|
84
|
+
this.info = { id: config.providerId, name: `Aliyun ${config.model}`, location: 'cloud', languages: ['auto', 'zh', 'en'] };
|
|
85
|
+
this.connection = new Connection(config, async () => {
|
|
86
|
+
// Read on every new connection (a few milliseconds), so a key saved, replaced or deleted in settings applies at once.
|
|
87
|
+
return {
|
|
88
|
+
host: config.host || await keychain('dshub.aliyun.host') || 'dashscope.aliyuncs.com',
|
|
89
|
+
key: config.apiKey || process.env.DASHSCOPE_API_KEY || await keychain('dshub.aliyun.dashscope'),
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
warm() { void this.connection.get().then(() => { this.connection.release(); }, () => { }); }
|
|
94
|
+
async open(_options, signal) {
|
|
95
|
+
// One task at a time on the shared connection.
|
|
96
|
+
const previous = this.busy;
|
|
97
|
+
let done;
|
|
98
|
+
this.busy = new Promise((resolve) => { done = resolve; });
|
|
99
|
+
await previous;
|
|
100
|
+
const events = new Channel();
|
|
101
|
+
const taskId = randomUUID().replaceAll('-', '');
|
|
102
|
+
const finals = [];
|
|
103
|
+
let segment = 0, ended = false;
|
|
104
|
+
const finish = (error) => {
|
|
105
|
+
if (ended)
|
|
106
|
+
return;
|
|
107
|
+
ended = true;
|
|
108
|
+
this.connection.listen(undefined);
|
|
109
|
+
this.connection.release();
|
|
110
|
+
if (error)
|
|
111
|
+
events.fail(error);
|
|
112
|
+
else {
|
|
113
|
+
events.push({ type: 'end', text: finals.join(''), audioSeconds: 0 });
|
|
114
|
+
events.close();
|
|
115
|
+
}
|
|
116
|
+
done();
|
|
117
|
+
};
|
|
118
|
+
let socket;
|
|
119
|
+
try {
|
|
120
|
+
socket = await this.connection.get();
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
finish(error);
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
const started = Promise.withResolvers();
|
|
127
|
+
const timer = setTimeout(() => { started.reject(new Error('Aliyun task did not start')); }, this.config.startTimeoutMs);
|
|
128
|
+
this.connection.listen((message) => {
|
|
129
|
+
switch (message.header?.event) {
|
|
130
|
+
case 'task-started':
|
|
131
|
+
clearTimeout(timer);
|
|
132
|
+
started.resolve();
|
|
133
|
+
break;
|
|
134
|
+
case 'result-generated': {
|
|
135
|
+
const sentence = message.payload?.output?.sentence;
|
|
136
|
+
if (!sentence?.text)
|
|
137
|
+
break;
|
|
138
|
+
if (sentence.sentence_end) {
|
|
139
|
+
finals.push(sentence.text);
|
|
140
|
+
events.push({ type: 'final', segment, text: sentence.text });
|
|
141
|
+
segment++;
|
|
142
|
+
}
|
|
143
|
+
else
|
|
144
|
+
events.push({ type: 'partial', segment, text: sentence.text });
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
case 'task-finished':
|
|
148
|
+
finish();
|
|
149
|
+
break;
|
|
150
|
+
case 'task-failed':
|
|
151
|
+
clearTimeout(timer);
|
|
152
|
+
started.reject(new Error(message.header.error_message ?? 'Aliyun task failed'));
|
|
153
|
+
finish(new Error(message.header.error_message ?? 'Aliyun task failed'));
|
|
154
|
+
break;
|
|
155
|
+
default: break;
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
socket.send(JSON.stringify({ header: { action: 'run-task', task_id: taskId, streaming: 'duplex' },
|
|
159
|
+
payload: { task_group: 'audio', task: 'asr', function: 'recognition', model: this.config.model,
|
|
160
|
+
parameters: { format: 'pcm', sample_rate: 16000 }, input: {} } }));
|
|
161
|
+
// WebSocket.send needs bytes backed by an ordinary ArrayBuffer.
|
|
162
|
+
const bytes = (pcm) => Uint8Array.from(new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength));
|
|
163
|
+
const pending = [];
|
|
164
|
+
let ready = false, finishing = false;
|
|
165
|
+
const sendFinish = () => {
|
|
166
|
+
socket.send(JSON.stringify({ header: { action: 'finish-task', task_id: taskId, streaming: 'duplex' }, payload: { input: {} } }));
|
|
167
|
+
};
|
|
168
|
+
void started.promise.then(() => {
|
|
169
|
+
ready = true;
|
|
170
|
+
for (const chunk of pending)
|
|
171
|
+
socket.send(chunk);
|
|
172
|
+
pending.length = 0;
|
|
173
|
+
if (finishing)
|
|
174
|
+
sendFinish();
|
|
175
|
+
}, (error) => { finish(error); });
|
|
176
|
+
signal.addEventListener('abort', () => { if (!ended) {
|
|
177
|
+
if (ready)
|
|
178
|
+
sendFinish();
|
|
179
|
+
finish(new Error('cancelled'));
|
|
180
|
+
} }, { once: true });
|
|
181
|
+
return {
|
|
182
|
+
write: (pcm) => { if (ready)
|
|
183
|
+
socket.send(bytes(pcm));
|
|
184
|
+
else
|
|
185
|
+
pending.push(bytes(pcm)); },
|
|
186
|
+
finish: () => { finishing = true; if (ready)
|
|
187
|
+
sendFinish(); },
|
|
188
|
+
events,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
dispose() { this.connection.close(); }
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Register the Aliyun realtime provider.
|
|
195
|
+
* @param ctx - streaming registry owner.
|
|
196
|
+
* @param config - model, host and credentials.
|
|
197
|
+
*/
|
|
198
|
+
export function apply(ctx, config) {
|
|
199
|
+
const provider = new AliyunRealtime(config);
|
|
200
|
+
ctx.effect(() => {
|
|
201
|
+
const unregister = ctx.speechStreaming.register(provider);
|
|
202
|
+
return async () => { provider.dispose(); await unregister(); };
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
//# sourceMappingURL=index.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lmk1010/speech-streaming-aliyun",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"description": "Aliyun Model Studio realtime speech recognition (Paraformer / Fun-ASR) over a reused WebSocket",
|
|
4
|
+
"version": "0.2.2-rc.1",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"types": "lib/types/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./lib/types/index.d.ts",
|
|
11
|
+
"default": "./lib/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./src/*": "./src/*",
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"lib/index.js",
|
|
18
|
+
"lib/types/**/*.js",
|
|
19
|
+
"lib/types/**/*.d.ts"
|
|
20
|
+
],
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@deepseek-ai/schemastery": "~3.19.0"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"@deepseek-ai/cordis": "~4.1.0",
|
|
27
|
+
"@lmk1010/speech-streaming": "0.2.2-rc.1"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@deepseek-ai/cordis": "~4.1.0",
|
|
31
|
+
"@lmk1010/speech-streaming": "0.2.2-rc.1"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/lmk1010/dsh-airpods-space.git",
|
|
39
|
+
"directory": "packages/airpods/speech-streaming-aliyun"
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://github.com/lmk1010/dsh-airpods-space#readme"
|
|
6
42
|
}
|
package/README.md
DELETED