@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
package/voice/controller.js
CHANGED
|
@@ -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
|
-
|
|
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" && !
|
|
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 (
|
|
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
|
-
|
|
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 && !
|
|
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
|
|
140
|
+
if (!manualTurns && state.continuous && (!playing || interruptible())) void listen();
|
|
130
141
|
},
|
|
131
142
|
async pause() {
|
|
132
143
|
clearTimers(); epoch += 1;
|
package/voice/conversation.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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 });
|
package/voice/device-input.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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")
|
|
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 || "
|
|
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
|
-
|
|
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
|
] });
|
package/voice/host-service.js
CHANGED
|
@@ -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: "
|
|
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: "
|
|
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" &&
|
|
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
|
-
|
|
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 || "
|
|
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 || "
|
|
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 || "
|
|
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
|
}
|