@olares/lares-mobile 0.15.10 → 0.15.11
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 +26 -24
- package/src/chat/playback.js +19 -10
- package/src/host.js +8 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@olares/lares-mobile",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.11",
|
|
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.11",
|
|
32
32
|
"markdown-it": "^14.3.0"
|
|
33
33
|
}
|
|
34
34
|
}
|
package/src/App.vue
CHANGED
|
@@ -110,7 +110,7 @@ import { createT } from "./i18n.js";
|
|
|
110
110
|
import { failText, withPendingUser } from "./chat/format.js";
|
|
111
111
|
import { createVoiceCapture } from "./chat/voice.js";
|
|
112
112
|
import { createAnswerPlayer } from "./chat/playback.js";
|
|
113
|
-
import { createReplySpeaker } from "@olares/lares-core/voice/speech";
|
|
113
|
+
import { createReplySpeaker, createSentencePlayer, speakable } from "@olares/lares-core/voice/speech";
|
|
114
114
|
import { wavBlob } from "@olares/lares-core/voice/client";
|
|
115
115
|
import { captureSource } from "@olares/lares-core/voice/input";
|
|
116
116
|
import { browserPlays } from "@olares/lares-core/voice/playback";
|
|
@@ -223,6 +223,7 @@ export default {
|
|
|
223
223
|
voiceError: "",
|
|
224
224
|
voiceLanguage: "",
|
|
225
225
|
voiceSpeak: false,
|
|
226
|
+
voiceStreaming: false,
|
|
226
227
|
speakBusy: false,
|
|
227
228
|
speakNotice: "",
|
|
228
229
|
intakeUnsub: null,
|
|
@@ -523,7 +524,20 @@ export default {
|
|
|
523
524
|
this.imageUploadControllers = new Map();
|
|
524
525
|
this.player = createAnswerPlayer();
|
|
525
526
|
this.replySpeaker = createReplySpeaker();
|
|
526
|
-
this.
|
|
527
|
+
this.reader = createSentencePlayer({
|
|
528
|
+
send: async (request) => {
|
|
529
|
+
const result = await this.runtime.speak?.(request);
|
|
530
|
+
if (!result) throw Object.assign(new Error("voice_speak_failed"), { code: "voice_speak_failed" });
|
|
531
|
+
return result;
|
|
532
|
+
},
|
|
533
|
+
play: (sentence) => {
|
|
534
|
+
if (!browserPlays("mobile", sentence.bluetooth)) return Promise.resolve(false);
|
|
535
|
+
return this.player?.play(wavBlob(sentence.audio));
|
|
536
|
+
},
|
|
537
|
+
halt: () => this.player?.stop(),
|
|
538
|
+
silence: () => void this.runtime.interruptSpeech?.(),
|
|
539
|
+
fail: (err) => this.showSpeakProblem(err),
|
|
540
|
+
});
|
|
527
541
|
this.voiceCap = createVoiceCapture({
|
|
528
542
|
transcribe: (blob, signal) => this.runtime.transcribe(blob, this.voiceLanguage, signal),
|
|
529
543
|
onPhase: (phase, extra = {}) => {
|
|
@@ -629,20 +643,21 @@ export default {
|
|
|
629
643
|
this.commandsError = "";
|
|
630
644
|
}
|
|
631
645
|
if (sessionChanged) {
|
|
632
|
-
this.
|
|
646
|
+
this.reader.stop();
|
|
633
647
|
this.player?.stop();
|
|
634
648
|
}
|
|
635
|
-
const
|
|
649
|
+
const utterance = this.replySpeaker.observe({
|
|
636
650
|
sessionChanged,
|
|
637
651
|
wasRunning: this.running,
|
|
638
652
|
running: snap.running,
|
|
639
|
-
|
|
653
|
+
utterance: snap.utterance,
|
|
640
654
|
});
|
|
641
655
|
this.sessionId = snap.sessionId;
|
|
642
656
|
this.items = snap.items;
|
|
643
657
|
this.running = snap.running;
|
|
644
658
|
this.runningSince = snap.runningSince || 0;
|
|
645
|
-
|
|
659
|
+
const target = this.voiceSpeak ? speakable(utterance, this.voiceStreaming) : null;
|
|
660
|
+
if (target) this.reader.follow({ ...target, key: `${snap.sessionId}:${target.key}` });
|
|
646
661
|
this.failed = snap.failed;
|
|
647
662
|
this.error = snap.error;
|
|
648
663
|
this.sessions = snap.sessions ?? this.sessions;
|
|
@@ -1016,9 +1031,11 @@ export default {
|
|
|
1016
1031
|
jobs.push(settings.voice().then((voice) => {
|
|
1017
1032
|
this.voiceLanguage = voice.config?.language || "";
|
|
1018
1033
|
this.voiceSpeak = voice.config?.speak === true;
|
|
1034
|
+
this.voiceStreaming = voice.config?.streaming === true;
|
|
1019
1035
|
}).catch(() => {
|
|
1020
1036
|
this.voiceLanguage = "";
|
|
1021
1037
|
this.voiceSpeak = false;
|
|
1038
|
+
this.voiceStreaming = false;
|
|
1022
1039
|
}));
|
|
1023
1040
|
}
|
|
1024
1041
|
await Promise.all(jobs);
|
|
@@ -1176,7 +1193,7 @@ export default {
|
|
|
1176
1193
|
});
|
|
1177
1194
|
if (captureSource("mobile") !== "device") return;
|
|
1178
1195
|
if (this.voicePhase === "idle" || this.voicePhase === "error") void this.voiceCap?.start();
|
|
1179
|
-
this.
|
|
1196
|
+
this.reader.stop();
|
|
1180
1197
|
this.player?.stop();
|
|
1181
1198
|
this.player?.unlock();
|
|
1182
1199
|
void this.runtime.interruptSpeech?.();
|
|
@@ -1203,13 +1220,14 @@ export default {
|
|
|
1203
1220
|
this.voiceSpeak = next;
|
|
1204
1221
|
this.speakBusy = true;
|
|
1205
1222
|
if (!next) {
|
|
1206
|
-
this.
|
|
1223
|
+
this.reader.stop();
|
|
1207
1224
|
this.player?.stop();
|
|
1208
1225
|
void this.runtime.interruptSpeech?.();
|
|
1209
1226
|
}
|
|
1210
1227
|
try {
|
|
1211
1228
|
const voice = await this.runtime.settings.saveVoice({ speak: next });
|
|
1212
1229
|
this.voiceSpeak = voice?.config ? voice.config.speak === true : next;
|
|
1230
|
+
this.voiceStreaming = voice?.config?.streaming === true;
|
|
1213
1231
|
const problem = this.voiceSpeak ? speakToggleNotice(voice?.ttsModels) : "";
|
|
1214
1232
|
this.speakNotice = problem ? this.t(`error.${problem}`) : "";
|
|
1215
1233
|
} catch (err) {
|
|
@@ -1219,22 +1237,6 @@ export default {
|
|
|
1219
1237
|
this.speakBusy = false;
|
|
1220
1238
|
}
|
|
1221
1239
|
},
|
|
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
1240
|
toggleHistory() {
|
|
1239
1241
|
if (this.panel === "history") {
|
|
1240
1242
|
this.panel = "";
|
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
|
@@ -278,13 +278,18 @@ export function createHostClient(ports = {}) {
|
|
|
278
278
|
},
|
|
279
279
|
reportLog(scope, event, fields) {
|
|
280
280
|
return postReport({ scope, event, ...(fields ?? {}) }, {
|
|
281
|
-
request,
|
|
281
|
+
request: direct,
|
|
282
282
|
url: urlFor(REPORT_PATH),
|
|
283
283
|
});
|
|
284
284
|
},
|
|
285
|
-
|
|
286
|
-
|
|
285
|
+
/** Hands over a streaming answer's text; with `part`, also returns that sentence's audio. */
|
|
286
|
+
async speak({ key, text, done, part }) {
|
|
287
|
+
const res = await request(`${VOICE_API}/speak`, {
|
|
288
|
+
method: "POST",
|
|
289
|
+
body: Number.isInteger(part) ? { key, text, done, part } : { key, text, done },
|
|
290
|
+
});
|
|
287
291
|
const body = res?.body;
|
|
292
|
+
if (res?.ok && body && !Number.isInteger(part)) return { sentences: Number(body.sentences) || 0 };
|
|
288
293
|
if (res?.ok && body && typeof body.audio === "string") {
|
|
289
294
|
return {
|
|
290
295
|
bluetooth: body.bluetooth === "played" ? "played" : "skipped",
|