@bojackduy/opencode-voice 0.7.0 → 0.8.0

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/README.md CHANGED
@@ -554,6 +554,19 @@ Options:
554
554
  Not in v1: speaker diarization, automatic summaries/action items, and
555
555
  uploading the notes anywhere - the Markdown file stays local.
556
556
 
557
+ ### Global cancel
558
+
559
+ | Command | Keybind | Description |
560
+ | --------------- | ---------- | ---------------------------------- |
561
+ | `/voice-cancel` | `leader+.` | Stop any in-flight voice operation |
562
+
563
+ One keypress cancels whatever is running: batch recording (or its
564
+ transcribe/normalize pipeline), streaming dictation, TTS playback (or a
565
+ pending speech handoff), the voice-conversation loop, and live-notes capture
566
+ (live notes finishes saving in the background, like `/voice-notes-cancel`).
567
+ Idle invocations toast `Nothing to cancel`. Keybind override:
568
+ `"keybinds": { "voice.cancel": "none" }`.
569
+
557
570
  ### Streaming dictation
558
571
 
559
572
  Live local dictation for short prompts: the mic stays on, partial
package/index.js CHANGED
@@ -32,6 +32,7 @@
32
32
  // /voice-notes-stop - stop live notes, flush, and save (palette-only)
33
33
  // /voice-notes-cancel - stop live notes immediately, save in background (palette-only)
34
34
  // /voice-notes-status - show live-notes recording status (palette-only)
35
+ // /voice-cancel - cancel everything in flight (default: <leader>.)
35
36
  // All also palette-accessible via Ctrl+P or /slash. Override via plugin options `keybinds`:
36
37
  // { "keybinds": { "stt.record": "ctrl+r", "tts.speak-last": "none", "voice.conversation": "none" } }
37
38
  // Weird keys [ ] ; were chosen because opencode doesn't use them and shift variants were ignored in terminals.
@@ -43,6 +44,7 @@ import { registerSTT } from "./lib/stt.js";
43
44
  import { registerTTS } from "./lib/tts.js";
44
45
  import { registerConversation } from "./lib/conversation.js";
45
46
  import { registerLiveNotes } from "./lib/live-notes.js";
47
+ import { registerVoiceCancel } from "./lib/cancel.js";
46
48
  import { registerVoiceModel, resolveVoiceProviderModel } from "./lib/voice-model.js";
47
49
  import { createClient } from "./lib/llm-client.js";
48
50
  import { createLogger } from "./lib/logger.js";
@@ -112,6 +114,12 @@ export default {
112
114
  });
113
115
  shared.liveNotes = liveNotes.controller;
114
116
  const voiceModel = registerVoiceModel(api, kv, options, logger);
117
+ const cancel = registerVoiceCancel(api, options, logger, {
118
+ stt: stt.controller,
119
+ tts: tts.controller,
120
+ conversation: shared.conversation,
121
+ liveNotes: shared.liveNotes,
122
+ });
115
123
 
116
124
  api.command.register(() => [
117
125
  ...stt.commands,
@@ -119,6 +127,7 @@ export default {
119
127
  ...conversation.commands,
120
128
  ...liveNotes.commands,
121
129
  ...voiceModel.commands,
130
+ ...cancel.commands,
122
131
  ]);
123
132
  },
124
133
  };
