@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olares/lares-mobile",
3
- "version": "0.15.10",
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.10",
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.speakGen = 0;
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.speakGen += 1;
646
+ this.reader.stop();
633
647
  this.player?.stop();
634
648
  }
635
- const answer = this.replySpeaker.observe({
649
+ const utterance = this.replySpeaker.observe({
636
650
  sessionChanged,
637
651
  wasRunning: this.running,
638
652
  running: snap.running,
639
- reply: snap.reply,
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
- if (answer) void this.speakReply(answer, ++this.speakGen);
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.speakGen += 1;
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.speakGen += 1;
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 = "";
@@ -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
- const release = () => {
12
- if (!url) return;
13
- URL.revokeObjectURL(url);
14
- url = "";
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
- release();
30
+ finish(false);
25
31
  url = URL.createObjectURL(blob);
26
32
  audio.src = url;
27
- audio.onended = release;
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
- release();
33
- return false;
41
+ finish(false);
34
42
  }
43
+ return ended;
35
44
  },
36
45
  stop() {
37
46
  audio?.pause();
38
- release();
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
- async speak(text) {
286
- const res = await request(`${VOICE_API}/speak`, { method: "POST", body: { text } });
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",