@olares/lares-core 0.15.26 → 0.15.27
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/package.json +1 -1
- package/router/tts.js +7 -4
- package/voice/speech-segmenter.js +33 -14
- package/voice/stream-client.js +7 -3
- package/voice/stream-gateway.js +3 -1
- package/voice/tts-input.js +10 -0
- package/voice/tts-preview.js +3 -1
package/package.json
CHANGED
package/router/tts.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { speechInput } from "../voice/tts-input.js";
|
|
1
2
|
import { fetchTtsVoices } from "../voice/tts-voice.js";
|
|
2
3
|
import { VoiceError } from "./stt.js";
|
|
3
4
|
import { routerShimBaseUrl } from "./gateway.js";
|
|
@@ -54,8 +55,9 @@ export function pcmToWav(pcm, sampleRate = PCM_RATE) {
|
|
|
54
55
|
* @returns {Promise<Buffer>}
|
|
55
56
|
*/
|
|
56
57
|
export async function synthesize(request) {
|
|
57
|
-
const
|
|
58
|
-
if (!
|
|
58
|
+
const spoken = request.text.trim();
|
|
59
|
+
if (!spoken) throw new VoiceError("voice_config_invalid", 400, "nothing to speak");
|
|
60
|
+
const text = speechInput(request.model, spoken);
|
|
59
61
|
let res;
|
|
60
62
|
try {
|
|
61
63
|
res = await fetch(`${routerShimBaseUrl()}/audio/speech`, {
|
|
@@ -92,8 +94,9 @@ export async function synthesize(request) {
|
|
|
92
94
|
* @param {(chunk: Buffer) => Promise<void> | void} onChunk
|
|
93
95
|
*/
|
|
94
96
|
export async function synthesizeStream(request, onChunk) {
|
|
95
|
-
const
|
|
96
|
-
if (!
|
|
97
|
+
const spoken = request.text.trim();
|
|
98
|
+
if (!spoken) throw new VoiceError("voice_config_invalid", 400, "nothing to speak");
|
|
99
|
+
const text = speechInput(request.model, spoken);
|
|
97
100
|
let res;
|
|
98
101
|
try {
|
|
99
102
|
const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
|
@@ -20,30 +20,49 @@ function boundaries(text) {
|
|
|
20
20
|
return { positions, balanced: !stack.length };
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
function
|
|
24
|
-
const
|
|
25
|
-
|
|
23
|
+
function cjkCuts(text, positions) {
|
|
24
|
+
const cuts = [];
|
|
25
|
+
for (let i = 0; i < text.length; i++) {
|
|
26
|
+
if (!/[。!?]/.test(text[i])) continue;
|
|
27
|
+
let end = i + 1;
|
|
28
|
+
while (/[”’」』))\]]/.test(text[end] || "") && end < text.length) end++;
|
|
29
|
+
if (positions.has(end)) cuts.push(end);
|
|
30
|
+
}
|
|
31
|
+
return [...new Set(cuts)].sort((a, b) => a - b);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// sentencex is an English segmenter. A Chinese 。!? already closed the previous
|
|
35
|
+
// sentence; feeding that terminator back in makes it treat "Mr." as another end.
|
|
36
|
+
function sentenceCuts(slice, sliceClosed) {
|
|
37
|
+
const parts = segment("en", slice);
|
|
38
|
+
const ends = [];
|
|
26
39
|
let from = 0;
|
|
27
|
-
const parts = segment("en", text);
|
|
28
40
|
for (let i = 0; i < parts.length; i++) {
|
|
29
|
-
const at =
|
|
41
|
+
const at = slice.indexOf(parts[i], from);
|
|
42
|
+
if (at < 0) continue;
|
|
30
43
|
from = at + parts[i].length;
|
|
31
|
-
if (i < parts.length - 1
|
|
44
|
+
if (i < parts.length - 1) ends.push(from);
|
|
45
|
+
else if (sliceClosed) ends.push(slice.length);
|
|
32
46
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
47
|
+
return ends;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sentences(text, closed) {
|
|
51
|
+
const { positions, balanced } = boundaries(text);
|
|
52
|
+
const ends = new Set();
|
|
53
|
+
const cuts = cjkCuts(text, positions);
|
|
54
|
+
let start = 0;
|
|
55
|
+
for (const cut of cuts) {
|
|
56
|
+
for (const end of sentenceCuts(text.slice(start, cut), true)) ends.add(start + end);
|
|
57
|
+
start = cut;
|
|
39
58
|
}
|
|
40
|
-
|
|
59
|
+
for (const end of sentenceCuts(text.slice(start), closed)) ends.add(start + end);
|
|
41
60
|
const out = []; let consumed = 0;
|
|
42
61
|
for (const end of [...ends].sort((a, b) => a - b)) {
|
|
43
62
|
if (!positions.has(end) && !(closed && end === text.length)) continue;
|
|
44
63
|
const sentence = text.slice(consumed, end).trim();
|
|
45
64
|
if (sentence.includes("\uFFFC")) break;
|
|
46
|
-
if (sentence) out.push(sentence);
|
|
65
|
+
if (/[\p{L}\p{N}]/u.test(sentence)) out.push(sentence);
|
|
47
66
|
consumed = end;
|
|
48
67
|
}
|
|
49
68
|
return { out, consumed, balanced };
|
package/voice/stream-client.js
CHANGED
|
@@ -49,7 +49,7 @@ export async function playAudioStream(options) {
|
|
|
49
49
|
if (!progressNoted) { progressNoted = true; streamMetric(options, { stage: "playback_progress_sent", frames }); }
|
|
50
50
|
},
|
|
51
51
|
onMetric: (event) => { if (event.stage === "playback_started") options.onStatus?.("playing"); streamMetric(options, { ...event, durationMs: Date.now() - started }); } });
|
|
52
|
-
let chain = Promise.resolve(), complete = false, firstReceived = false, ready = false, paused = false, segmentIndex = -1;
|
|
52
|
+
let chain = Promise.resolve(), complete = false, ending = false, firstReceived = false, ready = false, paused = false, segmentIndex = -1;
|
|
53
53
|
options.controls?.({
|
|
54
54
|
get canPause() { return ready; },
|
|
55
55
|
pause() { if (!ready || paused) return; paused = true; player.pause(); file.pause(); if (connection.readyState === 1) connection.send(JSON.stringify({ type: "pause" })); },
|
|
@@ -69,10 +69,14 @@ export async function playAudioStream(options) {
|
|
|
69
69
|
options.signal?.addEventListener("abort", cancelled, { once: true });
|
|
70
70
|
connection.addEventListener("message", ({ data }) => {
|
|
71
71
|
if (typeof data !== "string" && !firstReceived) { firstReceived = true; streamMetric(options, { stage: "playback_first_received", durationMs: Date.now() - started }); }
|
|
72
|
+
let event = null;
|
|
73
|
+
if (typeof data === "string") {
|
|
74
|
+
try { event = JSON.parse(data); } catch (error) { chain = chain.then(() => { throw error; }).catch(fail); return; }
|
|
75
|
+
if (event?.type === "done") ending = true;
|
|
76
|
+
}
|
|
72
77
|
chain = chain.then(async () => {
|
|
73
78
|
if (options.signal?.aborted) throw new Error("voice_cancelled");
|
|
74
79
|
if (typeof data !== "string") { player.write(new Uint8Array(data)); return; }
|
|
75
|
-
const event = JSON.parse(data);
|
|
76
80
|
if (event.type === "segment") {
|
|
77
81
|
if (event.index !== segmentIndex + 1) throw new Error("voice_reply_sequence");
|
|
78
82
|
segmentIndex = event.index;
|
|
@@ -88,7 +92,7 @@ export async function playAudioStream(options) {
|
|
|
88
92
|
connection.addEventListener("error", () => fail(dropped()), { once: true });
|
|
89
93
|
connection.addEventListener("close", () => {
|
|
90
94
|
options.signal?.removeEventListener("abort", cancelled);
|
|
91
|
-
if (!complete) { const error = dropped(); noteFailure(error); reject(error); }
|
|
95
|
+
if (!complete && !ending) { const error = dropped(); noteFailure(error); reject(error); }
|
|
92
96
|
}, { once: true });
|
|
93
97
|
if (options.signal?.aborted) cancelled();
|
|
94
98
|
});
|
package/voice/stream-gateway.js
CHANGED
|
@@ -159,7 +159,9 @@ export function createAudioStreamGateway({ config, models, replies, synthesize =
|
|
|
159
159
|
else if (event.type === "error") throw fault(event.code || "voice_stream_failed");
|
|
160
160
|
}
|
|
161
161
|
};
|
|
162
|
-
|
|
162
|
+
// `done` means the bytes have been handed over. The browser still has up to
|
|
163
|
+
// the two-second send-ahead queued. Closing now is a disconnect and stops that tail.
|
|
164
|
+
void pump().catch((cause) => { if (!abort.signal.aborted) error(cause); }).finally(() => unsub());
|
|
163
165
|
}
|
|
164
166
|
});
|
|
165
167
|
},
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const QWEN_12HZ = /qwen3-tts|audioqwen3tts/i;
|
|
2
|
+
|
|
3
|
+
/** 12Hz Qwen drops its last codec frames. This tail is not spoken, so those frames land on it. */
|
|
4
|
+
export const QWEN_SPEECH_TAIL = " ^.";
|
|
5
|
+
|
|
6
|
+
export function speechInput(model, text) {
|
|
7
|
+
const body = String(text ?? "");
|
|
8
|
+
if (!QWEN_12HZ.test(String(model ?? ""))) return body;
|
|
9
|
+
return body.endsWith(QWEN_SPEECH_TAIL) ? body : `${body}${QWEN_SPEECH_TAIL}`;
|
|
10
|
+
}
|
package/voice/tts-preview.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { speechInput } from "./tts-input.js";
|
|
2
|
+
|
|
1
3
|
/** Explicit audition is independent of automatic read-aloud and business sessions. */
|
|
2
4
|
export async function previewTts({ model, voice, text, signal }, { fetcher = fetch, createAudio = url => new Audio(url) } = {}) {
|
|
3
5
|
const response = await fetcher("/llm/v1/audio/speech", {
|
|
4
6
|
method: "POST", headers: { "content-type": "application/json" }, signal,
|
|
5
|
-
body: JSON.stringify({ model, input: text, response_format: "wav", ...(voice ? { voice } : {}) }),
|
|
7
|
+
body: JSON.stringify({ model, input: speechInput(model, text), response_format: "wav", ...(voice ? { voice } : {}) }),
|
|
6
8
|
});
|
|
7
9
|
if (!response.ok) throw new Error("tts_preview_failed");
|
|
8
10
|
const blob = await response.blob(); signal.throwIfAborted();
|