@flowingspring/dsh-voco 0.2.0
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/README.i18n.yaml +6 -0
- package/README.md +31 -0
- package/README.zh.md +31 -0
- package/cordis.patch.yml +43 -0
- package/lib/client.js +1469 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +6 -0
- package/lib/invariant.js +9 -0
- package/lib/plugins/llm-tool-call-compat.js +101 -0
- package/lib/plugins/voice-assistant.js +1248 -0
- package/lib/plugins/voice-local.js +593 -0
- package/lib/plugins/voice-web.js +267 -0
- package/lib/plugins/voice.js +401 -0
- package/lib/types/index.d.ts +4 -0
- package/lib/types/invariant.d.ts +7 -0
- package/package.json +119 -0
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
import { VoiceCommandCallId, VoiceResponseId, VoiceUtteranceId } from "./voice.js";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import z from "@deepseek-ai/schemastery";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { dirname, join, resolve } from "node:path";
|
|
6
|
+
import { loadEnvFile } from "node:process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { EdgeTTS } from "node-edge-tts";
|
|
11
|
+
//#region ../voice-local/src/edge-tts.ts
|
|
12
|
+
const EDGE_TTS_VOICE = "zh-CN-XiaoxiaoNeural";
|
|
13
|
+
const DEFAULT_EDGE_TTS_RATE = "+20%";
|
|
14
|
+
/** Edge TTS adapter. Returns the service's MP3 bytes for browser decoding. */
|
|
15
|
+
async function synthesizeEdgeSpeech(text, rate = DEFAULT_EDGE_TTS_RATE) {
|
|
16
|
+
const normalized = normalizeSpeechText(text);
|
|
17
|
+
if (normalized === "") return /* @__PURE__ */ new Uint8Array();
|
|
18
|
+
const directory = await mkdtemp(join(tmpdir(), "dsh-voco-edge-"));
|
|
19
|
+
const output = join(directory, "speech.mp3");
|
|
20
|
+
try {
|
|
21
|
+
await new EdgeTTS({
|
|
22
|
+
voice: EDGE_TTS_VOICE,
|
|
23
|
+
lang: "zh-CN",
|
|
24
|
+
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
|
|
25
|
+
rate,
|
|
26
|
+
pitch: "default",
|
|
27
|
+
volume: "default",
|
|
28
|
+
timeout: 2e4
|
|
29
|
+
}).ttsPromise(normalized, output);
|
|
30
|
+
return new Uint8Array(await readFile(output));
|
|
31
|
+
} finally {
|
|
32
|
+
await rm(directory, {
|
|
33
|
+
recursive: true,
|
|
34
|
+
force: true
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function normalizeSpeechText(text) {
|
|
39
|
+
return text.normalize("NFKC").replace(/[`*_#>|]/g, " ").replace(/\s+/g, " ").replace(/\s+([,。!?;:,.!?;:])/g, "$1").trim();
|
|
40
|
+
}
|
|
41
|
+
/** Split prose into short requests so the first sentence can play immediately. */
|
|
42
|
+
function splitSpeechText(text) {
|
|
43
|
+
const normalized = normalizeSpeechText(text);
|
|
44
|
+
if (normalized === "") return [];
|
|
45
|
+
return (normalized.match(/[^。!?!?;;::\n]+[。!?!?;;::]?|[^。!?!?;;::\n]+$/g) ?? [normalized]).map((part) => part.trim()).filter(Boolean);
|
|
46
|
+
}
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region ../voice-local/src/session.ts
|
|
49
|
+
/** Provider session translating local speech events to the shared voice seam. */
|
|
50
|
+
var LocalSession = class {
|
|
51
|
+
backend;
|
|
52
|
+
emit;
|
|
53
|
+
voiceSessionId;
|
|
54
|
+
interactionMode;
|
|
55
|
+
audio;
|
|
56
|
+
closed = false;
|
|
57
|
+
pending = [];
|
|
58
|
+
pendingSpeech = [];
|
|
59
|
+
pendingCommands = /* @__PURE__ */ new Map();
|
|
60
|
+
pendingOutputs = [];
|
|
61
|
+
activeSpeech;
|
|
62
|
+
activeTaskId;
|
|
63
|
+
constructor(backend, emit, voiceSessionId, interactionMode = "speech-shell") {
|
|
64
|
+
this.backend = backend;
|
|
65
|
+
this.emit = emit;
|
|
66
|
+
this.voiceSessionId = voiceSessionId;
|
|
67
|
+
this.interactionMode = interactionMode;
|
|
68
|
+
this.audio = backend.audio;
|
|
69
|
+
}
|
|
70
|
+
async start() {
|
|
71
|
+
await this.backend.start((event) => this.receive(event));
|
|
72
|
+
}
|
|
73
|
+
appendAudio(audio) {
|
|
74
|
+
this.backend.appendAudio(audio);
|
|
75
|
+
}
|
|
76
|
+
commitAudio() {
|
|
77
|
+
this.backend.commitAudio();
|
|
78
|
+
}
|
|
79
|
+
interruptResponse() {
|
|
80
|
+
this.backend.interrupt();
|
|
81
|
+
this.pendingSpeech.splice(0);
|
|
82
|
+
this.pendingOutputs.splice(0);
|
|
83
|
+
this.activeSpeech = void 0;
|
|
84
|
+
this.emit({ type: "response.interrupted" });
|
|
85
|
+
}
|
|
86
|
+
playbackEnded() {
|
|
87
|
+
for (const output of this.pendingOutputs.splice(0)) this.emit({
|
|
88
|
+
type: "output_text.done",
|
|
89
|
+
utteranceId: output.utteranceId,
|
|
90
|
+
responseId: output.responseId,
|
|
91
|
+
text: output.text
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
appendTaskObservation(event) {
|
|
95
|
+
const text = event.voiceMessage?.text.trim() || event.announcement?.trim();
|
|
96
|
+
if (text !== void 0 && text !== "") this.pending.push(text);
|
|
97
|
+
if (event.taskId === this.activeTaskId && isTerminalTaskStatus(event.status)) this.activeTaskId = void 0;
|
|
98
|
+
}
|
|
99
|
+
appendSpeechText(text) {
|
|
100
|
+
if (text.trim() === "") return;
|
|
101
|
+
if (this.activeSpeech === void 0) {
|
|
102
|
+
const responseId = VoiceResponseId(String(this.voiceSessionId) + ":response:" + randomUUID());
|
|
103
|
+
const utteranceId = VoiceUtteranceId(String(responseId) + ":text");
|
|
104
|
+
this.activeSpeech = {
|
|
105
|
+
responseId,
|
|
106
|
+
utteranceId,
|
|
107
|
+
text: ""
|
|
108
|
+
};
|
|
109
|
+
this.pendingOutputs.push(this.activeSpeech);
|
|
110
|
+
this.emit({
|
|
111
|
+
type: "output_text.started",
|
|
112
|
+
utteranceId,
|
|
113
|
+
responseId
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
this.activeSpeech.text += text;
|
|
117
|
+
this.emit({
|
|
118
|
+
type: "output_text.delta",
|
|
119
|
+
utteranceId: this.activeSpeech.utteranceId,
|
|
120
|
+
responseId: this.activeSpeech.responseId,
|
|
121
|
+
text
|
|
122
|
+
});
|
|
123
|
+
this.backend.synthesize(String(this.activeSpeech.responseId), text);
|
|
124
|
+
}
|
|
125
|
+
requestResponse(_policy) {
|
|
126
|
+
const text = [...this.pending.splice(0), ...this.pendingSpeech.splice(0)].join("\n");
|
|
127
|
+
if (text !== "") this.appendSpeechText(text);
|
|
128
|
+
const responseId = this.activeSpeech?.responseId;
|
|
129
|
+
this.activeSpeech = void 0;
|
|
130
|
+
if (responseId !== void 0) this.backend.finishSynthesis?.(String(responseId));
|
|
131
|
+
}
|
|
132
|
+
completeTaskCommand(callId, result) {
|
|
133
|
+
if (this.interactionMode === "speech-shell") throw new Error("local speech-shell sessions do not accept task commands");
|
|
134
|
+
const command = this.pendingCommands.get(callId);
|
|
135
|
+
if (command === void 0) return;
|
|
136
|
+
this.pendingCommands.delete(callId);
|
|
137
|
+
if (result.kind === "accepted" && (command.type === "route_transcription" || command.type === "realtime_delegation")) this.activeTaskId = result.taskId;
|
|
138
|
+
else if (result.kind === "rejected" && command.type === "send_task_message" && (result.code === "task_not_active" || result.code === "task_not_found")) this.activeTaskId = void 0;
|
|
139
|
+
}
|
|
140
|
+
async close() {
|
|
141
|
+
if (this.closed) return;
|
|
142
|
+
this.closed = true;
|
|
143
|
+
await this.backend.close();
|
|
144
|
+
}
|
|
145
|
+
receive(event) {
|
|
146
|
+
switch (event.type) {
|
|
147
|
+
case "ready": return;
|
|
148
|
+
case "transcription.started":
|
|
149
|
+
this.emit({
|
|
150
|
+
type: "transcription.started",
|
|
151
|
+
utteranceId: VoiceUtteranceId(String(this.voiceSessionId) + ":input:" + event.utteranceId)
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
case "transcription.updated":
|
|
155
|
+
this.emit({
|
|
156
|
+
type: "transcription.updated",
|
|
157
|
+
utteranceId: VoiceUtteranceId(String(this.voiceSessionId) + ":input:" + event.utteranceId),
|
|
158
|
+
text: event.text
|
|
159
|
+
});
|
|
160
|
+
return;
|
|
161
|
+
case "transcription.completed": {
|
|
162
|
+
const utteranceId = VoiceUtteranceId(String(this.voiceSessionId) + ":input:" + event.utteranceId);
|
|
163
|
+
this.emit({
|
|
164
|
+
type: "transcription.completed",
|
|
165
|
+
utteranceId,
|
|
166
|
+
text: event.text
|
|
167
|
+
});
|
|
168
|
+
if (this.interactionMode === "frontend-agent" && event.text.trim() !== "") {
|
|
169
|
+
const text = event.text.trim();
|
|
170
|
+
this.emitTaskCommand(this.activeTaskId === void 0 ? {
|
|
171
|
+
type: "route_transcription",
|
|
172
|
+
input: text
|
|
173
|
+
} : {
|
|
174
|
+
type: "send_task_message",
|
|
175
|
+
taskId: this.activeTaskId,
|
|
176
|
+
message: text
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
case "transcription.failed":
|
|
182
|
+
this.emit({
|
|
183
|
+
type: "transcription.failed",
|
|
184
|
+
utteranceId: VoiceUtteranceId(String(this.voiceSessionId) + ":input:" + event.utteranceId),
|
|
185
|
+
message: event.message
|
|
186
|
+
});
|
|
187
|
+
return;
|
|
188
|
+
case "tts.started": {
|
|
189
|
+
const responseId = this.responseId(event.responseId);
|
|
190
|
+
this.emit({
|
|
191
|
+
type: "output_audio.started",
|
|
192
|
+
responseId
|
|
193
|
+
});
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
case "tts.delta":
|
|
197
|
+
this.emit({
|
|
198
|
+
type: "output_audio.delta",
|
|
199
|
+
responseId: this.responseId(event.responseId),
|
|
200
|
+
audio: event.audio
|
|
201
|
+
});
|
|
202
|
+
return;
|
|
203
|
+
case "tts.done": {
|
|
204
|
+
const responseId = this.responseId(event.responseId);
|
|
205
|
+
this.emit({
|
|
206
|
+
type: "output_audio.done",
|
|
207
|
+
responseId
|
|
208
|
+
});
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
case "error":
|
|
212
|
+
this.emit({
|
|
213
|
+
type: "error",
|
|
214
|
+
message: event.message
|
|
215
|
+
});
|
|
216
|
+
return;
|
|
217
|
+
case "closed":
|
|
218
|
+
this.emit({
|
|
219
|
+
type: "closed",
|
|
220
|
+
...event.reason === void 0 ? {} : { reason: event.reason }
|
|
221
|
+
});
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
responseId(value) {
|
|
226
|
+
return VoiceResponseId(value.startsWith(String(this.voiceSessionId) + ":response:") ? value : String(this.voiceSessionId) + ":response:" + value);
|
|
227
|
+
}
|
|
228
|
+
emitTaskCommand(command) {
|
|
229
|
+
const id = VoiceCommandCallId(String(this.voiceSessionId) + ":task:" + randomUUID());
|
|
230
|
+
this.pendingCommands.set(id, command);
|
|
231
|
+
this.emit({
|
|
232
|
+
type: "task.command",
|
|
233
|
+
call: {
|
|
234
|
+
id,
|
|
235
|
+
command
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
function isTerminalTaskStatus(status) {
|
|
241
|
+
return status === "completed" || status === "failed" || status === "cancelled" || status === "interrupted";
|
|
242
|
+
}
|
|
243
|
+
//#endregion
|
|
244
|
+
//#region ../voice-local/src/siliconflow-asr.ts
|
|
245
|
+
const WAV_HEADER_BYTES = 44;
|
|
246
|
+
/** Upload one completed PCM utterance to SiliconFlow and return its final transcript. */
|
|
247
|
+
var SiliconFlowAsr = class {
|
|
248
|
+
config;
|
|
249
|
+
constructor(config) {
|
|
250
|
+
this.config = config;
|
|
251
|
+
}
|
|
252
|
+
async transcribe(pcm, sampleRate) {
|
|
253
|
+
if (pcm.byteLength === 0) return "";
|
|
254
|
+
const wav = pcm16MonoWav(pcm, sampleRate);
|
|
255
|
+
const form = new FormData();
|
|
256
|
+
form.append("file", new Blob([wav.buffer], { type: "audio/wav" }), "utterance.wav");
|
|
257
|
+
form.append("model", this.config.model);
|
|
258
|
+
const response = await (this.config.fetch ?? fetch)(this.config.endpoint, {
|
|
259
|
+
method: "POST",
|
|
260
|
+
headers: { Authorization: `Bearer ${this.config.apiKey}` },
|
|
261
|
+
body: form,
|
|
262
|
+
signal: AbortSignal.timeout(this.config.timeoutMs)
|
|
263
|
+
});
|
|
264
|
+
const body = await response.text();
|
|
265
|
+
if (!response.ok) throw new Error(`SiliconFlow ASR returned ${response.status}: ${body.slice(0, 500)}`);
|
|
266
|
+
let parsed;
|
|
267
|
+
try {
|
|
268
|
+
parsed = JSON.parse(body);
|
|
269
|
+
} catch {
|
|
270
|
+
throw new Error("SiliconFlow ASR returned invalid JSON");
|
|
271
|
+
}
|
|
272
|
+
if (!isRecord(parsed) || typeof parsed.text !== "string") throw new Error("SiliconFlow ASR response has no text field");
|
|
273
|
+
return parsed.text.trim();
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
/** Wrap signed 16-bit little-endian mono PCM in a standard WAV container. */
|
|
277
|
+
function pcm16MonoWav(pcm, sampleRate) {
|
|
278
|
+
if (pcm.byteLength % 2 !== 0) throw new Error("PCM16 input has an odd byte length");
|
|
279
|
+
const wav = new Uint8Array(WAV_HEADER_BYTES + pcm.byteLength);
|
|
280
|
+
const view = new DataView(wav.buffer);
|
|
281
|
+
writeAscii(wav, 0, "RIFF");
|
|
282
|
+
view.setUint32(4, 36 + pcm.byteLength, true);
|
|
283
|
+
writeAscii(wav, 8, "WAVE");
|
|
284
|
+
writeAscii(wav, 12, "fmt ");
|
|
285
|
+
view.setUint32(16, 16, true);
|
|
286
|
+
view.setUint16(20, 1, true);
|
|
287
|
+
view.setUint16(22, 1, true);
|
|
288
|
+
view.setUint32(24, sampleRate, true);
|
|
289
|
+
view.setUint32(28, sampleRate * 2, true);
|
|
290
|
+
view.setUint16(32, 2, true);
|
|
291
|
+
view.setUint16(34, 16, true);
|
|
292
|
+
writeAscii(wav, 36, "data");
|
|
293
|
+
view.setUint32(40, pcm.byteLength, true);
|
|
294
|
+
wav.set(pcm, WAV_HEADER_BYTES);
|
|
295
|
+
return wav;
|
|
296
|
+
}
|
|
297
|
+
function writeAscii(target, offset, value) {
|
|
298
|
+
for (const [index, character] of [...value].entries()) target[offset + index] = character.charCodeAt(0);
|
|
299
|
+
}
|
|
300
|
+
function isRecord(value) {
|
|
301
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
302
|
+
}
|
|
303
|
+
//#endregion
|
|
304
|
+
//#region ../voice-local/src/node-backend.ts
|
|
305
|
+
const INPUT_SAMPLE_RATE = 16e3;
|
|
306
|
+
/** Lightweight PCM silence detector plus SiliconFlow cloud ASR and Edge TTS. */
|
|
307
|
+
var NodeSpeechBackend = class {
|
|
308
|
+
config;
|
|
309
|
+
audio;
|
|
310
|
+
asr;
|
|
311
|
+
emit;
|
|
312
|
+
preRoll = [];
|
|
313
|
+
preRollBytes = 0;
|
|
314
|
+
active;
|
|
315
|
+
recognitionQueue = Promise.resolve();
|
|
316
|
+
synthesisQueue = Promise.resolve();
|
|
317
|
+
synthesisGeneration = 0;
|
|
318
|
+
synthesisResponses = /* @__PURE__ */ new Map();
|
|
319
|
+
closed = false;
|
|
320
|
+
constructor(config) {
|
|
321
|
+
this.config = config;
|
|
322
|
+
if (config.inputSampleRate !== INPUT_SAMPLE_RATE) throw new Error("SiliconFlow speech input requires 16000 Hz microphone audio");
|
|
323
|
+
if (config.apiKey.trim() === "") throw new Error("SILICONFLOW_API_KEY is required for cloud speech recognition");
|
|
324
|
+
if (config.speechThreshold <= 0 || config.speechThreshold >= 1) throw new Error("speechThreshold must be between 0 and 1");
|
|
325
|
+
if ((config.minSpeechDurationMs ?? 250) <= 0) throw new Error("minSpeechDurationMs must be greater than 0");
|
|
326
|
+
if (!/^[+-]\d{1,3}%$/.test(config.ttsRate)) throw new Error("ttsRate must use an Edge TTS relative percentage such as +20%");
|
|
327
|
+
this.audio = {
|
|
328
|
+
inputSampleRate: config.inputSampleRate,
|
|
329
|
+
outputSampleRate: config.outputSampleRate,
|
|
330
|
+
format: "audio_mpeg"
|
|
331
|
+
};
|
|
332
|
+
this.asr = new SiliconFlowAsr({
|
|
333
|
+
apiKey: config.apiKey,
|
|
334
|
+
endpoint: config.endpoint,
|
|
335
|
+
model: config.model,
|
|
336
|
+
timeoutMs: config.requestTimeoutMs,
|
|
337
|
+
...config.fetch === void 0 ? {} : { fetch: config.fetch }
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
start(emit) {
|
|
341
|
+
if (this.emit !== void 0) return Promise.reject(/* @__PURE__ */ new Error("speech backend is already started"));
|
|
342
|
+
this.emit = emit;
|
|
343
|
+
emit({ type: "ready" });
|
|
344
|
+
return Promise.resolve();
|
|
345
|
+
}
|
|
346
|
+
appendAudio(audio) {
|
|
347
|
+
if (this.closed || audio.byteLength === 0) return;
|
|
348
|
+
if (audio.byteLength % 2 !== 0) {
|
|
349
|
+
this.emit?.({
|
|
350
|
+
type: "error",
|
|
351
|
+
message: "microphone PCM16 frame has an odd byte length"
|
|
352
|
+
});
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const frame = audio.slice();
|
|
356
|
+
const voiced = pcm16Rms(frame) >= this.config.speechThreshold;
|
|
357
|
+
if (this.active === void 0) {
|
|
358
|
+
this.rememberPreRoll(frame);
|
|
359
|
+
if (!voiced) return;
|
|
360
|
+
this.beginUtterance();
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
this.active.frames.push(frame);
|
|
364
|
+
this.active.totalBytes += frame.byteLength;
|
|
365
|
+
if (voiced) {
|
|
366
|
+
this.active.voicedBytes += frame.byteLength;
|
|
367
|
+
this.active.silenceBytes = 0;
|
|
368
|
+
if (!this.active.confirmed && this.active.voicedBytes >= this.bytesFor(this.config.minSpeechDurationMs ?? 250)) this.confirmUtterance();
|
|
369
|
+
} else this.active.silenceBytes += frame.byteLength;
|
|
370
|
+
if (!this.active.confirmed && this.active.silenceBytes >= this.bytesFor(this.config.minSpeechDurationMs ?? 250)) {
|
|
371
|
+
this.active = void 0;
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (this.active.silenceBytes >= this.bytesFor(this.config.silenceDurationMs) || this.active.totalBytes >= this.bytesFor(this.config.maxUtteranceMs)) this.finishUtterance();
|
|
375
|
+
}
|
|
376
|
+
commitAudio() {
|
|
377
|
+
this.finishUtterance();
|
|
378
|
+
}
|
|
379
|
+
synthesize(responseId, text) {
|
|
380
|
+
const generation = this.synthesisGeneration;
|
|
381
|
+
const response = this.synthesisResponses.get(responseId) ?? {
|
|
382
|
+
finished: false,
|
|
383
|
+
started: false
|
|
384
|
+
};
|
|
385
|
+
this.synthesisResponses.set(responseId, response);
|
|
386
|
+
this.synthesisQueue = this.synthesisQueue.catch(() => {}).then(async () => {
|
|
387
|
+
if (generation !== this.synthesisGeneration || this.closed) return;
|
|
388
|
+
if (!response.started) {
|
|
389
|
+
response.started = true;
|
|
390
|
+
this.emit?.({
|
|
391
|
+
type: "tts.started",
|
|
392
|
+
responseId
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
try {
|
|
396
|
+
for (const sentence of splitSpeechText(text)) {
|
|
397
|
+
if (generation !== this.synthesisGeneration || this.closed) return;
|
|
398
|
+
const audio = await synthesizeEdgeSpeech(sentence, this.config.ttsRate);
|
|
399
|
+
if (generation !== this.synthesisGeneration || this.closed) return;
|
|
400
|
+
this.emit?.({
|
|
401
|
+
type: "tts.delta",
|
|
402
|
+
responseId,
|
|
403
|
+
audio
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
if (generation !== this.synthesisGeneration || this.closed) return;
|
|
407
|
+
if (response.finished) {
|
|
408
|
+
this.synthesisResponses.delete(responseId);
|
|
409
|
+
this.emit?.({
|
|
410
|
+
type: "tts.done",
|
|
411
|
+
responseId
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
} catch (error) {
|
|
415
|
+
if (generation === this.synthesisGeneration && !this.closed) this.emit?.({
|
|
416
|
+
type: "error",
|
|
417
|
+
message: "Edge TTS failed: " + errorMessage(error)
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
finishSynthesis(responseId) {
|
|
423
|
+
const response = this.synthesisResponses.get(responseId);
|
|
424
|
+
if (response !== void 0) response.finished = true;
|
|
425
|
+
}
|
|
426
|
+
interrupt() {
|
|
427
|
+
this.synthesisGeneration += 1;
|
|
428
|
+
this.synthesisResponses.clear();
|
|
429
|
+
}
|
|
430
|
+
async close() {
|
|
431
|
+
if (this.closed) return;
|
|
432
|
+
this.closed = true;
|
|
433
|
+
this.synthesisGeneration += 1;
|
|
434
|
+
this.synthesisResponses.clear();
|
|
435
|
+
this.active = void 0;
|
|
436
|
+
this.preRoll.splice(0);
|
|
437
|
+
await Promise.allSettled([this.recognitionQueue, this.synthesisQueue]);
|
|
438
|
+
this.emit?.({
|
|
439
|
+
type: "closed",
|
|
440
|
+
reason: "SiliconFlow speech backend closed"
|
|
441
|
+
});
|
|
442
|
+
this.emit = void 0;
|
|
443
|
+
}
|
|
444
|
+
beginUtterance() {
|
|
445
|
+
const frames = this.preRoll.splice(0);
|
|
446
|
+
const totalBytes = this.preRollBytes;
|
|
447
|
+
this.preRollBytes = 0;
|
|
448
|
+
const last = frames.at(-1);
|
|
449
|
+
this.active = {
|
|
450
|
+
id: randomUUID(),
|
|
451
|
+
frames,
|
|
452
|
+
confirmed: false,
|
|
453
|
+
totalBytes,
|
|
454
|
+
voicedBytes: last?.byteLength ?? 0,
|
|
455
|
+
silenceBytes: 0
|
|
456
|
+
};
|
|
457
|
+
if (this.active.voicedBytes >= this.bytesFor(this.config.minSpeechDurationMs ?? 250)) this.confirmUtterance();
|
|
458
|
+
}
|
|
459
|
+
confirmUtterance() {
|
|
460
|
+
const utterance = this.active;
|
|
461
|
+
if (utterance === void 0 || utterance.confirmed) return;
|
|
462
|
+
utterance.confirmed = true;
|
|
463
|
+
this.interrupt();
|
|
464
|
+
this.emit?.({
|
|
465
|
+
type: "transcription.started",
|
|
466
|
+
utteranceId: utterance.id
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
finishUtterance() {
|
|
470
|
+
const utterance = this.active;
|
|
471
|
+
if (utterance === void 0) return;
|
|
472
|
+
this.active = void 0;
|
|
473
|
+
const keepSilence = this.bytesFor(this.config.trailingSilenceMs);
|
|
474
|
+
const trimBytes = Math.max(0, utterance.silenceBytes - keepSilence);
|
|
475
|
+
const pcm = concatFrames(utterance.frames, Math.max(0, utterance.totalBytes - trimBytes));
|
|
476
|
+
if (!utterance.confirmed || utterance.voicedBytes < this.bytesFor(this.config.minSpeechDurationMs ?? 250) || pcm.byteLength === 0) return;
|
|
477
|
+
this.recognitionQueue = this.recognitionQueue.catch(() => {}).then(async () => {
|
|
478
|
+
if (this.closed) return;
|
|
479
|
+
try {
|
|
480
|
+
const text = await this.asr.transcribe(pcm, this.config.inputSampleRate);
|
|
481
|
+
if (this.closed) return;
|
|
482
|
+
if (text === "") this.emit?.({
|
|
483
|
+
type: "transcription.failed",
|
|
484
|
+
utteranceId: utterance.id,
|
|
485
|
+
message: "云端语音识别没有返回文字。"
|
|
486
|
+
});
|
|
487
|
+
else this.emit?.({
|
|
488
|
+
type: "transcription.completed",
|
|
489
|
+
utteranceId: utterance.id,
|
|
490
|
+
text
|
|
491
|
+
});
|
|
492
|
+
} catch (error) {
|
|
493
|
+
if (!this.closed) this.emit?.({
|
|
494
|
+
type: "transcription.failed",
|
|
495
|
+
utteranceId: utterance.id,
|
|
496
|
+
message: errorMessage(error)
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
rememberPreRoll(frame) {
|
|
502
|
+
this.preRoll.push(frame);
|
|
503
|
+
this.preRollBytes += frame.byteLength;
|
|
504
|
+
const limit = this.bytesFor(this.config.preRollMs);
|
|
505
|
+
while (this.preRollBytes > limit && this.preRoll.length > 1) {
|
|
506
|
+
const removed = this.preRoll.shift();
|
|
507
|
+
if (removed !== void 0) this.preRollBytes -= removed.byteLength;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
bytesFor(milliseconds) {
|
|
511
|
+
return Math.round(this.config.inputSampleRate * 2 * milliseconds / 1e3);
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
function pcm16Rms(bytes) {
|
|
515
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
516
|
+
let squares = 0;
|
|
517
|
+
const samples = bytes.byteLength / 2;
|
|
518
|
+
for (let offset = 0; offset < bytes.byteLength; offset += 2) {
|
|
519
|
+
const value = view.getInt16(offset, true) / 32768;
|
|
520
|
+
squares += value * value;
|
|
521
|
+
}
|
|
522
|
+
return samples === 0 ? 0 : Math.sqrt(squares / samples);
|
|
523
|
+
}
|
|
524
|
+
function concatFrames(frames, length) {
|
|
525
|
+
const output = new Uint8Array(length);
|
|
526
|
+
let offset = 0;
|
|
527
|
+
for (const frame of frames) {
|
|
528
|
+
if (offset >= length) break;
|
|
529
|
+
const part = frame.subarray(0, Math.min(frame.byteLength, length - offset));
|
|
530
|
+
output.set(part, offset);
|
|
531
|
+
offset += part.byteLength;
|
|
532
|
+
}
|
|
533
|
+
return output;
|
|
534
|
+
}
|
|
535
|
+
function errorMessage(error) {
|
|
536
|
+
return error instanceof Error ? error.message : String(error);
|
|
537
|
+
}
|
|
538
|
+
//#endregion
|
|
539
|
+
//#region ../voice-local/src/index.ts
|
|
540
|
+
/** Silence-gated SiliconFlow cloud ASR with Edge TTS. */
|
|
541
|
+
const name = "voice-local";
|
|
542
|
+
const inject = ["voice"];
|
|
543
|
+
const Config = z.object({
|
|
544
|
+
apiKey: z.string(),
|
|
545
|
+
endpoint: z.string().default("https://api.siliconflow.cn/v1/audio/transcriptions"),
|
|
546
|
+
model: z.string().default("XingChenAGI/XingChenASR-V3.2-Ultra"),
|
|
547
|
+
interactionMode: z.union(["speech-shell", "frontend-agent"]).default("speech-shell"),
|
|
548
|
+
requestTimeoutMs: z.natural().min(1).default(6e4),
|
|
549
|
+
inputSampleRate: z.natural().min(1).default(16e3),
|
|
550
|
+
outputSampleRate: z.natural().min(1).default(48e3),
|
|
551
|
+
ttsRate: z.string().default(DEFAULT_EDGE_TTS_RATE),
|
|
552
|
+
silenceDurationMs: z.natural().min(100).default(1500),
|
|
553
|
+
speechThreshold: z.number().min(.001).max(.5).default(.015),
|
|
554
|
+
minSpeechDurationMs: z.natural().min(20).default(250),
|
|
555
|
+
preRollMs: z.natural().default(400),
|
|
556
|
+
trailingSilenceMs: z.natural().default(200),
|
|
557
|
+
maxUtteranceMs: z.natural().min(1e3).default(6e4)
|
|
558
|
+
});
|
|
559
|
+
const PACKAGE_ROOT = dirname(fileURLToPath(import.meta.url));
|
|
560
|
+
const PROJECT_ENV = resolve(PACKAGE_ROOT, "../../../.env");
|
|
561
|
+
function apply(ctx, config = {}) {
|
|
562
|
+
if (existsSync(PROJECT_ENV)) loadEnvFile(PROJECT_ENV);
|
|
563
|
+
return ctx.voice.registerProvider({
|
|
564
|
+
id: "local",
|
|
565
|
+
available: () => true,
|
|
566
|
+
connect: async ({ voiceSessionId, emit }) => {
|
|
567
|
+
const session = new LocalSession(new NodeSpeechBackend({
|
|
568
|
+
apiKey: config.apiKey ?? process.env.SILICONFLOW_API_KEY ?? "",
|
|
569
|
+
endpoint: config.endpoint ?? "https://api.siliconflow.cn/v1/audio/transcriptions",
|
|
570
|
+
model: config.model ?? "XingChenAGI/XingChenASR-V3.2-Ultra",
|
|
571
|
+
requestTimeoutMs: config.requestTimeoutMs ?? 6e4,
|
|
572
|
+
inputSampleRate: config.inputSampleRate ?? 16e3,
|
|
573
|
+
outputSampleRate: config.outputSampleRate ?? 48e3,
|
|
574
|
+
ttsRate: config.ttsRate ?? "+20%",
|
|
575
|
+
silenceDurationMs: config.silenceDurationMs ?? 1500,
|
|
576
|
+
speechThreshold: config.speechThreshold ?? .015,
|
|
577
|
+
minSpeechDurationMs: config.minSpeechDurationMs ?? 250,
|
|
578
|
+
preRollMs: config.preRollMs ?? 400,
|
|
579
|
+
trailingSilenceMs: config.trailingSilenceMs ?? 200,
|
|
580
|
+
maxUtteranceMs: config.maxUtteranceMs ?? 6e4
|
|
581
|
+
}), emit, voiceSessionId, config.interactionMode ?? "speech-shell");
|
|
582
|
+
try {
|
|
583
|
+
await session.start();
|
|
584
|
+
return session;
|
|
585
|
+
} catch (error) {
|
|
586
|
+
await session.close().catch(() => {});
|
|
587
|
+
throw error;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
//#endregion
|
|
593
|
+
export { Config, LocalSession, NodeSpeechBackend, SiliconFlowAsr, apply, inject, name, pcm16MonoWav };
|