@olares/lares-core 0.15.17 → 0.15.19

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.
Files changed (59) hide show
  1. package/i18n/chat-model-switch.js +8 -2
  2. package/i18n/voice.js +78 -2
  3. package/i18n/webhook.js +39 -0
  4. package/larepass/mux.js +1 -0
  5. package/larepass/rpc.js +4 -3
  6. package/larepass/runtime.js +4 -1
  7. package/larepass/settings.js +11 -8
  8. package/package.json +29 -2
  9. package/router/catalog-cache.js +1 -1
  10. package/router/catalog-coordinator.js +60 -0
  11. package/router/catalog-sync.js +17 -62
  12. package/router/catalog.d.ts +3 -1
  13. package/router/catalog.js +6 -13
  14. package/router/default-selection.d.ts +3 -0
  15. package/router/default-selection.js +22 -0
  16. package/router/model-authorization.js +53 -0
  17. package/router/model-state.js +35 -0
  18. package/router/models.js +2 -6
  19. package/router/session-model.js +5 -18
  20. package/router/stt.js +2 -1
  21. package/router/tts.js +13 -20
  22. package/voice/activity.js +10 -0
  23. package/voice/agent-session.js +4 -0
  24. package/voice/browser-capture.js +72 -6
  25. package/voice/browser-playback.js +16 -20
  26. package/voice/client.js +28 -14
  27. package/voice/composite-gateway.js +7 -3
  28. package/voice/config.js +6 -0
  29. package/voice/controller.js +19 -8
  30. package/voice/conversation.js +48 -15
  31. package/voice/device-input.js +14 -9
  32. package/voice/dialogue.js +4 -3
  33. package/voice/host-service.js +7 -2
  34. package/voice/interruption.js +44 -0
  35. package/voice/lares-policy.js +12 -0
  36. package/voice/options.js +2 -1
  37. package/voice/panel.js +61 -20
  38. package/voice/pcm-player.js +51 -0
  39. package/voice/pcm-worklet.js +71 -0
  40. package/voice/preferences.js +89 -0
  41. package/voice/presentation.js +13 -1
  42. package/voice/reply-stream.js +99 -0
  43. package/voice/router-audio.js +23 -6
  44. package/voice/script.js +27 -36
  45. package/voice/service.js +46 -33
  46. package/voice/shortcuts.js +54 -5
  47. package/voice/speech-segmenter.js +119 -0
  48. package/voice/speech.js +60 -13
  49. package/voice/spoken-text.js +196 -0
  50. package/voice/startup.js +9 -0
  51. package/voice/stream-client.js +165 -0
  52. package/voice/stream-gateway.js +146 -0
  53. package/voice/tts-preview.js +27 -0
  54. package/voice/tts-settings.js +79 -0
  55. package/voice/tts-voice.js +20 -0
  56. package/webhook/client.js +34 -0
  57. package/webhook/config.js +55 -0
  58. package/webhook/public.js +11 -0
  59. package/webhook/reply.js +12 -0
