@olares/lares-mobile 0.15.11 → 0.15.13

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.11",
3
+ "version": "0.15.13",
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.11",
31
+ "@olares/lares-core": "0.15.13",
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"
@@ -100,19 +101,26 @@
100
101
  @pick-command="pickCommand"
101
102
  @toggle-speak="toggleSpeak"
102
103
  />
104
+ <LaresVoicePanel v-if="voicePanelState" :view="voicePanelState" :t="t"
105
+ @close="runVoicePanel('close')" @mode="openVoiceMode" @start="runVoicePanel('start')" @pause="runVoicePanel('pause')"
106
+ @complete="runVoicePanel('complete')" @discard="runVoicePanel('discard')" @end="runVoicePanel('end')" @action="voiceAction" />
103
107
  </div>
104
108
  </template>
105
109
 
106
110
  <script>
111
+ import { createVoicePanel } from "@olares/lares-core/voice/panel";
112
+ import { mergeVoiceTranscript, subscribeVoiceHistory } from "@olares/lares-core/voice/projection";
113
+ import LaresVoicePanel from "./chat/VoicePanel.vue";
107
114
  import { pickHostPorts } from "@olares/lares-core/larepass/host";
108
115
  import { connectChat } from "./runtime.js";
109
116
  import { createT } from "./i18n.js";
110
117
  import { failText, withPendingUser } from "./chat/format.js";
111
- import { createVoiceCapture } from "./chat/voice.js";
118
+ import { captureSource } from "@olares/lares-core/voice/input";
119
+ import { createRealtimeConversation } from "@olares/lares-core/voice/realtime-conversation";
120
+ import { createVoiceConversation } from "@olares/lares-core/voice/conversation";
112
121
  import { createAnswerPlayer } from "./chat/playback.js";
113
122
  import { createReplySpeaker, createSentencePlayer, speakable } from "@olares/lares-core/voice/speech";
114
123
  import { wavBlob } from "@olares/lares-core/voice/client";
115
- import { captureSource } from "@olares/lares-core/voice/input";
116
124
  import { browserPlays } from "@olares/lares-core/voice/playback";
117
125
  import { speakProblemText, speakToggleNotice } from "@olares/lares-core/voice/problem";
118
126
  import { appendDraftMentions } from "@olares/lares-core/files/mention";