package/lib/cancel.js ADDED
@@ -0,0 +1,106 @@
1
+ // Voice: cancel everything - a global panic button for in-flight voice work.
2
+ //
3
+ // One keypress stops whatever is running: batch STT recording (or the
4
+ // transcribe/normalize pipeline behind it), streaming dictation, TTS
5
+ // playback (or a pending normalize handoff), the voice-conversation loop,
6
+ // and live-notes capture (background-save semantics, like /voice-notes-cancel).
7
+ // Idle invocations never throw and toast "Nothing to cancel".
8
+
9
+ import { clearProcessingToast, clearStreamingToast } from "./stt.js";
10
+
11
+ export function registerVoiceCancel(api, opts, logger, { stt, tts, conversation, liveNotes }) {
12
+ function toast(message, variant = "info") {
13
+ api.ui.toast({ message, variant, duration: 3000 });
14
+ }
15
+
16
+ function guarded(fn) {
17
+ try {
18
+ return fn();
19
+ } catch {
20
+ return undefined;
21
+ }
22
+ }
23
+
24
+ async function cancelEverything() {
25
+ const cancelled = [];
26
+ try {
27
+ // TTS playback: stop() also invalidates a normalize handoff still in
28
+ // flight via the speech generation guard.
29
+ const wasSpeaking = guarded(() => tts?.isSpeaking?.()) === true;
30
+ if (wasSpeaking) {
31
+ guarded(() => tts?.stop?.());
32
+ cancelled.push("TTS");
33
+ }
34
+
35
+ // Streaming dictation owns the mic; batch recording otherwise. A
36
+ // transcribe/normalize pipeline with no active recording is still
37
+ // cancelled via the same path (it bumps the pipeline generation so
38
+ // the stale transcript never lands).
39
+ const streaming = guarded(() => stt?.isStreaming?.()) === true;
40
+ if (streaming) {
41
+ await guarded(() => stt?.cancelStreaming?.());
42
+ cancelled.push("streaming");
43
+ } else {
44
+ const busy =
45
+ guarded(() => stt?.isRecording?.()) === true ||
46
+ guarded(() => stt?.isProcessing?.()) === true;
47
+ if (busy) {
48
+ guarded(() => stt?.cancel?.());
49
+ cancelled.push("recording");
50
+ }
51
+ }
52
+
53
+ if (guarded(() => conversation?.isActive?.()) === true) {
54
+ guarded(() => conversation?.stop?.("cancelled"));
55
+ cancelled.push("conversation");
56
+ }
57
+
58
+ if (guarded(() => liveNotes?.isActive?.()) === true) {
59
+ guarded(() => liveNotes?.cancel?.());
60
+ cancelled.push("live notes");
61
+ }
62
+
63
+ guarded(() => clearProcessingToast());
64
+ guarded(() => clearStreamingToast());
65
+
66
+ if (cancelled.length > 0) {
67
+ toast(`Cancelled: ${cancelled.join(", ")}`, "success");
68
+ } else {
69
+ toast("Nothing to cancel");
70
+ }
71
+ logger?.log?.("VOICE", `Cancel everything: ${cancelled.join(", ") || "idle"}`, "debug");
72
+ } catch (err) {
73
+ logger?.log?.("VOICE", `Cancel everything failed: ${err?.message || err}`, "error");
74
+ }
75
+ }
76
+
77
+ const DEFAULT_KEYBINDS = {
78
+ "voice.cancel": "<leader>.",
79
+ };
80
+ function kb(value) {
81
+ const overrides = opts?.keybinds;
82
+ if (!overrides || typeof overrides !== "object" || Array.isArray(overrides)) {
83
+ return DEFAULT_KEYBINDS[value];
84
+ }
85
+ if (!Object.prototype.hasOwnProperty.call(overrides, value)) return DEFAULT_KEYBINDS[value];
86
+ const v = overrides[value];
87
+ if (!v || v === "none") return undefined;
88
+ return v;
89
+ }
90
+
91
+ const commands = [
92
+ {
93
+ title: "Voice: cancel everything",
94
+ value: "voice.cancel",
95
+ category: "opencode-voice",
96
+ description: "Stop any in-flight voice operation (recording, dictation, speech, loop, notes)",
97
+ ...(kb("voice.cancel") ? { keybind: kb("voice.cancel") } : {}),
98
+ slash: { name: "voice-cancel" },
99
+ onSelect() {
100
+ void cancelEverything();
101
+ },
102
+ },
103
+ ];
104
+
105
+ return { commands };
106
+ }
package/lib/live-notes.js CHANGED
@@ -618,5 +618,5 @@ export function registerLiveNotes(api, kv, complete, opts, logger, deps = {}) {
618
618
  },
619
619
  ];
620
620
 
621
- return { commands, controller: { isActive } };
621
+ return { commands, controller: { isActive, stop: stopCommand, cancel: cancelCommand } };
622
622
  }
package/lib/stt.js CHANGED
@@ -271,6 +271,10 @@ let soxProc = null;
271
271
  let soxStderr = "";
272
272
  let recording = false;
273
273
  let processing = false;
274
+ // Invalidated by cancelRecording/discardTurn: a cancel landing between
275
+ // LLM normalization and transcript-insert must prevent the stale text from
276
+ // landing afterwards. Checked in doTranscribePipeline before any append.
277
+ let pipelineGeneration = 0;
274
278
 
275
279
  // ---- Conversation-mode hooks (wired by index.js) ----
276
280
  // When voice conversation is active, the plain record/submit keys defer to the
