@olares/lares-core 0.15.17 → 0.15.18

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 (60) hide show
  1. package/brand/identity.js +25 -2
  2. package/i18n/chat-model-switch.js +8 -2
  3. package/i18n/voice.js +78 -2
  4. package/i18n/webhook.js +39 -0
  5. package/larepass/mux.js +1 -0
  6. package/larepass/rpc.js +4 -3
  7. package/larepass/runtime.js +4 -1
  8. package/larepass/settings.js +11 -8
  9. package/package.json +29 -2
  10. package/router/catalog-cache.js +1 -1
  11. package/router/catalog-coordinator.js +60 -0
  12. package/router/catalog-sync.js +17 -62
  13. package/router/catalog.d.ts +3 -1
  14. package/router/catalog.js +6 -13
  15. package/router/default-selection.d.ts +3 -0
  16. package/router/default-selection.js +22 -0
  17. package/router/model-authorization.js +53 -0
  18. package/router/model-state.js +35 -0
  19. package/router/models.js +2 -6
  20. package/router/session-model.js +5 -18
  21. package/router/stt.js +2 -1
  22. package/router/tts.js +13 -20
  23. package/voice/activity.js +10 -0
  24. package/voice/agent-session.js +4 -0
  25. package/voice/browser-capture.js +72 -6
  26. package/voice/browser-playback.js +16 -20
  27. package/voice/client.js +28 -14
  28. package/voice/composite-gateway.js +7 -3
  29. package/voice/config.js +6 -0
  30. package/voice/controller.js +19 -8
  31. package/voice/conversation.js +48 -15
  32. package/voice/device-input.js +14 -9
  33. package/voice/dialogue.js +4 -3
  34. package/voice/host-service.js +7 -2
  35. package/voice/interruption.js +44 -0
  36. package/voice/lares-policy.js +12 -0
  37. package/voice/options.js +2 -1
  38. package/voice/panel.js +61 -20
  39. package/voice/pcm-player.js +51 -0
  40. package/voice/pcm-worklet.js +71 -0
  41. package/voice/preferences.js +89 -0
  42. package/voice/presentation.js +13 -1
  43. package/voice/reply-stream.js +97 -0
  44. package/voice/router-audio.js +23 -6
  45. package/voice/script.js +27 -36
  46. package/voice/service.js +46 -33
  47. package/voice/shortcuts.js +54 -5
  48. package/voice/speech-segmenter.js +119 -0
  49. package/voice/speech.js +60 -13
  50. package/voice/spoken-text.js +196 -0
  51. package/voice/startup.js +9 -0
  52. package/voice/stream-client.js +165 -0
  53. package/voice/stream-gateway.js +146 -0
  54. package/voice/tts-preview.js +27 -0
  55. package/voice/tts-settings.js +79 -0
  56. package/voice/tts-voice.js +20 -0
  57. package/webhook/client.js +34 -0
  58. package/webhook/config.js +55 -0
  59. package/webhook/public.js +11 -0
  60. package/webhook/reply.js +12 -0
package/voice/config.js CHANGED
@@ -23,6 +23,12 @@ export function validateConfigPatch(raw) {
23
23
  const patch = {};
24
24
  for (const [key, value] of Object.entries(raw)) {
25
25
  if (!WRITABLE.has(key)) invalid(`unknown voice setting: ${key}`);
26
+ if (key === "ttsVoiceByModel") {
27
+ if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).length > 256) invalid("invalid TTS voice map");
28
+ const entries = Object.entries(value);
29
+ for (const [model, voice] of entries) if (!model || !SAFE_MODEL.test(model) || ["__proto__", "constructor", "prototype"].includes(model) || typeof voice !== "string" || !SAFE_MODEL.test(voice)) invalid("invalid TTS voice map");
30
+ patch[key] = Object.fromEntries(entries); continue;
31
+ }
26
32
  if (key === "dictationPreset") {
27
33
  if (!DICTATION_PRESETS.includes(value)) invalid("invalid dictation preset");
28
34
  patch[key] = value; continue;
@@ -1,3 +1,4 @@
1
+ import { createInterruptionGuard } from "./interruption.js";
1
2
  import { mintRpcId } from "../larepass/rpc.js";
2
3
 
3
4
  /** Device, agent and playback ports are injected; UI frameworks own no turn state. */
@@ -44,28 +45,36 @@ export function createVoiceController({ input, agent, output, voiceId = mintRpcI
44
45
  old?.abort.abort();
45
46
  try { if (old) await input.cancel?.(); } finally { change({ capture: "idle", userSpeaking: false, inputLevel: null }); }
46
47
  };
48
+ const interruption = createInterruptionGuard({ output, now, schedule, unschedule,
49
+ onState: playback => change({ playback }), onError: error => change({ error }) });
50
+ const interruptible = () => state.continuous && !manualTurns && input.capabilities?.bargeIn === true && input.capabilities?.echoTested === true && output.canPause === true;
47
51
  const stopPlayback = async () => {
52
+ interruption.cancel();
48
53
  const started = now();
49
- await output.stop();
54
+ const pending = output.stop();
50
55
  change({ playback: "idle" });
56
+ await pending;
51
57
  report("interrupt", started);
52
58
  };
53
59
  const listen = async () => {
54
60
  if (state.connection !== "open" || state.inputPaused || take || state.capture !== "idle") return;
55
61
  if (!manualTurns && state.continuous && (
56
62
  (state.agent !== "idle" && !input.capabilities?.listenWhileWorking)
57
- || (state.playback !== "idle" && !input.capabilities?.bargeIn)
63
+ || (state.playback !== "idle" && !interruptible())
58
64
  )) return;
59
65
  const run = { epoch, abort: new AbortController(), id: mintRpcId(), started: now() };
60
66
  take = run;
61
67
  change({ capture: "recording", inputReady: false, error: null });
62
68
  try {
63
69
  const text = String(await input.capture({ signal: run.abort.signal, continuous: state.continuous && !manualTurns, onReady: () => { if (live(run)) change({ inputReady: true }); }, onLevel: (level) => { if (live(run)) change({ inputLevel: Math.max(0, Math.min(1, level)) }); }, onSpeech: () => {
64
- if (live(run)) { touch(); change({ userSpeaking: true }); if (input.capabilities?.bargeIn) void stopPlayback(); }
65
- }, onTranscribing: () => { if (live(run)) change({ capture: "transcribing", userSpeaking: false }); } }) ?? "").trim();
70
+ if (live(run)) { touch(); change({ userSpeaking: true }); if (interruptible() && state.playback !== "idle") interruption.suspect(); }
71
+ }, onTranscribing: () => { if (live(run)) { interruption.speechEnded(); change({ capture: "transcribing", userSpeaking: false }); } } }) ?? "").trim();
66
72
  if (!live(run)) return;
