@pellux/goodvibes-tui 1.26.0 → 1.27.1

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +3756 -1110
  4. package/package.json +8 -7
  5. package/scripts/postinstall.js +57 -1
  6. package/src/audio/activation-sound.ts +117 -0
  7. package/src/audio/capture.ts +112 -0
  8. package/src/audio/onnxruntime-assets.d.ts +24 -0
  9. package/src/audio/voice-capture-wiring.ts +119 -0
  10. package/src/audio/voice-input-session.ts +181 -0
  11. package/src/audio/wake-inference.ts +182 -0
  12. package/src/audio/wake-runtime.ts +341 -0
  13. package/src/cli/bundle-command.ts +4 -1
  14. package/src/cli/management-utils.ts +4 -1
  15. package/src/cli/tui-startup.ts +10 -1
  16. package/src/core/voice-capture-status.ts +53 -0
  17. package/src/core/voice-stt-gateway.ts +75 -0
  18. package/src/core/wake-provision-runner.ts +86 -0
  19. package/src/core/wake-provision-status.ts +170 -0
  20. package/src/daemon/cli.ts +117 -28
  21. package/src/daemon/fatal-boot-report.ts +111 -0
  22. package/src/daemon/provision-wake-model.ts +81 -0
  23. package/src/input/command-registry.ts +6 -0
  24. package/src/input/commands/experience-runtime.ts +37 -3
  25. package/src/input/handler-shortcuts.ts +8 -0
  26. package/src/input/keybindings.ts +10 -1
  27. package/src/input/onboarding/onboarding-feature-units.ts +5 -5
  28. package/src/input/settings-modal-types.ts +14 -1
  29. package/src/main.ts +12 -14
  30. package/src/renderer/process-indicator.ts +66 -0
  31. package/src/renderer/settings-modal-helpers.ts +4 -2
  32. package/src/renderer/shell-surface.ts +22 -3
  33. package/src/runtime/bootstrap-core.ts +7 -7
  34. package/src/runtime/bootstrap.ts +3 -3
  35. package/src/runtime/device-posture-composition.ts +110 -0
  36. package/src/runtime/disposal-wiring.ts +16 -0
  37. package/src/runtime/embedded-service-factories.ts +16 -0
  38. package/src/runtime/legacy-daemon-migration.ts +12 -1
  39. package/src/runtime/runtime-services-types.ts +17 -0
  40. package/src/runtime/services.ts +81 -4
  41. package/src/runtime/voice-setup-services.ts +50 -4
  42. package/src/shell/voice-capture-shell.ts +69 -0
  43. package/src/verification/verification-ledger.ts +59 -20
  44. package/src/version.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "1.26.0",
3
+ "version": "1.27.1",
4
4
  "description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
5
5
  "type": "module",
6
6
  "main": "src/main.ts",
@@ -107,13 +107,14 @@
107
107
  "@anthropic-ai/vertex-sdk": "^0.16.0",
108
108
  "@ast-grep/napi": "^0.42.0",
109
109
  "@aws/bedrock-token-generator": "^1.1.0",
110
- "@pellux/goodvibes-sdk": "1.19.2",
111
- "@pellux/goodvibes-terminal-shell": "1.19.2",
110
+ "@pellux/goodvibes-sdk": "1.21.0",
111
+ "@pellux/goodvibes-terminal-shell": "1.21.0",
112
112
  "bash-language-server": "^5.6.0",
113
113
  "fuse.js": "^7.1.0",
114
114
  "graphql": "^16.13.2",
115
115
  "jszip": "^3.10.1",
116
116
  "node-edge-tts": "^1.2.10",
117
+ "onnxruntime-web": "^1.27.0",
117
118
  "openai": "^6.29.0",
118
119
  "pyright": "^1.1.408",
119
120
  "simple-git": "^3.33.0",
@@ -134,10 +135,10 @@
134
135
  "typescript": "^5.9.3"
135
136
  },
136
137
  "optionalDependencies": {
137
- "@pellux/goodvibes-tui-linux-x64": "1.26.0",
138
- "@pellux/goodvibes-tui-linux-arm64": "1.26.0",
139
- "@pellux/goodvibes-tui-darwin-x64": "1.26.0",
140
- "@pellux/goodvibes-tui-darwin-arm64": "1.26.0"
138
+ "@pellux/goodvibes-tui-linux-x64": "1.27.1",
139
+ "@pellux/goodvibes-tui-linux-arm64": "1.27.1",
140
+ "@pellux/goodvibes-tui-darwin-x64": "1.27.1",
141
+ "@pellux/goodvibes-tui-darwin-arm64": "1.27.1"
141
142
  },
