@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 +13 -0
- package/index.js +9 -0
- package/lib/cancel.js +106 -0
- package/lib/live-notes.js +1 -1
- package/lib/stt.js +15 -0
- package/lib/tts.js +24 -0
- package/package.json +1 -1
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
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.
|
|
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",
|