@olares/lares-mobile 0.15.10 → 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 +131 -81
- package/src/chat/Composer.vue +44 -52
- package/src/chat/playback.js +19 -10
- package/src/host.js +16 -5
- 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
|
-
import { createReplySpeaker } from "@olares/lares-core/voice/speech";
|
|
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,16 @@ 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",
|
|
232
|
+
voiceStreaming: false,
|
|
226
233
|
speakBusy: false,
|
|
227
234
|
speakNotice: "",
|
|
228
235
|
intakeUnsub: null,
|
|
@@ -328,6 +335,8 @@ export default {
|
|
|
328
335
|
effortDisabled: this.effortDisabled,
|
|
329
336
|
upload: this.upload,
|
|
330
337
|
draftImages: this.draftImages,
|
|
338
|
+
voiceDefaultMode: this.voiceDefaultMode,
|
|
339
|
+
voiceCall: this.voiceCall,
|
|
331
340
|
voicePhase: this.voicePhase,
|
|
332
341
|
voiceElapsed: this.voiceElapsed,
|
|
333
342
|
voiceErrorText: this.voiceErrorText,
|
|
@@ -523,26 +532,21 @@ export default {
|
|
|
523
532
|
this.imageUploadControllers = new Map();
|
|
524
533
|
this.player = createAnswerPlayer();
|
|
525
534
|
this.replySpeaker = createReplySpeaker();
|
|
526
|
-
this.
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
if (extra.elapsed != null) this.voiceElapsed = extra.elapsed;
|
|
532
|
-
if (extra.error != null) this.voiceError = extra.error;
|
|
535
|
+
this.reader = createSentencePlayer({
|
|
536
|
+
send: async (request) => {
|
|
537
|
+
const result = await this.runtime.speak?.(request);
|
|
538
|
+
if (!result) throw Object.assign(new Error("voice_speak_failed"), { code: "voice_speak_failed" });
|
|
539
|
+
return result;
|
|
533
540
|
},
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
submit: (text) => {
|
|
538
|
-
void this.runtime.reportLog?.("voice", "submit", {
|
|
539
|
-
chars: String(text ?? "").trim().length,
|
|
540
|
-
running: Boolean(this.running),
|
|
541
|
-
session: Boolean(this.sessionId),
|
|
542
|
-
});
|
|
543
|
-
void this.sendTranscript(text);
|
|
541
|
+
play: (sentence) => {
|
|
542
|
+
if (!browserPlays("mobile", sentence.bluetooth)) return Promise.resolve(false);
|
|
543
|
+
return this.player?.play(wavBlob(sentence.audio));
|
|
544
544
|
},
|
|
545
|
+
halt: () => this.player?.stop(),
|
|
546
|
+
silence: () => void this.runtime.interruptSpeech?.(),
|
|
547
|
+
fail: (err) => this.showSpeakProblem(err),
|
|
545
548
|
});
|
|
549
|
+
|
|
546
550
|
this.bindIntake(this.sessionId);
|
|
547
551
|
this.bindRuntime();
|
|
548
552
|
try {
|
|
@@ -558,6 +562,13 @@ export default {
|
|
|
558
562
|
this.conversationUnsub = subscribeConversationSettings((section) => {
|
|
559
563
|
this.busyEnter = section.busyEnter;
|
|
560
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);
|
|
561
572
|
this.retry();
|
|
562
573
|
},
|
|
563
574
|
activated() {
|
|
@@ -567,11 +578,14 @@ export default {
|
|
|
567
578
|
this.captureLog();
|
|
568
579
|
},
|
|
569
580
|
beforeUnmount() {
|
|
581
|
+
document.removeEventListener("visibilitychange", this.voiceVisibilityHandler);
|
|
582
|
+
clearInterval(this.voiceClock);
|
|
570
583
|
this.captureLog();
|
|
571
584
|
this.noteSeen(this.sessionId);
|
|
572
585
|
for (const controller of this.imageUploadControllers?.values?.() ?? []) controller.abort();
|
|
573
586
|
this.imageUploadControllers?.clear?.();
|
|
574
|
-
this.
|
|
587
|
+
void this.voiceConversation?.stop().catch(() => {});
|
|
588
|
+
this.voiceUnsub?.();
|
|
575
589
|
this.conversationUnsub?.();
|
|
576
590
|
this.intakeUnsub?.();
|
|
577
591
|
if (this.sessionId) this.intake?.cancelSession(this.sessionId);
|
|
@@ -628,21 +642,24 @@ export default {
|
|
|
628
642
|
this.commands = null;
|
|
629
643
|
this.commandsError = "";
|
|
630
644
|
}
|
|
631
|
-
if (sessionChanged) {
|
|
632
|
-
this.
|
|
645
|
+
if (sessionChanged && snap.sessionId !== this.voiceAdoptingSession) {
|
|
646
|
+
void this.voiceConversation?.stop().catch(() => {});
|
|
647
|
+
this.reader.stop();
|
|
633
648
|
this.player?.stop();
|
|
634
649
|
}
|
|
635
|
-
|
|
650
|
+
if (sessionChanged) this.voiceAdoptingSession = null;
|
|
651
|
+
const utterance = this.replySpeaker.observe({
|
|
636
652
|
sessionChanged,
|
|
637
653
|
wasRunning: this.running,
|
|
638
654
|
running: snap.running,
|
|
639
|
-
|
|
655
|
+
utterance: snap.utterance,
|
|
640
656
|
});
|
|
641
657
|
this.sessionId = snap.sessionId;
|
|
642
658
|
this.items = snap.items;
|
|
643
659
|
this.running = snap.running;
|
|
644
660
|
this.runningSince = snap.runningSince || 0;
|
|
645
|
-
|
|
661
|
+
const target = this.voiceSpeak && !this.voiceCall ? speakable(utterance, this.voiceStreaming) : null;
|
|
662
|
+
if (target) this.reader.follow({ ...target, key: `${snap.sessionId}:${target.key}` });
|
|
646
663
|
this.failed = snap.failed;
|
|
647
664
|
this.error = snap.error;
|
|
648
665
|
this.sessions = snap.sessions ?? this.sessions;
|
|
@@ -936,7 +953,7 @@ export default {
|
|
|
936
953
|
this.unsub = this.runtime.subscribe((snap) => this.applySnap(snap));
|
|
937
954
|
},
|
|
938
955
|
switchHost() {
|
|
939
|
-
this.
|
|
956
|
+
void this.voiceConversation?.stop().catch(() => {});
|
|
940
957
|
for (const controller of this.imageUploadControllers.values()) controller.abort();
|
|
941
958
|
this.imageUploadControllers.clear();
|
|
942
959
|
this.releaseDraftImages();
|
|
@@ -1015,10 +1032,13 @@ export default {
|
|
|
1015
1032
|
if (settings) {
|
|
1016
1033
|
jobs.push(settings.voice().then((voice) => {
|
|
1017
1034
|
this.voiceLanguage = voice.config?.language || "";
|
|
1035
|
+
this.voiceDefaultMode = voice.config?.mobileMode || "continuous";
|
|
1018
1036
|
this.voiceSpeak = voice.config?.speak === true;
|
|
1037
|
+
this.voiceStreaming = voice.config?.streaming === true;
|
|
1019
1038
|
}).catch(() => {
|
|
1020
1039
|
this.voiceLanguage = "";
|
|
1021
1040
|
this.voiceSpeak = false;
|
|
1041
|
+
this.voiceStreaming = false;
|
|
1022
1042
|
}));
|
|
1023
1043
|
}
|
|
1024
1044
|
await Promise.all(jobs);
|
|
@@ -1169,24 +1189,90 @@ export default {
|
|
|
1169
1189
|
this.draftImages = [];
|
|
1170
1190
|
this.imagesBySession = {};
|
|
1171
1191
|
},
|
|
1172
|
-
onHoldStart(
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
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 || "";
|
|
1176
1253
|
});
|
|
1177
|
-
|
|
1178
|
-
if (this.
|
|
1179
|
-
this.speakGen += 1;
|
|
1180
|
-
this.player?.stop();
|
|
1181
|
-
this.player?.unlock();
|
|
1182
|
-
void this.runtime.interruptSpeech?.();
|
|
1254
|
+
await conversation.start({ sessionId: target || null, continuous });
|
|
1255
|
+
if (this.voiceReleased && !continuous) conversation.controller.stopRecording();
|
|
1183
1256
|
},
|
|
1184
|
-
onHoldEnd(
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
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; }
|
|
1190
1276
|
},
|
|
1191
1277
|
showSpeakProblem(err) {
|
|
1192
1278
|
this.speakNotice = speakProblemText(this.t, err);
|
|
@@ -1203,13 +1289,14 @@ export default {
|
|
|
1203
1289
|
this.voiceSpeak = next;
|
|
1204
1290
|
this.speakBusy = true;
|
|
1205
1291
|
if (!next) {
|
|
1206
|
-
this.
|
|
1292
|
+
this.reader.stop();
|
|
1207
1293
|
this.player?.stop();
|
|
1208
1294
|
void this.runtime.interruptSpeech?.();
|
|
1209
1295
|
}
|
|
1210
1296
|
try {
|
|
1211
1297
|
const voice = await this.runtime.settings.saveVoice({ speak: next });
|
|
1212
1298
|
this.voiceSpeak = voice?.config ? voice.config.speak === true : next;
|
|
1299
|
+
this.voiceStreaming = voice?.config?.streaming === true;
|
|
1213
1300
|
const problem = this.voiceSpeak ? speakToggleNotice(voice?.ttsModels) : "";
|
|
1214
1301
|
this.speakNotice = problem ? this.t(`error.${problem}`) : "";
|
|
1215
1302
|
} catch (err) {
|
|
@@ -1219,22 +1306,6 @@ export default {
|
|
|
1219
1306
|
this.speakBusy = false;
|
|
1220
1307
|
}
|
|
1221
1308
|
},
|
|
1222
|
-
async speakReply(text, gen) {
|
|
1223
|
-
if (!this.voiceSpeak) return;
|
|
1224
|
-
try {
|
|
1225
|
-
const result = await this.runtime.speak?.(text);
|
|
1226
|
-
if (gen !== this.speakGen) return;
|
|
1227
|
-
if (!result?.audio) {
|
|
1228
|
-
this.showSpeakProblem(Object.assign(new Error("voice_speak_failed"), { code: "voice_speak_failed" }));
|
|
1229
|
-
return;
|
|
1230
|
-
}
|
|
1231
|
-
if (!browserPlays("mobile", result.bluetooth)) return;
|
|
1232
|
-
await this.player?.play(wavBlob(result.audio));
|
|
1233
|
-
} catch (err) {
|
|
1234
|
-
if (gen !== this.speakGen) return;
|
|
1235
|
-
this.showSpeakProblem(err);
|
|
1236
|
-
}
|
|
1237
|
-
},
|
|
1238
1309
|
toggleHistory() {
|
|
1239
1310
|
if (this.panel === "history") {
|
|
1240
1311
|
this.panel = "";
|
|
@@ -1379,27 +1450,6 @@ export default {
|
|
|
1379
1450
|
if (!sessionId) return;
|
|
1380
1451
|
return this.runtime.archiveSession(sessionId);
|
|
1381
1452
|
},
|
|
1382
|
-
async sendTranscript(text) {
|
|
1383
|
-
const spoken = String(text ?? "").trim();
|
|
1384
|
-
if (!spoken || !this.sessionId || this.modelBlocked || this.historyLoading || this.upload?.pending) return;
|
|
1385
|
-
const mode = resolveSubmitMode(this.running, false, true, this.busyEnter);
|
|
1386
|
-
this.sending = true;
|
|
1387
|
-
this.pendingUser = this.running ? "" : spoken;
|
|
1388
|
-
this.runtime.pinToBottom();
|
|
1389
|
-
this.pinLog();
|
|
1390
|
-
try {
|
|
1391
|
-
const sent = await this.runtime.send([{ type: "text", text: spoken }], mode);
|
|
1392
|
-
if (!sent.ok) {
|
|
1393
|
-
this.pendingUser = "";
|
|
1394
|
-
this.failed = sent.error?.message || sent.error?.code || "send";
|
|
1395
|
-
}
|
|
1396
|
-
} catch (err) {
|
|
1397
|
-
this.pendingUser = "";
|
|
1398
|
-
this.failed = err instanceof Error ? err.message : "send";
|
|
1399
|
-
} finally {
|
|
1400
|
-
this.sending = false;
|
|
1401
|
-
}
|
|
1402
|
-
},
|
|
1403
1453
|
async send(mode = "queue") {
|
|
1404
1454
|
const text = this.draft.trim();
|
|
1405
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/chat/playback.js
CHANGED
|
@@ -8,10 +8,15 @@ const SILENT_WAV = "data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAE
|
|
|
8
8
|
export function createAnswerPlayer() {
|
|
9
9
|
const audio = typeof Audio === "function" ? new Audio() : null;
|
|
10
10
|
let url = "";
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
let settle = null;
|
|
12
|
+
const finish = (played) => {
|
|
13
|
+
const done = settle;
|
|
14
|
+
settle = null;
|
|
15
|
+
if (url) {
|
|
16
|
+
URL.revokeObjectURL(url);
|
|
17
|
+
url = "";
|
|
18
|
+
}
|
|
19
|
+
done?.(played);
|
|
15
20
|
};
|
|
16
21
|
return {
|
|
17
22
|
unlock() {
|
|
@@ -19,23 +24,27 @@ export function createAnswerPlayer() {
|
|
|
19
24
|
audio.src = SILENT_WAV;
|
|
20
25
|
void audio.play().catch(() => {});
|
|
21
26
|
},
|
|
27
|
+
/** Resolves when the clip ends (true), or is stopped or fails (false). */
|
|
22
28
|
async play(blob) {
|
|
23
29
|
if (!audio || !blob) return false;
|
|
24
|
-
|
|
30
|
+
finish(false);
|
|
25
31
|
url = URL.createObjectURL(blob);
|
|
26
32
|
audio.src = url;
|
|
27
|
-
|
|
33
|
+
const ended = new Promise((resolve) => {
|
|
34
|
+
settle = resolve;
|
|
35
|
+
});
|
|
36
|
+
audio.onended = () => finish(true);
|
|
37
|
+
audio.onerror = () => finish(false);
|
|
28
38
|
try {
|
|
29
39
|
await audio.play();
|
|
30
|
-
return true;
|
|
31
40
|
} catch {
|
|
32
|
-
|
|
33
|
-
return false;
|
|
41
|
+
finish(false);
|
|
34
42
|
}
|
|
43
|
+
return ended;
|
|
35
44
|
},
|
|
36
45
|
stop() {
|
|
37
46
|
audio?.pause();
|
|
38
|
-
|
|
47
|
+
finish(false);
|
|
39
48
|
},
|
|
40
49
|
};
|
|
41
50
|
}
|
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),
|
|
@@ -278,13 +283,19 @@ export function createHostClient(ports = {}) {
|
|
|
278
283
|
},
|
|
279
284
|
reportLog(scope, event, fields) {
|
|
280
285
|
return postReport({ scope, event, ...(fields ?? {}) }, {
|
|
281
|
-
request,
|
|
286
|
+
request: direct,
|
|
282
287
|
url: urlFor(REPORT_PATH),
|
|
283
288
|
});
|
|
284
289
|
},
|
|
285
|
-
|
|
286
|
-
|
|
290
|
+
/** Hands over a streaming answer's text; with `part`, also returns that sentence's audio. */
|
|
291
|
+
async speak({ key, text, done, part }) {
|
|
292
|
+
const res = await request(`${VOICE_API}/speak`, {
|
|
293
|
+
method: "POST",
|
|
294
|
+
headers: voiceHeaders("device"),
|
|
295
|
+
body: Number.isInteger(part) ? { key, text, done, part } : { key, text, done },
|
|
296
|
+
});
|
|
287
297
|
const body = res?.body;
|
|
298
|
+
if (res?.ok && body && !Number.isInteger(part)) return { sentences: Number(body.sentences) || 0 };
|
|
288
299
|
if (res?.ok && body && typeof body.audio === "string") {
|
|
289
300
|
return {
|
|
290
301
|
bluetooth: body.bluetooth === "played" ? "played" : "skipped",
|
|
@@ -297,7 +308,7 @@ export function createHostClient(ports = {}) {
|
|
|
297
308
|
throw Object.assign(new Error(message), { code });
|
|
298
309
|
},
|
|
299
310
|
async interruptSpeech() {
|
|
300
|
-
const res = await request(`${VOICE_API}/playback/stop`, { method: "POST" });
|
|
311
|
+
const res = await request(`${VOICE_API}/playback/stop`, { method: "POST", headers: voiceHeaders("device") });
|
|
301
312
|
return Boolean(res?.ok);
|
|
302
313
|
},
|
|
303
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
|
-
}
|