{"version":3,"file":"voice-transcribe.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/voice/voice-transcribe.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;GAgBG;AAEH,MAAM,MAAM,WAAW,GAAG,WAAW,GAAG,cAAc,GAAG,MAAM,GAAG,WAAW,GAAG,MAAM,CAAC;AAEvF,MAAM,WAAW,uBAAuB;IACvC,uEAAuE;IACvE,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,4EAA4E;IAC5E,QAAQ,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,IAAI,CAAC;IACxC,2EAA2E;IAC3E,OAAO,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CACnC;AAED;;;GAGG;AACH,MAAM,WAAW,YAAY;IAC5B,IAAI,IAAI,IAAI,CAAC;IACb,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;CAC1B;AAUD;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,uBAAuB,GAAG,YAAY,CAoEjG;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,mBAAoB,SAAQ,uBAAuB;IACnE,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,4EAA4E;IAC5E,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;CACpB;AAED;;;;;;;;;GASG;AACH,MAAM,MAAM,sBAAsB,GAAG;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,WAAW,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,aAAa,GAAG,OAAO,CAAA;CAAE,CAAC;AAExH,gGAAgG;AAChG,MAAM,WAAW,kBAAkB;IAClC,gGAAgG;IAChG,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;yEAEqE;IACrE,aAAa,CAAC,EAAE,MAAM,CAAC;CACvB;AAED;;;;;GAKG;AACH,qBAAa,WAAW;IAMtB,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IAN1B,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,SAAS,CAA4C;IAC7D,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IAEvC,OAAO,eAMN;IAED,IAAI,OAAO,IAAI,OAAO,CAErB;IAED,MAAM,CAAC,KAAK,CACX,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,mBAAmB,EAC7B,OAAO,CAAC,EAAE,kBAAkB,GAC1B,OAAO,CAAC,sBAAsB,CAAC,CA8EjC;IAED,OAAO,CAAC,cAAc;IAUtB,OAAO,CAAC,aAAa;IAOrB,OAAO,CAAC,UAAU;IAsBlB,0FAA0F;IAC1F,YAAY,IAAI,IAAI,CAInB;IAED,wDAAwD;IACxD,MAAM,IAAI,IAAI,CAGb;IAED,gFAAgF;IAChF,QAAQ,IAAI,IAAI,CAcf;CACD","sourcesContent":["import { createInterface, type Interface } from \"node:readline\";\nimport { type ChildProcess, spawn } from \"child_process\";\n\n/**\n * Drives the external `voicetools` binary and streams its stdout line protocol\n * into callbacks. One line per event on stdout (stderr is free for debug logs):\n *\n * ```text\n * STATUS recording        # state transition (recording | transcribing | ...)\n * SEGMENT hello world     # a chunk of decoded text\n * DONE                    # finished successfully\n * ERROR no model found    # fatal error; process exits non-zero\n * ```\n *\n * `voicetools serve` (see `VoiceDaemon` below) reuses this same line protocol\n * plus a few daemon-only events (READY, LEVEL, PHASE).\n *\n * The caller wires `onSegment` to inject text into the editor (via bracketed\n * paste) and `onStatus` / `onError` to surface feedback.\n */\n\nexport type VoiceStatus = \"recording\" | \"transcribing\" | \"done\" | \"listening\" | string;\n\nexport interface VoiceTranscribeHandlers {\n\t/** A decoded chunk of text. Injected into the editor by the caller. */\n\tonSegment: (text: string) => void;\n\t/** A state transition reported by the binary, or `\"done\"` on completion. */\n\tonStatus: (status: VoiceStatus) => void;\n\t/** A fatal error: spawn failure, protocol ERROR line, or non-zero exit. */\n\tonError: (message: string) => void;\n}\n\n/**\n * A running voice-transcribe session. Call `stop()` to cancel early (e.g. the\n * user pressing the shortcut again). `stop()` is idempotent.\n */\nexport interface VoiceSession {\n\tstop(): void;\n\treadonly running: boolean;\n}\n\n/** Build a friendly message for a spawn failure, calling out a missing binary. */\nfunction describeSpawnError(err: unknown, bin: string): string {\n\tif (err && typeof err === \"object\" && (err as NodeJS.ErrnoException).code === \"ENOENT\") {\n\t\treturn `voicetools binary not found (tried \"${bin}\"). Install it or set VOICETOOLS_BIN to its path.`;\n\t}\n\treturn err instanceof Error ? err.message : String(err);\n}\n\n/**\n * Spawn `voicetools transcribe` for a single capture. This is the fallback\n * path for binaries that don't support `serve` (see `VoiceDaemon`): every\n * push-to-talk press pays the model load cold start.\n */\nexport function startVoiceTranscribe(bin: string, handlers: VoiceTranscribeHandlers): VoiceSession {\n\tlet proc: ChildProcess;\n\ttry {\n\t\tproc = spawn(bin, [\"transcribe\"], {\n\t\t\tstdio: [\"ignore\", \"pipe\", \"ignore\"],\n\t\t});\n\t} catch (err) {\n\t\thandlers.onError(describeSpawnError(err, bin));\n\t\treturn { stop: () => {}, running: false };\n\t}\n\n\tlet stopped = false;\n\tlet finished = false;\n\tlet rl: Interface | undefined;\n\n\tconst finish = (): void => {\n\t\tif (finished) return;\n\t\tfinished = true;\n\t\trl?.close();\n\t};\n\n\tif (!proc.stdout) {\n\t\tproc.kill();\n\t\thandlers.onError(\"failed to capture voicetools stdout\");\n\t\treturn { stop: () => {}, running: false };\n\t}\n\n\trl = createInterface({ input: proc.stdout });\n\trl.on(\"line\", (line) => {\n\t\tif (line.startsWith(\"STATUS \")) {\n\t\t\thandlers.onStatus(line.slice(7).trim());\n\t\t} else if (line.startsWith(\"SEGMENT \")) {\n\t\t\thandlers.onSegment(line.slice(8));\n\t\t} else if (line === \"DONE\") {\n\t\t\thandlers.onStatus(\"done\");\n\t\t\tfinish();\n\t\t} else if (line.startsWith(\"ERROR \")) {\n\t\t\thandlers.onError(line.slice(6).trim());\n\t\t\tfinish();\n\t\t}\n\t});\n\n\tproc.on(\"error\", (err) => {\n\t\tif (stopped || finished) return;\n\t\tfinish();\n\t\thandlers.onError(describeSpawnError(err, bin));\n\t});\n\n\tproc.on(\"close\", (code) => {\n\t\tconst wasFinished = finished;\n\t\tfinish();\n\t\tif (stopped || wasFinished) return;\n\t\tif (code && code !== 0) {\n\t\t\thandlers.onError(`voicetools exited with code ${code}`);\n\t\t}\n\t});\n\n\treturn {\n\t\tstop: () => {\n\t\t\tif (stopped) return;\n\t\t\tstopped = true;\n\t\t\tfinish();\n\t\t\tproc.kill();\n\t\t},\n\t\tget running() {\n\t\t\treturn !finished && !stopped;\n\t\t},\n\t};\n}\n\n/**\n * Handlers for a persistent `voicetools serve` daemon. Extends the base\n * transcribe handlers with the daemon-only events:\n *  - `onReady`   fires once after models finish loading.\n *  - `onLevel`   per-audio-chunk RMS, for a live meter/waveform.\n *  - `onPhase`   phase markers (e.g. `\"silence\"` when trailing silence begins).\n *  - `onPartial` interim transcript while the user speaks — the FULL growing\n *                hypothesis each time (supersedes the previous), never committed.\n *  - `onFinal`   the complete committed transcript for the utterance, emitted\n *                once before DONE — this is the text to inject into the editor.\n *  - `onCrash`   the process died after having been ready (caller should drop\n *                the reference and respawn lazily on the next push-to-talk).\n */\nexport interface VoiceDaemonHandlers extends VoiceTranscribeHandlers {\n\tonReady?: () => void;\n\tonLevel?: (rms: number) => void;\n\tonPhase?: (phase: string) => void;\n\tonPartial?: (text: string) => void;\n\tonFinal?: (text: string) => void;\n\tonCrash?: (message: string) => void;\n\t/** Fired when the daemon has been idle (no capture) for `idleTimeoutMs`. */\n\tonIdle?: () => void;\n}\n\n/**\n * Outcome of {@link VoiceDaemon.spawn}. `reason: \"unsupported\"` means the\n * process exited before READY with no ERROR line at all — the signature of\n * an old binary rejecting the unrecognized `serve` subcommand — and the\n * caller should silently fall back to `startVoiceTranscribe`. `reason:\n * \"error\"` means a genuine ERROR line (or OS-level spawn failure) was seen;\n * `handlers.onError` has already been called with it, and the caller should\n * surface that (not retry with the legacy path, which would just hit the\n * same failure) while leaving daemon mode available to retry next press.\n */\nexport type VoiceDaemonSpawnResult = { ok: true; daemon: VoiceDaemon } | { ok: false; reason: \"unsupported\" | \"error\" };\n\n/** Tunables passed to `voicetools serve` on spawn. Omitted fields use the binary's defaults. */\nexport interface VoiceDaemonOptions {\n\t/** Trailing-silence timeout, in ms, before the binary auto-stops a capture (`--silence-ms`). */\n\tsilenceMs?: number;\n\t/** Idle timeout, in ms. After a capture completes, the daemon auto-shuts down\n\t * if no new capture starts within this window, releasing the warm model from\n\t * memory. `0` disables idle shutdown (daemon stays warm forever). */\n\tidleTimeoutMs?: number;\n}\n\n/**\n * A persistent `voicetools serve` process: models are loaded once and stay\n * warm across captures. Only one capture runs at a time; call `startCapture`\n * to open the mic and `cancel` to stop early. `spawn` doubles as the support\n * probe for old binaries (see {@link VoiceDaemonSpawnResult}).\n */\nexport class VoiceDaemon {\n\tprivate closed = false;\n\tprivate idleTimer: ReturnType<typeof setTimeout> | undefined;\n\tprivate readonly idleTimeoutMs: number;\n\n\tprivate constructor(\n\t\tprivate readonly proc: ChildProcess,\n\t\tprivate readonly handlers: VoiceDaemonHandlers,\n\t\tidleTimeoutMs: number,\n\t) {\n\t\tthis.idleTimeoutMs = idleTimeoutMs;\n\t}\n\n\tget isReady(): boolean {\n\t\treturn !this.closed;\n\t}\n\n\tstatic spawn(\n\t\tbin: string,\n\t\thandlers: VoiceDaemonHandlers,\n\t\toptions?: VoiceDaemonOptions,\n\t): Promise<VoiceDaemonSpawnResult> {\n\t\treturn new Promise((resolve) => {\n\t\t\tconst args = [\"serve\"];\n\t\t\tif (options?.silenceMs !== undefined) {\n\t\t\t\targs.push(\"--silence-ms\", String(Math.max(0, Math.round(options.silenceMs))));\n\t\t\t}\n\t\t\tlet proc: ChildProcess;\n\t\t\ttry {\n\t\t\t\tproc = spawn(bin, args, { stdio: [\"pipe\", \"pipe\", \"ignore\"] });\n\t\t\t} catch (err) {\n\t\t\t\thandlers.onError(describeSpawnError(err, bin));\n\t\t\t\tresolve({ ok: false, reason: \"error\" });\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (!proc.stdout || !proc.stdin) {\n\t\t\t\tproc.kill();\n\t\t\t\thandlers.onError(\"failed to open voicetools serve stdio\");\n\t\t\t\tresolve({ ok: false, reason: \"error\" });\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tlet settled = false;\n\t\t\tlet daemon: VoiceDaemon | undefined;\n\t\t\tlet sawPreReadyError = false;\n\t\t\tconst rl = createInterface({ input: proc.stdout });\n\n\t\t\tconst fail = (reason: \"unsupported\" | \"error\"): void => {\n\t\t\t\tif (settled) return;\n\t\t\t\tsettled = true;\n\t\t\t\trl.close();\n\t\t\t\tresolve({ ok: false, reason });\n\t\t\t};\n\n\t\t\trl.on(\"line\", (line) => {\n\t\t\t\tif (daemon) {\n\t\t\t\t\tdaemon.handleLine(line);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (settled) return;\n\t\t\t\tif (line === \"READY\") {\n\t\t\t\t\tsettled = true;\n\t\t\t\t\tdaemon = new VoiceDaemon(proc, handlers, options?.idleTimeoutMs ?? 0);\n\t\t\t\t\thandlers.onReady?.();\n\t\t\t\t\tresolve({ ok: true, daemon });\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (line.startsWith(\"ERROR \")) {\n\t\t\t\t\t// Loading can fail before READY (e.g. no model installed yet).\n\t\t\t\t\t// Surface it now; the process still exits right after.\n\t\t\t\t\tsawPreReadyError = true;\n\t\t\t\t\thandlers.onError(line.slice(6).trim());\n\t\t\t\t}\n\t\t\t});\n\n\t\t\tproc.on(\"error\", (err) => {\n\t\t\t\tif (daemon) {\n\t\t\t\t\tif (!daemon.closed) {\n\t\t\t\t\t\tdaemon.closed = true;\n\t\t\t\t\t\thandlers.onCrash?.(describeSpawnError(err, bin));\n\t\t\t\t\t}\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (!sawPreReadyError) handlers.onError(describeSpawnError(err, bin));\n\t\t\t\tfail(\"error\");\n\t\t\t});\n\n\t\t\tproc.on(\"close\", (code) => {\n\t\t\t\tif (daemon) {\n\t\t\t\t\tif (!daemon.closed) {\n\t\t\t\t\t\tdaemon.closed = true;\n\t\t\t\t\t\thandlers.onCrash?.(`voicetools serve exited with code ${code ?? \"unknown\"}`);\n\t\t\t\t\t}\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tfail(sawPreReadyError ? \"error\" : \"unsupported\");\n\t\t\t});\n\t\t});\n\t}\n\n\tprivate startIdleTimer(): void {\n\t\tthis.stopIdleTimer();\n\t\tif (this.idleTimeoutMs <= 0 || this.closed) return;\n\t\tthis.idleTimer = setTimeout(() => {\n\t\t\tthis.idleTimer = undefined;\n\t\t\tif (this.closed) return;\n\t\t\tthis.handlers.onIdle?.();\n\t\t}, this.idleTimeoutMs);\n\t}\n\n\tprivate stopIdleTimer(): void {\n\t\tif (this.idleTimer) {\n\t\t\tclearTimeout(this.idleTimer);\n\t\t\tthis.idleTimer = undefined;\n\t\t}\n\t}\n\n\tprivate handleLine(line: string): void {\n\t\tif (line.startsWith(\"STATUS \")) {\n\t\t\tthis.handlers.onStatus(line.slice(7).trim());\n\t\t} else if (line.startsWith(\"PARTIAL \")) {\n\t\t\tthis.handlers.onPartial?.(line.slice(8));\n\t\t} else if (line.startsWith(\"FINAL \")) {\n\t\t\tthis.handlers.onFinal?.(line.slice(6));\n\t\t} else if (line.startsWith(\"SEGMENT \")) {\n\t\t\tthis.handlers.onSegment(line.slice(8));\n\t\t} else if (line.startsWith(\"LEVEL \")) {\n\t\t\tconst rms = Number.parseFloat(line.slice(6).trim());\n\t\t\tif (!Number.isNaN(rms)) this.handlers.onLevel?.(rms);\n\t\t} else if (line.startsWith(\"PHASE \")) {\n\t\t\tthis.handlers.onPhase?.(line.slice(6).trim());\n\t\t} else if (line === \"DONE\") {\n\t\t\tthis.handlers.onStatus(\"done\");\n\t\t\tthis.startIdleTimer();\n\t\t} else if (line.startsWith(\"ERROR \")) {\n\t\t\tthis.handlers.onError(line.slice(6).trim());\n\t\t}\n\t}\n\n\t/** Begin a capture: opens the mic, streams PARTIAL/FINAL (or SEGMENT), ends with DONE. */\n\tstartCapture(): void {\n\t\tif (this.closed) return;\n\t\tthis.stopIdleTimer();\n\t\tthis.proc.stdin?.write(\"START\\n\");\n\t}\n\n\t/** Cancel the in-flight capture, if any. Idempotent. */\n\tcancel(): void {\n\t\tif (this.closed) return;\n\t\tthis.proc.stdin?.write(\"CANCEL\\n\");\n\t}\n\n\t/** Ask the daemon to exit gracefully, force-killing if it doesn't within 1s. */\n\tshutdown(): void {\n\t\tif (this.closed) return;\n\t\tthis.closed = true;\n\t\tthis.stopIdleTimer();\n\t\ttry {\n\t\t\tthis.proc.stdin?.write(\"SHUTDOWN\\n\");\n\t\t} catch {\n\t\t\t// stdin may already be gone (process died); force-kill below covers it.\n\t\t}\n\t\tconst proc = this.proc;\n\t\tconst killTimer = setTimeout(() => {\n\t\t\tif (!proc.killed) proc.kill();\n\t\t}, 1000);\n\t\tproc.once(\"close\", () => clearTimeout(killTimer));\n\t}\n}\n"]}