@olares/lares-mobile 0.15.30 → 0.15.32
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 +77 -13
- package/src/settings/TtsSettings.vue +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@olares/lares-mobile",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.32",
|
|
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.32",
|
|
32
32
|
"markdown-it": "^14.3.0"
|
|
33
33
|
}
|
|
34
34
|
}
|
package/src/App.vue
CHANGED
|
@@ -127,6 +127,7 @@ import { createVoiceConversation } from "@olares/lares-core/voice/conversation";
|
|
|
127
127
|
import { createUnifiedConversation } from "@olares/lares-core/voice/unified-conversation";
|
|
128
128
|
import { createAnswerPlayer } from "./chat/playback.js";
|
|
129
129
|
import { createReplySpeaker, createSentencePlayer, readoutState, speakable } from "@olares/lares-core/voice/speech";
|
|
130
|
+
import { createScreenWakeLock } from "@olares/lares-core/voice/wake-lock";
|
|
130
131
|
import { prepareBrowserCapture } from "@olares/lares-core/voice/browser-capture";
|
|
131
132
|
import { playAudioStream, createStreamingCapture } from "@olares/lares-core/voice/stream-client";
|
|
132
133
|
import { voiceHeaders, wavBlob } from "@olares/lares-core/voice/client";
|
|
@@ -561,6 +562,9 @@ export default {
|
|
|
561
562
|
this.intake = new FileIntake((file, sessionId, options) => this.runtime.upload(file, options, sessionId));
|
|
562
563
|
this.imageUploadControllers = new Map();
|
|
563
564
|
this.player = createAnswerPlayer();
|
|
565
|
+
// A locked screen suspends the WebView and cuts a long answer short; hold
|
|
566
|
+
// the screen for as long as a readout is actually playing.
|
|
567
|
+
this.readoutWake = createScreenWakeLock();
|
|
564
568
|
this.voicePanel = createVoicePanel({
|
|
565
569
|
armOutput: () => this.player?.unlock(),
|
|
566
570
|
create: (options) => this.createMobileVoiceConversation(options),
|
|
@@ -591,6 +595,8 @@ export default {
|
|
|
591
595
|
const view = this.voicePanel.getSnapshot(); this.voicePanelState = view;
|
|
592
596
|
this.voiceCall = view.snapshot?.connection === "closed" ? null : view.snapshot;
|
|
593
597
|
this.voicePhase = view.snapshot?.capture || "idle"; this.voiceError = view.error || view.snapshot?.error || "";
|
|
598
|
+
// The call no longer owns the answer: a cancelled take gives it back.
|
|
599
|
+
if (this.voiceSuspended && view.snapshot?.connection !== "open") this.resumeSuspendedReadout();
|
|
594
600
|
});
|
|
595
601
|
this.replySpeaker = createReplySpeaker();
|
|
596
602
|
this.reader = createSentencePlayer({
|
|
@@ -607,6 +613,7 @@ export default {
|
|
|
607
613
|
halt: () => this.player?.stop(),
|
|
608
614
|
silence: (source) => void this.runtime.interruptSpeech?.(source),
|
|
609
615
|
fail: (err) => this.showSpeakProblem(err),
|
|
616
|
+
onState: (active) => this.setReadoutAwake(active),
|
|
610
617
|
});
|
|
611
618
|
|
|
612
619
|
this.bindIntake(this.sessionId);
|
|
@@ -624,12 +631,30 @@ export default {
|
|
|
624
631
|
this.conversationUnsub = subscribeConversationSettings((section) => {
|
|
625
632
|
this.busyEnter = section.busyEnter;
|
|
626
633
|
});
|
|
634
|
+
this.voiceReadoutPaused = false;
|
|
627
635
|
this.voiceVisibilityHandler = () => {
|
|
628
|
-
if (document.hidden) {
|
|
636
|
+
if (document.hidden) {
|
|
637
|
+
// A locked screen or an app switch is a pause, not the end of the
|
|
638
|
+
// answer: stopping here marks the utterance read and it never resumes.
|
|
639
|
+
if (this.reader.canPause && this.reader.pause()) this.voiceReadoutPaused = true;
|
|
640
|
+
void this.voicePanel.pause().catch(() => {});
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
if (this.readoutActive) void this.readoutWake?.hold();
|
|
644
|
+
if (this.voiceReadoutPaused) { this.voiceReadoutPaused = false; this.reader.resume(); }
|
|
645
|
+
};
|
|
646
|
+
this.voicePageHideHandler = () => {
|
|
647
|
+
if (this.reader.canPause && this.reader.pause()) this.voiceReadoutPaused = true;
|
|
648
|
+
void this.voicePanel.pause().catch(() => {});
|
|
629
649
|
};
|
|
630
|
-
this.
|
|
650
|
+
this.voicePageShowHandler = () => {
|
|
651
|
+
if (this.voiceReadoutPaused) { this.voiceReadoutPaused = false; this.reader.resume(); }
|
|
652
|
+
};
|
|
653
|
+
this.voiceOfflineHandler = () => { this.stopReadout(); void this.voicePanel.pause().catch(() => {}); };
|
|
631
654
|
window.addEventListener("offline", this.voiceOfflineHandler);
|
|
632
655
|
document.addEventListener("visibilitychange", this.voiceVisibilityHandler);
|
|
656
|
+
window.addEventListener("pagehide", this.voicePageHideHandler);
|
|
657
|
+
window.addEventListener("pageshow", this.voicePageShowHandler);
|
|
633
658
|
window.addEventListener("keydown", this.onVoiceShortcut, true);
|
|
634
659
|
this.voiceClock = setInterval(() => {
|
|
635
660
|
if (this.voicePhase === "recording") this.voiceElapsed += 1;
|
|
@@ -640,13 +665,16 @@ export default {
|
|
|
640
665
|
this.restoreLog();
|
|
641
666
|
},
|
|
642
667
|
deactivated() {
|
|
643
|
-
this.
|
|
668
|
+
this.stopReadout();
|
|
644
669
|
void this.voicePanel?.pause().catch(() => {});
|
|
645
670
|
this.captureLog();
|
|
646
671
|
},
|
|
647
672
|
beforeUnmount() {
|
|
648
673
|
window.removeEventListener("offline", this.voiceOfflineHandler);
|
|
649
674
|
document.removeEventListener("visibilitychange", this.voiceVisibilityHandler);
|
|
675
|
+
window.removeEventListener("pagehide", this.voicePageHideHandler);
|
|
676
|
+
window.removeEventListener("pageshow", this.voicePageShowHandler);
|
|
677
|
+
this.readoutWake?.release();
|
|
650
678
|
window.removeEventListener("keydown", this.onVoiceShortcut, true);
|
|
651
679
|
clearInterval(this.voiceClock);
|
|
652
680
|
this.captureLog();
|
|
@@ -716,7 +744,7 @@ export default {
|
|
|
716
744
|
}
|
|
717
745
|
if (sessionChanged && snap.sessionId !== this.voiceAdoptingSession) {
|
|
718
746
|
void this.voicePanel?.pause().catch(() => {});
|
|
719
|
-
this.
|
|
747
|
+
this.stopReadout();
|
|
720
748
|
this.player?.stop();
|
|
721
749
|
}
|
|
722
750
|
if (sessionChanged) this.voiceAdoptingSession = null;
|
|
@@ -733,9 +761,12 @@ export default {
|
|
|
733
761
|
// A paused call is not speaking. Only an open call owns the reply;
|
|
734
762
|
// leaving it set after the keyboard returns is how later answers stay silent.
|
|
735
763
|
const voiceSpeaking = this.voiceCall?.connection === "open";
|
|
736
|
-
if (voiceSpeaking) this.
|
|
764
|
+
if (voiceSpeaking) this.suspendReadout();
|
|
737
765
|
const target = this.voiceSpeak && !voiceSpeaking && !document.hidden ? speakable(utterance, this.voiceStreaming) : null;
|
|
738
|
-
if (target) this.
|
|
766
|
+
if (target) this.followReadout({ ...target, sessionId: snap.sessionId, source: { sessionId: snap.sessionId, turn: target.turn, step: target.step, attemptId: target.attemptId }, key: `${snap.sessionId}:${target.key}` });
|
|
767
|
+
// A cancelled voice take did not own the answer: play it again. A take
|
|
768
|
+
// that submitted a turn grew the transcript, so the call owns it.
|
|
769
|
+
if (!voiceSpeaking) this.resumeSuspendedReadout();
|
|
739
770
|
this.failed = snap.failed;
|
|
740
771
|
this.error = snap.error;
|
|
741
772
|
this.sessions = snap.sessions ?? this.sessions;
|
|
@@ -1276,7 +1307,7 @@ export default {
|
|
|
1276
1307
|
},
|
|
1277
1308
|
async onVoiceShortcut(event) {
|
|
1278
1309
|
if (this.chatDevice !== "desktop" || document.hidden || navigator.onLine === false || event.composedPath?.().some(node => node?.hasAttribute?.("data-voice-shortcut-recorder"))) return;
|
|
1279
|
-
if (event.key === "Escape" && !event.defaultPrevented) { this.
|
|
1310
|
+
if (event.key === "Escape" && !event.defaultPrevented) { this.stopReadout(); void this.voicePanel.pause().catch(() => {}); return; }
|
|
1280
1311
|
const action = matchVoiceShortcut(event, this.voiceSettings?.config?.dictationShortcuts);
|
|
1281
1312
|
if (!action || !laresVoiceAvailability(this.voiceSettings).dictation) return;
|
|
1282
1313
|
event.preventDefault();
|
|
@@ -1326,7 +1357,7 @@ export default {
|
|
|
1326
1357
|
const device = settings.config.device === "headset" ? "headset" : "device";
|
|
1327
1358
|
onDevice(device);
|
|
1328
1359
|
if (settings.config.backend === "realtime" && continuous) throw new Error("voice_manual_realtime_unavailable");
|
|
1329
|
-
this.
|
|
1360
|
+
this.suspendReadout(); this.player?.stop(); this.player?.unlock();
|
|
1330
1361
|
if (config.transport !== "legacy") {
|
|
1331
1362
|
return createUnifiedConversation({ request: (path, options) => this.runtime.voiceRequest(path, options), socket: (ticket) => this.runtime.voiceSocket(ticket),
|
|
1332
1363
|
device: captureSource("mobile", config.device), readAloud: continuous && config.speak, preset: config.preset, presentationSession: true,
|
|
@@ -1340,7 +1371,7 @@ export default {
|
|
|
1340
1371
|
send: (value) => conversation.speak(value),
|
|
1341
1372
|
play: (sentence) => sentence.bluetooth === "played" ? Promise.resolve() : this.player.play(wavBlob(sentence.audio)),
|
|
1342
1373
|
halt: () => this.player.stop(), silence: () => {},
|
|
1343
|
-
onState: (playing) => conversation?.playbackState(playing),
|
|
1374
|
+
onState: (playing) => { conversation?.playbackState(playing); this.setReadoutAwake(playing); },
|
|
1344
1375
|
onOutput: (value) => readoutState.publish(value?.status === "error" ? null : value),
|
|
1345
1376
|
fail: (error) => { void conversation.controller.disconnect(error?.code || error?.message || "voice_playback_failed"); },
|
|
1346
1377
|
});
|
|
@@ -1368,6 +1399,39 @@ export default {
|
|
|
1368
1399
|
onError: (error) => { this.voiceError = error.message; },
|
|
1369
1400
|
});
|
|
1370
1401
|
},
|
|
1402
|
+
followReadout(value) {
|
|
1403
|
+
if (!value) return;
|
|
1404
|
+
this.lastReadout = value;
|
|
1405
|
+
this.reader.follow(value);
|
|
1406
|
+
},
|
|
1407
|
+
suspendReadout() {
|
|
1408
|
+
const key = this.reader?.key;
|
|
1409
|
+
if (!key) return;
|
|
1410
|
+
if (this.lastReadout?.key === key) {
|
|
1411
|
+
this.voiceSuspended = this.lastReadout;
|
|
1412
|
+
this.voiceSuspendedItems = this.items?.length ?? 0;
|
|
1413
|
+
}
|
|
1414
|
+
this.reader.suspend?.();
|
|
1415
|
+
},
|
|
1416
|
+
resumeSuspendedReadout() {
|
|
1417
|
+
const suspended = this.voiceSuspended;
|
|
1418
|
+
if (!suspended) return;
|
|
1419
|
+
this.voiceSuspended = null;
|
|
1420
|
+
const taken = (this.items?.length ?? 0) > this.voiceSuspendedItems;
|
|
1421
|
+
if (taken) this.reader.consume(suspended.key);
|
|
1422
|
+
else if (suspended.sessionId === this.sessionId && this.voiceSpeak && !document.hidden) this.followReadout(suspended);
|
|
1423
|
+
},
|
|
1424
|
+
stopReadout() {
|
|
1425
|
+
this.voiceSuspended = null;
|
|
1426
|
+
this.voiceSuspendedItems = 0;
|
|
1427
|
+
this.lastReadout = null;
|
|
1428
|
+
this.reader.stop();
|
|
1429
|
+
},
|
|
1430
|
+
setReadoutAwake(active) {
|
|
1431
|
+
this.readoutActive = Boolean(active);
|
|
1432
|
+
if (this.readoutActive) void this.readoutWake?.hold();
|
|
1433
|
+
else this.readoutWake?.release();
|
|
1434
|
+
},
|
|
1371
1435
|
showSpeakProblem(err) {
|
|
1372
1436
|
this.speakNotice = speakProblemText(this.t, err);
|
|
1373
1437
|
},
|
|
@@ -1384,7 +1448,7 @@ export default {
|
|
|
1384
1448
|
this.voiceSpeak = next;
|
|
1385
1449
|
this.speakBusy = true;
|
|
1386
1450
|
if (!next) {
|
|
1387
|
-
this.
|
|
1451
|
+
this.stopReadout();
|
|
1388
1452
|
this.player?.stop();
|
|
1389
1453
|
void this.runtime.interruptSpeech?.();
|
|
1390
1454
|
}
|
|
@@ -1549,10 +1613,10 @@ export default {
|
|
|
1549
1613
|
const text = this.draft.trim();
|
|
1550
1614
|
if (!this.canSend) return;
|
|
1551
1615
|
this.player?.unlock();
|
|
1552
|
-
// A typed reply is the chat's.
|
|
1553
|
-
//
|
|
1616
|
+
// A typed reply is the chat's. Stop the old readout before pausing the
|
|
1617
|
+
// call, so the pause cannot hand a suspended answer back for a moment.
|
|
1618
|
+
this.stopReadout();
|
|
1554
1619
|
if (this.voiceCall?.connection === "open") await this.voicePanel.pause();
|
|
1555
|
-
this.reader.stop();
|
|
1556
1620
|
const sessionId = this.sessionId;
|
|
1557
1621
|
const images = this.draftImages.filter((row) => draftImageStatus(row) === "ready");
|
|
1558
1622
|
const wasRunning = this.running;
|
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
<p v-if="view.errors.ttsModel" class="lares-tts__alert" role="alert">{{ t('settings.saveFailed') }}</p>
|
|
18
18
|
<p v-if="!state.model" class="lares-tts__hint">{{ t('preferences.modelsMissing') }}</p>
|
|
19
19
|
<p v-else-if="state.loading" class="lares-tts__hint" role="status">{{ t('tts.loading') }}</p>
|
|
20
|
+
<div v-if="state.stale" class="lares-tts__status" role="status">
|
|
21
|
+
<p>{{ t('tts.stale') }}</p>
|
|
22
|
+
<button type="button" @click="controller.load()">{{ t('dictation.retry') }}</button>
|
|
23
|
+
</div>
|
|
20
24
|
<div v-else-if="state.error" class="lares-tts__status" role="alert">
|
|
21
25
|
<p>{{ t('tts.loadFailed') }}</p>
|
|
22
26
|
<button type="button" @click="controller.load()">{{ t('dictation.retry') }}</button>
|