gentle-pi 4.3.0 → 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -7
- package/assets/agents/gentle-ai-worker.md +1 -1
- package/assets/design-canvas/canvas.css +648 -0
- package/assets/design-canvas/canvas.js +1497 -0
- package/assets/design-canvas/index.html +88 -0
- package/assets/design-canvas/inspector.js +290 -0
- package/assets/orchestrator-delegation-reference.md +2 -2
- package/assets/orchestrator-verification.md +1 -1
- package/assets/orchestrator.md +2 -2
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +138 -155
- package/docs/bundled-install.md +47 -42
- package/docs/delegated-verification.md +6 -0
- package/docs/gentle-agents-activity.md +5 -1
- package/docs/gentle-music.md +82 -0
- package/docs/gentle-shell.md +80 -10
- package/docs/install-wizard.md +30 -6
- package/docs/permission-modes.md +7 -2
- package/docs/readme-reference.md +72 -55
- package/docs/session-profile-format.md +26 -0
- package/docs/voice.md +259 -0
- package/docs/yolo-mode.md +17 -2
- package/extensions/ask-user-choice.ts +4 -1
- package/extensions/gentle-agents.ts +187 -40
- package/extensions/gentle-ai.ts +1765 -1185
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-music.ts +354 -0
- package/extensions/gentle-shell.ts +418 -79
- package/extensions/gentle-voice.ts +11 -0
- package/extensions/session-scratch.ts +41 -22
- package/extensions/skill-registry.ts +63 -8
- package/lib/agent-profiles.ts +26 -0
- package/lib/agents-config.ts +35 -0
- package/lib/agents-protocol.ts +57 -1
- package/lib/agents-runner.ts +52 -3
- package/lib/agents-session-transport.ts +101 -6
- package/lib/agents-widget.ts +14 -1
- package/lib/auto-permission-memory.ts +48 -18
- package/lib/auto-permission-runtime.ts +21 -6
- package/lib/clipboard-image.ts +147 -0
- package/lib/command-palette-catalog.ts +3 -2
- package/lib/commit-slice-signal.ts +1 -1
- package/lib/context-backstop.ts +36 -0
- package/lib/design-canvas-server.ts +947 -0
- package/lib/design-comments.ts +437 -0
- package/lib/design-handoffs.ts +313 -0
- package/lib/design-open-browser.ts +58 -0
- package/lib/design-sources.ts +120 -0
- package/lib/external-editor.ts +31 -0
- package/lib/gentle-design.ts +513 -0
- package/lib/gentle-shell-bundled.ts +141 -57
- package/lib/gentle-shell-launcher.ts +11 -4
- package/lib/gentle-shell-startup-update.ts +61 -9
- package/lib/gentle-shell-uninstall.ts +33 -1
- package/lib/image-attachments.ts +150 -0
- package/lib/inprocess-reviewer.ts +33 -2
- package/lib/jobs-sidebar-state.ts +22 -3
- package/lib/jobs-widget.ts +112 -0
- package/lib/nan-provider.ts +3 -0
- package/lib/native-review-cli.ts +11 -4
- package/lib/notification-audio-native.ts +149 -13
- package/lib/notification-audio.ts +4 -0
- package/lib/notification-pulse-worker.ts +82 -18
- package/lib/notification-service.ts +5 -1
- package/lib/orchestrator-presence.ts +78 -8
- package/lib/orchestrator-scope.ts +10 -0
- package/lib/orchestrator-state.ts +11 -3
- package/lib/permission-mode-policy.ts +2 -2
- package/lib/review-host-relay.ts +11 -2
- package/lib/review-reminder-receipt.ts +24 -1
- package/lib/session-profile-append-controller.ts +713 -0
- package/lib/session-profile-disk-reader.ts +182 -0
- package/lib/session-profile-freeze.ts +2 -2
- package/lib/session-profile-persistence.ts +15 -7
- package/lib/session-scratch.ts +52 -3
- package/lib/session-worktree-registry.ts +7 -0
- package/lib/shell-bar.ts +6 -7
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +27 -7
- package/lib/shell-todo.ts +1 -1
- package/lib/verifier-admission.ts +5 -4
- package/lib/vim-editor-adapter.ts +14 -10
- package/lib/voice-artifacts.ts +188 -0
- package/lib/voice-command.ts +576 -0
- package/lib/voice-install.ts +455 -0
- package/lib/voice-platform.ts +99 -0
- package/lib/voice-push-to-talk.ts +262 -0
- package/lib/voice-session.ts +460 -0
- package/lib/voice-settings.ts +50 -0
- package/lib/voice-ui.ts +158 -0
- package/lib/yolo-session-policy.ts +58 -22
- package/package.json +1 -1
- package/runtime/gentle-shell-bundled.mjs +140 -56
- package/runtime/gentle-shell-launcher.mjs +11 -4
- package/runtime/gentle-shell-startup-update.mjs +61 -9
- package/runtime/gentle-shell-uninstall.mjs +33 -1
- package/runtime/native-review-cli.mjs +11 -4
- package/scripts/bundled-engram-migration.mjs +0 -6
- package/scripts/bundled-install.mjs +209 -13
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-downloads.mjs +31 -7
- package/scripts/installer-preflight.mjs +6 -4
- package/scripts/installer-windows.mjs +37 -7
- package/scripts/main-channel.mjs +91 -17
- package/scripts/pi-update.mjs +37 -7
- package/scripts/verify-package-files.mjs +26 -2
- package/scripts/voice-worker.cjs +476 -0
- package/skills/design/SKILL.md +110 -0
- package/tests/agent-profiles.test.ts +24 -0
- package/tests/agents-config.test.ts +61 -0
- package/tests/agents-fake-child.ts +8 -1
- package/tests/agents-protocol.test.ts +32 -0
- package/tests/agents-runner.test.ts +226 -5
- package/tests/agents-session-transport.test.ts +53 -3
- package/tests/ask-user-choice.test.ts +30 -0
- package/tests/auto-mode.test.ts +17 -0
- package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
- package/tests/auto-permission-runtime.test.ts +85 -1
- package/tests/autonomous-guard.test.ts +20 -0
- package/tests/bundled-install.test.ts +276 -5
- package/tests/clipboard-image.test.ts +114 -0
- package/tests/command-palette.test.ts +4 -4
- package/tests/context-backstop.test.ts +70 -4
- package/tests/delegate-for-reason-contract.test.ts +2 -1
- package/tests/design-canvas-assets.test.ts +710 -0
- package/tests/design-canvas-server.test.ts +1096 -0
- package/tests/design-comments.test.ts +201 -0
- package/tests/design-handoffs.test.ts +282 -0
- package/tests/design-open-browser.test.ts +70 -0
- package/tests/design-sources.test.ts +132 -0
- package/tests/engram-safety.test.ts +132 -57
- package/tests/external-editor.test.ts +84 -0
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
- package/tests/gentle-agents.test.ts +369 -24
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-ai.test.ts +928 -448
- package/tests/gentle-design.test.ts +1313 -0
- package/tests/gentle-music.test.ts +452 -0
- package/tests/gentle-shell-bundled.test.ts +321 -248
- package/tests/gentle-shell-launcher.test.ts +29 -2
- package/tests/gentle-shell-startup-update.test.ts +77 -1
- package/tests/gentle-shell-uninstall.test.ts +59 -0
- package/tests/gentle-shell.test.ts +227 -27
- package/tests/gentle-todo.test.ts +11 -0
- package/tests/image-attachments.test.ts +119 -0
- package/tests/image-paste-chips.test.ts +236 -0
- package/tests/inprocess-reviewer.test.ts +55 -2
- package/tests/installer-bundles.test.ts +12 -6
- package/tests/installer-downloads.test.ts +32 -0
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +16 -0
- package/tests/installer-server.test.ts +2 -2
- package/tests/installer-windows-bootstrap.test.ts +61 -3
- package/tests/installer-windows-diagnostics.test.ts +111 -0
- package/tests/jobs-widget.test.ts +138 -0
- package/tests/main-channel-upgrade.test.ts +232 -2
- package/tests/main-channel.test.ts +140 -21
- package/tests/model-profiles-preservation.test.ts +2 -2
- package/tests/nan-provider.test.ts +36 -0
- package/tests/native-review-capability-contract.test.ts +11 -4
- package/tests/native-review-parity.test.ts +1 -1
- package/tests/notification-audio-native.test.ts +128 -9
- package/tests/notification-customize.test.ts +17 -5
- package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
- package/tests/orchestrator-budget.test.ts +5 -2
- package/tests/orchestrator-presence.test.ts +100 -2
- package/tests/orchestrator-rdd-ownership.test.ts +2 -2
- package/tests/orchestrator-scope.test.ts +14 -0
- package/tests/orchestrator-state.test.ts +29 -0
- package/tests/package-files.test.ts +34 -0
- package/tests/package-manifest.test.ts +6 -6
- package/tests/permission-mode-policy.test.ts +10 -1
- package/tests/pi-update.test.ts +104 -4
- package/tests/rdd-aware-verification-contract.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +88 -1
- package/tests/review-host-relay.test.ts +9 -2
- package/tests/review-json-arguments.test.ts +20 -3
- package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
- package/tests/review-relay-transport-agent.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +31 -1
- package/tests/runtime-harness.mjs +188 -211
- package/tests/session-changes.test.ts +2 -1
- package/tests/session-profile-append-controller-fork.test.ts +458 -0
- package/tests/session-profile-append-controller-pi.test.ts +290 -0
- package/tests/session-profile-append-controller.test.ts +731 -0
- package/tests/session-profile-disk-reader-pi.test.ts +183 -0
- package/tests/session-profile-disk-reader.test.ts +311 -0
- package/tests/session-profile-freeze.test.ts +6 -4
- package/tests/session-scratch.test.ts +68 -2
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +18 -6
- package/tests/shell-changes.test.ts +148 -1
- package/tests/shell-prompt.test.ts +3 -3
- package/tests/shell-render-cache.test.ts +134 -1
- package/tests/shell-sidebar-layout.test.ts +67 -0
- package/tests/short-check-routing-contract.test.ts +1 -1
- package/tests/skill-collision-prefixes.test.ts +3 -1
- package/tests/skill-registry-watcher.test.ts +17 -5
- package/tests/skill-registry.test.ts +47 -3
- package/tests/task-size-routing-contract.test.ts +1 -1
- package/tests/test-strength-contract.test.ts +1 -1
- package/tests/verifier-admission.test.ts +18 -1
- package/tests/vim-editor-adapter.test.ts +2 -2
- package/tests/voice-command.test.ts +773 -0
- package/tests/voice-install.test.ts +323 -0
- package/tests/voice-platform.test.ts +176 -0
- package/tests/voice-push-to-talk.test.ts +444 -0
- package/tests/voice-session.test.ts +434 -0
- package/tests/voice-settings.test.ts +49 -0
- package/tests/voice-ui.test.ts +97 -0
- package/tests/voice-worker.test.ts +445 -0
- package/tests/weak-model-economy-prompt.test.ts +32 -0
- package/tests/windows-hidden-processes.test.ts +25 -2
- package/tests/yolo-customize.test.ts +5 -1
- package/tests/yolo-mode.test.ts +1 -1
- package/tests/yolo-session-policy-capture.test.ts +104 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import type { EventEmitter } from "node:events";
|
|
3
|
+
import { StringDecoder } from "node:string_decoder";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { PARAKEET_MODEL_MANIFEST, VOICE_TARGETS, voiceRuntimeManifest, type VoiceModelManifest, type VoiceRuntimeManifest, type VoiceTarget } from "./voice-artifacts.ts";
|
|
6
|
+
import { readVoiceComponentStatus, resolveVoiceHome, voiceComponentPath } from "./voice-install.ts";
|
|
7
|
+
|
|
8
|
+
// One dictation session (S1-S3): record from the default microphone until stop,
|
|
9
|
+
// then transcribe locally with the installed Parakeet model. Capture, the
|
|
10
|
+
// blocking model load and the decode all run in scripts/voice-worker.cjs, a
|
|
11
|
+
// separate Node process, so nothing native is ever loaded into Gentle AI.
|
|
12
|
+
// Importing this module or constructing a session does no IO; only start()
|
|
13
|
+
// reads the install and spawns the worker. The transcript is returned to the
|
|
14
|
+
// caller and never submitted or inserted anywhere by this module.
|
|
15
|
+
|
|
16
|
+
export const VOICE_WORKER_SCHEMA = "gentle.voice/v1";
|
|
17
|
+
export const VOICE_WORKER_URL = new URL("../scripts/voice-worker.cjs", import.meta.url);
|
|
18
|
+
|
|
19
|
+
export const VOICE_SESSION_DEFAULTS = {
|
|
20
|
+
// The worker stops capturing by itself at this length and transcribes.
|
|
21
|
+
maxRecordingMs: 120_000,
|
|
22
|
+
// Covers the runtime checks and opening the microphone.
|
|
23
|
+
startTimeoutMs: 120_000,
|
|
24
|
+
// Covers what is left of the model digest check (~670 MB, verified while
|
|
25
|
+
// recording), loading the recognizer and the decode.
|
|
26
|
+
transcribeTimeoutMs: 120_000,
|
|
27
|
+
// How long a stopping or cancelled worker may take to exit before it is killed.
|
|
28
|
+
exitGraceMs: 2_000,
|
|
29
|
+
numThreads: 2,
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
const MAX_LINE = 64 * 1024;
|
|
33
|
+
const MAX_LINES = 16;
|
|
34
|
+
const MAX_TEXT = 16 * 1024;
|
|
35
|
+
const RECORDING_MARGIN_MS = 10_000;
|
|
36
|
+
|
|
37
|
+
const WORKER_ERROR_CODES = [
|
|
38
|
+
"invalid-request",
|
|
39
|
+
"node-version",
|
|
40
|
+
"runtime-invalid",
|
|
41
|
+
"model-invalid",
|
|
42
|
+
"runtime-load",
|
|
43
|
+
"model-load",
|
|
44
|
+
"no-input-device",
|
|
45
|
+
"unsupported-input",
|
|
46
|
+
"capture",
|
|
47
|
+
"empty-audio",
|
|
48
|
+
"decode",
|
|
49
|
+
"internal",
|
|
50
|
+
] as const;
|
|
51
|
+
|
|
52
|
+
type WorkerErrorCode = (typeof WORKER_ERROR_CODES)[number];
|
|
53
|
+
|
|
54
|
+
export type VoiceSessionErrorCode = WorkerErrorCode | "invalid-state" | "unsupported" | "not-installed" | "no-speech" | "timeout" | "protocol" | "worker-exited" | "cancelled";
|
|
55
|
+
|
|
56
|
+
// Fixed text only: no path, environment value or native error ever reaches it.
|
|
57
|
+
const MESSAGES: Record<VoiceSessionErrorCode, string> = {
|
|
58
|
+
"invalid-state": "This voice session cannot do that in its current state.",
|
|
59
|
+
unsupported: "Voice dictation has no pinned runtime for this platform.",
|
|
60
|
+
"node-version": "Voice dictation needs Node 22 or newer.",
|
|
61
|
+
"not-installed": "The voice runtime and model are not installed or cannot be read.",
|
|
62
|
+
"invalid-request": "The voice worker rejected its request.",
|
|
63
|
+
"runtime-invalid": "The installed voice runtime failed verification. Reinstall it.",
|
|
64
|
+
"model-invalid": "The installed voice model failed verification. Reinstall it.",
|
|
65
|
+
"runtime-load": "The voice runtime could not be loaded on this system.",
|
|
66
|
+
"model-load": "The voice model could not be loaded.",
|
|
67
|
+
"no-input-device": "No microphone was found.",
|
|
68
|
+
"unsupported-input": "The default microphone uses an unsupported audio format.",
|
|
69
|
+
capture: "Microphone capture failed.",
|
|
70
|
+
"empty-audio": "No audio was recorded.",
|
|
71
|
+
"no-speech": "No speech was recognized.",
|
|
72
|
+
decode: "Transcription failed.",
|
|
73
|
+
internal: "The voice worker failed.",
|
|
74
|
+
timeout: "The voice session took too long and was stopped.",
|
|
75
|
+
protocol: "The voice worker sent an invalid response and was stopped.",
|
|
76
|
+
"worker-exited": "The voice worker stopped unexpectedly.",
|
|
77
|
+
cancelled: "Voice dictation was cancelled.",
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export class VoiceSessionError extends Error {
|
|
81
|
+
readonly code: VoiceSessionErrorCode;
|
|
82
|
+
|
|
83
|
+
constructor(code: VoiceSessionErrorCode) {
|
|
84
|
+
super(MESSAGES[code]);
|
|
85
|
+
this.name = "VoiceSessionError";
|
|
86
|
+
this.code = code;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type VoiceSessionState = "idle" | "starting" | "recording" | "transcribing" | "finished";
|
|
91
|
+
|
|
92
|
+
export interface VoiceTranscript {
|
|
93
|
+
text: string;
|
|
94
|
+
// True when the recording limit ended capture before stop() was called.
|
|
95
|
+
truncated: boolean;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface VoiceWorkerSpawnOptions {
|
|
99
|
+
readonly cwd: string;
|
|
100
|
+
readonly shell: false;
|
|
101
|
+
readonly windowsHide: true;
|
|
102
|
+
readonly detached: false;
|
|
103
|
+
readonly stdio: readonly ["pipe", "pipe", "pipe"];
|
|
104
|
+
readonly env: NodeJS.ProcessEnv;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface VoiceWorkerChild {
|
|
108
|
+
stdin: { write(chunk: string): unknown; end(): unknown; on(event: "error", listener: (error: unknown) => void): unknown } | null;
|
|
109
|
+
stdout: EventEmitter | null;
|
|
110
|
+
stderr: EventEmitter | null;
|
|
111
|
+
kill(signal: NodeJS.Signals): boolean;
|
|
112
|
+
once(event: string, listener: (...args: unknown[]) => void): unknown;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface VoiceSessionOptions {
|
|
116
|
+
target: VoiceTarget;
|
|
117
|
+
// Defaults to resolveVoiceHome(env).
|
|
118
|
+
voiceHome?: string;
|
|
119
|
+
env?: NodeJS.ProcessEnv;
|
|
120
|
+
maxRecordingMs?: number;
|
|
121
|
+
startTimeoutMs?: number;
|
|
122
|
+
transcribeTimeoutMs?: number;
|
|
123
|
+
exitGraceMs?: number;
|
|
124
|
+
onStateChange?: (state: VoiceSessionState) => void;
|
|
125
|
+
// The pinned manifests are the default; overriding them selects other
|
|
126
|
+
// installed components under the same voice home, as the installer allows.
|
|
127
|
+
manifests?: { model?: VoiceModelManifest; runtime?: VoiceRuntimeManifest };
|
|
128
|
+
spawn?: (executable: string, args: string[], options: VoiceWorkerSpawnOptions) => VoiceWorkerChild;
|
|
129
|
+
workerPath?: string;
|
|
130
|
+
nodeVersion?: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
type WorkerMessage = { type: "recording" } | { type: "transcribing" } | { type: "result"; text: string; truncated: boolean } | { type: "error"; code: WorkerErrorCode };
|
|
134
|
+
|
|
135
|
+
const hasExactKeys = (value: Record<string, unknown>, keys: string[]) => Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
|
|
136
|
+
|
|
137
|
+
// Accepts only the four known messages with exactly their fields.
|
|
138
|
+
function parseWorkerLine(line: string): WorkerMessage | undefined {
|
|
139
|
+
let value: unknown;
|
|
140
|
+
try {
|
|
141
|
+
value = JSON.parse(line);
|
|
142
|
+
} catch {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
146
|
+
const record = value as Record<string, unknown>;
|
|
147
|
+
if (record.schema !== VOICE_WORKER_SCHEMA) return undefined;
|
|
148
|
+
switch (record.type) {
|
|
149
|
+
case "recording":
|
|
150
|
+
case "transcribing":
|
|
151
|
+
return hasExactKeys(record, ["schema", "type"]) ? { type: record.type } : undefined;
|
|
152
|
+
case "result":
|
|
153
|
+
if (!hasExactKeys(record, ["schema", "type", "text", "truncated"]) || typeof record.text !== "string" || record.text.length > MAX_TEXT || typeof record.truncated !== "boolean") return undefined;
|
|
154
|
+
return { type: "result", text: record.text, truncated: record.truncated };
|
|
155
|
+
case "error":
|
|
156
|
+
if (!hasExactKeys(record, ["schema", "type", "code"]) || !WORKER_ERROR_CODES.includes(record.code as WorkerErrorCode)) return undefined;
|
|
157
|
+
return { type: "error", code: record.code as WorkerErrorCode };
|
|
158
|
+
default:
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// The child environment is an allowlist: home, temp, locale and the audio
|
|
164
|
+
// session variables survive; credentials, NODE_OPTIONS, NODE_PATH, loader
|
|
165
|
+
// overrides and PATH are dropped.
|
|
166
|
+
const CHILD_ENV_KEYS = ["HOME", "USERPROFILE", "SystemRoot", "SYSTEMROOT", "windir", "TEMP", "TMP", "TMPDIR", "XDG_RUNTIME_DIR", "PULSE_SERVER", "PULSE_COOKIE", "LANG", "LC_ALL", "LC_CTYPE"];
|
|
167
|
+
|
|
168
|
+
function childEnv(source: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
169
|
+
const env: NodeJS.ProcessEnv = {};
|
|
170
|
+
for (const key of CHILD_ENV_KEYS) if (source[key] !== undefined) env[key] = source[key];
|
|
171
|
+
env.NODE_NO_WARNINGS = "1";
|
|
172
|
+
env.GENTLE_PI_AGENTS_CHILD = "1";
|
|
173
|
+
return env;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function defaultSpawn(executable: string, args: string[], options: VoiceWorkerSpawnOptions): VoiceWorkerChild {
|
|
177
|
+
// SAFETY: ChildProcess provides stdin/stdout/stderr/kill/once compatible with the narrower VoiceWorkerChild; the cast only bridges that narrowing.
|
|
178
|
+
return spawn(executable, args, { ...options, stdio: ["pipe", "pipe", "pipe"] }) as unknown as VoiceWorkerChild;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
interface Deferred<T> {
|
|
182
|
+
promise: Promise<T>;
|
|
183
|
+
resolve(value: T): void;
|
|
184
|
+
reject(error: unknown): void;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function deferred<T>(): Deferred<T> {
|
|
188
|
+
let resolve!: (value: T) => void;
|
|
189
|
+
let reject!: (error: unknown) => void;
|
|
190
|
+
const promise = new Promise<T>((res, rej) => {
|
|
191
|
+
resolve = res;
|
|
192
|
+
reject = rej;
|
|
193
|
+
});
|
|
194
|
+
return { promise, resolve, reject };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const fail = (code: VoiceSessionErrorCode) => new VoiceSessionError(code);
|
|
198
|
+
|
|
199
|
+
function unref(handle: ReturnType<typeof setTimeout>): ReturnType<typeof setTimeout> {
|
|
200
|
+
(handle as { unref?: () => void }).unref?.();
|
|
201
|
+
return handle;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Single use: idle -> starting -> recording -> transcribing -> finished. A
|
|
205
|
+
// successful start() resolves while the worker keeps recording; stop(),
|
|
206
|
+
// cancel() and a failed start() settle only after the worker process has
|
|
207
|
+
// closed, so a finished session never leaves a child behind.
|
|
208
|
+
export class VoiceSession {
|
|
209
|
+
private readonly options: VoiceSessionOptions;
|
|
210
|
+
private currentState: VoiceSessionState = "idle";
|
|
211
|
+
private child: VoiceWorkerChild | undefined;
|
|
212
|
+
private outcome: { transcript: VoiceTranscript } | { error: VoiceSessionError } | undefined;
|
|
213
|
+
private readonly recording = deferred<void>();
|
|
214
|
+
private readonly closed = deferred<void>();
|
|
215
|
+
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
216
|
+
private exitTimer: ReturnType<typeof setTimeout> | undefined;
|
|
217
|
+
|
|
218
|
+
constructor(options: VoiceSessionOptions) {
|
|
219
|
+
this.options = { ...options };
|
|
220
|
+
// Settled by close; a session that never starts never awaits it.
|
|
221
|
+
this.closed.promise.catch(() => {});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
get state(): VoiceSessionState {
|
|
225
|
+
return this.currentState;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Resolves once the microphone is recording.
|
|
229
|
+
async start(): Promise<void> {
|
|
230
|
+
if (this.currentState !== "idle") throw fail("invalid-state");
|
|
231
|
+
this.setState("starting");
|
|
232
|
+
let launch: { root: string; request: Record<string, unknown> };
|
|
233
|
+
try {
|
|
234
|
+
launch = await this.prepare();
|
|
235
|
+
} catch (error) {
|
|
236
|
+
this.outcome ??= { error: error instanceof VoiceSessionError ? error : fail("not-installed") };
|
|
237
|
+
this.setState("finished");
|
|
238
|
+
throw this.outcomeError();
|
|
239
|
+
}
|
|
240
|
+
if (this.outcome) throw this.outcomeError();
|
|
241
|
+
this.launch(launch.root, launch.request);
|
|
242
|
+
const exited = this.closed.promise.then(() => {
|
|
243
|
+
throw this.outcomeError();
|
|
244
|
+
});
|
|
245
|
+
exited.catch(() => {});
|
|
246
|
+
await Promise.race([this.recording.promise, exited]);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Ends recording and resolves with the transcript, or rejects with
|
|
250
|
+
// "empty-audio" or "no-speech" when nothing usable was said.
|
|
251
|
+
async stop(): Promise<VoiceTranscript> {
|
|
252
|
+
if (this.currentState === "idle" || this.currentState === "starting") throw fail("invalid-state");
|
|
253
|
+
if (this.currentState === "recording" && !this.outcome) {
|
|
254
|
+
this.send({ schema: VOICE_WORKER_SCHEMA, type: "stop" });
|
|
255
|
+
this.enterTranscribing();
|
|
256
|
+
}
|
|
257
|
+
// A finished session answers with its outcome, such as a transcript the
|
|
258
|
+
// recording limit produced before stop() was called.
|
|
259
|
+
if (this.child) await this.closed.promise;
|
|
260
|
+
if (this.outcome && "transcript" in this.outcome) return this.outcome.transcript;
|
|
261
|
+
throw this.outcomeError();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Discards the session: any transcript, including one that arrives late, is
|
|
265
|
+
// dropped. Resolves after the worker has exited.
|
|
266
|
+
async cancel(): Promise<void> {
|
|
267
|
+
if (!this.child) {
|
|
268
|
+
if (this.currentState !== "finished") {
|
|
269
|
+
this.outcome ??= { error: fail("cancelled") };
|
|
270
|
+
this.setState("finished");
|
|
271
|
+
}
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
this.outcome = { error: fail("cancelled") };
|
|
275
|
+
this.send({ schema: VOICE_WORKER_SCHEMA, type: "cancel" });
|
|
276
|
+
this.endWorker(this.options.exitGraceMs ?? VOICE_SESSION_DEFAULTS.exitGraceMs);
|
|
277
|
+
await this.closed.promise;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
dispose(): Promise<void> {
|
|
281
|
+
return this.cancel();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private setState(state: VoiceSessionState): void {
|
|
285
|
+
if (this.currentState === state) return;
|
|
286
|
+
this.currentState = state;
|
|
287
|
+
this.options.onStateChange?.(state);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private outcomeError(): VoiceSessionError {
|
|
291
|
+
return this.outcome && "error" in this.outcome ? this.outcome.error : fail("worker-exited");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private async prepare(): Promise<{ root: string; request: Record<string, unknown> }> {
|
|
295
|
+
const { target } = this.options;
|
|
296
|
+
if (!VOICE_TARGETS.includes(target)) throw fail("unsupported");
|
|
297
|
+
if (Number((this.options.nodeVersion ?? process.versions.node).split(".")[0]) < 22) throw fail("node-version");
|
|
298
|
+
const model = this.options.manifests?.model ?? PARAKEET_MODEL_MANIFEST;
|
|
299
|
+
const runtime = this.options.manifests?.runtime ?? voiceRuntimeManifest(target);
|
|
300
|
+
if (runtime.target !== target) throw fail("unsupported");
|
|
301
|
+
const voiceHome = this.options.voiceHome ?? resolveVoiceHome(this.options.env);
|
|
302
|
+
const statuses = await Promise.all([
|
|
303
|
+
readVoiceComponentStatus({ voiceHome, kind: "runtime", manifest: runtime }),
|
|
304
|
+
readVoiceComponentStatus({ voiceHome, kind: "model", manifest: model }),
|
|
305
|
+
]);
|
|
306
|
+
if (statuses.some((status) => status.state !== "installed")) throw fail("not-installed");
|
|
307
|
+
const pick = (prefix: string) => {
|
|
308
|
+
const file = model.files.find((candidate) => candidate.name.startsWith(prefix));
|
|
309
|
+
if (!file) throw fail("model-invalid");
|
|
310
|
+
return { name: file.name, size: file.size, sha256: file.sha256 };
|
|
311
|
+
};
|
|
312
|
+
const root = voiceComponentPath(voiceHome, "runtime", runtime);
|
|
313
|
+
const request = {
|
|
314
|
+
schema: VOICE_WORKER_SCHEMA,
|
|
315
|
+
type: "start",
|
|
316
|
+
target,
|
|
317
|
+
maxRecordingMs: this.options.maxRecordingMs ?? VOICE_SESSION_DEFAULTS.maxRecordingMs,
|
|
318
|
+
numThreads: VOICE_SESSION_DEFAULTS.numThreads,
|
|
319
|
+
runtime: { root, files: runtime.packages.flatMap((pkg) => pkg.requiredFiles.map((file) => `node_modules/${pkg.name}/${file}`)) },
|
|
320
|
+
model: {
|
|
321
|
+
root: voiceComponentPath(voiceHome, "model", model),
|
|
322
|
+
files: { encoder: pick("encoder."), decoder: pick("decoder."), joiner: pick("joiner."), tokens: pick("tokens.") },
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
return { root, request };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private launch(root: string, request: Record<string, unknown>): void {
|
|
329
|
+
const workerPath = this.options.workerPath ?? fileURLToPath(VOICE_WORKER_URL);
|
|
330
|
+
// The worker runs from the runtime directory, never the user's project.
|
|
331
|
+
const child = (this.options.spawn ?? defaultSpawn)(process.execPath, [workerPath], {
|
|
332
|
+
cwd: root,
|
|
333
|
+
shell: false,
|
|
334
|
+
windowsHide: true,
|
|
335
|
+
detached: false,
|
|
336
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
337
|
+
env: childEnv(this.options.env ?? process.env),
|
|
338
|
+
});
|
|
339
|
+
this.child = child;
|
|
340
|
+
let buffered = "";
|
|
341
|
+
let lines = 0;
|
|
342
|
+
// Pipe chunks can end inside a multibyte character; the decoder holds the
|
|
343
|
+
// partial bytes until the rest arrives.
|
|
344
|
+
const decoder = new StringDecoder("utf8");
|
|
345
|
+
const onStdout = (chunk: unknown) => {
|
|
346
|
+
buffered += typeof chunk === "string" ? chunk : decoder.write(chunk as Buffer);
|
|
347
|
+
let newline: number;
|
|
348
|
+
while ((newline = buffered.indexOf("\n")) !== -1) {
|
|
349
|
+
const line = buffered.slice(0, newline);
|
|
350
|
+
buffered = buffered.slice(newline + 1);
|
|
351
|
+
const message = ++lines <= MAX_LINES && line.length <= MAX_LINE ? parseWorkerLine(line) : undefined;
|
|
352
|
+
if (message) this.onMessage(message);
|
|
353
|
+
else this.abort("protocol");
|
|
354
|
+
}
|
|
355
|
+
if (buffered.length > MAX_LINE) this.abort("protocol");
|
|
356
|
+
};
|
|
357
|
+
// stderr is drained so the worker never blocks on it, and then dropped:
|
|
358
|
+
// native libraries may print paths or device names there.
|
|
359
|
+
const onStderr = () => {};
|
|
360
|
+
child.stdout?.on("data", onStdout);
|
|
361
|
+
child.stderr?.on("data", onStderr);
|
|
362
|
+
child.stdin?.on("error", () => {});
|
|
363
|
+
child.once("error", () => {
|
|
364
|
+
/* Node emits close after spawn errors; keep waiting */
|
|
365
|
+
});
|
|
366
|
+
child.once("close", () => {
|
|
367
|
+
child.stdout?.removeListener("data", onStdout);
|
|
368
|
+
child.stderr?.removeListener("data", onStderr);
|
|
369
|
+
this.clearTimers();
|
|
370
|
+
this.outcome ??= { error: fail("worker-exited") };
|
|
371
|
+
this.child = undefined;
|
|
372
|
+
this.setState("finished");
|
|
373
|
+
this.closed.resolve();
|
|
374
|
+
});
|
|
375
|
+
this.send(request);
|
|
376
|
+
this.arm(this.options.startTimeoutMs ?? VOICE_SESSION_DEFAULTS.startTimeoutMs);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private onMessage(message: WorkerMessage): void {
|
|
380
|
+
if (this.outcome) return;
|
|
381
|
+
switch (message.type) {
|
|
382
|
+
case "recording":
|
|
383
|
+
if (this.currentState !== "starting") return this.abort("protocol");
|
|
384
|
+
this.setState("recording");
|
|
385
|
+
this.arm((this.options.maxRecordingMs ?? VOICE_SESSION_DEFAULTS.maxRecordingMs) + RECORDING_MARGIN_MS);
|
|
386
|
+
this.recording.resolve();
|
|
387
|
+
return;
|
|
388
|
+
case "transcribing":
|
|
389
|
+
// Sent after stop(), or on its own when the recording limit is reached.
|
|
390
|
+
if (this.currentState === "recording") this.enterTranscribing();
|
|
391
|
+
else if (this.currentState !== "transcribing") this.abort("protocol");
|
|
392
|
+
return;
|
|
393
|
+
case "result":
|
|
394
|
+
if (this.currentState !== "transcribing") return this.abort("protocol");
|
|
395
|
+
this.outcome = message.text.trim() ? { transcript: { text: message.text.trim(), truncated: message.truncated } } : { error: fail("no-speech") };
|
|
396
|
+
this.endWorker(this.options.exitGraceMs ?? VOICE_SESSION_DEFAULTS.exitGraceMs);
|
|
397
|
+
return;
|
|
398
|
+
case "error":
|
|
399
|
+
this.outcome = { error: fail(message.code) };
|
|
400
|
+
this.endWorker(this.options.exitGraceMs ?? VOICE_SESSION_DEFAULTS.exitGraceMs);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private enterTranscribing(): void {
|
|
405
|
+
this.setState("transcribing");
|
|
406
|
+
this.arm(this.options.transcribeTimeoutMs ?? VOICE_SESSION_DEFAULTS.transcribeTimeoutMs);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Records a failure and kills the worker at once.
|
|
410
|
+
private abort(code: VoiceSessionErrorCode): void {
|
|
411
|
+
if (this.outcome) return;
|
|
412
|
+
this.outcome = { error: fail(code) };
|
|
413
|
+
this.kill();
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// The worker exits by itself after its final message or a cancel; it is
|
|
417
|
+
// killed only if it is still running after the grace period.
|
|
418
|
+
private endWorker(graceMs: number): void {
|
|
419
|
+
this.clearTimers();
|
|
420
|
+
this.child?.stdin?.end();
|
|
421
|
+
this.exitTimer = unref(setTimeout(() => this.kill(), graceMs));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private arm(ms: number): void {
|
|
425
|
+
if (this.timer) clearTimeout(this.timer);
|
|
426
|
+
this.timer = unref(
|
|
427
|
+
setTimeout(() => {
|
|
428
|
+
this.outcome = { error: fail("timeout") };
|
|
429
|
+
this.kill();
|
|
430
|
+
}, ms),
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
private clearTimers(): void {
|
|
435
|
+
if (this.timer) clearTimeout(this.timer);
|
|
436
|
+
if (this.exitTimer) clearTimeout(this.exitTimer);
|
|
437
|
+
this.timer = this.exitTimer = undefined;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
private kill(): void {
|
|
441
|
+
this.clearTimers();
|
|
442
|
+
try {
|
|
443
|
+
this.child?.kill("SIGKILL");
|
|
444
|
+
} catch {
|
|
445
|
+
// close still follows; keep waiting for it.
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
private send(message: Record<string, unknown>): void {
|
|
450
|
+
try {
|
|
451
|
+
this.child?.stdin?.write(`${JSON.stringify(message)}\n`);
|
|
452
|
+
} catch {
|
|
453
|
+
// A worker that cannot be written to is gone; close settles the session.
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export function createVoiceSession(options: VoiceSessionOptions): VoiceSession {
|
|
459
|
+
return new VoiceSession(options);
|
|
460
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { gentlePiConfigHome } from "./agent-home.ts";
|
|
5
|
+
|
|
6
|
+
// The persisted push-to-talk toggle (hold Space to dictate). It lives in the
|
|
7
|
+
// Gentle AI config home next to the other per-feature preference files, never
|
|
8
|
+
// inside the voice directory, so reading it touches no voice install. A
|
|
9
|
+
// missing, malformed or foreign file reads as off.
|
|
10
|
+
|
|
11
|
+
export const VOICE_SETTINGS_SCHEMA = "gentle-pi.voice-settings/v1";
|
|
12
|
+
export const VOICE_SETTINGS_FILE = "voice-settings.json";
|
|
13
|
+
|
|
14
|
+
export interface VoiceSettingsOptions {
|
|
15
|
+
env?: NodeJS.ProcessEnv;
|
|
16
|
+
gentlePiConfigHome?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const homeFor = (options: VoiceSettingsOptions) => options.gentlePiConfigHome ?? gentlePiConfigHome(options.env);
|
|
20
|
+
|
|
21
|
+
export function parseVoiceSettingsFile(raw: string): boolean | undefined {
|
|
22
|
+
try {
|
|
23
|
+
const value: unknown = JSON.parse(raw);
|
|
24
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
25
|
+
if (Object.keys(value).length !== 2 || !("schema" in value) || value.schema !== VOICE_SETTINGS_SCHEMA || !("pushToTalk" in value)) return undefined;
|
|
26
|
+
return typeof value.pushToTalk === "boolean" ? value.pushToTalk : undefined;
|
|
27
|
+
} catch { return undefined; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function readVoicePushToTalk(options: VoiceSettingsOptions = {}): boolean {
|
|
31
|
+
try {
|
|
32
|
+
return parseVoiceSettingsFile(readFileSync(join(homeFor(options), VOICE_SETTINGS_FILE), "utf8")) ?? false;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function writeVoicePushToTalk(enabled: boolean, options: VoiceSettingsOptions = {}): string {
|
|
39
|
+
const home = homeFor(options);
|
|
40
|
+
const path = join(home, VOICE_SETTINGS_FILE);
|
|
41
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
42
|
+
mkdirSync(home, { recursive: true });
|
|
43
|
+
try {
|
|
44
|
+
writeFileSync(temporary, `${JSON.stringify({ schema: VOICE_SETTINGS_SCHEMA, pushToTalk: enabled })}\n`, { flag: "wx", mode: 0o600 });
|
|
45
|
+
renameSync(temporary, path);
|
|
46
|
+
} finally {
|
|
47
|
+
try { unlinkSync(temporary); } catch { /* Rename consumed the temporary file. */ }
|
|
48
|
+
}
|
|
49
|
+
return path;
|
|
50
|
+
}
|
package/lib/voice-ui.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { isKeyRelease, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { VoiceInstallProgress } from "./voice-install.ts";
|
|
3
|
+
import type { VoiceSessionState } from "./voice-session.ts";
|
|
4
|
+
|
|
5
|
+
// The two compact /gentle:voice overlays: download progress and one dictation.
|
|
6
|
+
// They only render state and report keys; the command owns the install and the
|
|
7
|
+
// session. After dispose() a view ignores every update and key and never asks
|
|
8
|
+
// for a repaint, so a late state change from a closing session is harmless.
|
|
9
|
+
|
|
10
|
+
export interface VoiceViewTheme {
|
|
11
|
+
fg(role: string, text: string): string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface VoiceViewOptions {
|
|
15
|
+
theme: VoiceViewTheme;
|
|
16
|
+
requestRender(): void;
|
|
17
|
+
onCancel(): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const MB = 1_000_000;
|
|
21
|
+
const BAR = 24;
|
|
22
|
+
|
|
23
|
+
const megabytes = (bytes: number) => `${Math.round(bytes / MB)} MB`;
|
|
24
|
+
|
|
25
|
+
abstract class VoiceView {
|
|
26
|
+
protected disposed = false;
|
|
27
|
+
protected cancelling = false;
|
|
28
|
+
private readonly options: VoiceViewOptions;
|
|
29
|
+
|
|
30
|
+
constructor(options: VoiceViewOptions) {
|
|
31
|
+
this.options = options;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
protected get theme(): VoiceViewTheme {
|
|
35
|
+
return this.options.theme;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
protected abstract body(): string[];
|
|
39
|
+
|
|
40
|
+
render(width: number): string[] {
|
|
41
|
+
const rule = this.theme.fg("border", "─".repeat(Math.max(0, width)));
|
|
42
|
+
const title = this.theme.fg("accent", "Voice dictation");
|
|
43
|
+
return [rule, title, ...this.body(), rule].map((line) => truncateToWidth(line, Math.max(0, width)));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
invalidate(): void {}
|
|
47
|
+
|
|
48
|
+
dispose(): void {
|
|
49
|
+
this.disposed = true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
handleInput(data: string): void {
|
|
53
|
+
if (this.disposed || isKeyRelease(data)) return;
|
|
54
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
55
|
+
if (this.cancelling) return;
|
|
56
|
+
this.cancelling = true;
|
|
57
|
+
this.repaint();
|
|
58
|
+
this.options.onCancel();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
this.onKey(data);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
protected onKey(_data: string): void {}
|
|
65
|
+
|
|
66
|
+
protected repaint(): void {
|
|
67
|
+
if (this.disposed) return;
|
|
68
|
+
try {
|
|
69
|
+
this.options.requestRender();
|
|
70
|
+
} catch {
|
|
71
|
+
// A dead terminal must not break the voice workflow.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class VoiceProgressView extends VoiceView {
|
|
77
|
+
private progress: VoiceInstallProgress | undefined;
|
|
78
|
+
|
|
79
|
+
update(progress: VoiceInstallProgress): void {
|
|
80
|
+
if (this.disposed) return;
|
|
81
|
+
this.progress = progress;
|
|
82
|
+
this.repaint();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
protected body(): string[] {
|
|
86
|
+
const p = this.progress;
|
|
87
|
+
const total = p?.overallTotalBytes ?? 0;
|
|
88
|
+
const received = Math.min(p?.overallReceivedBytes ?? 0, total);
|
|
89
|
+
const ratio = total > 0 ? received / total : 0;
|
|
90
|
+
const filled = Math.round(ratio * BAR);
|
|
91
|
+
const component = p?.component === "model" ? "Parakeet model" : "Voice runtime";
|
|
92
|
+
const phase = p?.phase === "extract" ? "Extracting" : p?.phase === "publish" ? "Finishing" : "Downloading";
|
|
93
|
+
return [
|
|
94
|
+
`${phase} ${component}${p?.item ? ` · ${p.item}` : ""}`,
|
|
95
|
+
`${this.theme.fg("accent", "█".repeat(filled))}${this.theme.fg("dim", "░".repeat(BAR - filled))} ${Math.floor(ratio * 100)}%`,
|
|
96
|
+
total > 0 ? this.theme.fg("muted", `${megabytes(received)} of ${megabytes(total)}`) : "",
|
|
97
|
+
this.theme.fg("dim", this.cancelling ? "Cancelling…" : "esc cancel"),
|
|
98
|
+
];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface VoiceDictationViewOptions extends VoiceViewOptions {
|
|
103
|
+
onStop(): void;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const STATE_TEXT: Record<VoiceSessionState, string> = {
|
|
107
|
+
idle: "Preparing the microphone and model…",
|
|
108
|
+
starting: "Preparing the microphone and model…",
|
|
109
|
+
recording: "● Recording · stops by itself after 2 minutes",
|
|
110
|
+
transcribing: "Transcribing locally…",
|
|
111
|
+
finished: "Finishing…",
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const PUSH_TO_TALK_STATUS: Record<VoiceSessionState, string> = {
|
|
115
|
+
idle: "Voice: preparing… keep holding Space",
|
|
116
|
+
starting: "Voice: preparing… keep holding Space",
|
|
117
|
+
recording: "● Voice: recording · release Space to insert · esc cancel",
|
|
118
|
+
transcribing: "Voice: transcribing…",
|
|
119
|
+
finished: "Voice: finishing…",
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// The one-line footer status of a push-to-talk dictation. Push-to-talk uses no
|
|
123
|
+
// overlay, so the editor keeps the focus and the transcript lands at its cursor.
|
|
124
|
+
export function pushToTalkStatus(state: VoiceSessionState): string {
|
|
125
|
+
return PUSH_TO_TALK_STATUS[state];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export class VoiceDictationView extends VoiceView {
|
|
129
|
+
private state: VoiceSessionState = "starting";
|
|
130
|
+
private stopping = false;
|
|
131
|
+
private readonly onStop: () => void;
|
|
132
|
+
|
|
133
|
+
constructor(options: VoiceDictationViewOptions) {
|
|
134
|
+
super(options);
|
|
135
|
+
this.onStop = options.onStop;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
setState(state: VoiceSessionState): void {
|
|
139
|
+
if (this.disposed || this.state === state) return;
|
|
140
|
+
this.state = state;
|
|
141
|
+
this.repaint();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
protected onKey(data: string): void {
|
|
145
|
+
if (this.state !== "recording" || this.stopping || this.cancelling) return;
|
|
146
|
+
if (!matchesKey(data, "enter") && !matchesKey(data, "return")) return;
|
|
147
|
+
this.stopping = true;
|
|
148
|
+
this.repaint();
|
|
149
|
+
this.onStop();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
protected body(): string[] {
|
|
153
|
+
const status = this.cancelling ? "Cancelling…" : this.stopping && this.state === "recording" ? "Stopping…" : STATE_TEXT[this.state];
|
|
154
|
+
const color = this.state === "recording" && !this.cancelling ? "error" : "muted";
|
|
155
|
+
const hint = this.cancelling ? "" : this.state === "recording" && !this.stopping ? "enter stop and insert · esc cancel" : "esc cancel";
|
|
156
|
+
return [this.theme.fg(color, status), this.theme.fg("dim", hint)];
|
|
157
|
+
}
|
|
158
|
+
}
|