@olares/lares-mobile 0.15.11 → 0.15.12
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 +2 -2
- package/src/App.vue +110 -62
- package/src/chat/Composer.vue +44 -52
- package/src/host.js +8 -2
- package/src/mobile/MobileShell.vue +7 -1
- package/src/settings/AgentSettings.vue +35 -2
- package/src/chat/voice.js +0 -269
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@olares/lares-mobile",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.12",
|
|
4
4
|
"description": "Lares chat component for LarePass",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"vue": "^3.2.0"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@olares/lares-core": "0.15.
|
|
31
|
+
"@olares/lares-core": "0.15.12",
|
|
32
32
|
"markdown-it": "^14.3.0"
|
|
33
33
|
}
|
|
34
34
|
}
|
package/src/App.vue
CHANGED
|
@@ -89,6 +89,7 @@
|
|
|
89
89
|
@files="attachFiles"
|
|
90
90
|
@hold-start="onHoldStart"
|
|
91
91
|
@hold-end="onHoldEnd"
|
|
92
|
+
@voice-action="voiceAction"
|
|
92
93
|
@close-model="modelSheet = false"
|
|
93
94
|
@choose-model="chooseModel"
|
|
94
95
|
@close-effort="effortSheet = false"
|
|
@@ -108,11 +109,12 @@ import { pickHostPorts } from "@olares/lares-core/larepass/host";
|
|
|
108
109
|
import { connectChat } from "./runtime.js";
|
|
109
110
|
import { createT } from "./i18n.js";
|
|
110
111
|
import { failText, withPendingUser } from "./chat/format.js";
|
|
111
|
-
import {
|
|
112
|
+
import { captureSource } from "@olares/lares-core/voice/input";
|
|
113
|
+
import { createRealtimeConversation } from "@olares/lares-core/voice/realtime-conversation";
|
|
114
|
+
import { createVoiceConversation } from "@olares/lares-core/voice/conversation";
|
|
112
115
|
import { createAnswerPlayer } from "./chat/playback.js";
|
|
113
116
|
import { createReplySpeaker, createSentencePlayer, speakable } from "@olares/lares-core/voice/speech";
|
|
114
117
|
import { wavBlob } from "@olares/lares-core/voice/client";
|
|
115
|
-
import { captureSource } from "@olares/lares-core/voice/input";
|
|
116
118
|
import { browserPlays } from "@olares/lares-core/voice/playback";
|
|
117
119
|
import { speakProblemText, speakToggleNotice } from "@olares/lares-core/voice/problem";
|
|
118
120
|
import { appendDraftMentions } from "@olares/lares-core/files/mention";
|
|
@@ -218,11 +220,15 @@ export default {
|
|
|
218
220
|
effortSheet: false,
|
|
219
221
|
upload: { pending: 0, failures: [] },
|
|
220
222
|
draftImages: [],
|
|
223
|
+
voiceCall: null,
|
|
224
|
+
voiceHistory: null,
|
|
225
|
+
voiceCaption: "",
|
|
221
226
|
voicePhase: "idle",
|
|
222
227
|
voiceElapsed: 0,
|
|
223
228
|
voiceError: "",
|
|
224
229
|
voiceLanguage: "",
|
|
225
230
|
voiceSpeak: false,
|
|
231
|
+
voiceDefaultMode: "continuous",
|
|
226
232
|
voiceStreaming: false,
|
|
227
233
|
speakBusy: false,
|
|
228
234
|
speakNotice: "",
|
|
@@ -329,6 +335,8 @@ export default {
|
|
|
329
335
|
effortDisabled: this.effortDisabled,
|
|
330
336
|
upload: this.upload,
|
|
331
337
|
draftImages: this.draftImages,
|
|
338
|
+
voiceDefaultMode: this.voiceDefaultMode,
|
|
339
|
+
voiceCall: this.voiceCall,
|
|
332
340
|
voicePhase: this.voicePhase,
|
|
333
341
|
voiceElapsed: this.voiceElapsed,
|
|
334
342
|
voiceErrorText: this.voiceErrorText,
|
|
@@ -538,25 +546,7 @@ export default {
|
|
|
538
546
|
silence: () => void this.runtime.interruptSpeech?.(),
|
|
539
547
|
fail: (err) => this.showSpeakProblem(err),
|
|
540
548
|
});
|
|
541
|
-
|
|
542
|
-
transcribe: (blob, signal) => this.runtime.transcribe(blob, this.voiceLanguage, signal),
|
|
543
|
-
onPhase: (phase, extra = {}) => {
|
|
544
|
-
this.voicePhase = phase;
|
|
545
|
-
if (extra.elapsed != null) this.voiceElapsed = extra.elapsed;
|
|
546
|
-
if (extra.error != null) this.voiceError = extra.error;
|
|
547
|
-
},
|
|
548
|
-
onTrace: (event, fields) => {
|
|
549
|
-
void this.runtime.reportLog?.("voice", event, fields);
|
|
550
|
-
},
|
|
551
|
-
submit: (text) => {
|
|
552
|
-
void this.runtime.reportLog?.("voice", "submit", {
|
|
553
|
-
chars: String(text ?? "").trim().length,
|
|
554
|
-
running: Boolean(this.running),
|
|
555
|
-
session: Boolean(this.sessionId),
|
|
556
|
-
});
|
|
557
|
-
void this.sendTranscript(text);
|
|
558
|
-
},
|
|
559
|
-
});
|
|
549
|
+
|
|
560
550
|
this.bindIntake(this.sessionId);
|
|
561
551
|
this.bindRuntime();
|
|
562
552
|
try {
|
|
@@ -572,6 +562,13 @@ export default {
|
|
|
572
562
|
this.conversationUnsub = subscribeConversationSettings((section) => {
|
|
573
563
|
this.busyEnter = section.busyEnter;
|
|
574
564
|
});
|
|
565
|
+
this.voiceVisibilityHandler = () => {
|
|
566
|
+
if (document.hidden) void this.voiceConversation?.controller.disconnect("voice_background");
|
|
567
|
+
};
|
|
568
|
+
document.addEventListener("visibilitychange", this.voiceVisibilityHandler);
|
|
569
|
+
this.voiceClock = setInterval(() => {
|
|
570
|
+
if (this.voicePhase === "recording") this.voiceElapsed += 1;
|
|
571
|
+
}, 1000);
|
|
575
572
|
this.retry();
|
|
576
573
|
},
|
|
577
574
|
activated() {
|
|
@@ -581,11 +578,14 @@ export default {
|
|
|
581
578
|
this.captureLog();
|
|
582
579
|
},
|
|
583
580
|
beforeUnmount() {
|
|
581
|
+
document.removeEventListener("visibilitychange", this.voiceVisibilityHandler);
|
|
582
|
+
clearInterval(this.voiceClock);
|
|
584
583
|
this.captureLog();
|
|
585
584
|
this.noteSeen(this.sessionId);
|
|
586
585
|
for (const controller of this.imageUploadControllers?.values?.() ?? []) controller.abort();
|
|
587
586
|
this.imageUploadControllers?.clear?.();
|
|
588
|
-
this.
|
|
587
|
+
void this.voiceConversation?.stop().catch(() => {});
|
|
588
|
+
this.voiceUnsub?.();
|
|
589
589
|
this.conversationUnsub?.();
|
|
590
590
|
this.intakeUnsub?.();
|
|
591
591
|
if (this.sessionId) this.intake?.cancelSession(this.sessionId);
|
|
@@ -642,10 +642,12 @@ export default {
|
|
|
642
642
|
this.commands = null;
|
|
643
643
|
this.commandsError = "";
|
|
644
644
|
}
|
|
645
|
-
if (sessionChanged) {
|
|
645
|
+
if (sessionChanged && snap.sessionId !== this.voiceAdoptingSession) {
|
|
646
|
+
void this.voiceConversation?.stop().catch(() => {});
|
|
646
647
|
this.reader.stop();
|
|
647
648
|
this.player?.stop();
|
|
648
649
|
}
|
|
650
|
+
if (sessionChanged) this.voiceAdoptingSession = null;
|
|
649
651
|
const utterance = this.replySpeaker.observe({
|
|
650
652
|
sessionChanged,
|
|
651
653
|
wasRunning: this.running,
|
|
@@ -656,7 +658,7 @@ export default {
|
|
|
656
658
|
this.items = snap.items;
|
|
657
659
|
this.running = snap.running;
|
|
658
660
|
this.runningSince = snap.runningSince || 0;
|
|
659
|
-
const target = this.voiceSpeak ? speakable(utterance, this.voiceStreaming) : null;
|
|
661
|
+
const target = this.voiceSpeak && !this.voiceCall ? speakable(utterance, this.voiceStreaming) : null;
|
|
660
662
|
if (target) this.reader.follow({ ...target, key: `${snap.sessionId}:${target.key}` });
|
|
661
663
|
this.failed = snap.failed;
|
|
662
664
|
this.error = snap.error;
|
|
@@ -951,7 +953,7 @@ export default {
|
|
|
951
953
|
this.unsub = this.runtime.subscribe((snap) => this.applySnap(snap));
|
|
952
954
|
},
|
|
953
955
|
switchHost() {
|
|
954
|
-
this.
|
|
956
|
+
void this.voiceConversation?.stop().catch(() => {});
|
|
955
957
|
for (const controller of this.imageUploadControllers.values()) controller.abort();
|
|
956
958
|
this.imageUploadControllers.clear();
|
|
957
959
|
this.releaseDraftImages();
|
|
@@ -1030,6 +1032,7 @@ export default {
|
|
|
1030
1032
|
if (settings) {
|
|
1031
1033
|
jobs.push(settings.voice().then((voice) => {
|
|
1032
1034
|
this.voiceLanguage = voice.config?.language || "";
|
|
1035
|
+
this.voiceDefaultMode = voice.config?.mobileMode || "continuous";
|
|
1033
1036
|
this.voiceSpeak = voice.config?.speak === true;
|
|
1034
1037
|
this.voiceStreaming = voice.config?.streaming === true;
|
|
1035
1038
|
}).catch(() => {
|
|
@@ -1186,24 +1189,90 @@ export default {
|
|
|
1186
1189
|
this.draftImages = [];
|
|
1187
1190
|
this.imagesBySession = {};
|
|
1188
1191
|
},
|
|
1189
|
-
onHoldStart(
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1192
|
+
async onHoldStart({ mode: selectedMode } = {}) {
|
|
1193
|
+
this.voiceReleased = false;
|
|
1194
|
+
if (this.voiceCall?.connection === "open") {
|
|
1195
|
+
await this.voiceConversation.stop(); return;
|
|
1196
|
+
}
|
|
1197
|
+
const target = this.sessionId;
|
|
1198
|
+
this.voicePhase = "preparing"; this.voiceElapsed = 0;
|
|
1199
|
+
try {
|
|
1200
|
+
const settings = await this.runtime.settings.voice(true);
|
|
1201
|
+
if (this.sessionId !== target) return;
|
|
1202
|
+
const continuous = (selectedMode || settings.config.mobileMode || "continuous") === "continuous";
|
|
1203
|
+
if (settings.config.backend === "realtime" && continuous) {
|
|
1204
|
+
const conversation = createRealtimeConversation({ readAloud: settings.config.speak, idleTimeoutMs: settings.config.idleTimeoutMs, recoveryTimeoutMs: settings.config.recoveryTimeoutMs,
|
|
1205
|
+
request: (path, options) => this.runtime.voiceRequest(path, options), socket: (ticket) => this.runtime.voiceSocket(ticket),
|
|
1206
|
+
device: captureSource("mobile", settings.config.device), playback: this.player,
|
|
1207
|
+
onCaption: (text) => { this.voiceCaption = `${this.voiceCaption}${text}`.slice(-300); },
|
|
1208
|
+
});
|
|
1209
|
+
await this.attachVoiceConversation(conversation, target, true);
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
this.reader.stop(); this.player?.stop(); this.player?.unlock();
|
|
1213
|
+
let conversation;
|
|
1214
|
+
const reader = createSentencePlayer({
|
|
1215
|
+
send: (value) => conversation.speak(value),
|
|
1216
|
+
play: (sentence) => sentence.bluetooth === "played" ? Promise.resolve() : this.player.play(wavBlob(sentence.audio)),
|
|
1217
|
+
halt: () => this.player.stop(), silence: () => {},
|
|
1218
|
+
onState: (playing) => conversation?.playbackState(playing),
|
|
1219
|
+
fail: (error) => { this.showSpeakProblem(error); void conversation.controller.disconnect("voice_playback_failed"); },
|
|
1220
|
+
});
|
|
1221
|
+
conversation = createVoiceConversation({
|
|
1222
|
+
request: (path, options) => this.runtime.voiceRequest(path, options),
|
|
1223
|
+
transcribe: (blob, signal) => this.runtime.transcribe(blob, this.voiceLanguage, signal),
|
|
1224
|
+
device: captureSource("mobile", settings.config.device),
|
|
1225
|
+
readAloud: continuous && settings.config.speak,
|
|
1226
|
+
preset: settings.config.preset, endpointSilenceMs: settings.config.endpointSilenceMs, idleTimeoutMs: settings.config.idleTimeoutMs, recoveryTimeoutMs: settings.config.recoveryTimeoutMs,
|
|
1227
|
+
onText: (text) => { this.voiceCaption = text; if (this.voiceCall) this.voiceCall = { ...this.voiceCall, caption: text }; },
|
|
1228
|
+
onTranscript: continuous && settings.config.preset !== "dictation" ? null : ({ text }) => {
|
|
1229
|
+
if (this.sessionId !== target) return;
|
|
1230
|
+
this.draft = [this.draft, text].filter(Boolean).join(" ");
|
|
1231
|
+
this.draftBySession = { ...this.draftBySession, [target || "@pending"]: this.draft };
|
|
1232
|
+
},
|
|
1233
|
+
mode: () => resolveSubmitMode(this.running, false, true, this.busyEnter),
|
|
1234
|
+
playback: { stop: () => { reader.stop(); this.player.stop(); } },
|
|
1235
|
+
onReply: (reply) => { const value = speakable(reply, settings.config.streaming); if (value) reader.follow(value); },
|
|
1236
|
+
});
|
|
1237
|
+
await this.attachVoiceConversation(conversation, target, continuous);
|
|
1238
|
+
} catch (error) { this.voicePhase = "error"; this.voiceError = error.message; }
|
|
1239
|
+
},
|
|
1240
|
+
async attachVoiceConversation(conversation, target, continuous) {
|
|
1241
|
+
this.voiceCaption = ""; this.voiceHistory = null;
|
|
1242
|
+
this.voiceConversation = conversation;
|
|
1243
|
+
this.voiceUnsub?.();
|
|
1244
|
+
this.voiceUnsub = conversation.controller.subscribe(() => {
|
|
1245
|
+
const state = conversation.controller.getSnapshot();
|
|
1246
|
+
if (state.connection === "open" && !target && !this.sessionId && !this.voiceAdoptingSession && state.sessionId) {
|
|
1247
|
+
this.voiceAdoptingSession = state.sessionId;
|
|
1248
|
+
void Promise.resolve(this.runtime.openSession(state.sessionId)).catch((error) => { this.voiceError = error.message; this.voiceAdoptingSession = null; });
|
|
1249
|
+
}
|
|
1250
|
+
this.voiceCall = state.connection === "closed" ? null : { ...state, caption: this.voiceCaption, history: this.voiceHistory };
|
|
1251
|
+
this.voicePhase = state.error ? "error" : state.capture;
|
|
1252
|
+
this.voiceError = state.error || "";
|
|
1193
1253
|
});
|
|
1194
|
-
|
|
1195
|
-
if (this.
|
|
1196
|
-
this.reader.stop();
|
|
1197
|
-
this.player?.stop();
|
|
1198
|
-
this.player?.unlock();
|
|
1199
|
-
void this.runtime.interruptSpeech?.();
|
|
1254
|
+
await conversation.start({ sessionId: target || null, continuous });
|
|
1255
|
+
if (this.voiceReleased && !continuous) conversation.controller.stopRecording();
|
|
1200
1256
|
},
|
|
1201
|
-
onHoldEnd(
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1257
|
+
onHoldEnd() {
|
|
1258
|
+
this.voiceReleased = true;
|
|
1259
|
+
if (!this.voiceConversation?.controller.getSnapshot().continuous) this.voiceConversation?.controller.stopRecording();
|
|
1260
|
+
},
|
|
1261
|
+
async voiceAction(action) {
|
|
1262
|
+
try {
|
|
1263
|
+
if (action === "mute") await this.voiceConversation?.setReadAloud(this.voiceCall.outputMuted);
|
|
1264
|
+
if (action === "history") {
|
|
1265
|
+
this.voiceHistory = (await this.voiceConversation?.records()).records;
|
|
1266
|
+
if (this.voiceCall) this.voiceCall = { ...this.voiceCall, history: this.voiceHistory };
|
|
1267
|
+
}
|
|
1268
|
+
if (action === "clear-history") { await this.voiceConversation?.clearRecords(); this.voiceHistory = null; }
|
|
1269
|
+
if (action === "input-pause") await this.voiceConversation?.controller.pauseInput();
|
|
1270
|
+
if (action === "input-resume") this.voiceConversation?.controller.resumeInput();
|
|
1271
|
+
if (action === "stop") await this.voiceConversation?.stop();
|
|
1272
|
+
if (action === "playback") await this.voiceConversation?.controller.stopPlayback();
|
|
1273
|
+
if (action === "cancel") await this.voiceConversation?.controller.cancelTask();
|
|
1274
|
+
if (action === "resume") await this.voiceConversation?.resume();
|
|
1275
|
+
} catch (error) { this.voiceError = error.message; }
|
|
1207
1276
|
},
|
|
1208
1277
|
showSpeakProblem(err) {
|
|
1209
1278
|
this.speakNotice = speakProblemText(this.t, err);
|
|
@@ -1381,27 +1450,6 @@ export default {
|
|
|
1381
1450
|
if (!sessionId) return;
|
|
1382
1451
|
return this.runtime.archiveSession(sessionId);
|
|
1383
1452
|
},
|
|
1384
|
-
async sendTranscript(text) {
|
|
1385
|
-
const spoken = String(text ?? "").trim();
|
|
1386
|
-
if (!spoken || !this.sessionId || this.modelBlocked || this.historyLoading || this.upload?.pending) return;
|
|
1387
|
-
const mode = resolveSubmitMode(this.running, false, true, this.busyEnter);
|
|
1388
|
-
this.sending = true;
|
|
1389
|
-
this.pendingUser = this.running ? "" : spoken;
|
|
1390
|
-
this.runtime.pinToBottom();
|
|
1391
|
-
this.pinLog();
|
|
1392
|
-
try {
|
|
1393
|
-
const sent = await this.runtime.send([{ type: "text", text: spoken }], mode);
|
|
1394
|
-
if (!sent.ok) {
|
|
1395
|
-
this.pendingUser = "";
|
|
1396
|
-
this.failed = sent.error?.message || sent.error?.code || "send";
|
|
1397
|
-
}
|
|
1398
|
-
} catch (err) {
|
|
1399
|
-
this.pendingUser = "";
|
|
1400
|
-
this.failed = err instanceof Error ? err.message : "send";
|
|
1401
|
-
} finally {
|
|
1402
|
-
this.sending = false;
|
|
1403
|
-
}
|
|
1404
|
-
},
|
|
1405
1453
|
async send(mode = "queue") {
|
|
1406
1454
|
const text = this.draft.trim();
|
|
1407
1455
|
if (!this.canSend) return;
|
package/src/chat/Composer.vue
CHANGED
|
@@ -82,6 +82,10 @@
|
|
|
82
82
|
</button>
|
|
83
83
|
</figure>
|
|
84
84
|
</div>
|
|
85
|
+
<select v-if="inputMode === 'voice' && !voiceCall" :value="voiceSelection || voiceDefaultMode" @change="voiceSelection = $event.target.value" class="lares-composer__voice-mode" :aria-label="t('settings.mode')">
|
|
86
|
+
<option value="single">{{ t('settings.mode.single') }}</option>
|
|
87
|
+
<option value="continuous">{{ t('settings.mode.continuous') }}</option>
|
|
88
|
+
</select>
|
|
85
89
|
<div class="lares-composer__line" :data-expanded="expanded ? 'true' : 'false'">
|
|
86
90
|
<button
|
|
87
91
|
type="button"
|
|
@@ -117,8 +121,7 @@
|
|
|
117
121
|
:data-phase="voicePhase"
|
|
118
122
|
:disabled="voiceBlocked"
|
|
119
123
|
:aria-label="voiceLabel"
|
|
120
|
-
@
|
|
121
|
-
@touchstart.prevent="onVoiceTouchStart"
|
|
124
|
+
@click="onVoiceClick"
|
|
122
125
|
@contextmenu.prevent
|
|
123
126
|
>
|
|
124
127
|
<span v-if="voicePhase === 'preparing' || voicePhase === 'transcribing'" class="lares-composer__talk-status">
|
|
@@ -181,6 +184,25 @@
|
|
|
181
184
|
</div>
|
|
182
185
|
</div>
|
|
183
186
|
</div>
|
|
187
|
+
<div v-if="voiceCall" class="lares-composer__hint" role="status" aria-live="polite">
|
|
188
|
+
<button v-if="voiceCall.continuous" type="button" @click="$emit('voice-action', 'mute')">{{ t(voiceCall.outputMuted ? 'call.unmute' : 'call.mute') }}</button>
|
|
189
|
+
<button v-if="voiceCall.continuous" type="button" @click="$emit('voice-action', 'history')">{{ t('call.history') }}</button>
|
|
190
|
+
<details v-if="voiceCall.history" open>
|
|
191
|
+
<summary>{{ t('call.history') }}</summary>
|
|
192
|
+
<p v-for="row in voiceCall.history" :key="row.id">{{ row.role }}: {{ row.text }}</p>
|
|
193
|
+
<button type="button" @click="$emit('voice-action', 'clear-history')">{{ t('call.clearHistory') }}</button>
|
|
194
|
+
</details>
|
|
195
|
+
<svg v-if="voiceCall.capture === 'recording' && voiceCall.inputLevel != null" width="80" height="24" aria-hidden="true">
|
|
196
|
+
<line v-for="(level, index) in levels" :key="index" :x1="index * 5" :x2="index * 5" :y1="12 - Math.min(11, level * 800)" :y2="12 + Math.min(11, level * 800)" stroke="currentColor" stroke-width="2" />
|
|
197
|
+
</svg>
|
|
198
|
+
<span v-if="voiceCall.caption">{{ voiceCall.caption }}</span>
|
|
199
|
+
<span>{{ voiceCall.capture === 'transcribing' ? t('mic.transcribing') : voiceCall.capture === 'recording' ? t('call.listening') : voiceCall.inputPaused ? t('call.inputPaused') : voiceCall.playback !== 'idle' ? t('call.halfDuplex') : t('call.waiting') }}</span>
|
|
200
|
+
<span v-if="voiceCall.playback !== 'idle'">{{ t('call.speaking') }}</span>
|
|
201
|
+
<button v-if="!voiceCall.continuous && voiceCall.capture === 'recording'" type="button" @click="$emit('hold-end')">{{ t('mic.finish') }}</button>
|
|
202
|
+
<button v-if="voiceCall.continuous && voiceCall.connection === 'open'" type="button" @click="$emit('voice-action', voiceCall.inputPaused ? 'input-resume' : 'input-pause')">{{ t(voiceCall.inputPaused ? 'call.resumeInput' : 'call.pauseInput') }}</button>
|
|
203
|
+
<button type="button" @click="$emit('voice-action', voiceCall.connection === 'paused' ? 'resume' : 'stop')">{{ t(voiceCall.connection === 'paused' ? 'call.resume' : voiceCall.continuous ? 'call.end' : 'mic.cancel') }}</button>
|
|
204
|
+
<button v-if="voiceCall.playback !== 'idle'" type="button" @click="$emit('voice-action', 'playback')">{{ t('call.stopPlayback') }}</button>
|
|
205
|
+
</div>
|
|
184
206
|
<p v-if="voiceError && voicePhase === 'error'" class="lares-composer__hint" data-status="error">{{ voiceError }}</p>
|
|
185
207
|
<p v-for="item in failures" :key="item.id" class="lares-composer__hint" data-status="error">
|
|
186
208
|
{{ t("upload.failed", { name: item.name, reason: t(`error.${item.code}`) }) }}
|
|
@@ -189,7 +211,7 @@
|
|
|
189
211
|
</template>
|
|
190
212
|
|
|
191
213
|
<script>
|
|
192
|
-
import { formatElapsed } from "
|
|
214
|
+
import { formatElapsed } from "@olares/lares-core/voice/recorder";
|
|
193
215
|
import {
|
|
194
216
|
REFERENCE_DEBOUNCE_MS,
|
|
195
217
|
activeReference,
|
|
@@ -208,6 +230,7 @@ export default {
|
|
|
208
230
|
canSend: { type: Boolean, default: false },
|
|
209
231
|
attachPending: { type: Boolean, default: false },
|
|
210
232
|
attachDisabled: { type: Boolean, default: false },
|
|
233
|
+
voiceCall: { type: Object, default: null },
|
|
211
234
|
voicePhase: { type: String, default: "idle" },
|
|
212
235
|
voiceElapsed: { type: Number, default: 0 },
|
|
213
236
|
voiceError: { type: String, default: "" },
|
|
@@ -219,11 +242,12 @@ export default {
|
|
|
219
242
|
commands: { type: Array, default: null },
|
|
220
243
|
commandsError: { type: String, default: "" },
|
|
221
244
|
hero: { type: Boolean, default: false },
|
|
245
|
+
voiceDefaultMode: { type: String, default: "continuous" },
|
|
222
246
|
inputMode: { type: String, default: "text" },
|
|
223
247
|
t: { type: Function, required: true },
|
|
224
248
|
},
|
|
225
249
|
emits: [
|
|
226
|
-
"update:draft", "update:inputMode", "send", "files", "voice", "hold-start", "hold-end",
|
|
250
|
+
"update:draft", "update:inputMode", "send", "files", "voice", "voice-action", "hold-start", "hold-end",
|
|
227
251
|
"open-commands", "open-references", "pick-command", "remove-image",
|
|
228
252
|
],
|
|
229
253
|
data() {
|
|
@@ -232,6 +256,8 @@ export default {
|
|
|
232
256
|
commandsOpen: false,
|
|
233
257
|
commandTyped: false,
|
|
234
258
|
commandIndex: 0,
|
|
259
|
+
levels: [],
|
|
260
|
+
voiceSelection: "",
|
|
235
261
|
referenceOpen: false,
|
|
236
262
|
referenceToken: null,
|
|
237
263
|
referenceIndex: 0,
|
|
@@ -244,10 +270,10 @@ export default {
|
|
|
244
270
|
},
|
|
245
271
|
talkLabel() {
|
|
246
272
|
if (this.voicePhase === "preparing") return this.t("composer.opening");
|
|
247
|
-
if (this.voicePhase === "recording") return `${this.t("
|
|
273
|
+
if (this.voicePhase === "recording") return `${this.t("call.listening")} · ${this.elapsed}`;
|
|
248
274
|
if (this.voicePhase === "transcribing") return this.t("mic.transcribing");
|
|
249
275
|
if (this.holding) return this.t("composer.hold");
|
|
250
|
-
return this.t("
|
|
276
|
+
return this.t("call.start");
|
|
251
277
|
},
|
|
252
278
|
voiceBusy() {
|
|
253
279
|
return this.voicePhase === "preparing"
|
|
@@ -264,9 +290,9 @@ export default {
|
|
|
264
290
|
voiceLabel() {
|
|
265
291
|
if (this.voicePhase === "error" && this.voiceError) return this.voiceError;
|
|
266
292
|
if (this.voicePhase === "preparing") return this.t("composer.opening");
|
|
267
|
-
if (this.voicePhase === "recording") return this.t("
|
|
293
|
+
if (this.voicePhase === "recording") return this.t("call.listening");
|
|
268
294
|
if (this.voicePhase === "transcribing") return this.t("mic.transcribing");
|
|
269
|
-
return this.t("
|
|
295
|
+
return this.t("call.start");
|
|
270
296
|
},
|
|
271
297
|
voiceBlocked() {
|
|
272
298
|
return this.voicePhase === "transcribing" || this.sending;
|
|
@@ -285,6 +311,7 @@ export default {
|
|
|
285
311
|
},
|
|
286
312
|
},
|
|
287
313
|
watch: {
|
|
314
|
+
"voiceCall.inputLevel"(level) { if (level != null) this.levels = [...this.levels.slice(-15), level]; },
|
|
288
315
|
draft(value) {
|
|
289
316
|
this.autosize();
|
|
290
317
|
if (value && this.inputMode === "voice" && !this.voiceBusy) this.setMode("text");
|
|
@@ -447,55 +474,15 @@ export default {
|
|
|
447
474
|
event.preventDefault();
|
|
448
475
|
}
|
|
449
476
|
},
|
|
450
|
-
|
|
451
|
-
if (
|
|
452
|
-
|
|
453
|
-
event.preventDefault();
|
|
454
|
-
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
455
|
-
this.beginHold("mouse");
|
|
456
|
-
},
|
|
457
|
-
onVoiceTouchStart() {
|
|
458
|
-
if (this.voiceBlocked || this.holdKind === "touch") return;
|
|
459
|
-
this.beginHold("touch");
|
|
460
|
-
},
|
|
461
|
-
beginHold(kind) {
|
|
462
|
-
const starting = !this.holding;
|
|
463
|
-
this.detachHold();
|
|
464
|
-
this.holding = true;
|
|
465
|
-
this.holdKind = kind;
|
|
466
|
-
if (starting) this.$refs.input?.blur();
|
|
467
|
-
const end = (event) => {
|
|
468
|
-
if (kind === "touch" && event.type.startsWith("pointer")) return;
|
|
469
|
-
if (kind === "mouse" && event.type.startsWith("touch")) return;
|
|
470
|
-
this.detachHold();
|
|
471
|
-
if (!this.holding) return;
|
|
472
|
-
this.holding = false;
|
|
473
|
-
this.holdKind = "";
|
|
474
|
-
this.$emit("hold-end", { via: event.type });
|
|
475
|
-
};
|
|
476
|
-
this.holdEnd = end;
|
|
477
|
-
if (kind === "touch") {
|
|
478
|
-
window.addEventListener("touchend", end);
|
|
479
|
-
window.addEventListener("touchcancel", end);
|
|
480
|
-
} else {
|
|
481
|
-
window.addEventListener("pointerup", end, true);
|
|
482
|
-
window.addEventListener("pointercancel", end, true);
|
|
483
|
-
}
|
|
484
|
-
if (starting) this.$emit("hold-start", { via: kind });
|
|
485
|
-
},
|
|
486
|
-
detachHold() {
|
|
487
|
-
if (!this.holdEnd) return;
|
|
488
|
-
window.removeEventListener("touchend", this.holdEnd);
|
|
489
|
-
window.removeEventListener("touchcancel", this.holdEnd);
|
|
490
|
-
window.removeEventListener("pointerup", this.holdEnd, true);
|
|
491
|
-
window.removeEventListener("pointercancel", this.holdEnd, true);
|
|
492
|
-
this.holdEnd = null;
|
|
477
|
+
onVoiceClick() {
|
|
478
|
+
if (this.voiceBusy) return;
|
|
479
|
+
this.$emit("hold-start", { mode: this.voiceSelection || undefined });
|
|
493
480
|
},
|
|
494
481
|
clearHold() {
|
|
495
|
-
this.detachHold();
|
|
496
482
|
this.holding = false;
|
|
497
483
|
this.holdKind = "";
|
|
498
484
|
},
|
|
485
|
+
detachHold() {},
|
|
499
486
|
pickFiles() {
|
|
500
487
|
this.$refs.picker?.click();
|
|
501
488
|
},
|
|
@@ -915,3 +902,8 @@ export default {
|
|
|
915
902
|
}
|
|
916
903
|
}
|
|
917
904
|
</style>
|
|
905
|
+
|
|
906
|
+
<style scoped>
|
|
907
|
+
.lares-composer__voice-mode { margin: 8px 12px; border: 0; border-radius: 8px; padding: 6px; background: transparent; color: inherit; font: inherit; }
|
|
908
|
+
.lares-composer__hint button { min-height: 44px; border: 0; border-radius: 12px; padding: 8px 12px; background: var(--lares-bg-secondary, #f0f0f0); color: inherit; font: inherit; }
|
|
909
|
+
</style>
|
package/src/host.js
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
rawFileUrl,
|
|
13
13
|
} from "@olares/lares-core/files/preview-workspace";
|
|
14
14
|
import { FILES_UPLOAD_PATH, uploadFile } from "@olares/lares-core/files/upload-client";
|
|
15
|
-
import { API as VOICE_API, postTranscribe } from "@olares/lares-core/voice/client";
|
|
15
|
+
import { API as VOICE_API, postTranscribe, voiceHeaders } from "@olares/lares-core/voice/client";
|
|
16
16
|
import { postReport, REPORT_PATH } from "@olares/lares-core/log/report";
|
|
17
17
|
|
|
18
18
|
function pageOrigin() {
|
|
@@ -270,6 +270,11 @@ export function createHostClient(ports = {}) {
|
|
|
270
270
|
request: direct,
|
|
271
271
|
});
|
|
272
272
|
},
|
|
273
|
+
voiceSocket(ticket) {
|
|
274
|
+
const auth = socketProtocols(ports);
|
|
275
|
+
return new WebSocket(muxWsUrl(urlFor(`${VOICE_API}/realtime`), pageOrigin()), ["lares-voice", `lares-ticket.${ticket}`, ...(Array.isArray(auth) ? auth : auth ? [auth] : [])]);
|
|
276
|
+
},
|
|
277
|
+
voiceRequest(path, options) { return request(path, options); },
|
|
273
278
|
transcribe(blob, language, signal) {
|
|
274
279
|
return postTranscribe(blob, language, signal, {
|
|
275
280
|
baseUrl: urlFor(VOICE_API),
|
|
@@ -286,6 +291,7 @@ export function createHostClient(ports = {}) {
|
|
|
286
291
|
async speak({ key, text, done, part }) {
|
|
287
292
|
const res = await request(`${VOICE_API}/speak`, {
|
|
288
293
|
method: "POST",
|
|
294
|
+
headers: voiceHeaders("device"),
|
|
289
295
|
body: Number.isInteger(part) ? { key, text, done, part } : { key, text, done },
|
|
290
296
|
});
|
|
291
297
|
const body = res?.body;
|
|
@@ -302,7 +308,7 @@ export function createHostClient(ports = {}) {
|
|
|
302
308
|
throw Object.assign(new Error(message), { code });
|
|
303
309
|
},
|
|
304
310
|
async interruptSpeech() {
|
|
305
|
-
const res = await request(`${VOICE_API}/playback/stop`, { method: "POST" });
|
|
311
|
+
const res = await request(`${VOICE_API}/playback/stop`, { method: "POST", headers: voiceHeaders("device") });
|
|
306
312
|
return Boolean(res?.ok);
|
|
307
313
|
},
|
|
308
314
|
async openMux(signal) {
|
|
@@ -72,6 +72,8 @@
|
|
|
72
72
|
:can-send="state.canSend"
|
|
73
73
|
:attach-pending="state.upload.pending > 0"
|
|
74
74
|
:attach-disabled="!state.sessionId || state.historyLoading || state.starting"
|
|
75
|
+
:voice-call="state.voiceCall"
|
|
76
|
+
:voice-default-mode="state.voiceDefaultMode"
|
|
75
77
|
:voice-phase="state.voicePhase"
|
|
76
78
|
:voice-elapsed="state.voiceElapsed"
|
|
77
79
|
:voice-error="state.voiceErrorText"
|
|
@@ -91,6 +93,7 @@
|
|
|
91
93
|
@voice="$emit('voice')"
|
|
92
94
|
@hold-start="$emit('hold-start', $event)"
|
|
93
95
|
@hold-end="$emit('hold-end', $event)"
|
|
96
|
+
@voice-action="$emit('voice-action', $event)"
|
|
94
97
|
@open-commands="$emit('open-commands')"
|
|
95
98
|
@open-references="$emit('open-references', $event)"
|
|
96
99
|
@pick-command="$emit('pick-command', $event)"
|
|
@@ -133,6 +136,8 @@
|
|
|
133
136
|
:can-send="state.canSend"
|
|
134
137
|
:attach-pending="state.upload.pending > 0"
|
|
135
138
|
:attach-disabled="!state.sessionId || state.historyLoading || state.starting"
|
|
139
|
+
:voice-call="state.voiceCall"
|
|
140
|
+
:voice-default-mode="state.voiceDefaultMode"
|
|
136
141
|
:voice-phase="state.voicePhase"
|
|
137
142
|
:voice-elapsed="state.voiceElapsed"
|
|
138
143
|
:voice-error="state.voiceErrorText"
|
|
@@ -152,6 +157,7 @@
|
|
|
152
157
|
@voice="$emit('voice')"
|
|
153
158
|
@hold-start="$emit('hold-start', $event)"
|
|
154
159
|
@hold-end="$emit('hold-end', $event)"
|
|
160
|
+
@voice-action="$emit('voice-action', $event)"
|
|
155
161
|
@open-commands="$emit('open-commands')"
|
|
156
162
|
@open-references="$emit('open-references', $event)"
|
|
157
163
|
@pick-command="$emit('pick-command', $event)"
|
|
@@ -249,7 +255,7 @@ export default {
|
|
|
249
255
|
emits: [
|
|
250
256
|
"history", "create", "toggle-speak", "close-history", "pick-session", "retry", "open-file",
|
|
251
257
|
"answer", "update-draft", "send", "queue-action", "open-model", "open-effort", "files",
|
|
252
|
-
"voice", "hold-start", "hold-end", "close-model", "choose-model",
|
|
258
|
+
"voice", "voice-action", "hold-start", "hold-end", "close-model", "choose-model",
|
|
253
259
|
"close-effort", "choose-effort", "close-preview",
|
|
254
260
|
"open-commands", "open-references", "pick-command", "remove-image",
|
|
255
261
|
"refresh-workspaces", "browse-workspaces", "browse-directory",
|
|
@@ -41,7 +41,6 @@
|
|
|
41
41
|
<p class="lares-agent__hint">{{ t("settings.input.busyEnter.hint") }}</p>
|
|
42
42
|
<p v-if="conversationError" class="lares-agent__hint" data-status="error">{{ conversationError }}</p>
|
|
43
43
|
</section>
|
|
44
|
-
<!-- Voice input parked.
|
|
45
44
|
<section class="lares-agent__group">
|
|
46
45
|
<h2>{{ tVoice("settings.title") }}</h2>
|
|
47
46
|
<div class="lares-agent__list">
|
|
@@ -62,9 +61,43 @@
|
|
|
62
61
|
@click="openSheet('voiceLang')"
|
|
63
62
|
/>
|
|
64
63
|
</div>
|
|
64
|
+
<label class="lares-agent__hint">{{ tVoice('settings.preset') }}
|
|
65
|
+
<select :value="voice?.config?.preset || 'assistant'" @change="patchVoice('preset', $event.target.value)">
|
|
66
|
+
<option v-for="preset in ['dictation', 'chat', 'assistant', 'direct']" :key="preset" :value="preset">{{ tVoice(`settings.preset.${preset}`) }}</option>
|
|
67
|
+
</select>
|
|
68
|
+
</label>
|
|
69
|
+
<label class="lares-agent__hint">{{ tVoice('settings.chatModel') }}
|
|
70
|
+
<select :value="voice?.config?.chatModel || 'default-chat'" @change="patchVoice('chatModel', $event.target.value)">
|
|
71
|
+
<option value="default-chat">default-chat</option>
|
|
72
|
+
<option v-for="model in voice?.chatModels || []" :key="model" :value="model">{{ model }}</option>
|
|
73
|
+
</select>
|
|
74
|
+
</label>
|
|
75
|
+
<label class="lares-agent__hint">{{ tVoice('settings.backend') }}
|
|
76
|
+
<select :value="voice?.config?.backend || 'pipeline'" @change="patchVoice('backend', $event.target.value)">
|
|
77
|
+
<option value="pipeline">{{ tVoice('settings.backend.pipeline') }}</option>
|
|
78
|
+
<option v-if="voice?.realtimeReady?.includes(voice?.config?.realtimeModel)" value="realtime">Realtime</option>
|
|
79
|
+
</select>
|
|
80
|
+
</label>
|
|
81
|
+
<label v-if="voice?.realtimeModels?.length" class="lares-agent__hint">{{ tVoice('settings.realtime.model') }}
|
|
82
|
+
<select :value="voice?.config?.realtimeModel || ''" @change="patchVoice('realtimeModel', $event.target.value)">
|
|
83
|
+
<option value="" disabled>—</option>
|
|
84
|
+
<option v-for="model in voice.realtimeModels" :key="model" :value="model">{{ model }}</option>
|
|
85
|
+
</select>
|
|
86
|
+
</label>
|
|
87
|
+
<p class="lares-agent__hint">{{ tVoice('settings.realtime.hint') }}</p>
|
|
88
|
+
<label class="lares-agent__hint">{{ tVoice('settings.mobileMode') }}
|
|
89
|
+
<select :value="voice?.config?.mobileMode || 'continuous'" @change="patchVoice('mobileMode', $event.target.value)">
|
|
90
|
+
<option value="single">{{ tVoice('settings.mode.single') }}</option>
|
|
91
|
+
<option value="continuous">{{ tVoice('settings.mode.continuous') }}</option>
|
|
92
|
+
</select>
|
|
93
|
+
</label>
|
|
94
|
+
<label class="lares-agent__hint">{{ tVoice('settings.device') }}
|
|
95
|
+
<select :value="voice?.config?.device || 'auto'" @change="patchVoice('device', $event.target.value)">
|
|
96
|
+
<option v-for="device in ['auto', 'device', 'headset']" :key="device" :value="device">{{ tVoice(`settings.device.${device}`) }}</option>
|
|
97
|
+
</select>
|
|
98
|
+
</label>
|
|
65
99
|
<p v-if="voiceError" class="lares-agent__hint" data-status="error">{{ voiceError }}</p>
|
|
66
100
|
</section>
|
|
67
|
-
-->
|
|
68
101
|
<section class="lares-agent__group">
|
|
69
102
|
<h2>{{ tSearch("settings.title") }}</h2>
|
|
70
103
|
<LaresSelect
|
package/src/chat/voice.js
DELETED
|
@@ -1,269 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
formatElapsed,
|
|
3
|
-
isRecordingTooShort,
|
|
4
|
-
pickRecordingMime,
|
|
5
|
-
} from "@olares/lares-core/voice/recorder";
|
|
6
|
-
|
|
7
|
-
export { formatElapsed };
|
|
8
|
-
|
|
9
|
-
/** Phone mic. Bare booleans are exact and iOS rejects the headset profile. */
|
|
10
|
-
const DEVICE_AUDIO = {
|
|
11
|
-
channelCount: { ideal: 1 },
|
|
12
|
-
echoCancellation: { ideal: true },
|
|
13
|
-
noiseSuppression: { ideal: true },
|
|
14
|
-
autoGainControl: { ideal: true },
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
const CHUNK_MS = 250;
|
|
18
|
-
|
|
19
|
-
function micFailure(err) {
|
|
20
|
-
const name = err?.name || "";
|
|
21
|
-
if (name === "NotAllowedError" || name === "PermissionDeniedError") return "voice_permission_denied";
|
|
22
|
-
if (name === "SecurityError") return "voice_unsupported";
|
|
23
|
-
return "voice_failed";
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
async function openDeviceMic(mediaDevices) {
|
|
27
|
-
try {
|
|
28
|
-
return await mediaDevices.getUserMedia({ audio: DEVICE_AUDIO });
|
|
29
|
-
} catch (err) {
|
|
30
|
-
if (micFailure(err) !== "voice_failed") throw err;
|
|
31
|
-
return mediaDevices.getUserMedia({ audio: true });
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function createVoiceCapture({
|
|
36
|
-
transcribe,
|
|
37
|
-
submit,
|
|
38
|
-
onPhase,
|
|
39
|
-
onTrace,
|
|
40
|
-
now = Date.now,
|
|
41
|
-
media,
|
|
42
|
-
}) {
|
|
43
|
-
const mediaDevices = media?.mediaDevices ?? globalThis.navigator?.mediaDevices;
|
|
44
|
-
const Recorder = media?.MediaRecorder ?? globalThis.MediaRecorder;
|
|
45
|
-
let recorder = null;
|
|
46
|
-
let stream = null;
|
|
47
|
-
let chunks = [];
|
|
48
|
-
let started = 0;
|
|
49
|
-
let timer = 0;
|
|
50
|
-
let errorTimer = 0;
|
|
51
|
-
let finishTimer = 0;
|
|
52
|
-
let cancel = false;
|
|
53
|
-
let released = false;
|
|
54
|
-
let finished = false;
|
|
55
|
-
let dead = false;
|
|
56
|
-
let abort = null;
|
|
57
|
-
let phase = "idle";
|
|
58
|
-
|
|
59
|
-
const emit = (next, extra = {}) => {
|
|
60
|
-
phase = next;
|
|
61
|
-
onPhase?.(next, extra);
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
const trace = (event, fields) => {
|
|
65
|
-
try {
|
|
66
|
-
onTrace?.(event, fields);
|
|
67
|
-
} catch {
|
|
68
|
-
/* a trace must not change the take */
|
|
69
|
-
}
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
const stopTimer = () => {
|
|
73
|
-
if (timer) {
|
|
74
|
-
clearInterval(timer);
|
|
75
|
-
timer = 0;
|
|
76
|
-
}
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
const release = () => {
|
|
80
|
-
stream?.getTracks().forEach((track) => track.stop());
|
|
81
|
-
stream = null;
|
|
82
|
-
recorder = null;
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
const fail = (code) => {
|
|
86
|
-
if (dead) return;
|
|
87
|
-
if (errorTimer) clearTimeout(errorTimer);
|
|
88
|
-
emit("error", { error: code, elapsed: 0 });
|
|
89
|
-
errorTimer = setTimeout(() => {
|
|
90
|
-
errorTimer = 0;
|
|
91
|
-
if (!dead && phase === "error") emit("idle", { error: "", elapsed: 0 });
|
|
92
|
-
}, 3200);
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
const finish = async () => {
|
|
96
|
-
if (finished || dead) return;
|
|
97
|
-
finished = true;
|
|
98
|
-
stopTimer();
|
|
99
|
-
const took = now() - started;
|
|
100
|
-
const type = recorder?.mimeType || chunks[0]?.type || "audio/webm";
|
|
101
|
-
const blobChunks = chunks;
|
|
102
|
-
chunks = [];
|
|
103
|
-
release();
|
|
104
|
-
if (cancel) {
|
|
105
|
-
emit("idle", { error: "", elapsed: 0 });
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
const blob = new Blob(blobChunks, { type });
|
|
109
|
-
trace("blob", { bytes: blob.size, type, ms: took, chunks: blobChunks.length });
|
|
110
|
-
if (isRecordingTooShort(took, blob.size)) {
|
|
111
|
-
trace("too-short", { bytes: blob.size, ms: took });
|
|
112
|
-
fail("voice_too_short");
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
emit("transcribing", { elapsed: took, error: "" });
|
|
116
|
-
abort = new AbortController();
|
|
117
|
-
try {
|
|
118
|
-
const text = String(await transcribe(blob, abort.signal) ?? "").trim();
|
|
119
|
-
if (dead || cancel) return;
|
|
120
|
-
if (!text) {
|
|
121
|
-
trace("no-input", {});
|
|
122
|
-
fail("voice_no_input");
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
trace("transcript", { chars: text.length, head: text.slice(0, 40) });
|
|
126
|
-
emit("idle", { error: "", elapsed: 0 });
|
|
127
|
-
submit?.(text);
|
|
128
|
-
} catch (err) {
|
|
129
|
-
if (dead || abort.signal.aborted || cancel) return;
|
|
130
|
-
const code = err instanceof Error ? err.message : "voice_failed";
|
|
131
|
-
trace("transcribe-error", { code });
|
|
132
|
-
fail(code);
|
|
133
|
-
} finally {
|
|
134
|
-
abort = null;
|
|
135
|
-
}
|
|
136
|
-
};
|
|
137
|
-
|
|
138
|
-
const scheduleFinish = () => {
|
|
139
|
-
if (finishTimer) clearTimeout(finishTimer);
|
|
140
|
-
finishTimer = setTimeout(() => {
|
|
141
|
-
finishTimer = 0;
|
|
142
|
-
void finish();
|
|
143
|
-
}, 30);
|
|
144
|
-
};
|
|
145
|
-
|
|
146
|
-
const endRecorder = () => {
|
|
147
|
-
if (!recorder || recorder.state === "inactive") {
|
|
148
|
-
scheduleFinish();
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
try {
|
|
152
|
-
recorder.requestData?.();
|
|
153
|
-
} catch {
|
|
154
|
-
/* the final dataavailable still arrives from stop() */
|
|
155
|
-
}
|
|
156
|
-
try {
|
|
157
|
-
recorder.stop();
|
|
158
|
-
} catch {
|
|
159
|
-
scheduleFinish();
|
|
160
|
-
}
|
|
161
|
-
};
|
|
162
|
-
|
|
163
|
-
return {
|
|
164
|
-
get phase() {
|
|
165
|
-
return phase;
|
|
166
|
-
},
|
|
167
|
-
async start() {
|
|
168
|
-
if (dead || phase === "preparing" || phase === "recording" || phase === "transcribing") return;
|
|
169
|
-
if (typeof Recorder === "undefined" || !mediaDevices?.getUserMedia) {
|
|
170
|
-
fail("voice_unsupported");
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
cancel = false;
|
|
174
|
-
released = false;
|
|
175
|
-
finished = false;
|
|
176
|
-
trace("open", {});
|
|
177
|
-
emit("preparing", { elapsed: 0, error: "" });
|
|
178
|
-
let next;
|
|
179
|
-
try {
|
|
180
|
-
next = await openDeviceMic(mediaDevices);
|
|
181
|
-
} catch (err) {
|
|
182
|
-
const code = micFailure(err);
|
|
183
|
-
trace("mic-fail", { code, name: err?.name || "" });
|
|
184
|
-
if (!dead) fail(code);
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
if (dead || cancel) {
|
|
188
|
-
next.getTracks().forEach((track) => track.stop());
|
|
189
|
-
if (!dead && phase === "preparing") emit("idle", { error: "", elapsed: 0 });
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
stream = next;
|
|
193
|
-
chunks = [];
|
|
194
|
-
const mime = pickRecordingMime(Recorder);
|
|
195
|
-
try {
|
|
196
|
-
recorder = new Recorder(stream, mime ? { mimeType: mime } : undefined);
|
|
197
|
-
} catch {
|
|
198
|
-
release();
|
|
199
|
-
fail("voice_unsupported");
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
recorder.ondataavailable = (event) => {
|
|
203
|
-
if (event.data?.size) chunks.push(event.data);
|
|
204
|
-
if (recorder?.state === "inactive") scheduleFinish();
|
|
205
|
-
};
|
|
206
|
-
recorder.onstop = () => scheduleFinish();
|
|
207
|
-
started = now();
|
|
208
|
-
try {
|
|
209
|
-
recorder.start(CHUNK_MS);
|
|
210
|
-
} catch {
|
|
211
|
-
trace("record-fail", { mime });
|
|
212
|
-
release();
|
|
213
|
-
fail("voice_unsupported");
|
|
214
|
-
return;
|
|
215
|
-
}
|
|
216
|
-
trace("recording", { mime: recorder.mimeType || mime || "" });
|
|
217
|
-
if (released || cancel) {
|
|
218
|
-
endRecorder();
|
|
219
|
-
return;
|
|
220
|
-
}
|
|
221
|
-
emit("recording", { elapsed: 0, error: "" });
|
|
222
|
-
timer = setInterval(() => emit("recording", { elapsed: now() - started, error: "" }), 200);
|
|
223
|
-
},
|
|
224
|
-
/**
|
|
225
|
-
* Finger-up finishes the take. The mic opens asynchronously, so the finger
|
|
226
|
-
* often lifts while still preparing — that is not a cancel.
|
|
227
|
-
*/
|
|
228
|
-
stop(aborting = false) {
|
|
229
|
-
if (phase === "transcribing") {
|
|
230
|
-
if (aborting) {
|
|
231
|
-
cancel = true;
|
|
232
|
-
abort?.abort();
|
|
233
|
-
}
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
if (phase !== "preparing" && phase !== "recording") {
|
|
237
|
-
trace("stop-ignored", { aborting: Boolean(aborting), phase });
|
|
238
|
-
return;
|
|
239
|
-
}
|
|
240
|
-
trace("stop", {
|
|
241
|
-
aborting: Boolean(aborting),
|
|
242
|
-
phase,
|
|
243
|
-
recorder: recorder?.state || "none",
|
|
244
|
-
});
|
|
245
|
-
if (aborting) {
|
|
246
|
-
cancel = true;
|
|
247
|
-
released = false;
|
|
248
|
-
if (recorder && recorder.state !== "inactive") recorder.stop();
|
|
249
|
-
return;
|
|
250
|
-
}
|
|
251
|
-
released = true;
|
|
252
|
-
stopTimer();
|
|
253
|
-
if (recorder && recorder.state !== "inactive") {
|
|
254
|
-
emit("transcribing", { elapsed: now() - started, error: "" });
|
|
255
|
-
endRecorder();
|
|
256
|
-
}
|
|
257
|
-
},
|
|
258
|
-
dispose() {
|
|
259
|
-
dead = true;
|
|
260
|
-
cancel = true;
|
|
261
|
-
if (errorTimer) clearTimeout(errorTimer);
|
|
262
|
-
if (finishTimer) clearTimeout(finishTimer);
|
|
263
|
-
stopTimer();
|
|
264
|
-
abort?.abort();
|
|
265
|
-
if (recorder && recorder.state !== "inactive") recorder.stop();
|
|
266
|
-
release();
|
|
267
|
-
},
|
|
268
|
-
};
|
|
269
|
-
}
|