142
143
  "trustedDependencies": [],
143
144
  "overrides": {
@@ -230,9 +230,65 @@ function deployBundledFiles() {
230
230
  }
231
231
  }
232
232
 
233
+ /**
234
+ * Put the wake-word model on the machine as part of installing.
235
+ *
236
+ * Everything needed to detect "hey goodvibes" already shipped, and provisioning
237
+ * was reachable only by typing `/voice wake setup` — so the ordinary outcome of
238
+ * installing was a wake word that could not start. The pins (URLs, byte counts,
239
+ * checksums) live in the SDK's wake-word manifest and stay there; this calls the
240
+ * SDK's install policy, which:
241
+ *
242
+ * - never throws (an absent network, DNS, a proxy serving HTML, an unwritable
243
+ * home directory and a provisioner that itself threw all come back as an
244
+ * outcome), so a failed download CANNOT fail this install;
245
+ * - reaps a torn artifact before retrying, so a killed attempt converges;
246
+ * - reports one plain line, printed below either way.
247
+ *
248
+ * A failure degrades to exactly the previous behaviour: the feature reports
249
+ * not-provisioned by content, `/voice wake setup` fetches it, and a running
250
+ * daemon retries at every boot.
251
+ *
252
+ * It honours the same skip switches as the binary install (`--no-download`,
253
+ * GOODVIBES_SKIP_BINARY_DOWNLOAD) plus its own
254
+ * GOODVIBES_SKIP_WAKE_MODEL_DOWNLOAD, and it stays out of a source checkout for
255
+ * the same reason the binary install does — a repo clone is a development tree,
256
+ * not an installation.
257
+ */
258
+ async function installWakeWordModel() {
259
+ if (noDownload) {
260
+ console.log('postinstall: skipping the wake-word model (--no-download)');
261
+ return;
262
+ }
263
+ if (isSourceCheckout()) {
264
+ console.log('postinstall: source checkout detected; skipping the wake-word model install');
265
+ return;
266
+ }
267
+ try {
268
+ const { provisionWakeWordModelsAtInstall, resolveManagedVoiceRoot } =
269
+ await import('@pellux/goodvibes-sdk/platform/voice');
270
+ const outcome = await provisionWakeWordModelsAtInstall({
271
+ managedRoot: resolveManagedVoiceRoot(home),
272
+ recoveryHint: '/voice wake setup',
273
+ });
274
+ console.log(`postinstall: ${outcome.message}`);
275
+ } catch (error) {
276
+ // The policy is contracted not to throw, so reaching here means the import
277
+ // itself failed (a partially installed dependency tree, most likely). Even
278
+ // that is not a reason to fail the install of everything else.
279
+ console.log(
280
+ 'postinstall: the wake-word model could not be installed '
281
+ + `(${error instanceof Error ? error.message : String(error)}); everything else installed normally. `
282
+ + 'Run /voice wake setup to fetch it, or leave it and the next daemon start tries again.',
283
+ );
284
+ }
285
+ }
286
+
233
287
  async function main() {
234
288
  await installPlatformBinaries();
235
289
  deployBundledFiles();
290
+ // Last, and never fatal: a wake-word model is not worth failing an install over.
291
+ await installWakeWordModel();
236
292
  }
237
293
 
238
294
  // Guarded so this module can be imported by tests (to exercise
@@ -242,4 +298,4 @@ if (import.meta.main) {
242
298
  await main();
243
299
  }
244
300
 