@@ -169,7 +177,7 @@ function imageData(file) {
169
177
 
170
178
  export default {
171
179
  name: "LaresApp",
172
- components: { LaresMobileShell, LaresDesktopShell },
180
+ components: { LaresMobileShell, LaresDesktopShell, LaresVoicePanel },
173
181
  props: {
174
182
  locale: { type: String, default: "en" },
175
183
  device: { type: String, default: "mobile" },
@@ -218,11 +226,15 @@ export default {
218
226
  effortSheet: false,
219
227
  upload: { pending: 0, failures: [] },
220
228
  draftImages: [],
229
+ voiceCall: null,
230
+ voicePanelState: null,
231
+ voiceRecords: [],
221
232
  voicePhase: "idle",
222
233
  voiceElapsed: 0,
223
234
  voiceError: "",
224
235
  voiceLanguage: "",
225
236
  voiceSpeak: false,
237
+ voiceDefaultMode: "continuous",
226
238
  voiceStreaming: false,
227
239
  speakBusy: false,
228
240
  speakNotice: "",
@@ -329,6 +341,8 @@ export default {
329
341
  effortDisabled: this.effortDisabled,
330
342
  upload: this.upload,
331
343
  draftImages: this.draftImages,
344
+ voiceDefaultMode: this.voiceDefaultMode,
345
+ voiceCall: this.voiceCall,
332
346
  voicePhase: this.voicePhase,
333
347
  voiceElapsed: this.voiceElapsed,
334
348
  voiceErrorText: this.voiceErrorText,
@@ -442,7 +456,7 @@ export default {
442
456
  return effortMenuRows(this.reasoning, this.t("reasoning.default"));
443
457
  },
444
458
  viewItems() {
445
- return withPendingUser(this.items, this.pendingUser);
459
+ return mergeVoiceTranscript(withPendingUser(this.items, this.pendingUser), this.voiceRecords);
446
460
  },
447
461
  failText() {
448
462
  return failText(this.t, this.failed, this.error);
@@ -510,6 +524,8 @@ export default {
510
524
  },
511
525
  },
512
526
  sessionId(id) {
527
+ this.bindVoiceHistory(id);
528
+ void this.voicePanel?.bind(id || null).catch(() => {});
513
529
  this.bindIntake(id);
514
530
  },
515
531
  hostKey() {
@@ -523,6 +539,25 @@ export default {
523
539
  this.intake = new FileIntake((file, sessionId, options) => this.runtime.upload(file, options, sessionId));
524
540
  this.imageUploadControllers = new Map();
525
541
  this.player = createAnswerPlayer();
542
+ this.voicePanel = createVoicePanel({
543
+ create: (options) => this.createMobileVoiceConversation(options),
544
+ complete: async ({ sessionId, text }) => {
545
+ const previous = sessionId === (this.sessionId || null) ? this.draft : this.draftBySession[sessionId || "@pending"] || "";
546
+ const value = [previous, text].filter(Boolean).join(" ");
547
+ this.draftBySession = { ...this.draftBySession, [sessionId || "@pending"]: value };
548
+ if (sessionId === (this.sessionId || null)) this.draft = value;
549
+ },
550
+ onSession: (sessionId) => {
551
+ this.voiceAdoptingSession = sessionId;
552
+ void Promise.resolve(this.runtime.openSession(sessionId)).catch((error) => { this.voiceError = error.message; this.voiceAdoptingSession = null; });
553
+ },
554
+ });
555
+ this.voicePanelState = this.voicePanel.getSnapshot();
556
+ this.voicePanelUnsub = this.voicePanel.subscribe(() => {
557
+ const view = this.voicePanel.getSnapshot(); this.voicePanelState = view;
558
+ this.voiceCall = view.snapshot?.connection === "closed" ? null : view.snapshot;
559
+ this.voicePhase = view.snapshot?.capture || "idle"; this.voiceError = view.error || view.snapshot?.error || "";
560
+ });
526
561
  this.replySpeaker = createReplySpeaker();
527
562
  this.reader = createSentencePlayer({
528
563
  send: async (request) => {
@@ -538,25 +573,7 @@ export default {
538
573
  silence: () => void this.runtime.interruptSpeech?.(),
539
574
  fail: (err) => this.showSpeakProblem(err),
540
575
  });
541
- this.voiceCap = createVoiceCapture({
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
- });
576
+
560
577
  this.bindIntake(this.sessionId);
561
578
  this.bindRuntime();
562
579
  try {
@@ -572,20 +589,32 @@ export default {
572
589
  this.conversationUnsub = subscribeConversationSettings((section) => {
573
590
  this.busyEnter = section.busyEnter;
574
591
  });
592
+ this.voiceVisibilityHandler = () => {
593
+ if (document.hidden) void this.voicePanel.pause().catch(() => {});
594
+ };
595
+ document.addEventListener("visibilitychange", this.voiceVisibilityHandler);
596
+ this.voiceClock = setInterval(() => {
597
+ if (this.voicePhase === "recording") this.voiceElapsed += 1;
598
+ }, 1000);
575
599
  this.retry();
576
600
  },
577
601
  activated() {
578
602
  this.restoreLog();
579
603
  },
580
604
  deactivated() {
605
+ void this.voicePanel?.pause().catch(() => {});
581
606
  this.captureLog();
582
607
  },
583
608
  beforeUnmount() {
609
+ document.removeEventListener("visibilitychange", this.voiceVisibilityHandler);
610
+ clearInterval(this.voiceClock);
584
611
  this.captureLog();
585
612
  this.noteSeen(this.sessionId);
586
613
  for (const controller of this.imageUploadControllers?.values?.() ?? []) controller.abort();
587
614
  this.imageUploadControllers?.clear?.();
588
- this.voiceCap?.dispose();
615
+ void this.voicePanel?.dispose().catch(() => {});
616
+ this.voicePanelUnsub?.();
617
+ this.voiceHistoryUnsub?.();
589
618
  this.conversationUnsub?.();
590
619
  this.intakeUnsub?.();
591
620
  if (this.sessionId) this.intake?.cancelSession(this.sessionId);
@@ -642,10 +671,12 @@ export default {
642
671
  this.commands = null;
643
672
  this.commandsError = "";
644
673
  }
645
- if (sessionChanged) {
674
+ if (sessionChanged && snap.sessionId !== this.voiceAdoptingSession) {
675
+ void this.voicePanel?.pause().catch(() => {});
646
676
  this.reader.stop();
647
677
  this.player?.stop();
648
678
  }
679
+ if (sessionChanged) this.voiceAdoptingSession = null;
649
680
  const utterance = this.replySpeaker.observe({
650
681
  sessionChanged,
651
682
  wasRunning: this.running,
@@ -656,7 +687,7 @@ export default {
656
687
  this.items = snap.items;
657
688
  this.running = snap.running;
658
689
  this.runningSince = snap.runningSince || 0;
659
- const target = this.voiceSpeak ? speakable(utterance, this.voiceStreaming) : null;
690
+ const target = this.voiceSpeak && !this.voiceCall ? speakable(utterance, this.voiceStreaming) : null;
660
691
  if (target) this.reader.follow({ ...target, key: `${snap.sessionId}:${target.key}` });
661
692
  this.failed = snap.failed;
662
693
  this.error = snap.error;
@@ -951,7 +982,7 @@ export default {
951
982
  this.unsub = this.runtime.subscribe((snap) => this.applySnap(snap));
952
983
  },
953
984
  switchHost() {
954
- this.voiceCap?.stop?.(true);
985
+ void this.voicePanel?.end().catch(() => {});
955
986
  for (const controller of this.imageUploadControllers.values()) controller.abort();
956
987
  this.imageUploadControllers.clear();
957
988
  this.releaseDraftImages();
@@ -1030,6 +1061,7 @@ export default {
1030
1061
  if (settings) {
1031
1062
  jobs.push(settings.voice().then((voice) => {
1032
1063
  this.voiceLanguage = voice.config?.language || "";
1064
+ this.voiceDefaultMode = voice.config?.mobileMode || "continuous";
1033
1065
  this.voiceSpeak = voice.config?.speak === true;
1034
1066
  this.voiceStreaming = voice.config?.streaming === true;
1035
1067
  }).catch(() => {
@@ -1186,24 +1218,52 @@ export default {
1186
1218
  this.draftImages = [];
1187
1219
  this.imagesBySession = {};
1188
1220
  },
1189
- onHoldStart(detail) {
1190
- void this.runtime.reportLog?.("voice", "hold-start", {
1191
- via: detail?.via || "",
1192
- phase: this.voicePhase,
1221
+ async onHoldStart({ mode } = {}) {
1222
+ await this.voicePanel.open({ mode: mode || this.voiceDefaultMode || "continuous", sessionId: this.sessionId || null });
1223
+ },
1224
+ openVoiceMode(mode) { void this.voicePanel.open({ mode }).catch(() => {}); },
1225
+ runVoicePanel(action) { void this.voicePanel[action]().catch(() => {}); },
1226
+ async createMobileVoiceConversation({ mode, onTranscript, onDevice }) {
1227
+ const settings = await this.runtime.settings.voice(true);
1228
+ const continuous = mode === "continuous";
1229
+ onDevice(captureSource("mobile", settings.config.device));
1230
+ if (settings.config.backend === "realtime" && continuous) return createRealtimeConversation({ presentationSession: true,
1231
+ readAloud: settings.config.speak, idleTimeoutMs: settings.config.idleTimeoutMs, recoveryTimeoutMs: settings.config.recoveryTimeoutMs,
1232
+ request: (path, options) => this.runtime.voiceRequest(path, options), socket: (ticket) => this.runtime.voiceSocket(ticket),
1233
+ device: captureSource("mobile", settings.config.device), playback: this.player,
1193
1234
  });
1194
- if (captureSource("mobile") !== "device") return;
1195
- if (this.voicePhase === "idle" || this.voicePhase === "error") void this.voiceCap?.start();
1196
- this.reader.stop();
1197
- this.player?.stop();
1198
- this.player?.unlock();
1199
- void this.runtime.interruptSpeech?.();
1235
+ this.reader.stop(); this.player?.stop(); this.player?.unlock();
1236
+ let conversation;
1237
+ const reader = createSentencePlayer({
1238
+ send: (value) => conversation.speak(value),
1239
+ play: (sentence) => sentence.bluetooth === "played" ? Promise.resolve() : this.player.play(wavBlob(sentence.audio)),
1240
+ halt: () => this.player.stop(), silence: () => {},
1241
+ onState: (playing) => conversation?.playbackState(playing),
1242
+ fail: (error) => { this.showSpeakProblem(error); void conversation.controller.disconnect("voice_playback_failed"); },
1243
+ });
1244
+ conversation = createVoiceConversation({
1245
+ request: (path, options) => this.runtime.voiceRequest(path, options),
1246
+ transcribe: (blob, signal) => this.runtime.transcribe(blob, this.voiceLanguage, signal),
1247
+ device: captureSource("mobile", settings.config.device),
1248
+ readAloud: continuous && settings.config.speak,
1249
+ preset: continuous && settings.config.preset === "dictation" ? "assistant" : settings.config.preset, endpointSilenceMs: settings.config.endpointSilenceMs, idleTimeoutMs: settings.config.idleTimeoutMs, recoveryTimeoutMs: settings.config.recoveryTimeoutMs,
1250
+ presentationSession: true,
1251
+ onTranscript: continuous ? null : onTranscript,
1252
+ mode: () => resolveSubmitMode(this.running, false, true, this.busyEnter),
1253
+ playback: { stop: () => { reader.stop(); this.player.stop(); } },
1254
+ onReply: (reply) => { const value = speakable(reply, settings.config.streaming); if (value) reader.follow(value); },
1255
+ });
1256
+ return conversation;
1200
1257
  },
1201
- onHoldEnd(detail) {
1202
- void this.runtime.reportLog?.("voice", "hold-end", {
1203
- via: detail?.via || "",
1204
- phase: this.voicePhase,
1258
+ onHoldEnd() { this.voicePanel.conversation?.controller.stopRecording(); },
1259
+ async voiceAction(action) { await this.voicePanel.action(action).catch(() => {}); },
1260
+ bindVoiceHistory(sessionId) {
1261
+ this.voiceHistoryUnsub?.(); this.voiceRecords = [];
1262
+ if (!sessionId) return;
1263
+ this.voiceHistoryUnsub = subscribeVoiceHistory({ sessionId, request: (path, options) => this.runtime.voiceRequest(path, options),
1264
+ onRecords: (rows) => { if (this.sessionId === sessionId) this.voiceRecords = rows; },
1265
+ onError: (error) => { this.voiceError = error.message; },
1205
1266
  });
1206
- this.voiceCap?.stop(false);
1207
1267
  },
1208
1268
  showSpeakProblem(err) {
1209
1269
  this.speakNotice = speakProblemText(this.t, err);
@@ -1381,27 +1441,6 @@ export default {
1381
1441
  if (!sessionId) return;
1382
1442
  return this.runtime.archiveSession(sessionId);
1383
1443
  },
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
1444
  async send(mode = "queue") {
1406
1445
  const text = this.draft.trim();
1407
1446
  if (!this.canSend) return;
@@ -96,7 +96,7 @@
96
96
  </svg>
97
97
  </button>
98
98
  <textarea
99
- v-show="inputMode === 'text'"
99
+
100
100
  ref="input"
101
101
  :value="draft"
102
102
  class="lares-composer__input"
@@ -110,25 +110,6 @@
110
110
  @select="onCaretMove"
111
111
  @contextmenu="onContextMenu"
112
112
  />
113
- <button
114
- v-show="inputMode === 'voice'"
115
- type="button"
116
- class="lares-composer__talk"
117
- :data-phase="voicePhase"
118
- :disabled="voiceBlocked"
119
- :aria-label="voiceLabel"
120
- @pointerdown="onVoiceDown"
121
- @touchstart.prevent="onVoiceTouchStart"
122
- @contextmenu.prevent
123
- >
124
- <span v-if="voicePhase === 'preparing' || voicePhase === 'transcribing'" class="lares-composer__talk-status">
125
- <svg class="lares-composer__spin" viewBox="0 0 16 16" aria-hidden="true">
126
- <circle cx="8" cy="8" r="6" />
127
- </svg>
128
- <span>{{ talkLabel }}</span>
129
- </span>
130
- <span v-else>{{ talkLabel }}</span>
131
- </button>
132
113
  <div class="lares-composer__tools">
133
114
  <input
134
115
  ref="picker"
@@ -138,13 +119,13 @@
138
119
  @change="onFiles"
139
120
  />
140
121
  <button
141
- v-if="inputMode === 'text'"
142
122
  type="button"
143
123
  class="lares-composer__icon"
144
124
  data-kind="voice"
145
125
  :disabled="sending"
146
- :aria-label="t('composer.voice')"
147
- @click="setMode('voice')"
126
+ :aria-label="t('call.start')"
127
+ aria-haspopup="dialog"
128
+ @click="onVoiceClick"
148
129
  >
149
130
  <svg class="lares-composer__wave" viewBox="0 0 24 24" aria-hidden="true">
150
131
  <path d="M5 8v8" />
@@ -164,24 +145,11 @@
164
145
  <path d="M12 19V5M5 12.5 12 5l7 7.5" />
165
146
  </svg>
166
147
  </button>
167
- <button
168
- v-if="inputMode === 'voice'"
169
- type="button"
170
- class="lares-composer__icon"
171
- data-kind="keyboard"
172
- :disabled="voiceBusy"
173
- :aria-label="t('composer.keyboard')"
174
- @click="setMode('text')"
175
- >
176
- <svg viewBox="0 0 24 24" aria-hidden="true">
177
- <rect x="3.5" y="6" width="17" height="12" rx="2" />
178
- <path d="M7 10h2M11 10h2M15 10h2M8 14h8" />
179
- </svg>
180
- </button>
148
+ <button type="button" class="lares-composer__voice-entry" aria-haspopup="dialog" @click="$emit('hold-start', { mode: 'single' })">{{ t('settings.mode.single') }}</button>
181
149
  </div>
182
150
  </div>
183
151
  </div>
184
- <p v-if="voiceError && voicePhase === 'error'" class="lares-composer__hint" data-status="error">{{ voiceError }}</p>
152
+ <p v-if="voiceCall?.connection === 'paused'" class="lares-composer__hint" role="status">{{ t('panel.paused') }}</p>
185
153
  <p v-for="item in failures" :key="item.id" class="lares-composer__hint" data-status="error">
186
154
  {{ t("upload.failed", { name: item.name, reason: t(`error.${item.code}`) }) }}
187
155
  </p>
@@ -189,7 +157,6 @@
189
157
  </template>
190
158
 
191
159
  <script>
192
- import { formatElapsed } from "./voice.js";
193
160
  import {
194
161
  REFERENCE_DEBOUNCE_MS,
195
162
  activeReference,
@@ -208,6 +175,7 @@ export default {
208
175
  canSend: { type: Boolean, default: false },
209
176
  attachPending: { type: Boolean, default: false },
210
177
  attachDisabled: { type: Boolean, default: false },
178
+ voiceCall: { type: Object, default: null },
211
179
  voicePhase: { type: String, default: "idle" },
212
180
  voiceElapsed: { type: Number, default: 0 },
213
181
  voiceError: { type: String, default: "" },
@@ -219,11 +187,12 @@ export default {
219
187
  commands: { type: Array, default: null },
220
188
  commandsError: { type: String, default: "" },
221
189
  hero: { type: Boolean, default: false },
190
+ voiceDefaultMode: { type: String, default: "continuous" },
222
191
  inputMode: { type: String, default: "text" },
223
192
  t: { type: Function, required: true },
224
193
  },
225
194
  emits: [
226
- "update:draft", "update:inputMode", "send", "files", "voice", "hold-start", "hold-end",
195
+ "update:draft", "update:inputMode", "send", "files", "voice", "voice-action", "hold-start", "hold-end",
227
196
  "open-commands", "open-references", "pick-command", "remove-image",
228
197
  ],
229
198
  data() {
@@ -239,21 +208,6 @@ export default {
239
208
  };
240
209
  },
241
210
  computed: {
242
- elapsed() {
243
- return formatElapsed(this.voiceElapsed);
244
- },
245
- talkLabel() {
246
- if (this.voicePhase === "preparing") return this.t("composer.opening");
247
- if (this.voicePhase === "recording") return `${this.t("composer.hold")} · ${this.elapsed}`;
248
- if (this.voicePhase === "transcribing") return this.t("mic.transcribing");
249
- if (this.holding) return this.t("composer.hold");
250
- return this.t("composer.holdToTalk");
251
- },
252
- voiceBusy() {
253
- return this.voicePhase === "preparing"
254
- || this.voicePhase === "recording"
255
- || this.voicePhase === "transcribing";
256
- },
257
211
  attachLabel() {
258
212
  return this.attachPending ? this.t("button.uploading", { count: 1 }) : this.t("composer.attach");
259
213
  },
@@ -261,16 +215,6 @@ export default {
261
215
  if (this.hero) return this.t("chat.placeholderNewSession");
262
216
  return this.t("chat.placeholder");
263
217
  },
264
- voiceLabel() {
265
- if (this.voicePhase === "error" && this.voiceError) return this.voiceError;
266
- if (this.voicePhase === "preparing") return this.t("composer.opening");
267
- if (this.voicePhase === "recording") return this.t("composer.hold");
268
- if (this.voicePhase === "transcribing") return this.t("mic.transcribing");
269
- return this.t("composer.voice");
270
- },
271
- voiceBlocked() {
272
- return this.voicePhase === "transcribing" || this.sending;
273
- },
274
218
  expanded() {
275
219
  return this.inputMode === "text" && (this.draft.trim().length > 0 || this.draftImages.length > 0);
276
220
  },
@@ -287,7 +231,6 @@ export default {
287
231
  watch: {
288
232
  draft(value) {
289
233
  this.autosize();
290
- if (value && this.inputMode === "voice" && !this.voiceBusy) this.setMode("text");
291
234
  },
292
235
  overlayPointerActive(active) {
293
236
  if (active) window.addEventListener("pointerdown", this.onTokenPointer, true);
@@ -383,18 +326,6 @@ export default {
383
326
  imageStatus(image) {
384
327
  return draftImageStatus(image);
385
328
  },
386
- setMode(mode) {
387
- if (mode === this.inputMode) return;
388
- if (mode === "text" && this.voiceBusy) return;
389
- if (mode === "voice") {
390
- this.closeReferences();
391
- this.commandsOpen = false;
392
- this.commandTyped = false;
393
- this.$refs.input?.blur();
394
- }
395
- this.$emit("update:inputMode", mode);
396
- if (mode === "text") this.$nextTick(() => this.focus());
397
- },
398
329
  onSend() {
399
330
  if (this.referenceOpen || this.commandsOpen) return;
400
331
  if (this.canSend) this.$emit("send");
@@ -447,55 +378,14 @@ export default {
447
378
  event.preventDefault();
448
379
  }
449
380
  },
450
- onVoiceDown(event) {
451
- if (event.pointerType === "touch") return;
452
- if (event.button > 0 || this.voiceBlocked || this.holding) return;
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;
381
+ onVoiceClick() {
382
+ this.$emit("hold-start", { mode: "continuous" });
493
383
  },
494
384
  clearHold() {
495
- this.detachHold();
496
385
  this.holding = false;
497
386
  this.holdKind = "";
498
387
  },
388
+ detachHold() {},
499
389
  pickFiles() {
500
390
  this.$refs.picker?.click();
501
391
  },
@@ -520,6 +410,9 @@ export default {
520
410
  </script>
521
411
 
522
412
  <style scoped>
413
+ .lares-composer__voice-entry { border: 0; border-radius: 12px; padding: 6px 8px; background: var(--q-background-3); color: var(--q-ink-2); font: inherit; font-size: 12px; min-height: 36px; cursor: pointer; }
414
+ .lares-composer__voice-entry:focus-visible { outline: 2px solid var(--q-blue-default); }
415
+
523
416
  .lares-composer {
524
417
  display: flex;
525
418
  flex-direction: column;
@@ -915,3 +808,11 @@ export default {
915
808
  }
916
809
  }
917
810
  </style>
811
+
812
+ <style scoped>
813
+ .lares-composer__voice-entry { border: 0; border-radius: 12px; padding: 6px 8px; background: var(--q-background-3); color: var(--q-ink-2); font: inherit; font-size: 12px; min-height: 36px; cursor: pointer; }
814
+ .lares-composer__voice-entry:focus-visible { outline: 2px solid var(--q-blue-default); }
815
+
816
+ .lares-composer__voice-mode { margin: 8px 12px; border: 0; border-radius: 8px; padding: 6px; background: transparent; color: inherit; font: inherit; }
817
+ .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; }
818
+ </style>
@@ -29,7 +29,7 @@
29
29
  </li>
30
30
  <li
31
31
  v-for="{ row, index } in shown(block)"
32
- :key="index"
32
+ :key="row.id || row.key || index"
33
33
  class="lares-log__item"
34
34
  :data-type="row.type"
35
35
  :data-pending="row.pending ? 'true' : 'false'"
@@ -78,6 +78,7 @@
78
78
  >{{ row.text }}</div>
79
79
  <template v-else-if="row.type === 'assistant'">
80
80
  <div class="lares-md" v-html="renderMarkdown(row.text)" />
81
+ <p v-if="row.voiceSummary" class="lares-voice-summary">{{ row.voiceSummary }}</p>
81
82
  <LaresMessageActions
82
83
  :text="row.text"
83
84
  :reaction="reactions[actionKey(index)] || ''"
@@ -483,6 +484,8 @@ export default {
483
484
  overflow-wrap: anywhere;
484
485
  }
485
486
 
487
+ .lares-voice-summary { border-left: 2px solid var(--q-blue-default); padding-left: 12px; font-size: 14px; opacity: .8; }
488
+
486
489
  .lares-md[data-pending="true"] {
487
490
  white-space: pre-wrap;
488
491
  opacity: 0.92;
@@ -0,0 +1,65 @@
1
+ <template>
2
+ <dialog ref="dialog" class="lares-voice-sheet" aria-labelledby="lares-mobile-voice-title" @cancel.prevent="$emit('close')" @click="onBackdrop">
3
+ <div class="lares-voice-sheet__body">
4
+ <header><h2 id="lares-mobile-voice-title">{{ t('panel.title') }}</h2><button type="button" @click="$emit('close')">{{ t('panel.close') }}</button></header>
5
+ <div role="tablist" :aria-label="t('settings.mode')" @keydown="onTabKey">
6
+ <button v-for="mode in ['single', 'continuous']" :id="`mobile-voice-${mode}`" :key="mode" type="button" role="tab" :aria-selected="view.mode === mode" :tabindex="view.mode === mode ? 0 : -1" aria-controls="lares-mobile-voice-content" :disabled="view.busy" @click="$emit('mode', mode)">{{ t(`settings.mode.${mode}`) }}</button>
7
+ </div>
8
+ <section id="lares-mobile-voice-content" role="tabpanel" :aria-labelledby="`mobile-voice-${view.mode}`">
9
+ <p class="lares-voice-sheet__device">{{ t(!view.device ? 'settings.device.auto' : view.device === 'headset' ? 'settings.device.headset' : 'settings.device.device') }}</p>
10
+ <svg class="lares-voice-sheet__wave" viewBox="0 0 120 48" aria-hidden="true"><line v-for="(level, index) in levels" :key="index" :x1="index * 6 + 3" :x2="index * 6 + 3" :y1="24 - Math.max(2, Math.min(22, level * 800))" :y2="24 + Math.max(2, Math.min(22, level * 800))" /></svg>
11
+ <p role="timer" :aria-label="t('panel.elapsed')">{{ elapsed }}s</p>
12
+ <p role="status" aria-live="polite">{{ status }}</p>
13
+ <div v-if="view.mode === 'single'" class="lares-voice-sheet__draft" :aria-label="t('panel.draft')" aria-live="polite">{{ view.draft || t('panel.draftHint') }}</div>
14
+ <p v-else>{{ t('panel.messages') }}</p>
15
+ <div class="lares-voice-sheet__actions">
16
+ <button v-if="!active" type="button" :disabled="view.busy" @click="$emit('start')">{{ t(view.snapshot?.connection === 'paused' ? 'panel.continue' : 'panel.start') }}</button>
17
+ <button v-else type="button" :disabled="view.busy" @click="$emit('pause')">{{ t('panel.pause') }}</button>
18
+ <template v-if="view.mode === 'single'">
19
+ <button type="button" :disabled="view.busy || (!view.draft && view.snapshot?.capture !== 'recording')" @click="$emit('complete')">{{ t('panel.complete') }}</button>
20
+ <button type="button" :disabled="view.busy" @click="$emit('discard')">{{ t('panel.discard') }}</button>
21
+ </template>
22
+ <template v-if="active && view.mode === 'continuous'">
23
+ <button type="button" :disabled="view.busy" @click="$emit('action', 'mute')">{{ t(view.snapshot.outputMuted ? 'call.unmute' : 'call.mute') }}</button>
24
+ <button type="button" :disabled="view.busy" @click="$emit('action', 'input')">{{ t(view.snapshot.inputPaused ? 'call.resumeInput' : 'call.pauseInput') }}</button>
25
+ </template>
26
+ <button v-if="view.snapshot && view.snapshot.playback !== 'idle'" type="button" :disabled="view.busy" @click="$emit('action', 'playback')">{{ t('call.stopPlayback') }}</button>
27
+ <button v-if="view.snapshot" type="button" :disabled="view.busy" @click="$emit('end')">{{ t('call.end') }}</button>
28
+ </div>
29
+ </section>
30
+ </div>
31
+ </dialog>
32
+ </template>
33
+ <script>
34
+ export default {
35
+ name: 'LaresVoicePanel', props: { view: { type: Object, required: true }, t: { type: Function, required: true } },
36
+ emits: ['close', 'mode', 'start', 'pause', 'complete', 'discard', 'end', 'action'],
37
+ data: () => ({ now: Date.now(), clock: null, previousFocus: null }),
38
+ computed: {
39
+ active() { return this.view.snapshot?.connection === 'open'; },
40
+ levels() { return this.view.levels?.length ? this.view.levels : Array(20).fill(.003); },
41
+ elapsed() { return Math.max(0, Math.floor(((this.view.elapsedMs || 0) + (this.view.startedAt ? this.now - this.view.startedAt : 0)) / 1000)); },
42
+ status() { const state = this.view.snapshot; return this.view.error || state?.error ? this.t(`error.${this.view.error || state.error}`) : this.t(this.view.busy ? 'mic.preparing' : !this.active ? 'panel.paused' : state.capture === 'transcribing' ? 'mic.transcribing' : state.capture === 'recording' ? 'call.listening' : state.playback !== 'idle' ? 'call.halfDuplex' : 'call.waiting'); },
43
+ },
44
+ watch: { 'view.visible': { immediate: true, handler() { this.$nextTick(this.sync); } } },
45
+ beforeUnmount() { clearInterval(this.clock); this.$refs.dialog?.close(); this.previousFocus?.focus?.(); },
46
+ methods: {
47
+ sync() { const dialog = this.$refs.dialog; if (!dialog) return; clearInterval(this.clock); if (this.view.visible) { if (!dialog.open) { this.previousFocus = document.activeElement; dialog.showModal(); } this.clock = setInterval(() => { this.now = Date.now(); }, 250); } else if (dialog.open) { dialog.close(); this.previousFocus?.focus?.(); } },
48
+ onBackdrop(event) { if (event.target === this.$refs.dialog) this.$emit('close'); },
49
+ onTabKey(event) { if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; event.preventDefault(); const mode = event.key === 'Home' ? 'single' : event.key === 'End' ? 'continuous' : this.view.mode === 'single' ? 'continuous' : 'single'; this.$emit('mode', mode); this.$refs.dialog.querySelector(`#mobile-voice-${mode}`)?.focus(); },
50
+ },
51
+ };
52
+ </script>
53
+ <style scoped>
54
+ .lares-voice-sheet { color: var(--q-ink-1, #202124); background: var(--q-background-1, white); width: 100vw; max-width: 100vw; height: 100dvh; max-height: 100dvh; margin: 0; border: 0; padding: 0; }
55
+ .lares-voice-sheet::backdrop { background: #10182866; }
56
+ .lares-voice-sheet__body { padding: max(24px, env(safe-area-inset-top)) 20px max(24px, env(safe-area-inset-bottom)); max-width: 560px; margin: auto; }
57
+ header { display: flex; justify-content: space-between; align-items: center; gap: 16px; } h2 { font-size: 22px; }
58
+ button { color: inherit; background: transparent; border: 1px solid var(--q-separator, #ddd); border-radius: 14px; padding: 12px 16px; font: inherit; min-height: 44px; cursor: pointer; }
59
+ button:focus-visible { outline: 2px solid #3478f6; outline-offset: 2px; } button:disabled { opacity: .45; }
60
+ [role=tablist] { display: flex; gap: 8px; margin: 24px 0; } [role=tab] { flex: 1; } [aria-selected=true] { border-color: #3478f6; background: #3478f61a; }
61
+ section { text-align: center; } .lares-voice-sheet__device { font-size: 14px; opacity: .65; }
62
+ .lares-voice-sheet__wave { width: 160px; height: 64px; margin: 24px auto 0; stroke: #3478f6; stroke-width: 3; stroke-linecap: round; }
63
+ .lares-voice-sheet__draft { text-align: start; white-space: pre-wrap; min-height: 100px; max-height: 30dvh; overflow: auto; background: #8881; padding: 16px; border-radius: 12px; }
64
+ .lares-voice-sheet__actions { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; margin-top: 32px; }
65
+ </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
- }