@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.
- package/i18n/chat-model-switch.js +8 -2
- package/i18n/voice.js +78 -2
- package/i18n/webhook.js +39 -0
- package/larepass/mux.js +1 -0
- package/larepass/rpc.js +4 -3
- package/larepass/runtime.js +4 -1
- package/larepass/settings.js +11 -8
- package/package.json +29 -2
- package/router/catalog-cache.js +1 -1
- package/router/catalog-coordinator.js +60 -0
- package/router/catalog-sync.js +17 -62
- package/router/catalog.d.ts +3 -1
- package/router/catalog.js +6 -13
- package/router/default-selection.d.ts +3 -0
- package/router/default-selection.js +22 -0
- package/router/model-authorization.js +53 -0
- package/router/model-state.js +35 -0
- package/router/models.js +2 -6
- package/router/session-model.js +5 -18
- package/router/stt.js +2 -1
- package/router/tts.js +13 -20
- package/voice/activity.js +10 -0
- package/voice/agent-session.js +4 -0
- package/voice/browser-capture.js +72 -6
- package/voice/browser-playback.js +16 -20
- package/voice/client.js +28 -14
- package/voice/composite-gateway.js +7 -3
- package/voice/config.js +6 -0
- package/voice/controller.js +19 -8
- package/voice/conversation.js +48 -15
- package/voice/device-input.js +14 -9
- package/voice/dialogue.js +4 -3
- package/voice/host-service.js +7 -2
- package/voice/interruption.js +44 -0
- package/voice/lares-policy.js +12 -0
- package/voice/options.js +2 -1
- package/voice/panel.js +61 -20
- package/voice/pcm-player.js +51 -0
- package/voice/pcm-worklet.js +71 -0
- package/voice/preferences.js +89 -0
- package/voice/presentation.js +13 -1
- package/voice/reply-stream.js +99 -0
- package/voice/router-audio.js +23 -6
- package/voice/script.js +27 -36
- package/voice/service.js +46 -33
- package/voice/shortcuts.js +54 -5
- package/voice/speech-segmenter.js +119 -0
- package/voice/speech.js +60 -13
- package/voice/spoken-text.js +196 -0
- package/voice/startup.js +9 -0
- package/voice/stream-client.js +165 -0
- package/voice/stream-gateway.js +146 -0
- package/voice/tts-preview.js +27 -0
- package/voice/tts-settings.js +79 -0
- package/voice/tts-voice.js +20 -0
- package/webhook/client.js +34 -0
- package/webhook/config.js +55 -0
- package/webhook/public.js +11 -0
- 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
|
+
}
|
package/voice/presentation.js
CHANGED
|
@@ -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
|
+
}
|
package/voice/router-audio.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { ttsVoiceForModel } from "./tts-voice.js";
|
|
1
2
|
import { transcribe } from "../router/stt.js";
|
|
2
|
-
import { pcmToWav,
|
|
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
|
|
22
|
-
const
|
|
23
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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.
|
|
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
|
}
|