{"version":3,"file":"voice-controller.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/voice/voice-controller.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,GAAG,EAAE,MAAM,0BAA0B,CAAC;AAkB/D,iEAAiE;AACjE,MAAM,WAAW,mBAAmB;IACnC,EAAE,EAAE,GAAG,CAAC;IACR,mEAAmE;IACnE,eAAe,EAAE,SAAS,CAAC;IAC3B,6DAA6D;IAC7D,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpC,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC;;;;;;;;OAQG;IACH,SAAS,EAAE,MAAM,CAAC;CAClB;AAED,qBAAa,eAAe;IAef,OAAO,CAAC,QAAQ,CAAC,IAAI;IAdjC,0DAA0D;IAC1D,OAAO,CAAC,OAAO,CAA2B;IAG1C,OAAO,CAAC,MAAM,CAA0B;IACxC,gFAAgF;IAChF,OAAO,CAAC,iBAAiB,CAAS;IAClC,6FAA6F;IAC7F,OAAO,CAAC,QAAQ,CAAS;IACzB,kEAAkE;IAClE,OAAO,CAAC,MAAM,CAAS;IACvB,oDAAoD;IACpD,OAAO,CAAC,KAAK,CAAyB;IAEtC,YAA6B,IAAI,EAAE,mBAAmB,EAAI;IAE1D,oCAAoC;IACpC,MAAM,IAAI,IAAI,CAmGb;IAED;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAM7B;IAED,kEAAkE;IAClE,OAAO,IAAI,IAAI,CAad;IAED;;;;;;;;;;;;;;OAcG;IACH,OAAO,CAAC,mBAAmB;IA2D3B,uFAAuF;IACvF,OAAO,CAAC,UAAU;IAIlB,OAAO,CAAC,kBAAkB;IAO1B,OAAO,CAAC,kBAAkB;YAgCZ,UAAU;IAUxB,8EAA8E;IAC9E,OAAO,CAAC,SAAS;IAUjB,OAAO,CAAC,WAAW;IAInB,8EAA8E;IAC9E,OAAO,CAAC,OAAO;CAQf","sourcesContent":["/**\n * Voice-to-text capture control (ctrl+r / app.input.voiceTranscribe).\n *\n * Prefers a persistent `voicetools serve` daemon: the first press probes for\n * support and (if found) loads models once, showing a \"warming up\" spinner;\n * every later press reuses the already-warm daemon and jumps straight to\n * Listening. Binaries without `serve` support fall back to spawning\n * `voicetools transcribe` per press. Pressing the shortcut again while\n * listening (or while still warming up) cancels.\n */\n\nimport type { Container, TUI } from \"@kolisachint/hoocode-tui\";\nimport { ensureTool } from \"../../../utils/tools-manager.js\";\nimport { keyHint } from \"../components/keybinding-hints.js\";\nimport { VoicePanel } from \"./voice-panel.js\";\nimport { startVoiceTranscribe, VoiceDaemon, type VoiceDaemonHandlers, type VoiceSession } from \"./voice-transcribe.js\";\n\n/** How long to keep the warm voice model in memory after the last capture\n * completes. The daemon auto-shuts down after this window, releasing the\n * ~900 MB resident model; the next ctrl+r pays a cold-start respawn cost.\n * Kept long enough that normal pacing between dictations (reading a reply,\n * typing) doesn't repeatedly hit the multi-second cold start. */\nconst VOICE_IDLE_TIMEOUT_MS = 300_000;\nconst VOICE_UNAVAILABLE_MESSAGE = [\n\t`Voice unavailable — no voicetools binary for ${process.platform}-${process.arch} (and download failed).`,\n\t\"  fix: set VOICETOOLS_BIN=/path/to/voicetools\",\n\t\"  or:  install voicetools from its published release, then press the voice key again\",\n].join(\"\\n\");\n\n/** The slice of the interactive mode the voice feature needs. */\nexport interface VoiceControllerDeps {\n\tui: TUI;\n\t/** The status area below the chat; the voice panel mounts here. */\n\tstatusContainer: Container;\n\t/** Feed raw input (bracketed paste) to the prompt editor. */\n\tsendEditorInput(data: string): void;\n\tshowError(message: string): void;\n\t/**\n\t * Trailing-silence window (ms): how long a pause while speaking lasts before\n\t * the capture auto-stops. Resolved once by the caller (env `VOICETOOLS_SILENCE_MS`\n\t * → settings → default 800, clamped 300-10000) and used for both the binary\n\t * cutoff — passed to `voicetools serve` via `--silence-ms` (see\n\t * VoiceDaemon.spawn) — and the on-screen countdown, so the two stay in sync.\n\t * A longer window tolerates brief mid-sentence pauses without taxing every\n\t * dictation with dead air.\n\t */\n\tsilenceMs: number;\n}\n\nexport class VoiceController {\n\t/** In-flight legacy (`voicetools transcribe`) capture. */\n\tprivate session: VoiceSession | undefined;\n\t// Persistent `voicetools serve` process, once probed successfully. Stays alive\n\t// between captures so the model stays warm.\n\tprivate daemon: VoiceDaemon | undefined;\n\t/** Set when the binary rejected `serve`; future presses use the legacy path. */\n\tprivate daemonUnsupported = false;\n\t/** True while the voicetools binary is being resolved/downloaded before a session starts. */\n\tprivate starting = false;\n\t/** True while a capture is active (listening or transcribing). */\n\tprivate active = false;\n\t/** The status panel while voice input is active. */\n\tprivate panel: VoicePanel | undefined;\n\n\tconstructor(private readonly deps: VoiceControllerDeps) {}\n\n\t/** Toggle voice-to-text capture. */\n\ttoggle(): void {\n\t\tif (this.active) {\n\t\t\tif (this.daemon?.isReady) {\n\t\t\t\tthis.daemon.cancel();\n\t\t\t} else {\n\t\t\t\tthis.session?.stop();\n\t\t\t}\n\t\t\tthis.session = undefined;\n\t\t\tthis.active = false;\n\t\t\tthis.resetUI();\n\t\t\treturn;\n\t\t}\n\n\t\tif (this.starting) {\n\t\t\t// A second press while resolving/warming up: honour the cancel. Any\n\t\t\t// daemon that finishes loading afterwards is kept warm for next time.\n\t\t\tthis.starting = false;\n\t\t\tthis.resetUI();\n\t\t\treturn;\n\t\t}\n\n\t\tif (this.daemonUnsupported) {\n\t\t\tthis.starting = true;\n\t\t\tthis.showWarming(\"Starting voice input…\", \"first run downloads the voicetools binary and speech model\");\n\t\t\tvoid this.resolveBin()\n\t\t\t\t.then((bin) => {\n\t\t\t\t\tif (!this.starting) return;\n\t\t\t\t\tthis.starting = false;\n\t\t\t\t\tif (!bin) {\n\t\t\t\t\t\tthis.resetUI();\n\t\t\t\t\t\tthis.deps.showError(VOICE_UNAVAILABLE_MESSAGE);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tthis.beginLegacyCapture(bin);\n\t\t\t\t})\n\t\t\t\t.catch((err: unknown) => {\n\t\t\t\t\tthis.starting = false;\n\t\t\t\t\tthis.resetUI();\n\t\t\t\t\tthis.deps.showError(`Voice input failed: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\t\t});\n\t\t\treturn;\n\t\t}\n\n\t\tif (this.daemon?.isReady) {\n\t\t\tthis.beginDaemonCapture();\n\t\t\treturn;\n\t\t}\n\n\t\t// No daemon yet: resolve the binary, then probe for `serve` support by\n\t\t// spawning it. `VoiceDaemon.spawn` doubles as the probe: it resolves to a\n\t\t// live daemon once READY arrives, to \"unsupported\" if the process exits\n\t\t// with no output at all (an old binary rejecting the unrecognized `serve`\n\t\t// subcommand), or to \"error\" if it printed a real ERROR first (e.g. no\n\t\t// model installed yet) — already surfaced via onError, so that case skips\n\t\t// the legacy fallback (it would just hit the same error) but leaves\n\t\t// daemon mode available to retry on the next press.\n\t\tthis.starting = true;\n\t\tthis.showWarming(\"Warming up voice input…\", \"first run downloads the voicetools binary and speech model\");\n\t\tvoid this.resolveBin()\n\t\t\t.then(async (bin) => {\n\t\t\t\tif (!bin) {\n\t\t\t\t\tthis.starting = false;\n\t\t\t\t\tthis.resetUI();\n\t\t\t\t\tthis.deps.showError(VOICE_UNAVAILABLE_MESSAGE);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst result = await VoiceDaemon.spawn(bin, this.buildDaemonHandlers(), {\n\t\t\t\t\tsilenceMs: this.deps.silenceMs,\n\t\t\t\t\tidleTimeoutMs: VOICE_IDLE_TIMEOUT_MS,\n\t\t\t\t});\n\t\t\t\tif (!result.ok) {\n\t\t\t\t\tif (result.reason === \"unsupported\") {\n\t\t\t\t\t\tthis.daemonUnsupported = true;\n\t\t\t\t\t\tif (!this.starting) {\n\t\t\t\t\t\t\tthis.resetUI();\n\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthis.starting = false;\n\t\t\t\t\t\tthis.beginLegacyCapture(bin);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tthis.starting = false;\n\t\t\t\t\tthis.resetUI();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tthis.daemon = result.daemon;\n\t\t\t\tif (!this.starting) {\n\t\t\t\t\t// Cancelled while warming up: keep the loaded daemon warm for next time.\n\t\t\t\t\tthis.resetUI();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tthis.starting = false;\n\t\t\t\tthis.beginDaemonCapture();\n\t\t\t})\n\t\t\t.catch((err: unknown) => {\n\t\t\t\tthis.starting = false;\n\t\t\t\tthis.resetUI();\n\t\t\t\tthis.deps.showError(`Voice input failed: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\t});\n\t}\n\n\t/**\n\t * Update the trailing-silence window live (e.g. from the settings selector).\n\t * The daemon's `--silence-ms` is fixed at spawn, so drop any idle warm daemon\n\t * and let the next capture respawn — that keeps the binary cutoff matched to\n\t * the countdown, which reads the same value. A drop is skipped while a capture\n\t * is active so the in-flight session keeps its own spawn-time value.\n\t */\n\tsetSilenceMs(ms: number): void {\n\t\tthis.deps.silenceMs = ms;\n\t\tif (!this.active && this.daemon) {\n\t\t\tthis.daemon.shutdown();\n\t\t\tthis.daemon = undefined;\n\t\t}\n\t}\n\n\t/** Stop any capture, shut down the daemon, and drop the panel. */\n\tdispose(): void {\n\t\tif (this.session?.running) {\n\t\t\tthis.session.stop();\n\t\t\tthis.session = undefined;\n\t\t}\n\t\tthis.daemon?.shutdown();\n\t\tthis.daemon = undefined;\n\t\tif (this.panel) {\n\t\t\tthis.panel.dispose();\n\t\t\tthis.panel = undefined;\n\t\t}\n\t\tthis.active = false;\n\t\tthis.starting = false;\n\t}\n\n\t/**\n\t * Build the (stable, reused-across-captures) handlers for the daemon.\n\t *\n\t * Every handler bails out once `active` is false: CANCEL is a soft\n\t * request (unlike the legacy path's `proc.kill()`, it doesn't sever the\n\t * pipe), so the daemon can still have a trailing PARTIAL/FINAL/DONE for the\n\t * just-cancelled capture in flight when the user presses cancel. Without\n\t * this guard that stale text would land in the editor after the panel had\n\t * already collapsed.\n\t *\n\t * v0.1.4 serve streams `PARTIAL <full growing hypothesis>` (live preview,\n\t * never committed) and ends with a single `FINAL <complete text>` (the one\n\t * commit to the editor). `SEGMENT` is still handled for the legacy\n\t * transcribe path and any binary that streams committed chunks directly.\n\t */\n\tprivate buildDaemonHandlers(): VoiceDaemonHandlers {\n\t\treturn {\n\t\t\tonSegment: (text) => {\n\t\t\t\tif (!this.active) return;\n\t\t\t\tthis.commitText(text);\n\t\t\t},\n\t\t\tonPartial: (text) => {\n\t\t\t\tif (!this.active) return;\n\t\t\t\tthis.panel?.setPartial(text);\n\t\t\t},\n\t\t\tonFinal: (text) => {\n\t\t\t\tif (!this.active) return;\n\t\t\t\tthis.commitText(text);\n\t\t\t},\n\t\t\tonStatus: (status) => {\n\t\t\t\tif (!this.active) return;\n\t\t\t\tif (status === \"done\") {\n\t\t\t\t\tthis.active = false;\n\t\t\t\t\tthis.resetUI();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (status === \"transcribing\") {\n\t\t\t\t\tthis.panel?.setTranscribing();\n\t\t\t\t} else if (status === \"listening\") {\n\t\t\t\t\tthis.panel?.startListening();\n\t\t\t\t}\n\t\t\t},\n\t\t\tonLevel: (rms) => {\n\t\t\t\tif (!this.active) return;\n\t\t\t\tthis.panel?.pushLevel(rms);\n\t\t\t},\n\t\t\tonPhase: (phase) => {\n\t\t\t\tif (!this.active) return;\n\t\t\t\tif (phase === \"silence\") {\n\t\t\t\t\tthis.panel?.beginSilence(this.deps.silenceMs);\n\t\t\t\t} else {\n\t\t\t\t\tthis.panel?.endSilence();\n\t\t\t\t}\n\t\t\t},\n\t\t\tonError: (message) => {\n\t\t\t\tthis.active = false;\n\t\t\t\tthis.resetUI();\n\t\t\t\tthis.deps.showError(`Voice input failed: ${message}`);\n\t\t\t},\n\t\t\tonCrash: (message) => {\n\t\t\t\tthis.active = false;\n\t\t\t\tthis.daemon = undefined;\n\t\t\t\tthis.resetUI();\n\t\t\t\tthis.deps.showError(`Voice input daemon crashed: ${message}. It will restart on next use.`);\n\t\t\t},\n\t\t\tonIdle: () => {\n\t\t\t\t// Daemon auto-shut down after idle timeout: drop the reference so the\n\t\t\t\t// next ctrl+r respawns (cold start). No user-facing message — this is\n\t\t\t\t// an expected memory-reclamation event, not an error.\n\t\t\t\tthis.daemon = undefined;\n\t\t\t},\n\t\t};\n\t}\n\n\t/** Inject decoded text into the editor via bracketed paste (with a trailing space). */\n\tprivate commitText(text: string): void {\n\t\tthis.deps.sendEditorInput(`\\x1b[200~${text} \\x1b[201~`);\n\t}\n\n\tprivate beginDaemonCapture(): void {\n\t\tif (!this.daemon?.isReady) return;\n\t\tthis.active = true;\n\t\tthis.showPanel().startListening();\n\t\tthis.daemon.startCapture();\n\t}\n\n\tprivate beginLegacyCapture(bin: string): void {\n\t\tthis.active = true;\n\t\tconst panel = this.showPanel();\n\t\tpanel.startListening();\n\t\tthis.session = startVoiceTranscribe(bin, {\n\t\t\tonStatus: (status) => {\n\t\t\t\tif (status === \"done\") {\n\t\t\t\t\tthis.active = false;\n\t\t\t\t\tthis.session = undefined;\n\t\t\t\t\tthis.resetUI();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\t// Old binaries emit no LEVEL/PARTIAL, so the panel shows a spinner\n\t\t\t\t// for the batch phases; committed words still stream into the editor.\n\t\t\t\tif (status === \"transcribing\") panel.setTranscribing();\n\t\t\t},\n\t\t\tonSegment: (text) => {\n\t\t\t\tthis.commitText(text);\n\t\t\t},\n\t\t\tonError: (message) => {\n\t\t\t\tthis.active = false;\n\t\t\t\tthis.session = undefined;\n\t\t\t\tthis.resetUI();\n\t\t\t\tthis.deps.showError(`Voice input failed: ${message}`);\n\t\t\t},\n\t\t});\n\t}\n\n\t/** Resolve the `voicetools` binary path. Prefers an explicit VOICETOOLS_BIN\n\t * override, otherwise resolves via the managed tools manager (bin dir / PATH /\n\t * download from the published release). Returns undefined when unavailable.\n\t */\n\tprivate async resolveBin(): Promise<string | undefined> {\n\t\tconst override = process.env.VOICETOOLS_BIN?.trim();\n\t\tif (override) return override;\n\t\t// First run downloads the voicetools release archive; stream the byte counts\n\t\t// into the panel so the wait shows a determinate progress bar, not a spinner.\n\t\treturn ensureTool(\"voicetools\", true, (received, total) => {\n\t\t\tif (this.starting) this.panel?.setDownloadProgress(received, total);\n\t\t});\n\t}\n\n\t/** Create (or reuse) the voice panel and mount it in the status container. */\n\tprivate showPanel(): VoicePanel {\n\t\tif (!this.panel) {\n\t\t\tthis.deps.statusContainer.clear();\n\t\t\tthis.panel = new VoicePanel(this.deps.ui, keyHint(\"app.input.voiceTranscribe\", \"cancel\"));\n\t\t\tthis.deps.statusContainer.addChild(this.panel);\n\t\t}\n\t\tthis.deps.ui.requestRender();\n\t\treturn this.panel;\n\t}\n\n\tprivate showWarming(message: string, detail?: string): void {\n\t\tthis.showPanel().setWarming(message, detail);\n\t}\n\n\t/** Collapse the voice panel back to nothing (idle) and stop its animation. */\n\tprivate resetUI(): void {\n\t\tif (this.panel) {\n\t\t\tthis.panel.dispose();\n\t\t\tthis.panel = undefined;\n\t\t}\n\t\tthis.deps.statusContainer.clear();\n\t\tthis.deps.ui.requestRender();\n\t}\n}\n"]}