245
- export { verifyChecksum, parseChecksumFile, sha256, resolveArtifactNames, CHECKSUM_MANIFEST_NAME };
301
+ export { verifyChecksum, parseChecksumFile, sha256, resolveArtifactNames, CHECKSUM_MANIFEST_NAME, installWakeWordModel };
@@ -0,0 +1,117 @@
1
+ /**
2
+ * activation-sound.ts — the sound a confirmed wake makes.
3
+ *
4
+ * A wake takes the microphone from "scoring frames nobody hears" to "recording
5
+ * what you say next", and the user needs to know the moment that happened —
6
+ * otherwise the only feedback is a transcript arriving seconds later, and a
7
+ * false accept is indistinguishable from nothing having happened at all. So the
8
+ * sound is played AT the wake, before transcription, per
9
+ * `voice.wake.activationSound`:
10
+ *
11
+ * - `chime` — a short two-tone WAV synthesised here. No asset file to ship,
12
+ * install, provision or lose, and nothing to fetch at runtime.
13
+ * - `custom` — the file named by `voice.wake.activationSoundPath`.
14
+ * - `none` — silent.
15
+ *
16
+ * Playback reuses the streaming player the spoken-turn path already owns
17
+ * (player.ts), so a host with mpv or ffplay needs nothing extra and a host with
18
+ * neither degrades to silence with a reported reason rather than an exception in
19
+ * the middle of a capture.
20
+ */
21
+
22
+ import { readFileSync } from 'node:fs';
23
+ import { extname } from 'node:path';
24
+ import { encodeWavPcm16 } from '@pellux/goodvibes-sdk/platform/voice';
25
+ import type { WakeActivationSound } from '@pellux/goodvibes-sdk/platform/voice';
26
+ import type { VoiceAudioChunk } from '@pellux/goodvibes-sdk/platform/voice';
27
+ import type { StreamingAudioPlayer } from './player.ts';
28
+
29
+ /** Sample rate of the synthesised chime. 16 kHz is plenty for two short tones. */
30
+ const CHIME_SAMPLE_RATE = 16000;
31
+ /** The two tones, in Hz: a rising pair reads as "listening", not as an error. */
32
+ const CHIME_TONES_HZ = [880, 1320] as const;
33
+ /** Milliseconds per tone. Short enough not to overlap the start of an utterance. */
34
+ const CHIME_TONE_MS = 70;
35
+ /** Peak amplitude on the int16 magnitude scale — audible without being startling. */
36
+ const CHIME_PEAK = 6000;
37
+ /**
38
+ * Fade applied to each end of each tone, in samples. Without it the abrupt start
39
+ * and stop of a sine burst is a click, which on small laptop speakers is louder
40
+ * than the tone itself.
41
+ */
42
+ const CHIME_FADE_SAMPLES = 96;
43
+
44
+ /**
45
+ * Synthesise the built-in chime as a complete WAV file.
46
+ *
47
+ * Written as samples rather than shipped as an asset for the same reason the
48
+ * wake front end is computed in code: a binary blob is one more thing that can
49
+ * be missing, and this one is 40 milliseconds of two sine waves.
50
+ */
51
+ export function buildActivationChimeWav(): Uint8Array {
52
+ const perTone = Math.round((CHIME_TONE_MS / 1000) * CHIME_SAMPLE_RATE);
53
+ const samples = new Float32Array(perTone * CHIME_TONES_HZ.length);
54
+ let offset = 0;
55
+ for (const frequency of CHIME_TONES_HZ) {
56
+ for (let i = 0; i < perTone; i++) {
57
+ const fadeIn = Math.min(1, i / CHIME_FADE_SAMPLES);
58
+ const fadeOut = Math.min(1, (perTone - 1 - i) / CHIME_FADE_SAMPLES);
59
+ const envelope = Math.min(fadeIn, fadeOut);
60
+ samples[offset + i] = Math.sin((2 * Math.PI * frequency * i) / CHIME_SAMPLE_RATE) * CHIME_PEAK * envelope;
61
+ }
62
+ offset += perTone;
63
+ }
64
+ return encodeWavPcm16(samples, CHIME_SAMPLE_RATE);
65
+ }
66
+
67
+ /** One-chunk stream, matching what the streaming player consumes. */
68
+ async function* singleChunk(data: Uint8Array, format: string): AsyncIterable<VoiceAudioChunk> {
69
+ yield { data, sequence: 1, format };
70
+ }
71
+
72
+ export interface ActivationSoundPlayerDeps {
73
+ readonly player: StreamingAudioPlayer;
74
+ /** Where a sound that could not be played is reported, in the user's words. */
75
+ readonly notify: (message: string) => void;
76
+ /** Injected in tests; defaults to reading the custom file off disk. */
77
+ readonly readFile?: (path: string) => Uint8Array;
78
+ }
79
+
80
+ /**
81
+ * Play the resolved activation sound. Never throws and never blocks the caller
82
+ * on the audio finishing: a wake's next step is recording the utterance, and a
83
+ * sound that fails to play must not delay or cancel that.
84
+ *
85
+ * `settings.activationSound` arrives already resolved against surface capability
86
+ * — the SDK downgrades `custom` to `chime` on a surface that cannot read a local
87
+ * file, and this surface can, so `custom` here means a real path.
88
+ */
89
+ export function playActivationSound(sound: WakeActivationSound, deps: ActivationSoundPlayerDeps): void {
90
+ if (sound.kind === 'none') return;
91
+ if (!deps.player.available) {
92
+ deps.notify(`[Voice] Wake heard, but no audio player is installed to play the ${sound.kind} activation sound (${deps.player.label}).`);
93
+ return;
94
+ }
95
+ let data: Uint8Array;
96
+ let format: string;
97
+ if (sound.kind === 'custom') {
98
+ const path = sound.path.trim();
99
+ if (path.length === 0) {
100
+ deps.notify('[Voice] voice.wake.activationSound is "custom" but voice.wake.activationSoundPath is empty, so no sound was played.');
101
+ return;
102
+ }
103
+ try {
104
+ data = (deps.readFile ?? ((target: string) => new Uint8Array(readFileSync(target))))(path);
105
+ } catch (error) {
106
+ deps.notify(`[Voice] Could not read the activation sound at ${path}: ${error instanceof Error ? error.message : String(error)}`);
107
+ return;
108
+ }
109
+ format = extname(path).replace('.', '').toLowerCase() || 'wav';
110
+ } else {
111
+ data = buildActivationChimeWav();
112
+ format = 'wav';
113
+ }
114
+ void deps.player.play(singleChunk(data, format), { format }).catch((error: unknown) => {
115
+ deps.notify(`[Voice] The activation sound could not be played: ${error instanceof Error ? error.message : String(error)}`);
116
+ });
117
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * capture.ts — the ONE place this terminal opens a microphone.
3
+ *
4
+ * Two consumers sit on it and they share this single device path: push-to-talk
5
+ * voice input (src/audio/voice-input-session.ts) and wake-word detection
6
+ * (src/audio/wake-runtime.ts). A wake does not END a capture session, it starts
7
+ * one — the SDK listener keeps the same stream open and switches it to recording
8
+ * the utterance that follows — so a second opener would drop the beginning of a
9
+ * sentence and race the operating system for a device that is already held.
10
+ *
11
+ * The framing arithmetic, the recorder argv and the utterance policy all live in
12
+ * the SDK (`@pellux/goodvibes-sdk/platform/voice/capture`), because getting any
13
+ * of them subtly wrong is SILENT: a container header out of byte alignment, or a
14
+ * short frame, still "works" and simply never detects. What is local is the one
15
+ * thing that cannot be shared — actually starting a process. That mirrors
16
+ * playback exactly (see player.ts): resolve a command off PATH, spawn it, and
17
+ * treat "no tool installed" as a reported state rather than an exception.
18
+ */
19
+
20
+ import { accessSync, constants } from 'node:fs';
21
+ import { delimiter, join } from 'node:path';
22
+ import { spawn } from 'node:child_process';
23
+ import {
24
+ createRecorderCaptureOpener,
25
+ type AudioCaptureOpener,
26
+ type AudioCaptureWarn,
27
+ type CaptureChildProcess,
28
+ type CaptureSpawn,
29
+ } from '@pellux/goodvibes-sdk/platform/voice/capture';
30
+
31
+ /**
32
+ * Whether a command is runnable from PATH. Same scan `player.ts` uses for
33
+ * mpv/ffplay (`resolveStreamingAudioPlayerCommand` -> `findExecutable`): every
34
+ * PATH entry, X_OK access, Windows extensions included — kept identical so a
35
+ * host where playback finds its tool and capture does not is a real difference
36
+ * in what is installed, never a difference in how the two looked.
37
+ */
38
+ export function isExecutableOnPath(
39
+ name: string,
40
+ env: NodeJS.ProcessEnv = process.env,
41
+ platform: string = process.platform,
42
+ ): boolean {
43
+ const pathValue = env.PATH ?? '';
44
+ const extensions = platform === 'win32' ? ['', '.exe', '.cmd', '.bat'] : [''];
45
+ for (const dir of pathValue.split(delimiter)) {
46
+ if (!dir) continue;
47
+ for (const ext of extensions) {
48
+ try {
49
+ accessSync(join(dir, `${name}${ext}`), constants.X_OK);
50
+ return true;
51
+ } catch {
52
+ // Keep scanning PATH.
53
+ }
54
+ }
55
+ }
56
+ return false;
57
+ }
58
+
59
+ /**
60
+ * Spawn a recorder with its stdout piped and stdin closed.
61
+ *
62
+ * `stdio: ['ignore', 'pipe', 'pipe']` is deliberate on all three: a recorder
63
+ * with an inherited stdin would compete with the TUI for the terminal's raw-mode
64
+ * keystrokes, stdout carries the raw PCM, and stderr is the ONLY place the
65
+ * reason a device did not open is written, so it is kept for the error message
66
+ * rather than discarded.
67
+ */
68
+ export const spawnRecorderProcess: CaptureSpawn = (command, args): CaptureChildProcess => {
69
+ const child = spawn(command, [...args], { stdio: ['ignore', 'pipe', 'pipe'] });
70
+ // Adapted rather than returned directly: the SDK's port declares the signal as
71
+ // a plain `string` (it is shared with a browser bundle that has no
72
+ // NodeJS.Signals) and node's narrower union is not assignable to it. The two
73
+ // event listeners are branched explicitly for the same reason — node's `on` is
74
+ // overloaded per event name and takes no union.
75
+ return {
76
+ stdout: child.stdout,
77
+ stderr: child.stderr,
78
+ on(event: 'error' | 'close', listener: (...args: never[]) => void): unknown {
79
+ if (event === 'error') return child.on('error', listener as (error: Error) => void);
80
+ return child.on('close', listener as (code: number | null, signal: NodeJS.Signals | null) => void);
81
+ },
82
+ kill: (signal?: string) => child.kill(signal as NodeJS.Signals | undefined),
83
+ };
84
+ };
85
+
86
+ export interface TuiCaptureOpenerOptions {
87
+ /** Injected in tests so no real recorder is ever started. */
88
+ readonly spawn?: CaptureSpawn;
89
+ /** Injected in tests; defaults to the PATH + X_OK scan above. */
90
+ readonly isInstalled?: (command: string) => boolean;
91
+ readonly platform?: string;
92
+ readonly warn?: AudioCaptureWarn;
93
+ }
94
+
95
+ /**
96
+ * Build the terminal's capture opener. Called once during startup wiring; both
97
+ * voice consumers receive the SAME opener.
98
+ */
99
+ export function createTuiCaptureOpener(options: TuiCaptureOpenerOptions = {}): AudioCaptureOpener {
100
+ return createRecorderCaptureOpener({
101
+ spawn: options.spawn ?? spawnRecorderProcess,
102
+ isInstalled: options.isInstalled ?? ((command: string) => isExecutableOnPath(command)),
103
+ platform: options.platform ?? process.platform,
104
+ // FALSE here, and correct: a recorder subprocess captures raw PCM and filters
105
+ // nothing. The suppression stage is applied by the SDK's own wrapper inside
106
+ // WakeListener and PushToTalkSession, which ask this opener for raw frames —
107
+ // so `speex` arriving at the RECORDER directly, unwrapped by either, is still
108
+ // refused rather than passed through unfiltered.
109
+ speexAvailable: false,
110
+ ...(options.warn !== undefined ? { warn: options.warn } : {}),
111
+ });
112
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The two onnxruntime-web assets the wake engine needs on disk.
3
+ *
4
+ * Both are imported with `with { type: 'file' }`, which resolves to a PATH
5
+ * string rather than module contents — a real filesystem path when the shell
6
+ * runs from source, and a `/$bunfs/root/...` path inside a compiled binary,
7
+ * where the bytes are embedded. Declared here because these are asset
8
+ * specifiers, not typed modules: onnxruntime-web publishes them for exactly
9
+ * this purpose (its `exports` map lists both by name) but ships no types for
10
+ * them, and without the `file` attribute the `.mjs` would be pulled in as
11
+ * untyped JavaScript.
12
+ *
13
+ * They are extracted and pointed at by wake-inference.ts, which is the only
14
+ * importer.
15
+ */
16
+ declare module 'onnxruntime-web/ort-wasm-simd-threaded.mjs' {
17
+ const assetPath: string;
18
+ export default assetPath;
19
+ }
20
+
21
+ declare module 'onnxruntime-web/ort-wasm-simd-threaded.wasm' {
22
+ const assetPath: string;
23
+ export default assetPath;
24
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * voice-capture-wiring.ts — composes the terminal's two voice consumers over ONE
3
+ * capture path.
4
+ *
5
+ * Push-to-talk and wake detection are not two audio stacks. They share the
6
+ * opener built here (capture.ts), the transcription gateway (core/voice-stt-gateway.ts)
7
+ * and the same `voice.wake.*` capture rows — device, recorder, noise
8
+ * suppression, ceiling — so a device that works for one works for the other and
9
+ * a change in /settings applies to both.
10
+ *
11
+ * This is the composition root the shell calls once. It owns nothing the shell
12
+ * does not hand it: the composer draft, the turn submission, the message router
13
+ * and the render request all arrive as callbacks, which is what keeps this file
14
+ * out of the shell-UI layers the architecture check bars src/audio from importing.
15
+ */
16
+
17
+ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
18
+ import type { ConfigKey, ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
19
+ import type { VoiceCaptureIndicatorState } from '../core/voice-capture-status.ts';
20
+ import { createTuiCaptureOpener } from './capture.ts';
21
+ import { playActivationSound } from './activation-sound.ts';
22
+ import { LocalStreamingAudioPlayer } from './player.ts';
23
+ import type { StreamingAudioPlayer } from './player.ts';
24
+ import { createVoiceSttGateway } from '../core/voice-stt-gateway.ts';
25
+ import { wireVoiceInputRuntime } from './voice-input-session.ts';
26
+ import { startWakeRuntime, wireWakeRuntime } from './wake-runtime.ts';
27
+ import { terminalWakeCapabilities } from '../core/wake-provision-status.ts';
28
+ import { resolveWakeRuntimeSettings } from '@pellux/goodvibes-sdk/platform/voice/wake/runtime';
29
+ import { wakeProvisionStatus } from '@pellux/goodvibes-sdk/platform/voice';
30
+
31
+ export interface VoiceCaptureWiringDeps {
32
+ readonly configManager: ConfigManager;
33
+ /** `<root>/voice` — the managed root the wake tree hangs off (same root /voice setup uses). */
34
+ readonly managedVoiceRoot: string;
35
+ /** A directory this surface owns for the extracted onnxruntime assets. */
36
+ readonly assetDirectory: string;
37
+ readonly homeDirectory: string | (() => string);
38
+ /** Names retained clips so the SDK's sweeper can reap them when the session ends. */
39
+ readonly sessionId: string;
40
+ /** Writes recognised text into the composer draft. */
41
+ readonly writeDraft: (text: string) => void;
42
+ /** Submits recognised text as a turn (`voice.wake.autoSubmit` on). */
43
+ readonly submitTurn: (text: string) => void;
44
+ readonly notify: (message: string) => void;
45
+ readonly render: () => void;
46
+ /** Injected in tests; defaults to a real streaming player for the activation sound. */
47
+ readonly player?: StreamingAudioPlayer;
48
+ }
49
+
50
+ export interface VoiceCaptureWiring {
51
+ /** The Alt+V action: start recording, or stop and transcribe. */
52
+ readonly toggleVoiceInput: () => void;
53
+ /**
54
+ * The live footer row. A push-to-talk recording OUTRANKS the wake row: the user
55
+ * pressed a key and is waiting on that one, while the detector's row describes a
56
+ * standing condition they already know about.
57
+ */
58
+ readonly status: () => VoiceCaptureIndicatorState | null;
59
+ /** Teardown, for the shell's `unsubs` registry — releases any open device. */
60
+ readonly unsubs: readonly (() => void)[];
61
+ }
62
+
63
+ /** Compose voice input and wake detection. Opens no device by itself. */
64
+ export function wireVoiceCapture(deps: VoiceCaptureWiringDeps): VoiceCaptureWiring {
65
+ const readConfig = (key: string): unknown => deps.configManager.get(key as ConfigKey);
66
+ const warn = (message: string, meta?: Readonly<Record<string, unknown>>): void => {
67
+ logger.debug(`voice capture: ${message}`, meta ?? {});
68
+ };
69
+ // ONE opener, both consumers. The suppression stage is inside it, so `speex`
70
+ // is applied rather than refused (see capture.ts).
71
+ const openCapture = createTuiCaptureOpener({ warn });
72
+ const resolveTranscriber = () => {
73
+ const resolution = createVoiceSttGateway({ configManager: deps.configManager, homeDirectory: deps.homeDirectory });
74
+ return resolution.available
75
+ ? { available: true as const, gateway: resolution.gateway }
76
+ : { available: false as const, reason: resolution.reason };
77
+ };
78
+ // Capabilities are re-read per resolve rather than captured once: provisioning
79
+ // the speech gate is something a user does WHILE the session is running, and a
80
+ // capability snapshot taken at startup would keep reporting it unavailable.
81
+ const readSettings = () => resolveWakeRuntimeSettings(
82
+ readConfig,
83
+ 'tui',
84
+ terminalWakeCapabilities(wakeProvisionStatus({ managedRoot: deps.managedVoiceRoot })),
85
+ );
86
+ const subscribeConfig = (key: string, listener: () => void): (() => void) => deps.configManager.subscribe(key as ConfigKey, listener);
87
+ const player = deps.player ?? new LocalStreamingAudioPlayer();
88
+
89
+ const voiceInput = wireVoiceInputRuntime({
90
+ openCapture,
91
+ readSettings,
92
+ resolveTranscriber,
93
+ writeDraft: deps.writeDraft,
94
+ notify: deps.notify,
95
+ render: deps.render,
96
+ });
97
+
98
+ const wake = wireWakeRuntime({
99
+ readConfig,
100
+ subscribeConfig,
101
+ openCapture,
102
+ managedRoot: deps.managedVoiceRoot,
103
+ assetDirectory: deps.assetDirectory,
104
+ resolveTranscriber,
105
+ playActivationSound: (sound) => playActivationSound(sound, { player, notify: deps.notify }),
106
+ submitTurn: deps.submitTurn,
107
+ writeDraft: deps.writeDraft,
108
+ notify: deps.notify,
109
+ render: deps.render,
110
+ sessionId: deps.sessionId,
111
+ warn,
112
+ });
113
+
114
+ return {
115
+ toggleVoiceInput: voiceInput.toggle,
116
+ status: () => voiceInput.status() ?? wake.status(),
117
+ unsubs: [...startWakeRuntime(wake, { subscribeConfig }), () => { void voiceInput.release(); }],
118
+ };
119
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * voice-input-session.ts — push-to-talk voice input for the terminal.
3
+ *
4
+ * PRESS TO START, PRESS AGAIN TO STOP — not press-and-hold. A terminal receives
5
+ * discrete key EVENTS, not held-key state: there is no key-release event to
6
+ * observe, so a genuine hold would have to be faked with a timer that guesses
7
+ * when the user let go. The binding's description says "press to start, press
8
+ * again to stop" for that reason, and the SDK's `captureMaxSeconds` ceiling still
9
+ * applies as the backstop for a second press that never arrives (a lost focus, a
10
+ * closed terminal) so the microphone cannot be left open indefinitely.
11
+ *
12
+ * The transcript is written into the COMPOSER DRAFT and never submitted. Speech
13
+ * recognition is wrong often enough that submitting it unseen would send the
14
+ * wrong prompt, and the composer is where the user already fixes things. The
15
+ * wake path is where auto-submit is a choice (`voice.wake.autoSubmit`), because
16
+ * there the user spoke a whole command on purpose.
17
+ */
18
+
19
+ import {
20
+ PushToTalkSession,
21
+ utteranceToAudioArtifact,
22
+ AudioCaptureError,
23
+ type AudioCaptureOpener,
24
+ type CapturedUtterance,
25
+ type PushToTalkPhase,
26
+ type WakeRuntimeSettings,
27
+ } from '@pellux/goodvibes-sdk/platform/voice';
28
+ import type { VoiceCaptureIndicatorState } from '../core/voice-capture-status.ts';
29
+ import type { VoiceSttGateway } from '../core/voice-stt-gateway.ts';
30
+ import { describeTranscriptionFailure } from '../core/voice-stt-gateway.ts';
31
+
32
+ export interface VoiceInputRuntimeDeps {
33
+ readonly openCapture: AudioCaptureOpener;
34
+ /**
35
+ * Resolved `voice.wake.*` capture rows, read at each press so a device or
36
+ * recorder change in /settings applies without a restart.
37
+ */
38
+ readonly readSettings: () => Pick<WakeRuntimeSettings, 'capture' | 'captureMaxSeconds' | 'indicator'>;
39
+ /** Resolved per press, so a daemon that comes up mid-session starts working. */
40
+ readonly resolveTranscriber: () => { readonly available: true; readonly gateway: VoiceSttGateway } | { readonly available: false; readonly reason: string };
41
+ /** Writes the recognised text into the composer draft. */
42
+ readonly writeDraft: (text: string) => void;
43
+ readonly notify: (message: string) => void;
44
+ readonly render: () => void;
45
+ }
46
+
47
+ export interface VoiceInputRuntime {
48
+ /** The keybinding action: start capturing, or stop and transcribe. */
49
+ toggle(): void;
50
+ /** Current indicator row, or null when no microphone is open. */
51
+ status(): VoiceCaptureIndicatorState | null;
52
+ /** Release the device without transcribing. Used by the exit path. */
53
+ release(): Promise<void>;
54
+ }
55
+
56
+ /** Indicator kinds for the phases worth showing; `idle`/`error` render nothing. */
57
+ const PHASE_INDICATOR: Partial<Record<PushToTalkPhase, VoiceCaptureIndicatorState['kind']>> = {
58
+ requesting: 'requesting',
59
+ recording: 'recording',
60
+ stopping: 'transcribing',
61
+ };
62
+
63
+ /**
64
+ * Wire push-to-talk. One session object is reused across presses (the SDK session
65
+ * is restartable after both a stop and a failure), so a device is opened per
66
+ * press and released on every path out of it — including the failing ones.
67
+ */
68
+ export function wireVoiceInputRuntime(deps: VoiceInputRuntimeDeps): VoiceInputRuntime {
69
+ let session: PushToTalkSession | null = null;
70
+ let phase: PushToTalkPhase = 'idle';
71
+ let transcribing = false;
72
+
73
+ const status = (): VoiceCaptureIndicatorState | null => {
74
+ const indicator = deps.readSettings().indicator;
75
+ if (transcribing) {
76
+ return { kind: 'transcribing', deviceLabel: session?.deviceLabel ?? null, indicator };
77
+ }
78
+ const kind = PHASE_INDICATOR[phase];
79
+ if (kind === undefined) return null;
80
+ const seconds = Math.floor((session?.durationMs ?? 0) / 1000);
81
+ return {
82
+ kind,
83
+ deviceLabel: session?.deviceLabel ?? null,
84
+ indicator,
85
+ ...(kind === 'recording' ? { detail: `${seconds}s` } : {}),
86
+ };
87
+ };
88
+
89
+ const transcribe = async (utterance: CapturedUtterance): Promise<void> => {
90
+ if (utterance.silent) {
91
+ deps.notify('[Voice] Nothing above the silence floor was captured, so nothing was transcribed.');
92
+ deps.render();
93
+ return;
94
+ }
95
+ const resolution = deps.resolveTranscriber();
96
+ if (!resolution.available) {
97
+ deps.notify(`[Voice] Captured ${Math.round(utterance.durationMs)} ms of audio but could not transcribe it: ${resolution.reason}`);
98
+ deps.render();
99
+ return;
100
+ }
101
+ transcribing = true;
102
+ deps.render();
103
+ try {
104
+ const text = (await resolution.gateway.transcribe(utteranceToAudioArtifact(utterance))).trim();
105
+ if (text.length === 0) {
106
+ deps.notify('[Voice] Speech-to-text returned no words for that recording.');
107
+ } else {
108
+ deps.writeDraft(text);
109
+ }
110
+ } catch (error) {
111
+ deps.notify(`[Voice] Transcription failed: ${describeTranscriptionFailure(error)}`);
112
+ } finally {
113
+ // The device is already released by the session's stop(); this only clears
114
+ // the indicator so a failed transcription cannot leave a "recording" row up.
115
+ transcribing = false;
116
+ deps.render();
117
+ }
118
+ };
119
+
120
+ const start = (): void => {
121
+ const settings = deps.readSettings();
122
+ const active = new PushToTalkSession({
123
+ openCapture: deps.openCapture,
124
+ capture: settings.capture,
125
+ captureMaxSeconds: settings.captureMaxSeconds,
126
+ // 0 disables silence-stop: someone who pressed the key and paused has not
127
+ // finished talking, so the second press is what ends the recording.
128
+ silenceStopMs: 0,
129
+ onPhaseChange: (next) => { phase = next; deps.render(); },
130
+ onAutoStop: (utterance) => {
131
+ // The ceiling fired instead of a second keypress. The audio is still what
132
+ // the user said, so it is transcribed rather than discarded.
133
+ session = null;
134
+ deps.notify(`[Voice] Recording reached the ${settings.captureMaxSeconds}s ceiling (voice.wake.captureMaxSeconds) and stopped on its own.`);
135
+ void transcribe(utterance);
136
+ },
137
+ onError: (error) => { deps.notify(`[Voice] Capture stopped: ${error.message}`); deps.render(); },
138
+ });
139
+ session = active;
140
+ void active.start().catch((error: unknown) => {
141
+ session = null;
142
+ phase = 'idle';
143
+ const message = error instanceof AudioCaptureError
144
+ ? `${error.message} (${error.reason})`
145
+ : error instanceof Error ? error.message : String(error);
146
+ deps.notify(`[Voice] Could not start recording: ${message}`);
147
+ deps.render();
148
+ });
149
+ };
150
+
151
+ const stop = (): void => {
152
+ const active = session;
153
+ if (active === null) return;
154
+ session = null;
155
+ void active.stop().then(
156
+ (utterance) => { if (utterance !== null) void transcribe(utterance); else deps.render(); },
157
+ (error: unknown) => {
158
+ deps.notify(`[Voice] Recording could not be stopped cleanly: ${error instanceof Error ? error.message : String(error)}`);
159
+ deps.render();
160
+ },
161
+ );
162
+ };
163
+
164
+ return {
165
+ toggle: () => {
166
+ if (transcribing) {
167
+ deps.notify('[Voice] Still transcribing the previous recording.');
168
+ return;
169
+ }
170
+ if (session === null) start();
171
+ else stop();
172
+ },
173
+ status,
174
+ release: async () => {
175
+ const active = session;
176
+ session = null;
177
+ phase = 'idle';
178
+ if (active !== null) await active.cancel();
179
+ },
180
+ };
181
+ }