67
73
  report("recognition", run.started, { turnId: run.id });
68
- if (!text) return;
74
+ const confirming = interruption.pending;
75
+ interruption.final(text);
76
+ if (text && !confirming && interruptible() && state.playback !== "idle") await stopPlayback();
77
+ if (!text) { run.waitingInterruption = confirming; return; }
69
78
  touch();
70
79
  if (onTranscript) {
71
80
  await onTranscript({ text, turnId: run.id, voiceId: state.voiceId, sessionId: state.sessionId, signal: run.abort.signal });
@@ -78,13 +87,14 @@ export function createVoiceController({ input, agent, output, voiceId = mintRpcI
78
87
  // Acceptance is not completion; observeAgent receives the durable turn lifecycle.
79
88
  } catch (error) {
80
89
  if (live(run)) {
90
+ interruption.fail(error.code || error.message || "voice_failed");
81
91
  await controller.disconnect(error.code || error.message || "voice_failed");
82
92
  }
83
93
  } finally {
84
94
  if (take === run) {
85
95
  take = null;
86
96
  change({ capture: "idle", userSpeaking: false, inputLevel: null });
87
- if (!manualTurns && state.continuous && state.connection === "open" && (state.agent === "idle" || input.capabilities?.listenWhileWorking) && state.playback === "idle") {
97
+ if (!manualTurns && state.continuous && state.connection === "open" && (state.agent === "idle" || input.capabilities?.listenWhileWorking) && (state.playback === "idle" || interruptible())) {
88
98
  queueMicrotask(() => { void listen(); });
89
99
  }
90
100
  }
@@ -119,14 +129,15 @@ export function createVoiceController({ input, agent, output, voiceId = mintRpcI
119
129
  if (state.sessionId) await agent.cancel(state.sessionId);
120
130
  },
121
131
  observeAgent(running, playbackExpected = false) {
122
- if (playbackExpected && take && !input.capabilities?.bargeIn) void cancelInput();
132
+ if (playbackExpected && take && !interruptible()) void cancelInput();
123
133
  change({ agent: running ? "running" : "idle", ...(playbackExpected ? { playback: "pending" } : {}) });
124
134
  if (!manualTurns && !running && state.continuous) void listen();
125
135
  },
126
136
  observePlayback(playing) {
137
+ if (!playing) interruption.outputEnded();
127
138
  if (playing !== (state.playback === "playing")) touch();
128
139
  change({ playback: playing ? "playing" : "idle" });
129
- if (!manualTurns && !playing && state.continuous) void listen();
140
+ if (!manualTurns && state.continuous && (!playing || interruptible())) void listen();
130
141
  },
131
142
  async pause() {
132
143
  clearTimers(); epoch += 1;
@@ -3,21 +3,25 @@ import { createDeviceInput } from "./device-input.js";
3
3
  import { createHostInput } from "./host-input.js";
4
4
  import { mintRpcId } from "../larepass/rpc.js";
5
5
 
6
- export function createVoiceConversation({ request, transcribe, playback, onReply = () => {}, onPhase, mode = () => "queue", device = "device", metric = () => {}, readAloud = true, preset, presentationSession = false, endpointSilenceMs, idleTimeoutMs, recoveryTimeoutMs, onText = () => {}, onTranscript = null, input: inputPort, captureConfig }) {
6
+ export function createVoiceConversation({ request, transcribe, playback, onReply = () => {}, onPhase, mode = () => "queue", device = "device", metric = () => {}, readAloud = true, preset, backend, presentationSession = false, endpointSilenceMs, idleTimeoutMs, recoveryTimeoutMs, onText = () => {}, onTranscript = null, input: inputPort, captureConfig, streamInput, preparation }) {
7
7
  const voiceId = mintRpcId();
8
8
  const headers = { "x-lares-voice-id": voiceId, "x-lares-voice-device": device };
9
9
  let timer = null;
10
+ let outputAbort = null;
11
+ let outputRevision = 0, outputSerial = Promise.resolve(), confirmedReadAloud = readAloud;
10
12
  let disposed = false;
11
13
  let version = -1;
12
14
  let lastSessionId = null;
13
15
  let spokenKey = null;
14
16
  let lastReplyKey = null;
15
17
  let mutedKey = null;
18
+ const mutedTurns = new Set();
19
+ const mute = (reply, sessionId) => { if (Number.isFinite(reply?.turn)) mutedTurns.add(`${sessionId}:${reply.turn}`); };
16
20
  let submitted = false;
17
21
  let finalSeen = false;
18
22
  let continuous = false;
19
23
  let manualCapture = false;
20
- let reading = false;
24
+ let reading = false, lastUtterance = null;
21
25
  const call = async (path, body, signal) => {
22
26
  const res = await request(`/api/lares/voice${path}`, { method: body === undefined ? "GET" : "POST", headers: { ...headers, "x-lares-voice-speaking": controller.getSnapshot().userSpeaking ? "true" : "false" }, ...(body === undefined ? {} : { body }), signal });
23
27
  if (!res?.ok) throw new Error(res?.body?.error?.code || "voice_failed");
@@ -26,11 +30,11 @@ export function createVoiceConversation({ request, transcribe, playback, onReply
26
30
  const input = inputPort ?? (device === "headset" ? createHostInput({
27
31
  start: (signal) => call("/capture", { config: captureConfig, manual: manualCapture || Boolean(onTranscript) }, signal), status: (signal) => call("/capture", undefined, signal),
28
32
  stop: () => call("/capture/stop", {}), cancel: () => call("/capture/cancel", {}),
29
- }) : createDeviceInput({ transcribe, onPhase, endpointSilenceMs }));
33
+ }) : createDeviceInput({ transcribe, streamInput, preparation, onPhase, endpointSilenceMs }));
30
34
  const controller = createVoiceController({ voiceId, input, idleTimeoutMs, recoveryTimeoutMs, onExpired: () => stop(), onTranscript: onTranscript ? async (value) => { await onTranscript(value); if (!continuous) await stop(); } : null, metric: (event) => {
31
35
  metric(event);
32
36
  void call("/session/metric", { stage: event.stage, durationMs: event.durationMs }).catch(() => {});
33
- }, output: { async stop() { playback.stop(); await call("/playback/stop", {}); } }, agent: {
37
+ }, output: { get canPause() { return playback.canPause === true; }, key: () => playback.key, pause: () => playback.pause?.(), resume: () => playback.resume?.(), async stop() { mute(lastUtterance, lastSessionId); playback.stop(); await call("/playback/stop", {}); } }, agent: {
34
38
  lazy: true,
35
39
  create: async () => null,
36
40
  async submit({ text, requestId, signal }) {
@@ -53,15 +57,17 @@ export function createVoiceConversation({ request, transcribe, playback, onReply
53
57
  version = state.version;
54
58
  // Reserve playback before releasing the completed Agent turn.
55
59
  const reply = state.utterance;
60
+ lastUtterance = reply;
56
61
  if (reply?.text && !reply.silent) onText(reply.text);
57
62
  const key = reply ? `${state.sessionId}:${reply.key}` : null;
58
63
  lastReplyKey = key;
59
- if (["recording", "transcribing"].includes(controller.getSnapshot().capture)) mutedKey = key;
60
- const reserve = readAloud && key !== mutedKey && reply?.text && !reply.silent && key !== spokenKey;
64
+ if (!input.capabilities?.echoTested && ["recording", "transcribing"].includes(controller.getSnapshot().capture)) { mutedKey = key; mute(reply, state.sessionId); }
65
+ const turnMuted = reply && mutedTurns.has(`${state.sessionId}:${reply.turn}`);
66
+ const reserve = !turnMuted && readAloud && key !== mutedKey && reply?.text && !reply.silent && key !== spokenKey;
61
67
  if (reserve) spokenKey = key;
62
68
  controller.observeAgent(Boolean(state.running), Boolean(reserve));
63
69
  if (!readAloud || reply?.silent) controller.observePlayback(false);
64
- if (readAloud && key !== mutedKey && reply) onReply({ ...reply, key: `${voiceId}:${state.sessionId}:${reply.key}` }, headers);
70
+ if (!turnMuted && readAloud && key !== mutedKey && reply) onReply({ ...reply, sessionId: state.sessionId, ...(device === "device" ? { source: { sessionId: state.sessionId, turn: reply.turn, step: reply.step, attemptId: reply.attemptId } } : {}), key: `${voiceId}:${state.sessionId}:${reply.key}` }, headers);
65
71
  }
66
72
  if (device === "headset" && !state.running && !reading) {
67
73
  const audio = await call("/playback/status");
@@ -81,7 +87,7 @@ export function createVoiceConversation({ request, transcribe, playback, onReply
81
87
  if (!disposed) timer = setTimeout(watch, 250);
82
88
  };
83
89
  const stop = async () => {
84
- disposed = true; clearTimeout(timer);
90
+ disposed = true; outputRevision++; outputAbort?.abort(); clearTimeout(timer);
85
91
  await controller.disconnect();
86
92
  if (!onTranscript) await call("/session/close", {});
87
93
  };
@@ -98,7 +104,7 @@ export function createVoiceConversation({ request, transcribe, playback, onReply
98
104
  continuous = nextContinuous; submitted = false; finalSeen = false; spokenKey = null;
99
105
  disposed = false; version = -1;
100
106
  clearTimeout(timer);
101
- const initial = onTranscript ? {} : await call("/session", { sessionId, readAloud, presentationSession, ...(preset ? { preset } : {}) });
107
+ const initial = onTranscript ? {} : await call("/session", { sessionId, readAloud, presentationSession, ...(preset ? { preset } : {}), ...(backend ? { backend } : {}) });
102
108
  if (!shouldStart()) { await stop(); return; }
103
109
  await controller.start({ sessionId, continuous, manual, running: initial.running, shouldStart });
104
110
  controller.setOutputMuted(!readAloud);
@@ -110,19 +116,46 @@ export function createVoiceConversation({ request, transcribe, playback, onReply
110
116
  if (onTranscript) { disposed = false; await controller.resume(options); return; }
111
117
  const current = await call("/session");
112
118
  version = current.version;
119
+ mute(current.utterance, current.sessionId);
113
120
  mutedKey = current.utterance ? `${current.sessionId}:${current.utterance.key}` : null;
114
121
  spokenKey = mutedKey;
115
- if (!current.active) await call("/session", { sessionId: lastSessionId, readAloud });
122
+ if (!current.active) await call("/session", { sessionId: lastSessionId, readAloud, presentationSession, ...(preset ? { preset } : {}), ...(backend ? { backend } : {}) });
116
123
  else await call("/session/resume", {});
117
124
  disposed = false;
118
125
  await controller.resume(options);
119
126
  void watch();
120
127
  },
121
- async setReadAloud(value) {
122
- readAloud = value === true; mutedKey = lastReplyKey;
123
- controller.setOutputMuted(!readAloud);
124
- if (!readAloud) await controller.stopPlayback();
125
- await call("/session/output", { readAloud });
128
+ setReadAloud(value) {
129
+ const next = value === true;
130
+ if (readAloud === next && confirmedReadAloud === next) return Promise.resolve();
131
+ const changed = readAloud !== next;
132
+ readAloud = next;
133
+ const revision = ++outputRevision;
134
+ confirmedReadAloud = undefined;
135
+ if (changed) { mutedKey = lastReplyKey; mute(lastUtterance, lastSessionId); }
136
+ controller.setOutputMuted(!next);
137
+ // Stop local audio immediately, while ordering remote changes per VoiceSession.
138
+ const stopped = Promise.resolve(!next && changed ? controller.stopPlayback() : null).then(() => null, error => error);
139
+ const result = outputSerial.catch(() => {}).then(async () => {
140
+ const error = await stopped;
141
+ if (disposed || revision !== outputRevision) return;
142
+ if (error) throw error;
143
+ const abort = outputAbort = new AbortController();
144
+ try {
145
+ await call("/session/output", { readAloud: next }, AbortSignal.any([abort.signal, AbortSignal.timeout(10_000)]));
146
+ if (!disposed && revision === outputRevision) confirmedReadAloud = next;
147
+ } catch (error) {
148
+ if (!disposed && revision === outputRevision) {
149
+ readAloud = false;
150
+ controller.setOutputMuted(true);
151
+ playback.stop();
152
+ controller.observePlayback(false);
153
+ }
154
+ throw error;
155
+ } finally { if (outputAbort === abort) outputAbort = null; }
156
+ });
157
+ outputSerial = result;
158
+ return result;
126
159
  },
127
160
  records: async () => {
128
161
  const res = await request("/api/lares/voice/records", { method: "GET", headers });
@@ -10,13 +10,14 @@ export function createEnergyEndpoint(stream, end, onSpeech = () => {}, onLevel =
10
10
  const gain = context.createGain();
11
11
  gain.gain.value = 0;
12
12
  const endpoint = createSpeechEndpoint(options);
13
- let heard = false;
13
+ let heard = false, consecutiveMs = 0;
14
14
  const silenceTimer = setTimeout(() => { if (!heard) end(false); }, 10_000);
15
15
  processor.onaudioprocess = (event) => {
16
16
  const samples = event.inputBuffer.getChannelData(0);
17
17
  const rms = frameRms(samples);
18
18
  onLevel(rms);
19
- if (rms >= 0.004 && !heard) { heard = true; onSpeech(); }
19
+ consecutiveMs = rms >= 0.004 ? consecutiveMs + samples.length * 1000 / context.sampleRate : 0;
20
+ if (consecutiveMs >= (options.interruptionMs || 0) && rms >= 0.004 && !heard) { heard = true; onSpeech(); }
20
21
  if (endpoint.push(1, rms, samples.length * 1000 / context.sampleRate)) end(true);
21
22
  };
22
23
  source.connect(processor); processor.connect(gain); gain.connect(context.destination);
@@ -37,7 +38,7 @@ function createInputMeter(stream, onLevel) {
37
38
  return { stop() { clearInterval(timer); source.disconnect(); analyser.disconnect(); void context.close(); } };
38
39
  }
39
40
 
40
- export function createDeviceInput({ transcribe, onPhase = () => {}, endpoint = createEnergyEndpoint, endpointSilenceMs = 500, media } = {}) {
41
+ export function createDeviceInput({ transcribe, onPhase = () => {}, endpoint = createEnergyEndpoint, endpointSilenceMs = 500, media, preparation, streamInput, echoTested = false } = {}) {
41
42
  let recorder = null;
42
43
  let settle = null;
43
44
  let timer = null;
@@ -47,30 +48,34 @@ export function createDeviceInput({ transcribe, onPhase = () => {}, endpoint = c
47
48
  settle?.(""); settle = null;
48
49
  };
49
50
  return {
50
- capabilities: { duplex: true, bargeIn: false, listenWhileWorking: true, audio: "encoded" },
51
+ capabilities: { duplex: true, bargeIn: echoTested === true, echoTested: echoTested === true, listenWhileWorking: true, audio: "encoded" },
51
52
  capture({ signal, onTranscribing, onReady, onSpeech, onLevel = () => {}, continuous = false } = {}) {
52
53
  cancel();
53
54
  return new Promise((resolve, reject) => {
55
+ let ready = false;
54
56
  settle = resolve;
55
57
  const done = (text) => { clearTimeout(timer); signal?.removeEventListener("abort", cancel); settle = null; resolve(text); };
56
- recorder = createVoiceCapture({ media, transcribe, submit: done, onStream: (stream) => {
58
+ const prepared = preparation; preparation = null;
59
+ recorder = createVoiceCapture({ media, streamInput, preparation: prepared, transcribe, submit: done, onStream: (stream) => {
57
60
  for (const track of stream.getTracks()) track.addEventListener?.("ended", () => { settle = null; cancel(); reject(new Error("voice_device_lost")); }, { once: true });
58
61
  if (!continuous) return createInputMeter(stream, onLevel);
59
62
  return endpoint(stream, (heard = true) => { if (heard) recorder?.stop(); else {
60
63
  if (continuous) { cancel(); done(""); }
61
64
  else { settle = null; cancel(); reject(new Error("voice_no_input")); }
62
- } }, onSpeech, onLevel, { silenceMs: endpointSilenceMs });
65
+ } }, onSpeech, onLevel, { silenceMs: endpointSilenceMs, interruptionMs: echoTested ? 500 : 0 });
63
66
  }, onPhase(phase, extra) {
64
67
  onPhase(phase, extra);
65
- if (phase === "recording") onReady?.();
68
+ if (phase === "recording" && !ready) {
69
+ ready = true; onReady?.();
70
+ timer = setTimeout(() => { if (continuous) recorder?.stop(); else { settle = null; cancel(); reject(new Error("voice_capture_limit")); } }, 60_000);
71
+ }
66
72
  if (phase === "transcribing") onTranscribing?.();
67
73
  if (phase === "error") {
68
- if (continuous && ["voice_no_input", "voice_too_short"].includes(extra.error)) { done(""); return; }
74
+ if (continuous && (extra.recognizedEmpty || !echoTested && ["voice_no_input", "voice_too_short"].includes(extra.error))) { done(""); return; }
69
75
  clearTimeout(timer); signal?.removeEventListener("abort", cancel); settle = null; reject(new Error(extra.error)); }
70
76
  } });
71
77
  signal?.addEventListener("abort", cancel, { once: true });
72
78
  if (signal?.aborted) { cancel(); return; }
73
- timer = setTimeout(() => { if (continuous) recorder?.stop(); else { settle = null; cancel(); reject(new Error("voice_capture_limit")); } }, 60_000);
74
79
  void recorder.start();
75
80
  });
76
81
  },
package/voice/dialogue.js CHANGED
@@ -8,7 +8,7 @@ export async function voiceChat({ messages, model = "default-chat", owner, signa
8
8
  const response = await request(`${routerGatewayUrl(env)}/chat/completions`, {
9
9
  method: "POST", signal: AbortSignal.any([AbortSignal.timeout(45_000), ...(signal ? [signal] : [])]),
10
10
  headers: { "content-type": "application/json", ...routerAuthHeaders(env.LARES_ROUTER_API_KEY, env.OLARES_APP_ID, owner) },
11
- body: JSON.stringify({ model, messages, ...(tools ? { tools, tool_choice: "auto" } : {}), stream: Boolean(onDelta), max_tokens: maxTokens }),
11
+ body: JSON.stringify({ model, messages, ...(tools ? { tools, tool_choice: "auto" } : {}), stream: Boolean(onDelta), ...(maxTokens === null ? {} : { max_tokens: maxTokens }) }),
12
12
  });
13
13
  if (!response.ok) throw Object.assign(new Error("voice_chat_failed"), { code: "voice_chat_failed", status: 502 });
14
14
  if (onDelta) {
@@ -90,12 +90,13 @@ export function createVoiceDialogue({ config, owner, context = [], summary = "",
90
90
  };
91
91
  }
92
92
 
93
- export async function cleanDictation(text, { config, owner, signal, chat = voiceChat, preset = config.dictationPreset || "clean" }) {
93
+ export async function cleanDictation(text, { config, owner, signal, chat = voiceChat, preset = config.dictationPreset || "raw" }) {
94
94
  if (!DICTATION_PRESETS.includes(preset)) throw Object.assign(new Error("voice_config_invalid"), { status: 400 });
95
95
  if (preset === "raw") return text;
96
96
  const profile = (config.dictationProfiles || defaultDictationProfiles())[preset];
97
97
  if (!profile?.model) throw new Error("voice_dictation_model_missing");
98
- const result = await chat({ owner, model: profile.model, signal, maxTokens: 8192, messages: [
98
+ // Router groups may select backends with different output limits.
99
+ const result = await chat({ owner, model: profile.model, signal, maxTokens: null, messages: [
99
100
  { role: "system", content: `${DICTATION_BOUNDARY}\n${profile.instructions}` },
100
101
  { role: "user", content: text },
101
102
  ] });
@@ -2,6 +2,8 @@ import { createVoiceAgentSession } from "./agent-session.js";
2
2
  import { createVoiceRecords } from "./records.js";
3
3
  import { createVoiceDialogue, cleanDictation } from "./dialogue.js";
4
4
  import { readConfig, validateConfigPatch } from "./config.js";
5
+ import { listModels } from "../router/stt.js";
6
+ import { validatedRealtimeModels } from "./realtime.js";
5
7
  import { VOICE_PRESETS } from "./options.js";
6
8
 
7
9
  const failure = (code, status) => Object.assign(new Error(code), { code, status });
@@ -46,9 +48,12 @@ export function createVoiceHostService(api, { env = process.env, metric = () =>
46
48
  const selected = config();
47
49
  const preset = body.preset || selected.preset;
48
50
  if (!VOICE_PRESETS.includes(preset)) throw failure("voice_preset_invalid", 400);
51
+ const backend = body.backend === undefined ? selected.backend : validateConfigPatch({ backend: body.backend }).backend;
52
+ if (body.backend === "realtime" && !validatedRealtimeModels(await listModels(), env).includes(selected.realtimeModel)) throw failure("voice_realtime_unavailable", 409);
53
+ if (scope.epoch !== epoch) throw failure("voice_session_closed", 409);
49
54
  scopes.add(scope);
50
55
  scope.voiceAbort = new AbortController();
51
- scope.voiceConfig = { ...selected, preset };
56
+ scope.voiceConfig = { ...selected, preset, backend };
52
57
  const history = records.read(ownerOf(scope), boundSessionId || `voice:${scope.contextId || scope.id}`);
53
58
  scope.recoveryContext = history.summary || null;
54
59
  const restoredRequests = [...new Set(history.records.map((row) => row.taskId).filter(Boolean))].map((requestId) => ({ requestId }));
@@ -64,7 +69,7 @@ export function createVoiceHostService(api, { env = process.env, metric = () =>
64
69
  scope.active = true; scope.readAloud = body.readAloud === true;
65
70
  scope.voiceState = { version: 0, running: false, utterance: null, error: null };
66
71
  scope.voiceRequests = new Map(); scope.deliveredTasks = new Set(restoredRequests.map((item) => item.requestId));
67
- scope.dialogue = createVoiceDialogue({ config: scope.voiceConfig, owner: scope.owner, context: history.summary?.recent || [], summary: history.summary?.text || "", chat,
72
+ scope.dialogue = preset === "direct" ? null : createVoiceDialogue({ config: scope.voiceConfig, owner: scope.owner, context: history.summary?.recent || [], summary: history.summary?.text || "", chat,
68
73
  submit: async (text, requestId, operation) => {
69
74
  if (operation === "status") return scope.session.snapshot();
70
75
  const id = `${requestId}:delegate`;
@@ -0,0 +1,44 @@
1
+ /** A suspected acoustic interruption preserves audio until ASR actually settles. */
2
+ export function createInterruptionGuard({ output, onState = () => {}, onError = () => {}, now = Date.now, schedule = setTimeout, unschedule = clearTimeout, silenceMs = 2000, limitMs = 10000 }) {
3
+ let candidate = null;
4
+ const clear = () => {
5
+ if (!candidate) return;
6
+ unschedule(candidate.limit); unschedule(candidate.resume);
7
+ candidate = null;
8
+ };
9
+ const valid = run => candidate === run && run.key === output.key?.();
10
+ const stop = code => {
11
+ clear(); void Promise.resolve(output.stop()).catch(() => {});
12
+ onState("idle"); if (code) onError(code);
13
+ };
14
+ return {
15
+ get pending() { return Boolean(candidate); },
16
+ suspect() {
17
+ if (candidate && !valid(candidate)) clear();
18
+ if (candidate) { unschedule(candidate.resume); candidate.empty = false; candidate.endedAt = null; return true; }
19
+ if (typeof output.key?.() !== "string" || output.canPause !== true || output.pause() === false) return false;
20
+ const run = candidate = { key: output.key?.(), limit: null, resume: null, endedAt: null, empty: false };
21
+ run.limit = schedule(() => { if (valid(run)) stop("voice_interruption_timeout"); else if (candidate === run) clear(); }, limitMs);
22
+ onState("confirming"); return true;
23
+ },
24
+ outputEnded() { clear(); },
25
+ speechEnded() { if (candidate) candidate.endedAt = now(); },
26
+ final(text) {
27
+ const run = candidate;
28
+ if (!run) return;
29
+ if (!valid(run)) { if (candidate === run) clear(); return; }
30
+ if (text.trim()) { stop(); return; }
31
+ run.empty = true;
32
+ if (run.endedAt == null) run.endedAt = now();
33
+ unschedule(run.resume);
34
+ run.resume = schedule(() => {
35
+ if (!valid(run) || !run.empty) { if (candidate === run) clear(); return; }
36
+ clear();
37
+ try { Promise.resolve(output.resume()).catch(() => { if (output.key?.() === run.key) stop("voice_playback_failed"); }); onState("playing"); }
38
+ catch { stop("voice_playback_failed"); }
39
+ }, Math.max(0, silenceMs - (now() - run.endedAt)));
40
+ },
41
+ fail(code) { if (candidate) stop(code); },
42
+ cancel() { clear(); },
43
+ };
44
+ }
@@ -0,0 +1,12 @@
1
+ import { voiceAvailability } from "./presentation.js";
2
+
3
+ /** Product entry policy is scoped to this connection, never written to API defaults. */
4
+ export function laresVoiceConfig(config, mode = "continuous") {
5
+ return { ...structuredClone(config), preset: mode === "single" ? "dictation" : "direct", backend: "pipeline" };
6
+ }
7
+
8
+ export function laresVoiceAvailability(settings, agentAvailable = false) {
9
+ const available = voiceAvailability(settings ? { ...settings, config: laresVoiceConfig(settings.config || {}) } : null);
10
+ return { ...available, conversation: available.conversation && agentAvailable === true,
11
+ reason: available.conversation && agentAvailable !== true ? "agent" : available.reason };
12
+ }
package/voice/options.js CHANGED
@@ -2,12 +2,13 @@ import { defaultDictationProfiles } from "./dictation.js";
2
2
 
3
3
  export const VOICE_DEFAULTS = {
4
4
  preset: "assistant",
5
- dictationPreset: "clean",
5
+ dictationPreset: "raw",
6
6
  dictationProfiles: defaultDictationProfiles(),
7
7
  dictationShortcuts: { default: "", raw: "", clean: "", outline: "", summary: "" },
8
8
  chatModel: "default-chat",
9
9
  instructions: "",
10
10
  voice: "",
11
+ ttsVoiceByModel: {},
11
12
  mode: "single",
12
13
  mobileMode: "continuous",
13
14
  endpointSilenceMs: 500,
package/voice/panel.js CHANGED
@@ -1,17 +1,21 @@
1
+ import { setVoiceActivity } from "./activity.js";
1
2
  import { DICTATION_PRESETS } from "./dictation.js";
2
3
 
3
4
  /** The panel owns drafts and turn policy; adapters only supply settings, audio and insertion ports. */
4
- export function createVoicePanel({ create, complete, loadConfig = async () => ({}), process = async ({ text }) => text, onSession = () => {}, manualTurns = true, inlineDictation = false }) {
5
- let state = { visible: false, inputActive: false, previewVisible: false, mode: "single", sessionId: null, raw: "", draft: "", edited: false, preset: "clean", stage: "idle", snapshot: null, busy: false, error: null, device: "", levels: [], startedAt: null, elapsedMs: 0, holding: false, cancelArmed: false };
5
+ export function createVoicePanel({ create, complete, loadConfig = async () => ({}), process = async ({ text }) => text, onSession = () => {}, manualTurns = true, inlineDictation = false, prepareInput = () => null }) {
6
+ let state = { visible: false, inputActive: false, previewVisible: false, mode: "single", sessionId: null, raw: "", draft: "", edited: false, preset: "raw", stage: "idle", snapshot: null, busy: false, error: null, readAloudError: null, device: "", levels: [], startedAt: null, elapsedMs: 0, holding: false, cancelArmed: false };
6
7
  let call = null, config = null, generation = 0, intent = 0, processing = null;
8
+ let inputPreparation = null;
9
+ let readAloudPreference, appliedReadAloud, readAloudPending = null, readAloudReady = false, outputRevision = 0;
10
+ const cancelPreparation = () => { inputPreparation?.cancel(); inputPreparation = null; };
7
11
  let unsubscribe = () => {};
8
12
  let serial = Promise.resolve();
9
13
  const drafts = new Map(), listeners = new Set();
10
- const emit = (patch) => { state = { ...state, ...patch }; state.previewVisible = !inlineDictation && state.visible && state.mode === "single" && (state.stage === "preview" || state.stage === "processing" && state.previewVisible); state.inputActive = state.visible && !state.previewVisible; for (const listener of listeners) listener(); };
14
+ const emit = (patch) => { state = { ...state, ...patch }; state.previewVisible = !inlineDictation && state.visible && state.mode === "single" && (state.stage === "preview" || state.stage === "processing" && state.previewVisible); state.inputActive = state.visible && !state.previewVisible; setVoiceActivity(listeners, state.visible && (state.busy || state.snapshot?.capture === "recording" || state.snapshot?.capture === "transcribing" || state.snapshot?.connection === "open")); for (const listener of listeners) listener(); };
11
15
  const save = () => state.mode === "single" && drafts.set(state.sessionId, { raw: state.raw, draft: state.draft, edited: state.edited, preset: state.preset, config });
12
16
  const cancelProcessing = () => { processing?.abort(); processing = null; };
13
17
  const enqueue = (operation) => {
14
- const result = serial.catch(() => {}).then(async () => { emit({ busy: true, error: null }); try { return await operation(); } catch (error) { emit({ error: error.code || error.message, holding: false, cancelArmed: false }); throw error; } finally { emit({ busy: false }); } });
18
+ const result = serial.catch(() => {}).then(async () => { emit({ busy: true, error: null }); try { return await operation(); } catch (error) { cancelPreparation(); emit({ error: error.code || error.message, holding: false, cancelArmed: false }); throw error; } finally { emit({ busy: false }); } });
15
19
  serial = result; return result;
16
20
  };
17
21
  const finishCapture = async () => {
@@ -26,9 +30,9 @@ export function createVoicePanel({ create, complete, loadConfig = async () => ({
26
30
  });
27
31
  };
28
32
  const end = async () => {
29
- generation++; unsubscribe(); unsubscribe = () => {};
33
+ generation++; outputRevision++; readAloudReady = false; appliedReadAloud = undefined; readAloudPending = null; unsubscribe(); unsubscribe = () => {};
30
34
  const previous = call; call = null;
31
- try { await previous?.stop(); } finally { emit({ snapshot: null, startedAt: null, elapsedMs: 0, levels: [] }); }
35
+ try { await previous?.stop(); } finally { emit({ snapshot: null, readAloudError: null, startedAt: null, elapsedMs: 0, levels: [] }); }
32
36
  };
33
37
  const renderDraft = () => { emit({ stage: state.raw ? "preview" : "idle" }); save(); };
34
38
  const transform = async (preset) => {
@@ -56,24 +60,30 @@ export function createVoicePanel({ create, complete, loadConfig = async () => ({
56
60
  if (call?.controller.getSnapshot().connection === "open") return;
57
61
  await end(); config = state.mode === "single" && config ? config : structuredClone(await loadConfig());
58
62
  if (token !== intent || !state.visible) return;
63
+ if (state.mode === "continuous" && typeof readAloudPreference === "boolean") config = { ...config, speak: readAloudPreference };
64
+ const initialReadAloud = state.mode === "continuous" && config.speak === true;
59
65
  const target = state.sessionId, epoch = ++generation;
60
- const conversation = await create({ mode: state.mode, sessionId: target, config,
66
+ const conversation = await create({ mode: state.mode, sessionId: target, config, preparation: inputPreparation,
61
67
  onTranscript({ text }) {
62
68
  if (epoch !== generation) return;
63
69
  const raw = [state.raw, text].filter(Boolean).join(" "); emit({ raw, draft: raw, edited: false }); save();
64
70
  }, onDevice(device) { if (epoch === generation) emit({ device }); },
65
71
  });
66
72
  if (token !== intent || !state.visible) { await conversation.stop(); return; }
67
- call = conversation;
73
+ call = conversation; appliedReadAloud = initialReadAloud;
68
74
  unsubscribe = call.controller.subscribe(() => {
69
75
  if (epoch !== generation) return;
70
76
  const snapshot = call.controller.getSnapshot();
71
77
  if (!state.sessionId && snapshot.sessionId) { emit({ sessionId: snapshot.sessionId }); onSession(snapshot.sessionId); }
72
78
  emit({ snapshot, ...(snapshot.inputLevel == null ? {} : { levels: [...state.levels.slice(-19), snapshot.inputLevel] }),
73
- ...(snapshot.capture === "recording" && state.snapshot?.capture !== "recording" ? { startedAt: Date.now(), elapsedMs: 0 } : snapshot.capture !== "recording" && state.startedAt ? { startedAt: null, elapsedMs: Date.now() - state.startedAt } : {}) });
79
+ ...(snapshot.capture === "recording" && snapshot.inputReady && !state.startedAt ? { startedAt: Date.now(), elapsedMs: 0 } : snapshot.capture !== "recording" && state.startedAt ? { startedAt: null, elapsedMs: Date.now() - state.startedAt } : {}) });
74
80
  });
75
81
  await call.start({ sessionId: target, continuous: state.mode === "continuous", manual: state.mode === "continuous" && manualTurns, shouldStart: () => token === intent && state.visible });
76
82
  if (token !== intent || !state.visible) await call.controller.cancelRecording();
83
+ else {
84
+ readAloudReady = true;
85
+ if (state.mode === "continuous" && typeof readAloudPreference === "boolean") await panel.setReadAloud(readAloudPreference);
86
+ }
77
87
  };
78
88
  const pause = async () => {
79
89
  try { if (state.mode === "single") { await finishCapture(); renderDraft(); } else await call?.controller.cancelRecording(); }
@@ -89,27 +99,55 @@ export function createVoicePanel({ create, complete, loadConfig = async () => ({
89
99
  getSnapshot: () => state,
90
100
  subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
91
101
  get conversation() { return call; },
92
- open({ mode = state.mode, sessionId = state.sessionId, preset } = {}) {
102
+ setReadAloud(value) {
103
+ const next = readAloudPreference = value === true;
104
+ if (config) config = { ...config, speak: next };
105
+ const target = call, epoch = generation;
106
+ if (!readAloudReady || !target?.setReadAloud || state.mode !== "continuous") return Promise.resolve(true);
107
+ if (readAloudPending?.target === target && readAloudPending.value === next) return readAloudPending.promise;
108
+ if (!readAloudPending && appliedReadAloud === next && !state.readAloudError) return Promise.resolve(true);
109
+ const revision = ++outputRevision;
110
+ const current = () => target === call && epoch === generation && revision === outputRevision;
111
+ const pending = { target, value: next, promise: null };
112
+ pending.promise = Promise.resolve().then(() => {
113
+ if (target !== call || epoch !== generation) return;
114
+ return target.setReadAloud(next);
115
+ }).then(() => {
116
+ if (current()) { appliedReadAloud = next; emit({ readAloudError: null }); }
117
+ return true;
118
+ }, error => {
119
+ if (current()) emit({ readAloudError: error.code || error.message || "voice_failed" });
120
+ return false;
121
+ }).finally(() => { if (readAloudPending === pending) readAloudPending = null; });
122
+ readAloudPending = pending;
123
+ return pending.promise;
124
+ },
125
+ retryReadAloud() { return panel.setReadAloud(readAloudPreference); },
126
+ open({ mode = state.mode, sessionId = state.sessionId, preset, configSnapshot } = {}) {
127
+ if (state.busy) return Promise.resolve();
128
+ cancelPreparation();
129
+ const snapshot = configSnapshot ? structuredClone(configSnapshot) : null;
130
+ if (mode === "single" && snapshot && !(sessionId === state.sessionId ? state.raw : drafts.get(sessionId)?.raw)) inputPreparation = prepareInput(snapshot);
93
131
  const token = ++intent; emit({ visible: true });
94
132
  return enqueue(async () => {
95
133
  if (sessionId !== state.sessionId || mode !== state.mode) {
96
- try { await pause(); } finally { await end(); save(); const held = drafts.get(sessionId); config = held?.config || null; emit({ sessionId, mode, raw: mode === "single" ? held?.raw || "" : "", draft: mode === "single" ? held?.draft || "" : "", edited: mode === "single" && (held?.edited || false), preset: held?.preset || "clean", stage: mode === "single" && held?.raw ? "preview" : "idle" }); }
134
+ try { await pause(); } finally { await end(); save(); const held = drafts.get(sessionId); config = held?.config || null; emit({ sessionId, mode, raw: mode === "single" ? held?.raw || "" : "", draft: mode === "single" ? held?.draft || "" : "", edited: mode === "single" && (held?.edited || false), preset: held?.preset || "raw", stage: mode === "single" && held?.raw ? "preview" : "idle" }); }
97
135
  }
98
136
  if (mode === "single" && !state.raw && token === intent && state.visible) {
99
- config = structuredClone(await loadConfig());
100
- emit({ preset: preset || config.dictationPreset || "clean" });
137
+ config = snapshot || structuredClone(await loadConfig());
138
+ emit({ preset: preset || config.dictationPreset || "raw" });
101
139
  await prepare(token); if (token === intent && state.visible) emit({ stage: "recording" });
102
140
  }
103
141
  });
104
142
  },
105
143
  bind(sessionId) {
106
144
  if (sessionId === state.sessionId) return Promise.resolve();
107
- intent++; cancelProcessing(); emit({ visible: false, holding: false, cancelArmed: false });
145
+ intent++; cancelPreparation(); cancelProcessing(); emit({ visible: false, holding: false, cancelArmed: false });
108
146
  if (state.mode === "continuous") void call?.controller.cancelRecording();
109
- return enqueue(async () => { try { await pause(); } finally { await end(); save(); const held = drafts.get(sessionId); config = held?.config || null; emit({ sessionId, raw: held?.raw || "", draft: held?.draft || "", edited: held?.edited || false, preset: held?.preset || "clean", stage: held?.raw ? "preview" : "idle" }); } });
147
+ return enqueue(async () => { try { await pause(); } finally { await end(); save(); const held = drafts.get(sessionId); config = held?.config || null; emit({ sessionId, raw: held?.raw || "", draft: held?.draft || "", edited: held?.edited || false, preset: held?.preset || "raw", stage: held?.raw ? "preview" : "idle" }); } });
110
148
  },
111
- close() { intent++; cancelProcessing(); emit({ visible: false, holding: false, cancelArmed: false }); if (state.mode === "continuous") void call?.controller.cancelRecording(); return enqueue(pause); },
112
- pause() { intent++; cancelProcessing(); emit({ holding: false, cancelArmed: false }); if (state.mode === "continuous") void call?.controller.cancelRecording(); return enqueue(pause); },
149
+ close() { intent++; cancelPreparation(); cancelProcessing(); emit({ visible: false, holding: false, cancelArmed: false }); if (state.mode === "continuous") void call?.controller.cancelRecording(); return enqueue(pause); },
150
+ pause() { intent++; cancelPreparation(); cancelProcessing(); emit({ holding: false, cancelArmed: false }); if (state.mode === "continuous") void call?.controller.cancelRecording(); return enqueue(pause); },
113
151
  start() { const token = ++intent; return enqueue(() => prepare(token)); },
114
152
  finish() {
115
153
  if (state.busy) return Promise.resolve();
@@ -124,6 +162,9 @@ export function createVoicePanel({ create, complete, loadConfig = async () => ({
124
162
  edit(text) { cancelProcessing(); emit({ draft: text, edited: true, stage: "preview" }); save(); },
125
163
  selectPreset(preset, replaceEdited = false) {
126
164
  if (state.edited && !replaceEdited) return Promise.resolve(false);
165
+ if (preset === "raw" && state.raw && state.stage === "processing") {
166
+ intent++; cancelProcessing();
167
+ }
127
168
  return enqueue(async () => { await transform(preset); return true; });
128
169
  },
129
170
  holdStart() {
@@ -143,9 +184,9 @@ export function createVoicePanel({ create, complete, loadConfig = async () => ({
143
184
  return Promise.resolve(cancel || !call.controller.getSnapshot().inputReady ? call.controller.cancelRecording() : call.controller.stopRecording());
144
185
  },
145
186
  complete() { return enqueue(insertDraft); },
146
- discard() { intent++; cancelProcessing(); void call?.controller.cancelRecording().catch(() => {}); return enqueue(async () => { await end(); drafts.delete(state.sessionId); config = null; emit({ raw: "", draft: "", edited: false, stage: "idle", visible: false }); }); },
147
- end() { intent++; emit({ holding: false, cancelArmed: false }); return enqueue(async () => { await end(); emit({ visible: false }); }); },
148
- dispose() { intent++; cancelProcessing(); return enqueue(async () => { try { await end(); } finally { listeners.clear(); drafts.clear(); } }); },
187
+ discard() { intent++; cancelPreparation(); cancelProcessing(); void call?.controller.cancelRecording().catch(() => {}); return enqueue(async () => { await end(); drafts.delete(state.sessionId); config = null; emit({ raw: "", draft: "", edited: false, stage: "idle", visible: false }); }); },
188
+ end() { intent++; cancelPreparation(); emit({ visible: false, holding: false, cancelArmed: false }); return enqueue(async () => { await end(); emit({ visible: false }); }); },
189
+ dispose() { intent++; cancelPreparation(); cancelProcessing(); return enqueue(async () => { try { await end(); } finally { setVoiceActivity(listeners, false); listeners.clear(); drafts.clear(); } }); },
149
190
  };
150
191
  return panel;
151
192
  }