pi-voicekit 0.1.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.
Files changed (34) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +341 -0
  3. package/extensions/voice/config.ts +395 -0
  4. package/extensions/voice/deepgram.ts +33 -0
  5. package/extensions/voice/device.ts +382 -0
  6. package/extensions/voice/hold-to-talk.ts +69 -0
  7. package/extensions/voice/local.ts +1143 -0
  8. package/extensions/voice/model-download.ts +636 -0
  9. package/extensions/voice/onboarding.ts +739 -0
  10. package/extensions/voice/release-controller.ts +55 -0
  11. package/extensions/voice/settings-panel.ts +1602 -0
  12. package/extensions/voice/sherpa-engine.ts +464 -0
  13. package/extensions/voice/sherpa-loader.ts +143 -0
  14. package/extensions/voice/sherpa-onnx-node.d.ts +4 -0
  15. package/extensions/voice/speak.ts +430 -0
  16. package/extensions/voice/tts-deepgram.ts +454 -0
  17. package/extensions/voice/tts-engine.ts +653 -0
  18. package/extensions/voice/tts-install-progress.ts +257 -0
  19. package/extensions/voice/tts-local-models.ts +1255 -0
  20. package/extensions/voice/tts-onboarding-overlay.ts +186 -0
  21. package/extensions/voice/tts-onboarding.ts +87 -0
  22. package/extensions/voice/tts-playback-indicator.ts +127 -0
  23. package/extensions/voice/tts-playback.ts +675 -0
  24. package/extensions/voice/tts-text-filter.ts +404 -0
  25. package/extensions/voice/ui-aura.ts +272 -0
  26. package/extensions/voice/ui-help-overlay.ts +161 -0
  27. package/extensions/voice/ui-icons.ts +124 -0
  28. package/extensions/voice/ui-locale-labels.ts +110 -0
  29. package/extensions/voice/ui-picker.ts +209 -0
  30. package/extensions/voice/ui-render-ticker.ts +171 -0
  31. package/extensions/voice/ui-widget-base.ts +219 -0
  32. package/extensions/voice/ui-width.ts +112 -0
  33. package/extensions/voice.ts +3644 -0
  34. package/package.json +75 -0
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Sticky download progress widget — §5 of v7.1 plan.
3
+ *
4
+ * Visual: one-line widget under the editor showing model name, a
5
+ * `█`-filled / `░`-empty progress bar, percentage, transfer speed, and
6
+ * ETA. After download completes, the bar morphs into a spinner-led
7
+ * status line for the extract / verify phases. On `phase: "done"` (or
8
+ * abort) the widget disposes itself.
9
+ *
10
+ * Lifecycle (§1 contract, v5):
11
+ * - Per-instance widget key via `installWidgetKey(modelId)` so two
12
+ * concurrent installs (different model ids) coexist.
13
+ * - Subscribes to the shared `RenderTicker` as a `TickerSubscriber`
14
+ * `{ tick, dispose, label }` so an auto-eviction (3 throws in a
15
+ * row) cleanly tears down the slot.
16
+ * - `dispose()` runs the BaseDisposableWidget order: idempotency
17
+ * guard → set `disposed` → unsubscribe ticker → `onDispose()` →
18
+ * clear slot → owner-checked `registry.unregister()`.
19
+ * - `onProgress()` is guarded by `disposed` so a fetched-tar chunk
20
+ * landing 200 ms after dispose is a no-op.
21
+ *
22
+ * Cancellation (§4 contract):
23
+ * - Owns an `AbortController`; `cancel()` aborts it and disposes.
24
+ * - `cancel()` is what voice.ts wires to the [esc] handler when the
25
+ * install widget is at the top of the escape priority order.
26
+ */
27
+
28
+ import { BaseDisposableWidget, type WidgetRegistry, installWidgetKey } from "./ui-widget-base";
29
+ import type { RenderTicker } from "./ui-render-ticker";
30
+ import type { TtsInstallProgress } from "./tts-local-models";
31
+ import { ICON, spinnerFrame } from "./ui-icons";
32
+ import { truncateToVisualWidth, visualWidth } from "./ui-width";
33
+
34
+ // Pi extension UI surface — typed as the minimal subset we use, kept
35
+ // loose intentionally so the widget can be instantiated from
36
+ // production code (real ExtensionContext) and tests (a stub).
37
+ export interface InstallWidgetUI {
38
+ setWidget(
39
+ key: string,
40
+ content: ((tui: any, theme: any) => { invalidate(): void; render(width: number): string[] }) | undefined,
41
+ options?: { placement?: "aboveEditor" | "belowEditor" }
42
+ ): void;
43
+ }
44
+
45
+ export interface TtsInstallProgressWidgetOpts {
46
+ readonly ui: InstallWidgetUI;
47
+ readonly modelId: string;
48
+ readonly modelName: string;
49
+ readonly totalBytesEstimate: number;
50
+ readonly registry: WidgetRegistry;
51
+ readonly ticker: RenderTicker;
52
+ readonly controller: AbortController;
53
+ }
54
+
55
+ const SAMPLE_WINDOW = 30; // ~3 s of byte samples at 10 Hz tick
56
+
57
+ export class TtsInstallProgressWidget extends BaseDisposableWidget {
58
+ readonly key: string;
59
+ private readonly ui: InstallWidgetUI;
60
+ private readonly modelId: string;
61
+ private readonly modelName: string;
62
+ private readonly controller: AbortController;
63
+ private phase: TtsInstallProgress["phase"] = "download";
64
+ private bytes = 0;
65
+ private totalBytes: number;
66
+ private spinnerTick = 0;
67
+ private readonly samples: { t: number; bytes: number }[] = [];
68
+
69
+ constructor(opts: TtsInstallProgressWidgetOpts) {
70
+ super(opts.registry, () => opts.ui.setWidget(installWidgetKey(opts.modelId), undefined));
71
+ this.key = installWidgetKey(opts.modelId);
72
+ this.ui = opts.ui;
73
+ this.modelId = opts.modelId;
74
+ this.modelName = opts.modelName;
75
+ this.totalBytes = opts.totalBytesEstimate;
76
+ this.controller = opts.controller;
77
+
78
+ // Explicit ownership: ticker calls dispose() on auto-eviction.
79
+ this.unsubTicker = opts.ticker.subscribe({
80
+ tick: () => this.onTick(),
81
+ dispose: () => this.dispose(),
82
+ label: `install:${opts.modelId}`,
83
+ });
84
+ opts.registry.register(this);
85
+ // Immediate first frame so the widget appears before the first tick.
86
+ this.renderFrame();
87
+ }
88
+
89
+ /** Receives `ensureTtsModelInstalled`'s onProgress events. */
90
+ onProgress(info: TtsInstallProgress): void {
91
+ if (this.disposed) return;
92
+ this.phase = info.phase;
93
+ if (typeof info.bytes === "number") this.bytes = info.bytes;
94
+ if (typeof info.totalBytes === "number") this.totalBytes = info.totalBytes;
95
+ if (info.phase === "done") this.dispose();
96
+ }
97
+
98
+ /** Abort the install (calls controller.abort) and dispose the widget. */
99
+ cancel(): void {
100
+ if (this.disposed) return;
101
+ try {
102
+ this.controller.abort();
103
+ } catch {
104
+ /* abort never throws but be defensive */
105
+ }
106
+ this.dispose();
107
+ }
108
+
109
+ protected override onDispose(): void {
110
+ this.samples.length = 0;
111
+ }
112
+
113
+ private onTick(): void {
114
+ if (this.disposed) return;
115
+ this.spinnerTick++;
116
+ // Sample byte counter for speed/ETA smoothing.
117
+ const now = Date.now();
118
+ this.samples.push({ t: now, bytes: this.bytes });
119
+ if (this.samples.length > SAMPLE_WINDOW) this.samples.shift();
120
+ this.renderFrame();
121
+ }
122
+
123
+ private speedBytesPerSec(): number | null {
124
+ if (this.samples.length < 2) return null;
125
+ const first = this.samples[0]!;
126
+ const last = this.samples[this.samples.length - 1]!;
127
+ const dt = (last.t - first.t) / 1000;
128
+ if (dt <= 0) return null;
129
+ const dbytes = last.bytes - first.bytes;
130
+ if (dbytes <= 0) return null;
131
+ return dbytes / dt;
132
+ }
133
+
134
+ private etaSeconds(): number | null {
135
+ const speed = this.speedBytesPerSec();
136
+ if (speed == null || this.totalBytes <= 0) return null;
137
+ const remaining = Math.max(0, this.totalBytes - this.bytes);
138
+ if (remaining === 0) return 0;
139
+ return Math.round(remaining / speed);
140
+ }
141
+
142
+ private formatBytes(n: number): string {
143
+ if (n < 1024) return `${n} B`;
144
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
145
+ if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
146
+ return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
147
+ }
148
+
149
+ private formatEta(seconds: number): string {
150
+ if (seconds < 60) return `${seconds}s`;
151
+ const m = Math.floor(seconds / 60);
152
+ const s = seconds % 60;
153
+ return `${m}m${String(s).padStart(2, "0")}s`;
154
+ }
155
+
156
+ private renderFrame(): void {
157
+ // Captured snapshot — render closure reads these, not `this.*`,
158
+ // so a dispose-mid-render leaves the closure with last values.
159
+ const phase = this.phase;
160
+ const bytes = this.bytes;
161
+ const totalBytes = this.totalBytes;
162
+ const spinner = spinnerFrame(this.spinnerTick);
163
+ const speed = this.speedBytesPerSec();
164
+ const eta = this.etaSeconds();
165
+ const modelName = this.modelName;
166
+
167
+ this.ui.setWidget(
168
+ this.key,
169
+ (_tui: any, theme: any) => ({
170
+ invalidate() {},
171
+ render: (width: number): string[] => {
172
+ return renderInstallLine({
173
+ theme,
174
+ width,
175
+ phase,
176
+ bytes,
177
+ totalBytes,
178
+ spinner,
179
+ speed,
180
+ eta,
181
+ modelName,
182
+ formatBytes: (n: number) => this.formatBytes(n),
183
+ formatEta: (s: number) => this.formatEta(s),
184
+ });
185
+ },
186
+ }),
187
+ { placement: "belowEditor" }
188
+ );
189
+ }
190
+ }
191
+
192
+ interface RenderInput {
193
+ theme: any;
194
+ width: number;
195
+ phase: TtsInstallProgress["phase"];
196
+ bytes: number;
197
+ totalBytes: number;
198
+ spinner: string;
199
+ speed: number | null;
200
+ eta: number | null;
201
+ modelName: string;
202
+ formatBytes: (n: number) => string;
203
+ formatEta: (s: number) => string;
204
+ }
205
+
206
+ /** Pure render — exported for tests and small-screen tier dispatch. */
207
+ export function renderInstallLine(input: RenderInput): string[] {
208
+ const { theme, width, phase, bytes, totalBytes, spinner, speed, eta, modelName, formatBytes, formatEta } = input;
209
+ const fg = (role: string, s: string): string => (theme?.fg ? theme.fg(role, s) : s);
210
+ const dim = (s: string) => fg("dim", s);
211
+ const accent = (s: string) => fg("accent", s);
212
+
213
+ if (phase === "extract" || phase === "verify") {
214
+ const status = phase === "extract" ? "Extracting" : "Verifying";
215
+ const label = ` ${accent(spinner)} ${accent(modelName)} ${dim("·")} ${status}…`;
216
+ return [label];
217
+ }
218
+
219
+ // download phase — bar + percentage + speed + ETA
220
+ const percent = totalBytes > 0 ? Math.min(100, Math.floor((bytes * 100) / totalBytes)) : 0;
221
+ const showEta = width >= 80;
222
+ const showSpeed = width >= 70;
223
+
224
+ // Reserve room for: " <name> <pct>% <bytes/total> <speed> <eta>"
225
+ const nameMax = Math.min(visualWidth(modelName), Math.floor(width * 0.28));
226
+ const truncatedName = truncateToVisualWidth(modelName, nameMax);
227
+ const sizeStr = totalBytes > 0 ? `${formatBytes(bytes)} / ${formatBytes(totalBytes)}` : formatBytes(bytes);
228
+ const speedStr = speed != null ? `${formatBytes(speed)}/s` : "";
229
+ const etaStr = eta != null ? `ETA ${formatEta(eta)}` : "";
230
+
231
+ const parts: string[] = [];
232
+ parts.push(` ${accent(truncatedName)} `);
233
+ parts.push(`${dim(`${percent}%`.padStart(4))} `);
234
+ parts.push(`${dim(sizeStr)}`);
235
+ if (showSpeed && speedStr) parts.push(` ${dim(ICON.middot)} ${dim(speedStr)}`);
236
+ if (showEta && etaStr) parts.push(` ${dim(ICON.middot)} ${dim(etaStr)}`);
237
+
238
+ const prefix = parts.join("");
239
+ const used = visualWidth(prefix);
240
+ const barCols = Math.max(0, width - used - 1);
241
+
242
+ if (barCols < 4) {
243
+ // Not enough room for a meaningful bar — fall back to a percent-only line.
244
+ return [prefix + ` ${dim(`(${percent}%)`)}`];
245
+ }
246
+ // v7.2 — subpixel-smooth progress bar (Charm/lipgloss convention).
247
+ // Each cell can be 0/8, 1/8, 2/8 ... 8/8 filled via the U+2581-2588
248
+ // block elements. Drops the chunky █░ "8-bit pixel" look in favor
249
+ // of a gradient fill that updates smoothly at 10Hz.
250
+ const totalEighths = Math.round((bytes / Math.max(1, totalBytes)) * barCols * 8);
251
+ const fullCells = Math.floor(totalEighths / 8);
252
+ const partialEighths = totalEighths % 8; // 0..7
253
+ const partialCell = partialEighths === 0 ? "" : ICON.barPartial[partialEighths - 1]!;
254
+ const emptyCells = Math.max(0, barCols - fullCells - (partialCell ? 1 : 0));
255
+ const bar = ICON.barFilled.repeat(fullCells) + partialCell + ICON.barEmpty.repeat(emptyCells);
256
+ return [prefix + ` ${accent(bar)}`];
257
+ }