@@ -1516,6 +1520,7 @@ async function appendTurnText(client, renderer, toast, text, logger) {
1516
1520
  // Clears the sticky toast left up by transcribeTurn and resets the flags.
1517
1521
 
1518
1522
  function discardTurn() {
1523
+ pipelineGeneration += 1;
1519
1524
  clearProcessingToast();
1520
1525
  processing = false;
1521
1526
  recording = false;
@@ -1524,6 +1529,7 @@ function discardTurn() {
1524
1529
  // Cancel an in-progress recording without transcribing.
1525
1530
 
1526
1531
  function cancelRecording(logger) {
1532
+ pipelineGeneration += 1;
1527
1533
  recording = false;
1528
1534
  clearRecordingToast();
1529
1535
  forceKillSox(logger);
@@ -1541,7 +1547,16 @@ async function doTranscribePipeline(
1541
1547
  renderer,
1542
1548
  ) {
1543
1549
  logger?.log("STT", `Pipeline started submit=${submit}`, "debug");
1550
+ const myGen = pipelineGeneration;
1544
1551
  const turn = await transcribeTurn(kv, complete, client, api, toast, systemPrompt, logger);
1552
+ // A cancel during transcribe/normalize invalidates this turn: drop the
1553
+ // stale text instead of appending it after the user cancelled.
1554
+ if (myGen !== pipelineGeneration) {
1555
+ clearProcessingToast();
1556
+ processing = false;
1557
+ recording = false;
1558
+ return;
1559
+ }
1545
1560
  if (!turn.text) return;
1546
1561
  if (submit) {
1547
1562
  await submitTurnText(client, renderer, toast, turn.text, logger);
package/lib/tts.js CHANGED
@@ -281,6 +281,9 @@ export function registerTTS(api, kv, complete, prompts, opts, logger, deps = {})
281
281
 
282
282
  let piperProc = null;
283
283
  let playProc = null;
284
+ // Invalidated by stopSpeech: a cancel landing between LLM normalization
285
+ // and playback must prevent the stale speech from starting afterwards.
286
+ let speechGen = 0;
284
287
 
285
288
  function killProcs() {
286
289
  if (piperProc) {
@@ -387,6 +390,7 @@ export function registerTTS(api, kv, complete, prompts, opts, logger, deps = {})
387
390
  }
388
391
 
389
392
  function speak(text) {
393
+ const myGen = speechGen;
390
394
  if (!text) return Promise.resolve();
391
395
  const line = text.replace(/\n/g, " ").trim();
392
396
  if (!line) return Promise.resolve();
@@ -397,6 +401,8 @@ export function registerTTS(api, kv, complete, prompts, opts, logger, deps = {})
397
401
  // switching.
398
402
  const voiceModel = resolveVoiceOrWarn(line);
399
403
  if (!voiceModel) return Promise.resolve();
404
+ // A cancel (stopSpeech) during normalization invalidates this speech.
405
+ if (myGen !== speechGen) return Promise.resolve();
400
406
  logger?.log?.("TTS", `Speak requested chars=${line.length} voice=${voiceModel}`, "debug");
401
407
 
402
408
  return new Promise((resolve) => {
@@ -420,6 +426,9 @@ export function registerTTS(api, kv, complete, prompts, opts, logger, deps = {})
420
426
  }
421
427
 
422
428
  function stopSpeech() {
429
+ // Always invalidate: a pending normalize handoff must not speak after
430
+ // the cancel even when nothing is currently playing.
431
+ speechGen += 1;
423
432
  const wasPlaying = piperProc !== null || playProc !== null;
424
433
  killProcs();
425
434
  return wasPlaying;
@@ -458,7 +467,12 @@ export function registerTTS(api, kv, complete, prompts, opts, logger, deps = {})
458
467
  lastSpokenMessageID = result.lastMessageID;
459
468
 
460
469
  showProcessingToast("Normalizing response...");
470
+ const genBeforeNormalize = speechGen;
461
471
  const llmResult = await normalizeOrLocal(result.text, systemAuto, 4096);
472
+ if (genBeforeNormalize !== speechGen) {
473
+ clearProcessingToast();
474
+ return;
475
+ }
462
476
  if (!llmResult.text) {
463
477
  clearProcessingToast();
464
478
  logger?.log?.("TTS", `Auto normalization failed: ${llmResult.error}`, "warn");
@@ -499,7 +513,12 @@ export function registerTTS(api, kv, complete, prompts, opts, logger, deps = {})
499
513
  }
500
514
 
501
515
  showProcessingToast("Normalizing response...");
516
+ const manualGen = speechGen;
502
517
  const llmResult = await normalizeOrLocal(result.text, systemManual, 4096);
518
+ if (manualGen !== speechGen) {
519
+ clearProcessingToast();
520
+ return;
521
+ }
503
522
  if (!llmResult.text) {
504
523
  clearProcessingToast();
505
524
  logger?.log?.("TTS", `Manual normalization failed: ${llmResult.error}`, "warn");
@@ -536,10 +555,15 @@ export function registerTTS(api, kv, complete, prompts, opts, logger, deps = {})
536
555
 
537
556
  showProcessingToast("Normalizing response...");
538
557
  const tNormalize = Date.now();
558
+ const turnGen = speechGen;
539
559
  // Auto replies are narrated/summarized, so a tight cap is safe here (the
540
560
  // manual read-aloud keeps the full 4096).
541
561
  const llmResult = await normalizeOrLocal(result.text, systemAuto, 2048);
542
562
  const normalizeMs = Date.now() - tNormalize;
563
+ if (turnGen !== speechGen) {
564
+ clearProcessingToast();
565
+ return { spoken: false };
566
+ }
543
567
  if (!llmResult.text) {
544
568
  clearProcessingToast();
545
569
  logger?.log?.("TTS", `Conversation normalization failed: ${llmResult.error}`, "warn");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bojackduy/opencode-voice",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Speech-to-text and text-to-speech for OpenCode. Record voice prompts with whisper-cpp, hear responses via Piper TTS, with LLM normalization through any OpenAI-compatible endpoint.",
5
5
  "keywords": [
6
6
  "opencode",