mixdog 0.9.1 → 0.9.3
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/package.json +9 -1
- package/scripts/_bench-cwc.json +20 -0
- package/scripts/agent-loop-policy-test.mjs +37 -0
- package/scripts/agent-parallel-smoke.mjs +54 -10
- package/scripts/anthropic-maxtokens-test.mjs +119 -0
- package/scripts/background-task-meta-smoke.mjs +1 -1
- package/scripts/bench-run.mjs +262 -0
- package/scripts/build-tui.mjs +13 -1
- package/scripts/compact-smoke.mjs +12 -0
- package/scripts/compact-trigger-migration-smoke.mjs +67 -1
- package/scripts/explore-bench.mjs +124 -0
- package/scripts/hook-bus-test.mjs +191 -0
- package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
- package/scripts/internal-comms-bench.mjs +727 -0
- package/scripts/internal-comms-smoke.mjs +75 -0
- package/scripts/lead-workflow-smoke.mjs +4 -4
- package/scripts/live-worker-smoke.mjs +9 -9
- package/scripts/output-style-bench.mjs +285 -0
- package/scripts/output-style-smoke.mjs +13 -10
- package/scripts/patch-replay.mjs +90 -0
- package/scripts/path-suffix-test.mjs +57 -0
- package/scripts/provider-stream-stall-test.mjs +276 -0
- package/scripts/provider-toolcall-test.mjs +599 -1
- package/scripts/recall-bench.mjs +207 -0
- package/scripts/routing-corpus.mjs +281 -0
- package/scripts/session-bench.mjs +1526 -0
- package/scripts/session-diag.mjs +595 -0
- package/scripts/task-bench.mjs +207 -0
- package/scripts/tool-failures.mjs +6 -6
- package/scripts/tool-smoke.mjs +310 -67
- package/scripts/toolcall-args-test.mjs +81 -0
- package/src/agents/debugger/AGENT.md +4 -4
- package/src/agents/heavy-worker/AGENT.md +20 -9
- package/src/agents/reviewer/AGENT.md +4 -4
- package/src/agents/worker/AGENT.md +17 -9
- package/src/app.mjs +10 -6
- package/src/defaults/{hidden-roles.json → agents.json} +7 -7
- package/src/examples/schedules/SCHEDULE.example.md +32 -0
- package/src/examples/webhooks/WEBHOOK.example.md +40 -0
- package/src/headless-role.mjs +14 -14
- package/src/help.mjs +1 -0
- package/src/lib/rules-builder.cjs +32 -54
- package/src/mixdog-session-runtime.mjs +1040 -2036
- package/src/output-styles/default.md +12 -7
- package/src/output-styles/minimal.md +25 -0
- package/src/output-styles/oneline.md +21 -0
- package/src/output-styles/simple.md +10 -9
- package/src/repl.mjs +17 -7
- package/src/rules/agent/00-common.md +7 -5
- package/src/rules/agent/30-explorer.md +8 -12
- package/src/rules/lead/01-general.md +3 -1
- package/src/rules/lead/lead-tool.md +13 -2
- package/src/rules/shared/01-tool.md +23 -12
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
- package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
- package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
- package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
- package/src/runtime/agent/orchestrator/config.mjs +3 -0
- package/src/runtime/agent/orchestrator/context/collect.mjs +182 -67
- package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
- package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
- package/src/runtime/agent/orchestrator/mcp/client.mjs +100 -18
- package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +93 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +313 -84
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +104 -38
- package/src/runtime/agent/orchestrator/providers/api-usage.mjs +28 -33
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
- package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +32 -0
- package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +54 -20
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +227 -31
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +83 -6
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +229 -115
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +213 -33
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
- package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +49 -17
- package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +22 -17
- package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
- package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +28 -0
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +262 -0
- package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +38 -0
- package/src/runtime/agent/orchestrator/session/loop/env.mjs +14 -0
- package/src/runtime/agent/orchestrator/session/loop/hidden-agents.mjs +21 -0
- package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +49 -0
- package/src/runtime/agent/orchestrator/session/loop/steering.mjs +63 -0
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +100 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +52 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +218 -0
- package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +101 -0
- package/src/runtime/agent/orchestrator/session/loop/usage.mjs +35 -0
- package/src/runtime/agent/orchestrator/session/loop.mjs +513 -1000
- package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +227 -0
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +235 -0
- package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +137 -0
- package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +155 -0
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +303 -0
- package/src/runtime/agent/orchestrator/session/manager.mjs +232 -1152
- package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
- package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
- package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -24
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
- package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +241 -0
- package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +162 -0
- package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
- package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +42 -2
- package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
- package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +14 -5
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +11 -4
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +23 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +50 -39
- package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
- package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +70 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +303 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +43 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +382 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +497 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +295 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +158 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/lang-predicates.mjs +128 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +66 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +44 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1192 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/source-access.mjs +81 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +19 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +280 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +347 -0
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
- package/src/runtime/agent/orchestrator/tools/code-graph.mjs +39 -4189
- package/src/runtime/agent/orchestrator/tools/patch.mjs +119 -5
- package/src/runtime/agent/orchestrator/tools/progress-message.mjs +1 -2
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -2
- package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +2 -4
- package/src/runtime/channels/backends/discord.mjs +99 -9
- package/src/runtime/channels/backends/telegram.mjs +501 -0
- package/src/runtime/channels/index.mjs +437 -1439
- package/src/runtime/channels/lib/boot-profile.mjs +23 -0
- package/src/runtime/channels/lib/config.mjs +54 -2
- package/src/runtime/channels/lib/crash-log.mjs +106 -0
- package/src/runtime/channels/lib/format.mjs +4 -2
- package/src/runtime/channels/lib/index-drop-trace.mjs +72 -0
- package/src/runtime/channels/lib/output-forwarder.mjs +88 -67
- package/src/runtime/channels/lib/runtime-paths.mjs +29 -0
- package/src/runtime/channels/lib/scheduler.mjs +1 -1
- package/src/runtime/channels/lib/telegram-format.mjs +280 -0
- package/src/runtime/channels/lib/tool-format.mjs +1 -1
- package/src/runtime/channels/lib/transcript-discovery.mjs +19 -1
- package/src/runtime/channels/lib/webhook.mjs +59 -31
- package/src/runtime/channels/lib/whisper-language.mjs +42 -0
- package/src/runtime/channels/tool-defs.mjs +1 -1
- package/src/runtime/memory/index.mjs +465 -345
- package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
- package/src/runtime/memory/lib/core-memory-store.mjs +352 -2
- package/src/runtime/memory/lib/cycle-signatures.mjs +34 -0
- package/src/runtime/memory/lib/http-wire.mjs +57 -0
- package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle2.mjs +65 -9
- package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
- package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +24 -0
- package/src/runtime/memory/lib/memory-retrievers.mjs +8 -0
- package/src/runtime/memory/lib/memory.mjs +121 -4
- package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
- package/src/runtime/memory/lib/promotion-fingerprint.mjs +50 -0
- package/src/runtime/memory/lib/recall-format.mjs +183 -0
- package/src/runtime/memory/lib/session-ingest.mjs +107 -0
- package/src/runtime/memory/lib/trace-store.mjs +69 -22
- package/src/runtime/memory/tool-defs.mjs +10 -7
- package/src/runtime/shared/abort-controller.mjs +1 -1
- package/src/runtime/shared/background-tasks.mjs +2 -3
- package/src/runtime/shared/buffered-appender.mjs +149 -0
- package/src/runtime/shared/channel-notification-routing.mjs +12 -0
- package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
- package/src/runtime/shared/config.mjs +9 -0
- package/src/runtime/shared/llm/http-agent.mjs +12 -5
- package/src/runtime/shared/schedules-store.mjs +21 -19
- package/src/runtime/shared/task-notification-envelope.mjs +98 -0
- package/src/runtime/shared/task-notification-envelope.test.mjs +107 -0
- package/src/runtime/shared/tool-execution-contract.mjs +2 -2
- package/src/runtime/shared/tool-surface.mjs +98 -13
- package/src/runtime/shared/transcript-writer.mjs +156 -0
- package/src/runtime/shared/update-checker.mjs +214 -0
- package/src/session-runtime/config-helpers.mjs +209 -0
- package/src/session-runtime/effort.mjs +128 -0
- package/src/session-runtime/fs-utils.mjs +10 -0
- package/src/session-runtime/model-capabilities.mjs +130 -0
- package/src/session-runtime/output-styles.mjs +124 -0
- package/src/session-runtime/plugin-mcp.mjs +114 -0
- package/src/session-runtime/session-text.mjs +100 -0
- package/src/session-runtime/statusline-route.mjs +35 -0
- package/src/session-runtime/tool-catalog.mjs +720 -0
- package/src/session-runtime/workflow.mjs +358 -0
- package/src/standalone/agent-tool.mjs +302 -117
- package/src/standalone/channel-admin.mjs +133 -40
- package/src/standalone/channel-worker.mjs +10 -292
- package/src/standalone/explore-tool.mjs +12 -5
- package/src/standalone/hook-bus.mjs +165 -8
- package/src/standalone/memory-runtime-proxy.mjs +3 -1
- package/src/standalone/opencode-go-login.mjs +121 -0
- package/src/standalone/provider-admin.mjs +25 -3
- package/src/standalone/usage-dashboard.mjs +1 -1
- package/src/tui/App.jsx +2883 -778
- package/src/tui/components/ConfirmBar.jsx +47 -0
- package/src/tui/components/ContextPanel.jsx +5 -3
- package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
- package/src/tui/components/Markdown.jsx +22 -98
- package/src/tui/components/Message.jsx +14 -35
- package/src/tui/components/Picker.jsx +87 -12
- package/src/tui/components/PromptInput.jsx +355 -22
- package/src/tui/components/QueuedCommands.jsx +1 -1
- package/src/tui/components/SlashCommandPalette.jsx +8 -5
- package/src/tui/components/Spinner.jsx +7 -7
- package/src/tui/components/StatusLine.jsx +40 -21
- package/src/tui/components/TextEntryPanel.jsx +51 -7
- package/src/tui/components/ToolExecution.jsx +183 -101
- package/src/tui/components/TurnDone.jsx +4 -4
- package/src/tui/components/UsagePanel.jsx +1 -1
- package/src/tui/components/tool-output-format.mjs +161 -23
- package/src/tui/components/tool-output-format.test.mjs +87 -0
- package/src/tui/display-width.mjs +69 -0
- package/src/tui/display-width.test.mjs +35 -0
- package/src/tui/dist/index.mjs +6731 -2333
- package/src/tui/engine/agent-envelope.mjs +296 -0
- package/src/tui/engine/boot-profile.mjs +21 -0
- package/src/tui/engine/labels.mjs +67 -0
- package/src/tui/engine/notice-text.mjs +112 -0
- package/src/tui/engine/queue-helpers.mjs +161 -0
- package/src/tui/engine/session-stats.mjs +46 -0
- package/src/tui/engine/tool-call-fields.mjs +23 -0
- package/src/tui/engine/tool-result-text.mjs +126 -0
- package/src/tui/engine.mjs +569 -948
- package/src/tui/index.jsx +117 -7
- package/src/tui/input-editing.mjs +58 -8
- package/src/tui/input-editing.selection.test.mjs +75 -0
- package/src/tui/keyboard-protocol.mjs +42 -0
- package/src/tui/lib/voice-recorder.mjs +469 -0
- package/src/tui/markdown/format-token.mjs +128 -76
- package/src/tui/markdown/format-token.test.mjs +61 -19
- package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
- package/src/tui/markdown/render-ansi.test.mjs +1 -1
- package/src/tui/markdown/streaming-markdown.mjs +167 -0
- package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
- package/src/tui/markdown/table-layout.mjs +9 -9
- package/src/tui/paste-attachments.mjs +36 -9
- package/src/tui/paste-fix.test.mjs +119 -0
- package/src/tui/prompt-history-store.mjs +129 -0
- package/src/tui/prompt-history-store.test.mjs +52 -0
- package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
- package/src/tui/theme.mjs +41 -657
- package/src/tui/themes/base.mjs +86 -0
- package/src/tui/themes/basic.mjs +85 -0
- package/src/tui/themes/catppuccin.mjs +72 -0
- package/src/tui/themes/dracula.mjs +70 -0
- package/src/tui/themes/everforest.mjs +71 -0
- package/src/tui/themes/gruvbox.mjs +71 -0
- package/src/tui/themes/index.mjs +71 -0
- package/src/tui/themes/indigo.mjs +78 -0
- package/src/tui/themes/kanagawa.mjs +80 -0
- package/src/tui/themes/light.mjs +81 -0
- package/src/tui/themes/nord.mjs +72 -0
- package/src/tui/themes/onedark.mjs +16 -0
- package/src/tui/themes/rosepine.mjs +70 -0
- package/src/tui/themes/teal.mjs +80 -0
- package/src/tui/themes/tokyonight.mjs +79 -0
- package/src/tui/themes/utils.mjs +106 -0
- package/src/tui/themes/warm.mjs +79 -0
- package/src/tui/transcript-tool-failures.mjs +13 -2
- package/src/ui/markdown.mjs +1 -1
- package/src/ui/model-display.mjs +2 -2
- package/src/ui/statusline.mjs +75 -27
- package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
- package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
- package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
- package/src/workflows/default/WORKFLOW.md +46 -12
- package/src/workflows/sequential/WORKFLOW.md +51 -0
- package/src/workflows/solo/WORKFLOW.md +12 -1
- package/vendor/ink/build/display-width.js +62 -0
- package/vendor/ink/build/ink.js +100 -12
- package/vendor/ink/build/measure-text.js +4 -1
- package/vendor/ink/build/output.js +115 -9
- package/vendor/ink/build/render-node-to-output.js +4 -1
- package/vendor/ink/build/render.js +4 -0
- package/src/output-styles/extreme-simple.md +0 -20
- package/src/rules/lead/04-workflow.md +0 -51
- package/src/workflows/default/workflow.json +0 -13
- package/src/workflows/solo/workflow.json +0 -7
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/tui/lib/voice-recorder.mjs — TUI-local voice install/toggle orchestration.
|
|
3
|
+
*
|
|
4
|
+
* Step1 (/voice command): owns the `voice.enabled` config flag and drives the
|
|
5
|
+
* managed whisper.cpp + ffmpeg runtime install (voice-runtime-fetcher.mjs) the
|
|
6
|
+
* first time voice is turned ON.
|
|
7
|
+
*
|
|
8
|
+
* Step2 (Ctrl+Space recorder): startRecording/stopRecording/cancelRecording
|
|
9
|
+
* drive a small idle -> recording -> transcribing -> idle state machine.
|
|
10
|
+
* Recording spawns ffmpeg against a DirectShow ("dshow") capture device (the
|
|
11
|
+
* only capture API implemented — mixdog's managed voice runtime targets
|
|
12
|
+
* win32; the enumerate/record commands below are DirectShow-specific).
|
|
13
|
+
* Transcription reuses the SAME whisper-server.mjs singleton manager the
|
|
14
|
+
* channels pipeline uses (ensureReady + transcribe), so a TUI-triggered
|
|
15
|
+
* transcription and a Discord-attachment transcription share one warm
|
|
16
|
+
* whisper-server child instead of racing two.
|
|
17
|
+
*
|
|
18
|
+
* Config load is a STATIC import here (approved design decision) — unlike
|
|
19
|
+
* theme.mjs's dist-aware dynamic import of config.mjs, this file is not on the
|
|
20
|
+
* TUI's hot boot path and esbuild bundling config.mjs's relative dependency
|
|
21
|
+
* graph (plugin-paths/atomic-file/user-data-guard + the keychain .cjs via
|
|
22
|
+
* createRequire) into src/tui/dist/index.mjs is harmless: config.mjs's
|
|
23
|
+
* createRequire('../../lib/keychain-cjs.cjs') resolves relative to
|
|
24
|
+
* import.meta.url, and src/tui/dist/ sits at the same depth-from-src as
|
|
25
|
+
* src/runtime/shared/ (2 levels), so the relative path still lands on
|
|
26
|
+
* src/lib/keychain-cjs.cjs after bundling.
|
|
27
|
+
*
|
|
28
|
+
* The whisper.cpp/ffmpeg runtime installer (voice-runtime-fetcher.mjs) and
|
|
29
|
+
* the whisper-server manager (whisper-server.mjs) are BOTH loaded lazily via
|
|
30
|
+
* dynamic import — neither should load into memory unless a user actually
|
|
31
|
+
* asks for voice.
|
|
32
|
+
*/
|
|
33
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
34
|
+
import { cpus, tmpdir } from 'node:os';
|
|
35
|
+
import { join } from 'node:path';
|
|
36
|
+
import { unlinkSync } from 'node:fs';
|
|
37
|
+
import { readSection, updateSection } from '../../runtime/shared/config.mjs';
|
|
38
|
+
import { resolvePluginData } from '../../runtime/shared/plugin-paths.mjs';
|
|
39
|
+
|
|
40
|
+
let _voiceRuntimeFetcherPromise = null;
|
|
41
|
+
function loadVoiceRuntimeFetcher() {
|
|
42
|
+
if (!_voiceRuntimeFetcherPromise) {
|
|
43
|
+
_voiceRuntimeFetcherPromise = import('../../runtime/channels/lib/voice-runtime-fetcher.mjs');
|
|
44
|
+
}
|
|
45
|
+
return _voiceRuntimeFetcherPromise;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let _whisperServerPromise = null;
|
|
49
|
+
// Set once loadWhisperServer()'s import resolves, so disposeRecorder() (a
|
|
50
|
+
// SYNC function called from a React unmount cleanup) can check "was the
|
|
51
|
+
// whisper-server manager ever engaged this session" without triggering a
|
|
52
|
+
// fresh dynamic import just to shut down something that was never started.
|
|
53
|
+
let _whisperServerModule = null;
|
|
54
|
+
function loadWhisperServer() {
|
|
55
|
+
if (!_whisperServerPromise) {
|
|
56
|
+
_whisperServerPromise = import('../../runtime/channels/lib/whisper-server.mjs').then((mod) => {
|
|
57
|
+
_whisperServerModule = mod;
|
|
58
|
+
return mod;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return _whisperServerPromise;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Reentrancy guard: only one install (ensureWhisperRuntime/ensureWhisperModel/
|
|
65
|
+
// ensureFfmpegRuntime sequence) may run at a time per process.
|
|
66
|
+
let _voiceInstallBusy = false;
|
|
67
|
+
|
|
68
|
+
/** True while a Step1 install sequence is in flight. */
|
|
69
|
+
export function isVoiceInstallBusy() {
|
|
70
|
+
return _voiceInstallBusy;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Read `voice.enabled` from mixdog-config.json (top-level `voice` section). */
|
|
74
|
+
export function isVoiceEnabled() {
|
|
75
|
+
try {
|
|
76
|
+
return readSection('voice')?.enabled === true;
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Coalesce ensure*'s onProgress ({ phase, downloaded, total } | { phase:'extra', ... })
|
|
83
|
+
// into a sticky, in-place input-hint-line progress bar (setProgressHint) so a
|
|
84
|
+
// multi-hundred-MB model download doesn't spam the notice toast on every
|
|
85
|
+
// chunk. Falls back to the old throttled (2s) toast-spam behavior when the
|
|
86
|
+
// caller (e.g. the channels pipeline) doesn't have a progress-hint slot to
|
|
87
|
+
// render into — only pushNotice is guaranteed to exist everywhere.
|
|
88
|
+
const PROGRESS_BAR_CELLS = 6;
|
|
89
|
+
function renderProgressBarText(phase, downloaded, total) {
|
|
90
|
+
const label = phaseLabel(phase);
|
|
91
|
+
if (total > 0) {
|
|
92
|
+
const ratio = Math.max(0, Math.min(1, downloaded / total));
|
|
93
|
+
const filled = Math.round(ratio * PROGRESS_BAR_CELLS);
|
|
94
|
+
const bar = '▓'.repeat(filled) + '░'.repeat(PROGRESS_BAR_CELLS - filled);
|
|
95
|
+
return `⬇ Voice ${label} ${bar} ${Math.round(ratio * 100)}%`;
|
|
96
|
+
}
|
|
97
|
+
return `⬇ Voice ${label} …`;
|
|
98
|
+
}
|
|
99
|
+
function phaseLabel(phase) {
|
|
100
|
+
if (phase === 'model') return 'model';
|
|
101
|
+
if (phase === 'ffmpeg') return 'ffmpeg';
|
|
102
|
+
if (phase === 'extra') return 'extra data';
|
|
103
|
+
return 'whisper runtime';
|
|
104
|
+
}
|
|
105
|
+
function makeThrottledProgressNotice({ pushNotice, setProgressHint } = {}, intervalMs = 500) {
|
|
106
|
+
let lastEmitAt = 0;
|
|
107
|
+
return (progress = {}) => {
|
|
108
|
+
const now = Date.now();
|
|
109
|
+
if (now - lastEmitAt < intervalMs) return;
|
|
110
|
+
lastEmitAt = now;
|
|
111
|
+
const total = Number(progress.total) || 0;
|
|
112
|
+
const downloaded = Number(progress.downloaded) || 0;
|
|
113
|
+
const text = renderProgressBarText(progress.phase, downloaded, total);
|
|
114
|
+
if (typeof setProgressHint === 'function') {
|
|
115
|
+
setProgressHint(text, 'info');
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (typeof pushNotice === 'function') pushNotice(text, 'info');
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Ensure every managed voice-runtime component (whisper binary+server,
|
|
124
|
+
* whisper model, ffmpeg) is installed. Installs ONLY the missing pieces —
|
|
125
|
+
* an already-installed component is never re-fetched. Returns the resolved
|
|
126
|
+
* runtime descriptor (resolveVoiceRuntime shape) once `installed` is true;
|
|
127
|
+
* throws on any ensure* failure (manifest fetch, sha256 mismatch, etc.).
|
|
128
|
+
*/
|
|
129
|
+
export async function ensureVoiceRuntimeReady({ dataDir = resolvePluginData(), pushNotice, setProgressHint } = {}) {
|
|
130
|
+
const fetcher = await loadVoiceRuntimeFetcher();
|
|
131
|
+
let runtime = fetcher.resolveVoiceRuntime(dataDir);
|
|
132
|
+
if (runtime.installed) return runtime;
|
|
133
|
+
|
|
134
|
+
const onProgress = makeThrottledProgressNotice({ pushNotice, setProgressHint });
|
|
135
|
+
if (!runtime.binary || !runtime.serverCmd) {
|
|
136
|
+
await fetcher.ensureWhisperRuntime(dataDir, onProgress);
|
|
137
|
+
}
|
|
138
|
+
if (!runtime.model) {
|
|
139
|
+
await fetcher.ensureWhisperModel(dataDir, onProgress);
|
|
140
|
+
}
|
|
141
|
+
if (!runtime.ffmpeg) {
|
|
142
|
+
await fetcher.ensureFfmpegRuntime(dataDir, onProgress);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
runtime = fetcher.resolveVoiceRuntime(dataDir);
|
|
146
|
+
if (!runtime.installed) {
|
|
147
|
+
throw new Error('voice runtime install did not complete (still missing a required component)');
|
|
148
|
+
}
|
|
149
|
+
return runtime;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* /voice command entry point. OFF -> immediately flips `voice.enabled` to
|
|
154
|
+
* false and notices "Voice OFF" (no install work, no busy gate — a disable
|
|
155
|
+
* must never be blocked by an in-flight install of a DIFFERENT toggle-on).
|
|
156
|
+
* ON -> busy-guarded: checks resolveVoiceRuntime, installs only missing
|
|
157
|
+
* components, then persists `voice.enabled: true` and notices "Voice ON —
|
|
158
|
+
* Ctrl+Space to record". Any ensure* failure notices the cause and leaves
|
|
159
|
+
* `voice.enabled` untouched (still off).
|
|
160
|
+
*
|
|
161
|
+
* Returns the NEW enabled state (true/false) on success, or null when the
|
|
162
|
+
* toggle could not run (install already in flight) or failed.
|
|
163
|
+
*/
|
|
164
|
+
export async function toggleVoice({ pushNotice, setProgressHint } = {}) {
|
|
165
|
+
const dataDir = resolvePluginData();
|
|
166
|
+
if (isVoiceEnabled()) {
|
|
167
|
+
// Med-5: updateSection is a synchronous file write (readAllForRmW +
|
|
168
|
+
// atomic write under a file lock) and can throw — a locked/corrupt
|
|
169
|
+
// config, a permissions error, etc. An unguarded throw here would
|
|
170
|
+
// propagate out of the OFF branch uncaught (toggleVoice's try/catch
|
|
171
|
+
// below only wraps the ON path) and crash the caller. Guard it the
|
|
172
|
+
// same way the ON path is guarded, and report failure via pushNotice
|
|
173
|
+
// instead of silently pretending the toggle succeeded.
|
|
174
|
+
try {
|
|
175
|
+
updateSection('voice', (current) => ({ ...current, enabled: false }));
|
|
176
|
+
} catch (err) {
|
|
177
|
+
pushNotice?.(`Voice OFF failed: ${err?.message || err}`, 'error');
|
|
178
|
+
return { ok: false, error: err?.message || String(err) };
|
|
179
|
+
}
|
|
180
|
+
pushNotice?.('Voice OFF', 'info');
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
if (_voiceInstallBusy) {
|
|
184
|
+
pushNotice?.('Voice install is already running', 'warn');
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
_voiceInstallBusy = true;
|
|
188
|
+
try {
|
|
189
|
+
await ensureVoiceRuntimeReady({ dataDir, pushNotice, setProgressHint });
|
|
190
|
+
updateSection('voice', (current) => ({ ...current, enabled: true }));
|
|
191
|
+
setProgressHint?.('');
|
|
192
|
+
pushNotice?.('Voice ON — Ctrl+Space to record', 'info');
|
|
193
|
+
return true;
|
|
194
|
+
} catch (err) {
|
|
195
|
+
setProgressHint?.('');
|
|
196
|
+
pushNotice?.(`Voice setup failed: ${err?.message || err}`, 'error');
|
|
197
|
+
return null;
|
|
198
|
+
} finally {
|
|
199
|
+
_voiceInstallBusy = false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── Step2: idle -> recording -> transcribing -> idle ────────────────────────
|
|
204
|
+
|
|
205
|
+
const RECORDER_STATE = Object.freeze({
|
|
206
|
+
IDLE: 'idle',
|
|
207
|
+
STARTING: 'starting',
|
|
208
|
+
RECORDING: 'recording',
|
|
209
|
+
TRANSCRIBING: 'transcribing',
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// Module-scoped: the TUI has exactly one prompt/recorder, so one singleton
|
|
213
|
+
// state machine (mirrors whisper-server.mjs's own single-manager pattern).
|
|
214
|
+
const rec = {
|
|
215
|
+
state: RECORDER_STATE.IDLE,
|
|
216
|
+
child: null, // ffmpeg ChildProcess while RECORDING
|
|
217
|
+
wavPath: null, // tmp wav path for the in-flight/just-finished recording
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
/** Current recorder state — 'idle' | 'recording' | 'transcribing'. */
|
|
221
|
+
export function getRecorderState() {
|
|
222
|
+
return rec.state;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function newTmpWavPath() {
|
|
226
|
+
return join(tmpdir(), `mixdog-rec-${Date.now()}.wav`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Resolve the ffmpeg binary: prefer the managed runtime (Step1 install),
|
|
230
|
+
// fall back to a bare 'ffmpeg' on PATH (spawn resolves PATH itself when no
|
|
231
|
+
// path separator is present), so a user with a system ffmpeg install still
|
|
232
|
+
// gets Ctrl+Space without running /voice's installer.
|
|
233
|
+
async function resolveFfmpegCmd(dataDir) {
|
|
234
|
+
const fetcher = await loadVoiceRuntimeFetcher();
|
|
235
|
+
const managed = fetcher.resolveManagedFfmpegPath(dataDir);
|
|
236
|
+
if (managed) return managed;
|
|
237
|
+
const probe = spawnSync(process.platform === 'win32' ? 'where' : 'which', ['ffmpeg'], { windowsHide: true });
|
|
238
|
+
if (probe.status === 0) return 'ffmpeg';
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Enumerate DirectShow audio-capture devices via
|
|
243
|
+
// `ffmpeg -hide_banner -list_devices true -f dshow -i dummy`, which ffmpeg
|
|
244
|
+
// (by design) always exits non-zero on while printing the device list to
|
|
245
|
+
// stderr. Returns the first "(audio)" device name, or null when none is
|
|
246
|
+
// found / the probe fails. UTF-8 decoded (device names may be non-ASCII on
|
|
247
|
+
// a localized Windows install).
|
|
248
|
+
function listFirstDshowAudioDevice(ffmpegCmd) {
|
|
249
|
+
const r = spawnSync(ffmpegCmd, ['-hide_banner', '-list_devices', 'true', '-f', 'dshow', '-i', 'dummy'], {
|
|
250
|
+
windowsHide: true,
|
|
251
|
+
encoding: 'utf8',
|
|
252
|
+
});
|
|
253
|
+
const text = `${r.stderr || ''}${r.stdout || ''}`;
|
|
254
|
+
// ffmpeg prints device lines like: "Mic Name" (audio)
|
|
255
|
+
const lines = text.split(/\r?\n/);
|
|
256
|
+
for (const line of lines) {
|
|
257
|
+
const m = /"([^"]+)"\s*\(audio\)/.exec(line);
|
|
258
|
+
if (m) return m[1];
|
|
259
|
+
}
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Begin recording. IDLE -> RECORDING. No-op (returns null) unless the
|
|
265
|
+
* recorder is currently idle. Claims the IDLE -> STARTING transition
|
|
266
|
+
* SYNCHRONOUSLY (before any `await`) so a second Ctrl+Space fired while
|
|
267
|
+
* ffmpeg/device resolution is still in flight sees STARTING (not IDLE) and
|
|
268
|
+
* is a no-op rather than racing a second spawn onto the same rec.* fields.
|
|
269
|
+
* Resolves ffmpeg (managed -> PATH fallback), enumerates the first dshow
|
|
270
|
+
* audio-capture device, and spawns ffmpeg writing 16kHz mono PCM to a fresh
|
|
271
|
+
* tmp wav. `-y` overwrites (tmp path is always fresh so this is defensive,
|
|
272
|
+
* not load-bearing). Any failure on the STARTING path (no ffmpeg, no
|
|
273
|
+
* device, spawn throw) reverts to IDLE before returning.
|
|
274
|
+
*
|
|
275
|
+
* Returns `{ ok: true }` on a successful spawn, `{ ok: false, reason }` on
|
|
276
|
+
* any failure (no ffmpeg, no device, spawn error) — the caller (App.jsx)
|
|
277
|
+
* turns `reason` into a user-facing notice — or `null` when the recorder
|
|
278
|
+
* was not IDLE (already starting/recording/transcribing): a silent no-op,
|
|
279
|
+
* matching stopRecording's null-for-wrong-state contract.
|
|
280
|
+
*/
|
|
281
|
+
export async function startRecording({ dataDir = resolvePluginData() } = {}) {
|
|
282
|
+
if (rec.state !== RECORDER_STATE.IDLE) return null;
|
|
283
|
+
// Claim the slot BEFORE the first await — see High-1 in the doc comment
|
|
284
|
+
// above. Everything below this line runs against an already-STARTING
|
|
285
|
+
// rec.state, so a concurrent startRecording() call bails out via the
|
|
286
|
+
// guard above instead of racing a second ffmpeg spawn.
|
|
287
|
+
rec.state = RECORDER_STATE.STARTING;
|
|
288
|
+
const ffmpegCmd = await resolveFfmpegCmd(dataDir);
|
|
289
|
+
if (!ffmpegCmd) {
|
|
290
|
+
rec.state = RECORDER_STATE.IDLE;
|
|
291
|
+
return { ok: false, reason: 'ffmpeg not found — run /voice to install it, or install ffmpeg on PATH' };
|
|
292
|
+
}
|
|
293
|
+
const device = listFirstDshowAudioDevice(ffmpegCmd);
|
|
294
|
+
if (!device) {
|
|
295
|
+
rec.state = RECORDER_STATE.IDLE;
|
|
296
|
+
return { ok: false, reason: 'no microphone found (DirectShow audio device enumeration returned none)' };
|
|
297
|
+
}
|
|
298
|
+
const wavPath = newTmpWavPath();
|
|
299
|
+
let child;
|
|
300
|
+
try {
|
|
301
|
+
child = spawn(ffmpegCmd, [
|
|
302
|
+
'-f', 'dshow',
|
|
303
|
+
'-i', `audio=${device}`,
|
|
304
|
+
'-ar', '16000',
|
|
305
|
+
'-ac', '1',
|
|
306
|
+
'-y', wavPath,
|
|
307
|
+
], { stdio: ['pipe', 'ignore', 'pipe'], windowsHide: true });
|
|
308
|
+
} catch (err) {
|
|
309
|
+
rec.state = RECORDER_STATE.IDLE;
|
|
310
|
+
return { ok: false, reason: `failed to start ffmpeg: ${err?.message || err}` };
|
|
311
|
+
}
|
|
312
|
+
// EPIPE guard (Med-3): a write to ffmpeg's stdin AFTER it has already
|
|
313
|
+
// closed the pipe (e.g. process died right as stopRecording() writes 'q')
|
|
314
|
+
// emits an 'error' on the stream — Node throws uncaught if nothing is
|
|
315
|
+
// listening. Attached once, here, so every future stdin write/close on
|
|
316
|
+
// this child is covered (stopRecording's write included).
|
|
317
|
+
child.stdin?.on('error', () => {});
|
|
318
|
+
// A spawn error surfaces async (e.g. EACCES on the resolved path even
|
|
319
|
+
// though the sync probe above found something on PATH) — fall back to
|
|
320
|
+
// IDLE so a stuck RECORDING state never blocks the next Ctrl+Space.
|
|
321
|
+
child.once('error', () => {
|
|
322
|
+
if (rec.child === child) {
|
|
323
|
+
rec.state = RECORDER_STATE.IDLE;
|
|
324
|
+
rec.child = null;
|
|
325
|
+
rec.wavPath = null;
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
rec.state = RECORDER_STATE.RECORDING;
|
|
329
|
+
rec.child = child;
|
|
330
|
+
rec.wavPath = wavPath;
|
|
331
|
+
return { ok: true };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Graceful-stop budget before escalating to a hard kill. ffmpeg needs a
|
|
335
|
+
// moment to flush the wav trailer after receiving 'q' on stdin.
|
|
336
|
+
const RECORDING_STOP_GRACE_MS = 3000;
|
|
337
|
+
|
|
338
|
+
function waitForExit(child, timeoutMs) {
|
|
339
|
+
return new Promise((resolve) => {
|
|
340
|
+
let done = false;
|
|
341
|
+
const finish = (exited) => {
|
|
342
|
+
if (done) return;
|
|
343
|
+
done = true;
|
|
344
|
+
resolve(exited);
|
|
345
|
+
};
|
|
346
|
+
child.once('exit', () => finish(true));
|
|
347
|
+
setTimeout(() => finish(false), timeoutMs).unref?.();
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Stop the in-flight recording. RECORDING -> TRANSCRIBING -> (transcribe) ->
|
|
353
|
+
* IDLE. Sends 'q' on ffmpeg's stdin (its documented graceful-quit key —
|
|
354
|
+
* flushes the wav trailer); if ffmpeg has not exited within
|
|
355
|
+
* RECORDING_STOP_GRACE_MS it is force-killed (the partial wav is still
|
|
356
|
+
* attempted). No-op (returns null) unless currently RECORDING.
|
|
357
|
+
*
|
|
358
|
+
* On success runs ensureReady + transcribe against the SAME whisper-server
|
|
359
|
+
* singleton the channels pipeline uses, using `voice.language` from config
|
|
360
|
+
* (undefined -> whisper auto-detect) and `voice.transcription.threadCount`
|
|
361
|
+
* (falls back to the same quartered-cpu-count default index.mjs uses).
|
|
362
|
+
* Returns `{ ok: true, text }` on success or `{ ok: false, reason }` on any
|
|
363
|
+
* failure; state returns to IDLE either way.
|
|
364
|
+
*
|
|
365
|
+
* Med-1: rec.child is INTENTIONALLY kept set (not nulled) for the entire
|
|
366
|
+
* grace-wait window below — only cleared once the child has actually
|
|
367
|
+
* exited (or been killed). This lets disposeRecorder(), called mid-grace
|
|
368
|
+
* (e.g. the user quits the TUI right after Ctrl+Space-stop), still see and
|
|
369
|
+
* kill the SAME child instead of finding rec.child already null and
|
|
370
|
+
* orphaning a live ffmpeg process.
|
|
371
|
+
*/
|
|
372
|
+
export async function stopRecording({ dataDir = resolvePluginData() } = {}) {
|
|
373
|
+
if (rec.state !== RECORDER_STATE.RECORDING || !rec.child) return null;
|
|
374
|
+
const child = rec.child;
|
|
375
|
+
const wavPath = rec.wavPath;
|
|
376
|
+
rec.state = RECORDER_STATE.TRANSCRIBING;
|
|
377
|
+
try {
|
|
378
|
+
try { child.stdin?.write('q'); } catch { /* stdin may already be closed */ }
|
|
379
|
+
const exited = await waitForExit(child, RECORDING_STOP_GRACE_MS);
|
|
380
|
+
if (!exited) {
|
|
381
|
+
try { child.kill('SIGKILL'); } catch { /* best-effort */ }
|
|
382
|
+
await waitForExit(child, RECORDING_STOP_GRACE_MS);
|
|
383
|
+
}
|
|
384
|
+
// Only now is the child provably gone (or we gave up trying) — safe to
|
|
385
|
+
// drop the reference so a concurrent disposeRecorder() no longer needs
|
|
386
|
+
// (and no longer has) anything to kill.
|
|
387
|
+
if (rec.child === child) rec.child = null;
|
|
388
|
+
return await transcribeWav(wavPath, { dataDir });
|
|
389
|
+
} finally {
|
|
390
|
+
if (rec.child === child) rec.child = null;
|
|
391
|
+
rec.state = RECORDER_STATE.IDLE;
|
|
392
|
+
rec.wavPath = null;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Cancel the in-flight recording. RECORDING -> IDLE (no transcription).
|
|
398
|
+
* Force-kills ffmpeg immediately and deletes the tmp wav. No-op (returns
|
|
399
|
+
* false) unless currently RECORDING.
|
|
400
|
+
*/
|
|
401
|
+
export function cancelRecording() {
|
|
402
|
+
if (rec.state !== RECORDER_STATE.RECORDING || !rec.child) return false;
|
|
403
|
+
const child = rec.child;
|
|
404
|
+
const wavPath = rec.wavPath;
|
|
405
|
+
try { child.kill('SIGKILL'); } catch { /* best-effort */ }
|
|
406
|
+
if (wavPath) {
|
|
407
|
+
try { unlinkSync(wavPath); } catch { /* tmp file may not exist yet */ }
|
|
408
|
+
}
|
|
409
|
+
rec.state = RECORDER_STATE.IDLE;
|
|
410
|
+
rec.child = null;
|
|
411
|
+
rec.wavPath = null;
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Best-effort teardown for TUI unmount/exit: kills any live ffmpeg child and
|
|
417
|
+
* deletes its tmp wav, regardless of current state (RECORDING or
|
|
418
|
+
* TRANSCRIBING — the latter can still hold a live child mid-stop-grace).
|
|
419
|
+
* Never throws.
|
|
420
|
+
*/
|
|
421
|
+
export function disposeRecorder() {
|
|
422
|
+
const child = rec.child;
|
|
423
|
+
const wavPath = rec.wavPath;
|
|
424
|
+
if (child) {
|
|
425
|
+
try { child.kill('SIGKILL'); } catch { /* best-effort */ }
|
|
426
|
+
}
|
|
427
|
+
if (wavPath) {
|
|
428
|
+
try { unlinkSync(wavPath); } catch { /* best-effort */ }
|
|
429
|
+
}
|
|
430
|
+
rec.state = RECORDER_STATE.IDLE;
|
|
431
|
+
rec.child = null;
|
|
432
|
+
rec.wavPath = null;
|
|
433
|
+
// High-2: shut down the whisper-server child too, not just ffmpeg — a
|
|
434
|
+
// TUI-triggered transcription can leave the managed whisper-server.exe
|
|
435
|
+
// process running past TUI exit (it's a long-lived singleton by design,
|
|
436
|
+
// kept warm across transcriptions), which otherwise orphans it. Only
|
|
437
|
+
// meaningful if loadWhisperServer() actually resolved at least once this
|
|
438
|
+
// session (_whisperServerModule set) — nothing to stop otherwise, and we
|
|
439
|
+
// must not trigger a fresh dynamic import from a sync unmount callback.
|
|
440
|
+
if (_whisperServerModule) {
|
|
441
|
+
try { void _whisperServerModule.stopVoiceWhisperServer?.(); } catch { /* best-effort */ }
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function transcribeWav(wavPath, { dataDir }) {
|
|
446
|
+
// Med-2: the runtime.installed check is now INSIDE the try so an early
|
|
447
|
+
// return here still hits the finally below and unlinks wavPath — the
|
|
448
|
+
// original early-return (before try) skipped cleanup and leaked the tmp
|
|
449
|
+
// wav on every "voice runtime not installed" path.
|
|
450
|
+
try {
|
|
451
|
+
const fetcher = await loadVoiceRuntimeFetcher();
|
|
452
|
+
const runtime = fetcher.resolveVoiceRuntime(dataDir);
|
|
453
|
+
if (!runtime?.installed) {
|
|
454
|
+
return { ok: false, reason: 'voice runtime not installed — run /voice to install it' };
|
|
455
|
+
}
|
|
456
|
+
const server = await loadWhisperServer();
|
|
457
|
+
const cfg = readSection('voice');
|
|
458
|
+
const cpuCount = (() => { try { return cpus().length; } catch { return 2; } })();
|
|
459
|
+
const threadCount = cfg?.transcription?.threadCount ?? Math.max(1, Math.ceil(cpuCount / 4));
|
|
460
|
+
const language = cfg?.language || undefined;
|
|
461
|
+
await server.ensureReady({ serverCmd: runtime.serverCmd, modelPath: runtime.modelPath, threadCount, host: '127.0.0.1' });
|
|
462
|
+
const text = await server.transcribe(wavPath, { language });
|
|
463
|
+
return { ok: true, text: text.trim() };
|
|
464
|
+
} catch (err) {
|
|
465
|
+
return { ok: false, reason: err?.message || String(err) };
|
|
466
|
+
} finally {
|
|
467
|
+
try { unlinkSync(wavPath); } catch { /* best-effort cleanup */ }
|
|
468
|
+
}
|
|
469
|
+
}
|