@@ -0,0 +1,51 @@
1
+ import { PCM_PLAYER_WORKLET } from "./pcm-worklet.js";
2
+ import { pcmSamples } from "./realtime-audio.js";
3
+
4
+ export function createPcmStreamPlayer({ onProgress = () => {}, onMetric = () => {}, context: Context = globalThis.AudioContext || globalThis.webkitAudioContext } = {}) {
5
+ let context, node, resolve, reject, played = 0, generation = 0, pendingByte = null, failed = null, paused = false;
6
+ const stop = () => {
7
+ generation++; paused = false; node?.disconnect(); if (node) node.port.onmessage = null; node = null;
8
+ if (context && context.state !== "closed") void context.close().catch(() => {}); context = null; resolve?.(); resolve = reject = null; pendingByte = null;
9
+ };
10
+ return {
11
+ async start({ sampleRate, channels, encoding }) {
12
+ stop(); failed = null; played = 0;
13
+ if (!Context || channels !== 1 || encoding !== "pcm_s16le" || !Number.isInteger(sampleRate) || sampleRate < 8000 || sampleRate > 192000) throw new Error("voice_audio_format");
14
+ const epoch = generation;
15
+ context = new Context(); const opened = context;
16
+ const url = URL.createObjectURL(new Blob([PCM_PLAYER_WORKLET], { type: "text/javascript" }));
17
+ try { await opened.audioWorklet.addModule(url); await opened.resume(); } finally { URL.revokeObjectURL(url); }
18
+ if (epoch !== generation) { await opened.close().catch(() => {}); throw new Error("voice_cancelled"); }
19
+ node = new AudioWorkletNode(opened, "lares-pcm-player", { processorOptions: { inputRate: sampleRate }, numberOfInputs: 0, numberOfOutputs: 1, outputChannelCount: [1] });
20
+ node.port.onmessage = ({ data }) => {
21
+ if (epoch !== generation) return;
22
+ if (data.type === "progress") { played = data.frames; onProgress(played); }
23
+ if (data.type === "started" || data.type === "underrun") onMetric({ stage: `playback_${data.type}` });
24
+ if (data.type === "done") { resolve?.(); resolve = reject = null; }
25
+ if (data.type === "error") { failed = new Error("voice_audio_backpressure"); reject?.(failed); }
26
+ };
27
+ node.connect(opened.destination);
28
+ },
29
+ write(bytes) {
30
+ if (!node) throw new Error("voice_cancelled");
31
+ let chunk = bytes;
32
+ if (pendingByte !== null) { chunk = new Uint8Array(bytes.length + 1); chunk[0] = pendingByte; chunk.set(bytes, 1); pendingByte = null; }
33
+ if (chunk.length % 2) { pendingByte = chunk[chunk.length - 1]; chunk = chunk.subarray(0, -1); }
34
+ if (!chunk.length) return;
35
+ const samples = pcmSamples(chunk);
36
+ node.port.postMessage({ type: "audio", samples }, [samples.buffer]);
37
+ },
38
+ finish() {
39
+ if (failed) return Promise.reject(failed);
40
+ if (!node) return Promise.resolve();
41
+ if (pendingByte !== null) return Promise.reject(new Error("voice_audio_format"));
42
+ return new Promise((done, fail) => { resolve = done; reject = fail; node.port.postMessage({ type: "finish" }); });
43
+ },
44
+ canPause: true,
45
+ pause() { paused = true; node?.port.postMessage({ type: "pause" }); },
46
+ resume() { paused = false; node?.port.postMessage({ type: "resume" }); },
47
+ get paused() { return paused; },
48
+ playedFrames: () => played,
49
+ stop,
50
+ };
51
+ }
@@ -0,0 +1,71 @@
1
+ /** Separate from the UI thread so rendering cannot interrupt PCM playback. */
2
+ export const PCM_PLAYER_WORKLET = `
3
+ class LaresPcmPlayer extends AudioWorkletProcessor {
4
+ constructor(options) {
5
+ super(); this.queue = []; this.offset = 0; this.frames = 0; this.reported = 0;
6
+ this.rate = options.processorOptions.inputRate; this.step = this.rate / sampleRate;
7
+ this.paused = false; this.queued = 0; this.finished = false; this.started = false; this.starved = false;
8
+ this.port.onmessage = ({ data }) => {
9
+ if (data.type === 'audio') {
10
+ if (this.queued + data.samples.length > this.rate * 4) { this.port.postMessage({ type: 'error' }); return; }
11
+ this.queue.push(data.samples); this.queued += data.samples.length;
12
+ } else if (data.type === 'finish') this.finished = true;
13
+ else if (data.type === 'pause') this.paused = true;
14
+ else if (data.type === 'resume') this.paused = false;
15
+ };
16
+ }
17
+ process(inputs, outputs) {
18
+ const out = outputs[0][0];
19
+ out.fill(0);
20
+ if (this.paused) return true;
21
+ for (let i = 0; i < out.length; i++) {
22
+ while (this.queue.length && this.offset >= this.queue[0].length) {
23
+ const head = this.queue.shift(); this.offset -= head.length; this.queued -= head.length;
24
+ this.frames += head.length;
25
+ }
26
+ const head = this.queue[0];
27
+ if (!head) {
28
+ if (this.finished) { this.port.postMessage({ type: 'progress', frames: this.frames }); this.port.postMessage({ type: 'done' }); return false; }
29
+ if (this.started && !this.starved) this.port.postMessage({ type: 'underrun' });
30
+ this.starved = true; continue;
31
+ }
32
+ const at = Math.floor(this.offset), fraction = this.offset - at;
33
+ // Interpolate across transport blocks; never clamp an unfinished block's edge.
34
+ let next = head[at + 1];
35
+ if (next === undefined) next = this.queue[1]?.[0];
36
+ if (fraction > 0 && next === undefined && !this.finished) {
37
+ if (this.started && !this.starved) this.port.postMessage({ type: 'underrun' });
38
+ this.starved = true; break;
39
+ }
40
+ if (!this.started) { this.started = true; this.port.postMessage({ type: 'started' }); }
41
+ this.starved = false;
42
+ out[i] = head[at] * (1 - fraction) + (next ?? head[at]) * fraction;
43
+ this.offset += this.step;
44
+ }
45
+ const played = this.frames + Math.min(this.queue[0]?.length || 0, Math.floor(this.offset));
46
+ if (played - this.reported >= this.rate * 0.05) { this.reported = played; this.port.postMessage({ type: 'progress', frames: played }); }
47
+ return true;
48
+ }
49
+ }
50
+ registerProcessor('lares-pcm-player', LaresPcmPlayer);
51
+ `;
52
+
53
+ export const PCM_CAPTURE_WORKLET = `
54
+ class LaresPcmCapture extends AudioWorkletProcessor {
55
+ constructor() {
56
+ super(); this.pending = new Float32Array(1024); this.length = 0;
57
+ this.port.onmessage = ({ data }) => { if (data.type === 'flush') { this.flush(); this.port.postMessage({ type: 'flushed' }); } };
58
+ }
59
+ flush() {
60
+ if (!this.length) return;
61
+ const samples = this.pending.slice(0, this.length); this.length = 0;
62
+ this.port.postMessage({ type: 'samples', samples }, [samples.buffer]);
63
+ }
64
+ process(inputs) {
65
+ const input = inputs[0]?.[0];
66
+ if (input) for (const value of input) { this.pending[this.length++] = value; if (this.length === this.pending.length) this.flush(); }
67
+ return true;
68
+ }
69
+ }
70
+ registerProcessor('lares-pcm-capture', LaresPcmCapture);
71
+ `;
@@ -0,0 +1,89 @@
1
+ import { DICTATION_INSTRUCTIONS, DICTATION_PRESETS } from "./dictation.js";
2
+ import { validateVoiceShortcuts } from "./shortcuts.js";
3
+
4
+ /** Editing drafts are independent of confirmed recording configuration. Writes merge at execution time. */
5
+ export function createVoicePreferences({ load, save }) {
6
+ let state = { value: null, drafts: {}, pending: {}, errors: {}, loading: false, loadError: null };
7
+ let serial = Promise.resolve(), loading = null;
8
+ const listeners = new Set();
9
+ const emit = patch => { state = { ...state, ...patch }; for (const listener of listeners) listener(); };
10
+ const dirty = id => Boolean(state.drafts[id] && JSON.stringify(state.drafts[id]) !== JSON.stringify(state.value?.config.dictationProfiles[id]));
11
+ const write = (key, build, accept = () => {}) => {
12
+ if (state.pending[key]) return state.pending[key];
13
+ const run = serial.catch(() => {}).then(async () => {
14
+ try { const next = await save(build(state.value.config)); emit({ value: next }); accept(next.config); return next; }
15
+ catch (error) { emit({ errors: { ...state.errors, [key]: error.reason || error.code || error.message } }); throw error; }
16
+ finally { const pending = { ...state.pending }; delete pending[key]; emit({ pending }); }
17
+ });
18
+ serial = run; emit({ pending: { ...state.pending, [key]: run }, errors: { ...state.errors, [key]: null } });
19
+ return run;
20
+ };
21
+ return {
22
+ getSnapshot: () => state,
23
+ subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
24
+ dirty,
25
+ accept(value) { emit({ value }); },
26
+ dirtyIds: () => Object.keys(state.drafts).filter(dirty),
27
+ profile: id => state.drafts[id] || state.value?.config.dictationProfiles[id],
28
+ async load(force = false) {
29
+ if (loading) return loading;
30
+ emit({ loading: true, loadError: null });
31
+ loading = load({ force, diagnostics: false }).then(value => { emit({ value }); return value; }, error => { emit({ loadError: error.code || error.message }); throw error; }).finally(() => { loading = null; emit({ loading: false }); });
32
+ return loading;
33
+ },
34
+ patch: partial => write(Object.keys(partial)[0], () => partial),
35
+ edit(id, partial) { emit({ drafts: { ...state.drafts, [id]: { ...(state.drafts[id] || state.value.config.dictationProfiles[id]), ...partial } }, errors: { ...state.errors, [`profile:${id}`]: null } }); },
36
+ restore(id) { this.edit(id, { instructions: DICTATION_INSTRUCTIONS[id] }); },
37
+ discard(ids = this.dirtyIds()) { const drafts = { ...state.drafts }, errors = { ...state.errors }; for (const id of ids) { delete drafts[id]; delete errors[`profile:${id}`]; } emit({ drafts, errors }); },
38
+ saveProfile(id) {
39
+ const take = structuredClone(this.profile(id));
40
+ return write(`profile:${id}`, config => ({ dictationProfiles: { ...config.dictationProfiles, [id]: take } }), () => {
41
+ if (JSON.stringify(state.drafts[id]) === JSON.stringify(take)) this.discard([id]);
42
+ });
43
+ },
44
+ async saveDirty() { for (const id of this.dirtyIds()) await this.saveProfile(id); },
45
+ saveTtsVoice(model, voice) { return write("ttsVoiceByModel", config => ({ ttsVoiceByModel: { ...config.ttsVoiceByModel, [model]: voice } })); },
46
+ saveShortcut(id, key) { return write(`shortcut:${id}`, config => ({ dictationShortcuts: validateVoiceShortcuts({ ...config.dictationShortcuts, [id]: key }) })); },
47
+ };
48
+ }
49
+
50
+ /** Shared three-way unsaved-edit guard; platforms only render its question. */
51
+ export function createPreferencesGuard(preferences) {
52
+ let state = { open: false, saving: false, selecting: false, error: false }, pending = null, selection = null;
53
+ const listeners = new Set();
54
+ const emit = patch => { state = { ...state, ...patch }; for (const listener of listeners) listener(); };
55
+ const finish = value => { pending?.resolve(value); pending = null; emit({ open: false, saving: false }); };
56
+ return {
57
+ getSnapshot: () => state,
58
+ subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
59
+ request() {
60
+ if (pending) return pending.promise;
61
+ if (!preferences.dirtyIds().length) return Promise.resolve(true);
62
+ let resolve; const promise = new Promise(done => { resolve = done; }); pending = { promise, resolve };
63
+ emit({ open: true, error: false }); return promise;
64
+ },
65
+ selectPreset(id) {
66
+ if (!DICTATION_PRESETS.includes(id)) return Promise.reject(new Error("voice_config_invalid"));
67
+ if (selection) return selection;
68
+ if (id === preferences.getSnapshot().value.config.dictationPreset) return Promise.resolve(true);
69
+ emit({ selecting: true });
70
+ selection = (async () => {
71
+ try {
72
+ if (!await this.request()) return false;
73
+ await preferences.patch({ dictationPreset: id });
74
+ return true;
75
+ } finally { selection = null; emit({ selecting: false }); }
76
+ })();
77
+ return selection;
78
+ },
79
+ async answer(action) {
80
+ if (state.saving) return;
81
+ if (action === "continue") { finish(false); return; }
82
+ if (action === "discard") { preferences.discard(); finish(true); return; }
83
+ if (action !== "save") return;
84
+ emit({ saving: true, error: false });
85
+ try { await preferences.saveDirty(); finish(true); } catch { emit({ saving: false, error: true }); }
86
+ },
87
+ dispose() { finish(false); listeners.clear(); },
88
+ };
89
+ }
@@ -24,7 +24,19 @@ export function voiceInputPresentation(view, now = Date.now()) {
24
24
  export function voiceDictationPresentation(view, now = Date.now()) {
25
25
  const ui = voiceInputPresentation(view, now);
26
26
  const processingFailed = Boolean(view.raw && view.error && view.error !== "voice_draft_changed");
27
- return { ...ui, exclusive: Boolean(view.visible && view.mode === "single"), waveform: ui.phase === "recording" && !ui.error, processingFailed, recoverable: Boolean(view.raw && ui.error) };
27
+ return { ...ui, exclusive: Boolean(view.visible && view.mode === "single"), waveform: ui.phase === "recording" && !ui.error, processingFailed, recoverable: Boolean(view.raw && (ui.error || view.stage === "processing")) };
28
+ }
29
+
30
+ export function voiceIndicatorPresentation(view, settingsError = null) {
31
+ const error = view.error || view.snapshot?.error || view.readAloudError || settingsError;
32
+ const continuous = view.mode === "continuous";
33
+ const active = view.visible && (view.snapshot?.connection === "open" || view.busy && !view.snapshot);
34
+ return {
35
+ visible: continuous ? Boolean(active || error) : Boolean(view.visible || settingsError),
36
+ orb: continuous && active && !error,
37
+ ticking: Boolean(view.visible && !continuous && view.startedAt && view.snapshot?.capture === "recording"),
38
+ error,
39
+ };
28
40
  }
29
41
 
30
42
  /** Discovery establishes capability; a transient connection error does not revoke it. */
@@ -0,0 +1,99 @@
1
+ import { VoiceError } from "../router/stt.js";
2
+ import { reduceUtterance } from "./speech.js";
3
+ import { createSpeechSegmenter } from "./script.js";
4
+
5
+ const STATUS = { voice_reply_invalid: 400, voice_reply_forbidden: 403 };
6
+ const fault = code => new VoiceError(code, STATUS[code] ?? 409, code);
7
+ const identity = source => JSON.stringify([source.sessionId, source.turn, source.step]);
8
+
9
+ /** Only trusted Agent events may authorize live text; client input supplies an address, never text. */
10
+ export function createReplyStreamRegistry({ inspect, owner, now = Date.now, segmenterFactory = () => createSpeechSegmenter({ now }) } = {}) {
11
+ const rows = new Map();
12
+ const notify = row => { row.version = (row.version || 0) + 1; for (const wake of row.listeners) wake(); };
13
+ const fail = (row, code) => { row.segmenter.reset(); row.ready = []; row.error = code; row.done = true; row.at = now(); notify(row); };
14
+ const advance = row => {
15
+ if (!row.claimed || row.error || !row.eligible && !row.done) return;
16
+ try { row.ready.push(...row.segmenter.update(row.utterance?.text || "", { done: row.done })); }
17
+ catch (error) { fail(row, error.message === "voice_reply_rewritten" ? error.message : "voice_reply_invalid"); }
18
+ };
19
+ return {
20
+ frame(sessionId, frame) {
21
+ if (frame.type === "start") {
22
+ const key = identity({ sessionId, turn: frame.turn, step: frame.step });
23
+ const previous = rows.get(key);
24
+ if (previous) fail(previous, "voice_reply_replaced");
25
+ for (const [id, row] of rows) {
26
+ if (!row.done && now() - row.at > 180000) fail(row, "voice_reply_timeout");
27
+ if (row.done && now() - row.at > 30000) rows.delete(id);
28
+ }
29
+ if (rows.size >= 128) return;
30
+ rows.set(key, { sessionId, turn: frame.turn, step: frame.step, attemptId: frame.attemptId,
31
+ segmenter: segmenterFactory(), ready: [], eligible: frame.speechEligible === true, utterance: null, next: 0, done: false, error: null, at: now(), listeners: new Set() });
32
+ return;
33
+ }
34
+ for (const row of rows.values()) {
35
+ if (row.sessionId !== sessionId || row.attemptId !== frame.attemptId || row.done) continue;
36
+ if (frame.type === "chunk") {
37
+ if (frame.index < row.next) return;
38
+ if (frame.index !== row.next++) { fail(row, "voice_reply_sequence"); return; }
39
+ row.utterance = reduceUtterance(row.utterance, { type: "assistant/chunk", data: { turn: row.turn, step: row.step, attemptId: row.attemptId, chunkIndex: frame.index, chunk: frame.chunk } });
40
+ if (row.utterance?.silent) fail(row, "voice_reply_untrusted");
41
+ else if ((row.utterance?.text.length || 0) > 8192) fail(row, "voice_text_limit");
42
+ if (!row.error) advance(row);
43
+ notify(row);
44
+ } else if (frame.type === "end" && !row.done) fail(row, "voice_reply_unsettled");
45
+ }
46
+ },
47
+ event(sessionId, event) {
48
+ for (const row of rows.values()) {
49
+ if (row.sessionId !== sessionId || row.done) continue;
50
+ if (event.type === "turn/end" && event.data?.turn === row.turn) { fail(row, "voice_reply_unsettled"); continue; }
51
+ if (event.type !== "assistant/message" || event.data?.turn !== row.turn || event.data?.step !== row.step) continue;
52
+ row.utterance = reduceUtterance(row.utterance, event);
53
+ if (row.utterance.text.length > 8192) { fail(row, "voice_text_limit"); continue; }
54
+ if (row.utterance.silent) fail(row, "voice_reply_untrusted");
55
+ else { row.done = true; row.at = now(); advance(row); notify(row); }
56
+ }
57
+ },
58
+ async bind(scope, source) {
59
+ if (!source || typeof source.sessionId !== "string" || source.sessionId.length > 256 || !Number.isSafeInteger(source.turn) || !Number.isSafeInteger(source.step)) throw fault("voice_reply_invalid");
60
+ if (owner && scope.owner !== owner()) throw fault("voice_reply_forbidden");
61
+ await inspect(source.sessionId);
62
+ const row = rows.get(identity(source));
63
+ if (!row || source.attemptId && source.attemptId !== row.attemptId) throw fault("voice_reply_unavailable");
64
+ if (row.claimed) throw fault("voice_output_owned");
65
+ row.claimed = true; advance(row);
66
+ return async function* segments(signal) {
67
+ let index = 0;
68
+ try {
69
+ while (!signal.aborted) {
70
+ if (row.error) throw fault(row.error);
71
+ const version = row.version;
72
+ row.ready.push(...row.segmenter.flush());
73
+ while (index < row.ready.length) {
74
+ if (signal.aborted || row.error) throw fault(row.error || "voice_cancelled");
75
+ yield { text: row.ready[index], index: index++, attemptId: row.attemptId };
76
+ }
77
+ if (row.done && version === row.version) return;
78
+ if (version !== row.version) continue;
79
+ await new Promise((resolve, reject) => {
80
+ const deadline = row.segmenter.deadline;
81
+ const wait = deadline === null ? 30000 : Math.max(0, Math.min(30000, deadline - now()));
82
+ const cleanup = () => { clearTimeout(timer); row.listeners.delete(wake); signal.removeEventListener("abort", wake); };
83
+ const wake = () => { cleanup(); resolve(); };
84
+ const timer = setTimeout(() => { cleanup(); deadline === null ? reject(fault("voice_reply_timeout")) : resolve(); }, wait);
85
+ row.listeners.add(wake); signal.addEventListener("abort", wake, { once: true });
86
+ if (signal.aborted) wake();
87
+ });
88
+ }
89
+ throw fault("voice_cancelled");
90
+ } finally {
91
+ if (!row.done) fail(row, "voice_cancelled");
92
+ row.segmenter.reset();
93
+ }
94
+ };
95
+ },
96
+ remove(sessionId) { for (const [key, row] of rows) if (row.sessionId === sessionId) { fail(row, "voice_session_closed"); rows.delete(key); } },
97
+ close() { for (const row of rows.values()) fail(row, "voice_session_closed"); rows.clear(); },
98
+ };
99
+ }
@@ -1,5 +1,6 @@
1
+ import { ttsVoiceForModel } from "./tts-voice.js";
1
2
  import { transcribe } from "../router/stt.js";
2
- import { pcmToWav, synthesize } from "../router/tts.js";
3
+ import { pcmToWav, synthesizeStream } from "../router/tts.js";
3
4
  import { normalizeSttPcm } from "./pcm.js";
4
5
  import { createPcmResampler, pcmSamples } from "./realtime-audio.js";
5
6
 
@@ -16,11 +17,27 @@ export function wavData(wav) {
16
17
  export function createRouterAudio(config) {
17
18
  return {
18
19
  transcribe: (pcm, scope) => transcribe({ bytes: pcmToWav(pcm, 24000), filename: "voice.wav", contentType: "audio/wav", model: config().model || "default-stt", language: config().language || undefined, signal: scope.voiceAbort?.signal, once: true }),
19
- async synthesize(text, scope) {
20
- const settings = config();
21
- const wav = await synthesize({ text, model: settings.ttsModel || "default-tts", voice: settings.voice || undefined, signal: scope.outputAbort?.signal });
22
- const normalized = await normalizeSttPcm(wav, "audio/wav");
23
- return Buffer.from(createPcmResampler(16000)(pcmSamples(wavData(normalized))));
20
+ async synthesize(text, scope, onChunk) {
21
+ const settings = structuredClone(config());
22
+ const model = settings.ttsModel || "default-tts";
23
+ const chunks = [];
24
+ let convert = null;
25
+ const write = async (pcm) => { if (onChunk) await onChunk(pcm); else chunks.push(pcm); };
26
+ let pending = Buffer.alloc(0);
27
+ const result = await synthesizeStream({ text, model, voice: settings.voice || ttsVoiceForModel(settings, model), signal: scope.outputAbort?.signal,
28
+ onFormat: (format) => { convert = createPcmResampler(format.sampleRate, 24000); },
29
+ }, async (chunk) => {
30
+ const joined = Buffer.concat([pending, chunk]);
31
+ const end = joined.length - joined.length % 2;
32
+ pending = joined.subarray(end);
33
+ if (end) await write(Buffer.from(convert(pcmSamples(joined.subarray(0, end)))));
34
+ });
35
+ if (pending.length) throw new Error("voice_audio_format");
36
+ if (!result.streamed) {
37
+ const normalized = await normalizeSttPcm(result.audio, "audio/wav");
38
+ await write(Buffer.from(createPcmResampler(16000, 24000)(pcmSamples(wavData(normalized)))));
39
+ }
40
+ return onChunk ? null : Buffer.concat(chunks);
24
41
  },
25
42
  };
26
43
  }
package/voice/script.js CHANGED
@@ -1,4 +1,9 @@
1
1
  import { segment } from "sentencex";
2
+ import { createNaturalSegmenter } from "./speech-segmenter.js";
3
+
4
+ // Same-voice listening accepted natural segmentation; legacy remains a diagnostic baseline.
5
+ export const SPEECH_SEGMENTATION_POLICY = "natural";
6
+ import { spokenLines } from "./spoken-text.js";
2
7
 
3
8
  /**
4
9
  * Kokoro's pipeline splits on newlines and stitches the audio with no gap, and
@@ -8,40 +13,6 @@ import { segment } from "sentencex";
8
13
  */
9
14
  export const SENTENCE_GAP_MS = 300;
10
15
 
11
- /** Text still streaming may hold a code fence that has not closed yet. */
12
- function closedPart(text) {
13
- const fences = text.match(/```/g)?.length ?? 0;
14
- return fences % 2 === 0 ? text : text.slice(0, text.lastIndexOf("```"));
15
- }
16
-
17
- /** Table rules and horizontal rules carry nothing to read. */
18
- const RULE_LINE = /^\s*(?:\|?\s*:?-{3,}:?\s*)+\|?\s*$|^\s*([-*_])(?:\s*\1){2,}\s*$/;
19
- const BLOCK_MARK = /^\s*(?:>\s*)*(?:#{1,6}\s+|[-*+]\s+(?:\[[ xX]]\s+)?|\d+[.)]\s+)?/;
20
-
21
- function plainLine(line) {
22
- if (RULE_LINE.test(line)) return "";
23
- return line
24
- .replace(BLOCK_MARK, "")
25
- .replace(/`([^`]+)`/g, "$1")
26
- .replace(/!\[[^\]]*]\([^)]*\)/g, " ")
27
- .replace(/\[([^\]]+)]\([^)]*\)/g, "$1")
28
- .replace(/[|#>*_]+/g, " ")
29
- .replace(/\s+/g, " ")
30
- .trim();
31
- }
32
-
33
- /**
34
- * Model answers are Markdown, and a heading, list item or table row often ends
35
- * without punctuation, so every line is a sentence boundary of its own.
36
- */
37
- function plainLines(text) {
38
- return String(text ?? "")
39
- .replace(/```[\s\S]*?```/g, " ")
40
- .slice(0, 2000)
41
- .split("\n")
42
- .map(plainLine);
43
- }
44
-
45
16
  function sentencesOf(line) {
46
17
  return line ? segment("en", line).map((part) => part.trim()).filter(Boolean) : [];
47
18
  }
@@ -92,8 +63,9 @@ export function gapAfter(segment) {
92
63
  * mid-word, so only the sentences before its last one count.
93
64
  * @returns {string[]}
94
65
  */
95
- export function speechSegments(text, { done = true } = {}) {
96
- const lines = plainLines(done ? text : closedPart(String(text ?? "")));
66
+ export function speechSegments(text, { done = true, policy = SPEECH_SEGMENTATION_POLICY } = {}) {
67
+ if (policy === "natural") return createNaturalSegmenter().update(text, { done });
68
+ const lines = spokenLines(text, { done });
97
69
  const open = done ? "" : lines.pop();
98
70
  const sentences = lines.flatMap(sentencesOf);
99
71
  sentences.push(...sentencesOf(open).slice(0, -1));
@@ -103,3 +75,22 @@ export function speechSegments(text, { done = true } = {}) {
103
75
  export function speechScript(text) {
104
76
  return speechSegments(text).join("\n");
105
77
  }
78
+
79
+
80
+ export function createSpeechSegmenter({ policy = SPEECH_SEGMENTATION_POLICY, now = Date.now } = {}) {
81
+ if (policy === "natural") return createNaturalSegmenter({ now });
82
+ let source = "", emitted = [];
83
+ return {
84
+ update(text, options) {
85
+ source = text;
86
+ const ready = speechSegments(text, { ...options, policy: "legacy" });
87
+ if (emitted.some((part, index) => part !== ready[index])) throw new Error("voice_reply_rewritten");
88
+ const result = ready.slice(emitted.length); emitted = ready; return result;
89
+ },
90
+ push(chunk, options = { done: false }) { return this.update(source + chunk, options); },
91
+ finish() { return this.update(source, { done: true }); },
92
+ flush() { return []; },
93
+ get deadline() { return null; },
94
+ reset() { source = ""; emitted = []; },
95
+ };
96
+ }
package/voice/service.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { ttsVoiceForModel } from "./tts-voice.js";
1
2
  import { writeReport } from "../log/report.js";
2
3
  import { createPcmResampler, pcmSamples } from "./realtime-audio.js";
3
4
  import { realtimeModelIds, validatedRealtimeModels } from "./realtime.js";
@@ -23,11 +24,10 @@ import { createCaptureSession } from "./capture.js";
23
24
  import { createDuplexCoordinator } from "./duplex.js";
24
25
  import { createDeviceLease } from "./lease.js";
25
26
  import { normalizeSttPcm } from "./pcm.js";
26
- import { gapAfter, speechSegments } from "./script.js";
27
+ import { gapAfter, createSpeechSegmenter } from "./script.js";
27
28
  import {
28
29
  listTtsVoices,
29
30
  pcmToWav,
30
- pickTtsVoice,
31
31
  silencePcm,
32
32
  synthesize,
33
33
  synthesizeStream,
@@ -118,18 +118,12 @@ export async function voiceStatusPayload(options) {
118
118
  };
119
119
  }
120
120
 
121
- export async function voiceModelsPayload(options) {
121
+ export async function voiceCapabilitiesPayload(options) {
122
122
  const models = await listModels(options);
123
123
  const config = readConfig();
124
124
  const ids = models.map((model) => model.id);
125
125
  const stt = sttModelIds(models);
126
126
  const tts = ttsModelIds(models);
127
- let charts = null;
128
- try {
129
- charts = await voiceChartsStatus();
130
- } catch {
131
- charts = null;
132
- }
133
127
  return {
134
128
  models: ids,
135
129
  stt,
@@ -139,10 +133,17 @@ export async function voiceModelsPayload(options) {
139
133
  realtimeReady: validatedRealtimeModels(models),
140
134
  selected: pickSttModelId(models, config.model),
141
135
  ttsSelected: pickTtsModelId(models, config.ttsModel),
142
- charts,
136
+ ttsDefault: pickTtsModelId(models, ""),
143
137
  };
144
138
  }
145
139
 
140
+ export async function voiceModelsPayload(options) {
141
+ const capabilities = await voiceCapabilitiesPayload(options);
142
+ let charts = null;
143
+ try { charts = await voiceChartsStatus(); } catch { /* Diagnostics do not revoke capabilities. */ }
144
+ return { ...capabilities, charts };
145
+ }
146
+
146
147
  export async function saveVoiceConfig(body) {
147
148
  const patch = validateConfigPatch(body);
148
149
  if (patch.model || patch.ttsModel || patch.realtimeModel || patch.backend === "realtime") {
@@ -156,6 +157,17 @@ export async function saveVoiceConfig(body) {
156
157
  throw new VoiceError("voice_model_unavailable", 400, "selected speech model is not available");
157
158
  }
158
159
  }
160
+ if (patch.ttsVoiceByModel) {
161
+ const current = readConfig().ttsVoiceByModel;
162
+ const changed = Object.entries(patch.ttsVoiceByModel).filter(([model, voice]) => voice && current[model] !== voice);
163
+ if (changed.length) {
164
+ const models = ttsModelIds(await listModels());
165
+ for (const [model, voice] of changed) {
166
+ if (!models.includes(model)) throw new VoiceError("voice_model_unavailable", 400, "selected speech model is not available");
167
+ if (!(await listTtsVoices(model)).includes(voice)) throw new VoiceError("voice_config_invalid", 400, "selected voice is not available");
168
+ }
169
+ }
170
+ }
159
171
  return writeConfig(patch);
160
172
  }
161
173
 
@@ -222,21 +234,6 @@ function cancelledSpeech() {
222
234
 
223
235
  const readouts = new WeakMap();
224
236
 
225
- /** model id → pinned voice. A failed lookup is not cached, so the next readout asks again. */
226
- const pinnedVoices = new Map();
227
-
228
- async function pinnedVoice(model, listVoices = listTtsVoices) {
229
- if (pinnedVoices.has(model)) return pinnedVoices.get(model);
230
- let voice = null;
231
- try {
232
- voice = pickTtsVoice(await listVoices(model));
233
- } catch {
234
- return null;
235
- }
236
- if (voice) pinnedVoices.set(model, voice);
237
- return voice;
238
- }
239
-
240
237
  function deferred() {
241
238
  let resolve;
242
239
  let reject;
@@ -286,21 +283,36 @@ export async function speakFromConfig(text, options = {}) {
286
283
  if (!config.speak) throw new VoiceError("voice_speak_disabled", 409, "speech playback is off");
287
284
  const key = typeof options.key === "string" && options.key ? options.key : null;
288
285
  const done = key ? options.done === true : true;
289
- const segments = speechSegments(text, { done });
290
286
  const part = partIndex(options.part);
291
287
  const coordinator = options.coordinator ?? options.scope?.duplex ?? duplex;
292
288
  const id = key ?? text;
293
289
  let readout = joinable(readouts.get(coordinator), id, key, part);
290
+ const segmenter = readout?.segmenter ?? (options.segmenterFactory?.() ?? createSpeechSegmenter());
291
+ let segments = segmenter.update(text, { done });
294
292
  if (!readout) {
295
- if (!segments.length) {
296
- if (key && part === null) return { sentences: 0 };
293
+ if (!segments.length && segmenter.deadline === null) {
294
+ if (part === null) return { sentences: 0 };
297
295
  throw new VoiceError("voice_config_invalid", 400, "nothing to speak");
298
296
  }
299
- const model = options.model ?? await pickTtsModelId(await listModels(), config.ttsModel);
297
+ const catalog = options.model == null ? await listModels() : null;
298
+ if (catalog && config.ttsModel && !ttsModelIds(catalog).includes(config.ttsModel)) throw new VoiceError("voice_tts_unavailable", 503, "selected speech model is not available");
299
+ const model = options.model ?? pickTtsModelId(catalog, config.ttsModel);
300
300
  if (!model) throw new VoiceError("voice_tts_unavailable", 503, "no speech model available");
301
- readout = joinable(readouts.get(coordinator), id, key, part) ?? startReadout(coordinator, id, model, options);
301
+ readout = joinable(readouts.get(coordinator), id, key, part) ?? startReadout(coordinator, id, model, { ...options, segmenter, config: structuredClone(config) });
302
+ }
303
+ if (readout.segmenter !== segmenter) segments = readout.segmenter.update(text, { done });
304
+ readout.extend([...readout.segments, ...segments], done);
305
+ const deadline = readout.segmenter.deadline;
306
+ if (deadline !== null) {
307
+ await new Promise((resolve, reject) => {
308
+ const cleanup = () => { clearTimeout(timer); readout.signal.removeEventListener("abort", cancel); };
309
+ const cancel = () => { cleanup(); reject(cancelledSpeech()); };
310
+ const timer = setTimeout(() => { cleanup(); resolve(); }, Math.max(0, deadline - Date.now()));
311
+ readout.signal.addEventListener("abort", cancel, { once: true });
312
+ if (readout.signal.aborted) cancel();
313
+ });
314
+ readout.extend([...readout.segments, ...readout.segmenter.flush()], done);
302
315
  }
303
- readout.extend(segments, done);
304
316
  if (part === null) return key ? { sentences: readout.segments.length } : readout.result;
305
317
  if (part >= readout.segments.length) {
306
318
  throw new VoiceError("voice_config_invalid", 400, `answer has ${readout.segments.length} sentence(s)`);
@@ -319,6 +331,7 @@ function startReadout(coordinator, id, model, options) {
319
331
  firstAudio: false,
320
332
  active: lease.active,
321
333
  signal: lease.signal,
334
+ segmenter: options.segmenter,
322
335
  segments: [],
323
336
  parts: [],
324
337
  final: false,
@@ -348,9 +361,9 @@ function startReadout(coordinator, id, model, options) {
348
361
  });
349
362
  },
350
363
  };
351
- readout.result = Promise.resolve(options.config?.voice || pinnedVoice(model, options.listVoices))
364
+ readout.result = Promise.resolve(options.voice ?? ttsVoiceForModel(options.config, model))
352
365
  .then((voice) => readAloud(readout, model, voice, lease, options));
353
- readout.result.catch(() => {});
366
+ readout.result.finally(() => readout.segmenter.reset()).catch(() => {});
354
367
  readouts.set(coordinator, readout);
355
368
  return readout;
356
369
  }