@olares/lares-core 0.15.21 → 0.15.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/voice/conversation.js +13 -1
- package/voice/panel.js +17 -3
- package/voice/presentation.js +3 -1
- package/voice/stream-client.js +6 -4
package/package.json
CHANGED
package/voice/conversation.js
CHANGED
|
@@ -3,6 +3,18 @@ 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
|
+
/** Bound a request without requiring AbortSignal.any / timeout, which some mobile webviews lack. */
|
|
7
|
+
function outputSignal(abort) {
|
|
8
|
+
const signals = [abort.signal];
|
|
9
|
+
if (typeof AbortSignal?.timeout === "function") {
|
|
10
|
+
try { signals.push(AbortSignal.timeout(10_000)); } catch { /* the caller signal still cancels the request */ }
|
|
11
|
+
}
|
|
12
|
+
if (signals.length > 1 && typeof AbortSignal.any === "function") {
|
|
13
|
+
try { return AbortSignal.any(signals); } catch { /* same */ }
|
|
14
|
+
}
|
|
15
|
+
return abort.signal;
|
|
16
|
+
}
|
|
17
|
+
|
|
6
18
|
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
19
|
const voiceId = mintRpcId();
|
|
8
20
|
const headers = { "x-lares-voice-id": voiceId, "x-lares-voice-device": device };
|
|
@@ -154,7 +166,7 @@ export function createVoiceConversation({ request, transcribe, playback, onReply
|
|
|
154
166
|
if (error) throw error;
|
|
155
167
|
const abort = outputAbort = new AbortController();
|
|
156
168
|
try {
|
|
157
|
-
await call("/session/output", { readAloud: next },
|
|
169
|
+
await call("/session/output", { readAloud: next }, outputSignal(abort));
|
|
158
170
|
if (!disposed && revision === outputRevision) confirmedReadAloud = next;
|
|
159
171
|
} catch (error) {
|
|
160
172
|
if (!disposed && revision === outputRevision) {
|
package/voice/panel.js
CHANGED
|
@@ -56,7 +56,11 @@ export function createVoicePanel({ create, complete, loadConfig = async () => ({
|
|
|
56
56
|
}
|
|
57
57
|
};
|
|
58
58
|
const prepare = async (token) => {
|
|
59
|
-
if (call?.controller.getSnapshot().connection === "paused") {
|
|
59
|
+
if (call?.controller.getSnapshot().connection === "paused") {
|
|
60
|
+
await call.resume({ shouldResume: () => token === intent && state.visible });
|
|
61
|
+
if (token === intent && state.visible && state.mode === "continuous" && typeof readAloudPreference === "boolean") await panel.setReadAloud(readAloudPreference);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
60
64
|
if (call?.controller.getSnapshot().connection === "open") return;
|
|
61
65
|
await end(); config = state.mode === "single" && config ? config : structuredClone(await loadConfig());
|
|
62
66
|
if (token !== intent || !state.visible) return;
|
|
@@ -87,7 +91,11 @@ export function createVoicePanel({ create, complete, loadConfig = async () => ({
|
|
|
87
91
|
};
|
|
88
92
|
const pause = async () => {
|
|
89
93
|
try { if (state.mode === "single") { await finishCapture(); renderDraft(); } else await call?.controller.cancelRecording(); }
|
|
90
|
-
finally {
|
|
94
|
+
finally {
|
|
95
|
+
if (call?.controller.getSnapshot().connection === "open") await call.pause();
|
|
96
|
+
// The banner belongs to the open call. Leaving it must not keep a mute/sync notice on the chat.
|
|
97
|
+
if (!state.visible) { outputRevision++; emit({ readAloudError: null }); }
|
|
98
|
+
}
|
|
91
99
|
};
|
|
92
100
|
const insertDraft = async () => {
|
|
93
101
|
if (!state.draft || state.stage !== "preview") return;
|
|
@@ -108,6 +116,10 @@ export function createVoicePanel({ create, complete, loadConfig = async () => ({
|
|
|
108
116
|
if (!readAloudPending && appliedReadAloud === next && !state.readAloudError) return Promise.resolve(true);
|
|
109
117
|
const revision = ++outputRevision;
|
|
110
118
|
const current = () => target === call && epoch === generation && revision === outputRevision;
|
|
119
|
+
// Phone playback is local. Asking for silence already stopped it, so a missed
|
|
120
|
+
// server ack is not a readout failure. A headset can keep playing until the host agrees.
|
|
121
|
+
const localSilence = next !== true && state.device !== "headset";
|
|
122
|
+
if (localSilence) emit({ readAloudError: null });
|
|
111
123
|
const pending = { target, value: next, promise: null };
|
|
112
124
|
pending.promise = Promise.resolve().then(() => {
|
|
113
125
|
if (target !== call || epoch !== generation) return;
|
|
@@ -116,7 +128,9 @@ export function createVoicePanel({ create, complete, loadConfig = async () => ({
|
|
|
116
128
|
if (current()) { appliedReadAloud = next; emit({ readAloudError: null }); }
|
|
117
129
|
return true;
|
|
118
130
|
}, error => {
|
|
119
|
-
if (current())
|
|
131
|
+
if (!current()) return false;
|
|
132
|
+
if (localSilence) { appliedReadAloud = false; emit({ readAloudError: null }); return true; }
|
|
133
|
+
emit({ readAloudError: error.code || error.message || "voice_failed" });
|
|
120
134
|
return false;
|
|
121
135
|
}).finally(() => { if (readAloudPending === pending) readAloudPending = null; });
|
|
122
136
|
readAloudPending = pending;
|
package/voice/presentation.js
CHANGED
|
@@ -6,7 +6,9 @@ export function voicePanelPresentation(view) {
|
|
|
6
6
|
const preview = view.mode === "single" && ["preview", "processing"].includes(view.stage);
|
|
7
7
|
const preparing = view.busy || capture === "recording" && view.snapshot?.inputReady === false;
|
|
8
8
|
const recording = capture === "recording" && !preparing;
|
|
9
|
-
const
|
|
9
|
+
const reported = view.error || view.snapshot?.error;
|
|
10
|
+
// A muted call is not a listen session. "Resume to listen again" is the readout copy.
|
|
11
|
+
const error = reported === "voice_disconnected" && view.snapshot?.outputMuted ? "voice_background" : reported;
|
|
10
12
|
const phase = view.cancelArmed ? "cancel" : error ? "error" : processing ? "processing" : transcribing ? "transcribing" : preparing ? "preparing" : recording ? "recording" : preview ? "preview" : view.snapshot && view.snapshot.playback !== "idle" ? "speaking" : view.snapshot?.agent === "running" ? "working" : "waiting";
|
|
11
13
|
const status = error ? `error.${error}` : ({ cancel: "gesture.cancelRelease", processing: "dictation.processing", transcribing: "mic.transcribing", preparing: "mic.preparing", recording: "call.listening", preview: "dictation.preview", speaking: "dictation.speaking", working: "call.working", waiting: view.mode === "continuous" ? "gesture.waiting" : "dictation.preview" })[phase];
|
|
12
14
|
return { phase, status, preview, recording, pending: processing || transcribing || preparing, error };
|
package/voice/stream-client.js
CHANGED
|
@@ -65,8 +65,9 @@ export async function playAudioStream(options) {
|
|
|
65
65
|
else if (event.type === "done") { await player.finish(); complete = true; resolve(); }
|
|
66
66
|
}).catch(fail);
|
|
67
67
|
});
|
|
68
|
-
|
|
69
|
-
connection.addEventListener("
|
|
68
|
+
const dropped = () => new Error(options.signal?.aborted ? "voice_cancelled" : "voice_disconnected");
|
|
69
|
+
connection.addEventListener("error", () => fail(dropped()), { once: true });
|
|
70
|
+
connection.addEventListener("close", () => { options.signal?.removeEventListener("abort", cancelled); if (!complete) reject(dropped()); }, { once: true });
|
|
70
71
|
if (options.signal?.aborted) cancelled();
|
|
71
72
|
});
|
|
72
73
|
return true;
|
|
@@ -101,8 +102,9 @@ export async function startTranscriptionStream(options) {
|
|
|
101
102
|
else if (event.type === "error") fail(new Error(event.code));
|
|
102
103
|
} catch { fail(new Error("voice_stream_failed")); }
|
|
103
104
|
});
|
|
104
|
-
|
|
105
|
-
connection.addEventListener("
|
|
105
|
+
const dropped = () => fail(new Error(options.signal?.aborted ? "voice_cancelled" : "voice_disconnected"));
|
|
106
|
+
connection.addEventListener("close", dropped, { once: true });
|
|
107
|
+
connection.addEventListener("error", dropped, { once: true });
|
|
106
108
|
if (options.signal?.aborted) fail(new Error("voice_cancelled"));
|
|
107
109
|
await ready;
|
|
108
110
|
return {
|