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,404 @@
1
+ /**
2
+ * Text preprocessing for TTS — strips formats that read aloud poorly,
3
+ * enforces length limits, and normalizes whitespace.
4
+ *
5
+ * Used by:
6
+ * - Auto-speak path (`speak.ts` → after_assistant_message): the agent's
7
+ * full response goes through `prepareForSpeech()` before synthesis.
8
+ * Critical because raw assistant output contains code fences,
9
+ * markdown links, ANSI escapes from prior tool output, and other
10
+ * forms that read as gibberish.
11
+ * - Manual `/voice-speak <text>` path: light normalization only —
12
+ * trim + collapse whitespace. Users typing explicit text don't want
13
+ * us second-guessing their input.
14
+ *
15
+ * Pure functions, no I/O, no global state. Easy to test against the
16
+ * regression cases locked in tests/tts-text-filter.test.ts.
17
+ *
18
+ * Design choices:
19
+ * - Code blocks are dropped entirely, not paraphrased. "function foo
20
+ * opens brace const x equals one closes brace" is worse than silence.
21
+ * Surface "[code block omitted]" once per response so users know
22
+ * content was skipped.
23
+ * - Markdown link syntax `[text](url)` collapses to `text` — URLs read
24
+ * as gibberish ("h-t-t-p-s-colon-slash-slash-...") and the link text
25
+ * is what the speaker meant.
26
+ * - ANSI escapes (color codes, cursor moves) are stripped — they leak
27
+ * in from quoted tool output and synthesize as noise.
28
+ * - Inline code spans (single backticks) are kept inline — "use the
29
+ * `useState` hook" reads naturally. Triple-backtick fences are the
30
+ * hard skip.
31
+ * - Length cap is enforced AFTER stripping, so a 5000-char response
32
+ * that's mostly code blocks may pass.
33
+ */
34
+
35
+ // ─── Public API ───────────────────────────────────────────────────────────────
36
+
37
+ export interface PrepareForSpeechOpts {
38
+ /**
39
+ * Maximum characters in the output. If the cleaned text exceeds this,
40
+ * `prepareForSpeech` returns `{ skipped: true, reason: "too long" }`.
41
+ * Auto-speak callers default to 2000; manual /voice-speak passes
42
+ * Infinity.
43
+ */
44
+ maxChars?: number;
45
+ /**
46
+ * If true, drop fenced code blocks entirely. If false, keep them but
47
+ * unwrap the fences (rare — code reads poorly aloud).
48
+ */
49
+ stripCodeBlocks?: boolean;
50
+ /**
51
+ * If true, replace markdown link syntax with link text only. If false,
52
+ * keep the URL appended (only useful for debugging — no real users
53
+ * want to hear "https colon slash slash ..." aloud).
54
+ */
55
+ collapseLinks?: boolean;
56
+ }
57
+
58
+ export interface PrepareForSpeechResult {
59
+ /** True if the text was rejected (length cap, empty after stripping, etc). */
60
+ skipped: boolean;
61
+ /** Cleaned text ready for synthesis. Empty string when skipped. */
62
+ text: string;
63
+ /** Human-readable reason when skipped. */
64
+ reason?: string;
65
+ /** Diagnostic counts so callers can show "[N code blocks omitted]" hints. */
66
+ stats: {
67
+ codeBlocksRemoved: number;
68
+ linksCollapsed: number;
69
+ ansiEscapesRemoved: number;
70
+ originalChars: number;
71
+ finalChars: number;
72
+ };
73
+ }
74
+
75
+ const DEFAULT_OPTS: Required<PrepareForSpeechOpts> = {
76
+ maxChars: 2000,
77
+ stripCodeBlocks: true,
78
+ collapseLinks: true,
79
+ };
80
+
81
+ /**
82
+ * Prepare assistant text for TTS synthesis. See module-level doc for the
83
+ * design rationale on each transform.
84
+ */
85
+ export function prepareForSpeech(input: string, opts: PrepareForSpeechOpts = {}): PrepareForSpeechResult {
86
+ const config = { ...DEFAULT_OPTS, ...opts };
87
+ const stats = {
88
+ codeBlocksRemoved: 0,
89
+ linksCollapsed: 0,
90
+ ansiEscapesRemoved: 0,
91
+ originalChars: typeof input === "string" ? input.length : 0,
92
+ finalChars: 0,
93
+ };
94
+
95
+ if (typeof input !== "string" || !input) {
96
+ return { skipped: true, text: "", reason: "empty input", stats };
97
+ }
98
+
99
+ let text = input;
100
+
101
+ // 1. Strip ANSI escape sequences. CSI patterns from tool output:
102
+ // - `\x1b[<digits>;<digits>m` (color/style)
103
+ // - `\x1b[<digits>;<digits>H` (cursor moves)
104
+ // - `\x1b]...\x07` (OSC sequences for window titles, hyperlinks)
105
+ const ansiPattern = /\x1b\[[\d;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
106
+ const ansiMatches = text.match(ansiPattern);
107
+ stats.ansiEscapesRemoved = ansiMatches?.length ?? 0;
108
+ text = text.replace(ansiPattern, "");
109
+
110
+ // 2. Drop fenced code blocks. Match ``` or ~~~ fences with an optional
111
+ // language tag. The middle content can include any characters
112
+ // including newlines and other backticks. Greedy on opening, lazy
113
+ // on closing.
114
+ if (config.stripCodeBlocks) {
115
+ const codeBlockPattern = /```[\w-]*\r?\n[\s\S]*?\r?\n```|~~~[\w-]*\r?\n[\s\S]*?\r?\n~~~/g;
116
+ const codeMatches = text.match(codeBlockPattern);
117
+ stats.codeBlocksRemoved = codeMatches?.length ?? 0;
118
+ text = text.replace(codeBlockPattern, " [code block omitted] ");
119
+ }
120
+
121
+ // 3. Collapse markdown link syntax `[text](url)` → `text`. We DO NOT
122
+ // resolve image syntax `![alt](url)` to alt text — image alt
123
+ // contents are usually decorative and rarely meaningful aloud.
124
+ // Drop image syntax entirely.
125
+ if (config.collapseLinks) {
126
+ // Image alt: drop entire `![alt](url)` form — alt text is usually
127
+ // decorative ("a screenshot showing...") and rarely worth speaking.
128
+ text = text.replace(/!\[[^\]]*\]\([^)]+\)/g, "");
129
+ // Regular links: keep the visible text only.
130
+ text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, (_full, linkText: string) => {
131
+ stats.linksCollapsed++;
132
+ return linkText;
133
+ });
134
+ }
135
+
136
+ // 4. Strip HTML tags that occasionally leak in from doc comments.
137
+ // Defensive — most assistant output is plain markdown.
138
+ text = text.replace(/<\/?[a-zA-Z][^>]*>/g, " ");
139
+
140
+ // 5. Strip raw URLs that aren't inside markdown link syntax. These
141
+ // read as gibberish aloud. Stop at whitespace; closing-paren is
142
+ // included as a stop char so URLs inside parenthetical asides
143
+ // like "(see https://x.dev/p)" don't swallow the closing `)`.
144
+ text = text.replace(/https?:\/\/[^\s)]+/g, " [link omitted] ");
145
+
146
+ // 6. Normalize markdown emphasis markers. "**bold** text *italic* text"
147
+ // should read as "bold text italic text" — TTS doesn't emphasize
148
+ // on punctuation. Preserve the inner text only.
149
+ text = text.replace(/\*\*([^*]+)\*\*/g, "$1");
150
+ text = text.replace(/__([^_]+)__/g, "$1");
151
+ text = text.replace(/\*([^*\n]+)\*/g, "$1");
152
+ text = text.replace(/_([^_\n]+)_/g, "$1");
153
+
154
+ // 7. Normalize headings — drop the `#` markers but keep the heading
155
+ // text as a sentence. "# Hello\n" → "Hello. ".
156
+ text = text.replace(/^#{1,6}\s+(.+?)$/gm, "$1.");
157
+
158
+ // 8. Strip blockquote markers ("> quoted text" → "quoted text").
159
+ text = text.replace(/^>\s+/gm, "");
160
+
161
+ // 9. Strip horizontal rules.
162
+ text = text.replace(/^[-*_]{3,}$/gm, "");
163
+
164
+ // 10. Strip leading bullet markers from list items so "- foo" reads
165
+ // as "foo". Each item retains its trailing newline so the
166
+ // sentence segmenter (Intl.Segmenter in speak.ts) treats them
167
+ // as separate sentences with natural pause boundaries — a more
168
+ // natural speech cadence than collapsing to a comma list, which
169
+ // would be one long run-on with no breath points.
170
+ text = text.replace(/^[ \t]*[-*+][ \t]+/gm, "");
171
+
172
+ // 11. Inline code spans: keep the inner text but drop backticks.
173
+ // "use `useState`" → "use useState".
174
+ text = text.replace(/`([^`\n]+)`/g, "$1");
175
+
176
+ // 11a. v7.1.3 — text normalization (TN). Compact local TTS engines
177
+ // (Kitten/Piper) read raw "Dr." as letters and bare numbers
178
+ // digit-by-digit. Expand the highest-value patterns:
179
+ // - common English abbreviations / titles
180
+ // - bare cardinal numbers up to a few digits
181
+ // Locale-aware long-form (Microsoft Recognizers-Text style)
182
+ // is out of scope; this is the deterministic ~30-pattern pass
183
+ // that catches 80% of CLI assistant output gripes.
184
+ text = expandAbbreviations(text);
185
+ text = expandSimpleNumbers(text);
186
+
187
+ // 12. v7.1.3 — strip emojis and pictographs. The TTS engines either
188
+ // read them as literal "smiling face with smiling eyes" (espeak
189
+ // fallback) or skip+space+resume in a way that breaks prosody.
190
+ // Using Unicode property `Extended_Pictographic` covers the
191
+ // full emoji set including skin-tone variants. Variation
192
+ // Selector-16 (U+FE0F) often follows pictographs to force emoji
193
+ // presentation; remove it too.
194
+ text = text.replace(/[\p{Extended_Pictographic}\u{FE0F}\u{200D}]/gu, "");
195
+
196
+ // 13. Strip leftover decorative chars that have no spoken equivalent:
197
+ // - U+2500-257F box drawing
198
+ // - U+2580-259F block elements
199
+ // - U+25A0-25FF geometric shapes
200
+ // - U+2600-26FF misc symbols (✓✗★, weather, etc.)
201
+ // - U+2700-27BF dingbats (✂✈✏…)
202
+ // - U+2190-21FF arrows (→←↑↓⇒)
203
+ // - U+2300-23FF technical (⌘⌥⏎)
204
+ text = text.replace(/[←-⇿⌀-⏿─-╿▀-▟■-◿☀-⛿✀-➿]/g, "");
205
+
206
+ // 14. Collapse runs of repeated punctuation. "!!!" / "..." / "???"
207
+ // read as comically long pauses. Reduce to a single mark.
208
+ text = text.replace(/([!?.])\1{2,}/g, "$1");
209
+ text = text.replace(/-{3,}/g, " ");
210
+
211
+ // 15. Collapse whitespace runs. Keep paragraph breaks (double newline)
212
+ // because the segmenter uses them; everything else becomes a
213
+ // single space.
214
+ text = text.replace(/[ \t]+/g, " ");
215
+ text = text.replace(/\n{3,}/g, "\n\n");
216
+ text = text.trim();
217
+
218
+ stats.finalChars = text.length;
219
+
220
+ if (!text) {
221
+ return { skipped: true, text: "", reason: "empty after stripping", stats };
222
+ }
223
+
224
+ if (text.length > config.maxChars) {
225
+ return {
226
+ skipped: true,
227
+ text: "",
228
+ reason: `text length (${text.length}) exceeds maxChars (${config.maxChars})`,
229
+ stats,
230
+ };
231
+ }
232
+
233
+ return { skipped: false, text, stats };
234
+ }
235
+
236
+ // ─── Text normalization helpers (v7.1.3) ──────────────────────────────────────
237
+
238
+ /**
239
+ * Common English abbreviations, in regex+replacement form. Word-boundary
240
+ * matched so "Dr." → "Doctor" but "Dropout" stays unchanged. Order matters:
241
+ * longer patterns must precede prefixes that would partially match them.
242
+ */
243
+ const ABBREV_RULES: ReadonlyArray<readonly [RegExp, string]> = [
244
+ [/\bDr\./g, "Doctor"],
245
+ [/\bMr\./g, "Mister"],
246
+ [/\bMrs\./g, "Misses"],
247
+ [/\bMs\./g, "Miss"],
248
+ [/\bSt\./g, "Saint"],
249
+ [/\bProf\./g, "Professor"],
250
+ [/\bSr\./g, "Senior"],
251
+ [/\bJr\./g, "Junior"],
252
+ [/\bvs\./g, "versus"],
253
+ [/\bi\.e\./gi, "that is"],
254
+ [/\be\.g\./gi, "for example"],
255
+ [/\betc\./gi, "et cetera"],
256
+ [/\bapprox\./gi, "approximately"],
257
+ [/\bvol\./gi, "volume"],
258
+ [/\bch\./gi, "chapter"],
259
+ [/\bp\.s\./gi, "P S"],
260
+ [/\bU\.S\./g, "U S"],
261
+ [/\bU\.K\./g, "U K"],
262
+ [/\bE\.U\./g, "E U"],
263
+ // CLI / dev terms commonly read poorly
264
+ [/\bAPI\b/g, "A P I"],
265
+ [/\bCLI\b/g, "C L I"],
266
+ [/\bURL\b/g, "U R L"],
267
+ [/\bHTTP\b/g, "H T T P"],
268
+ [/\bHTTPS\b/g, "H T T P S"],
269
+ [/\bSQL\b/g, "S Q L"],
270
+ [/\bJSON\b/g, "JAY-son"],
271
+ [/\bYAML\b/g, "YAH-mul"],
272
+ [/\bCSS\b/g, "C S S"],
273
+ [/\bHTML\b/g, "H T M L"],
274
+ ];
275
+
276
+ export function expandAbbreviations(text: string): string {
277
+ let out = text;
278
+ for (const [re, rep] of ABBREV_RULES) out = out.replace(re, rep);
279
+ return out;
280
+ }
281
+
282
+ const ONES = ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine"];
283
+ const TEENS = [
284
+ "ten",
285
+ "eleven",
286
+ "twelve",
287
+ "thirteen",
288
+ "fourteen",
289
+ "fifteen",
290
+ "sixteen",
291
+ "seventeen",
292
+ "eighteen",
293
+ "nineteen",
294
+ ];
295
+ const TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety"];
296
+
297
+ function numberToWords(n: number): string {
298
+ if (n < 0 || n > 9_999_999 || !Number.isInteger(n)) return String(n);
299
+ if (n === 0) return "zero";
300
+ const parts: string[] = [];
301
+ if (n >= 1_000_000) {
302
+ parts.push(numberToWords(Math.floor(n / 1_000_000)), "million");
303
+ n %= 1_000_000;
304
+ }
305
+ if (n >= 1_000) {
306
+ parts.push(numberToWords(Math.floor(n / 1_000)), "thousand");
307
+ n %= 1_000;
308
+ }
309
+ if (n >= 100) {
310
+ parts.push(ONES[Math.floor(n / 100)]!, "hundred");
311
+ n %= 100;
312
+ }
313
+ if (n >= 20) {
314
+ parts.push(TENS[Math.floor(n / 10)]!);
315
+ n %= 10;
316
+ if (n > 0) parts[parts.length - 1] += "-" + ONES[n]!;
317
+ n = 0;
318
+ }
319
+ if (n >= 10) parts.push(TEENS[n - 10]!);
320
+ else if (n > 0) parts.push(ONES[n]!);
321
+ return parts.join(" ");
322
+ }
323
+
324
+ /**
325
+ * Expand bare cardinal numbers (1..9_999_999) to words. Skips:
326
+ * - numbers attached to letters (`v2`, `5k`, `100ms`) — version/unit
327
+ * suffixes read fine as-is
328
+ * - decimals (`3.14`) — engines handle these reasonably
329
+ * - years between 1900-2099 (left as digits — engines say "twenty
330
+ * twenty-six" naturally for `2026`)
331
+ * - hex-like tokens (`0xFF`)
332
+ */
333
+ export function expandSimpleNumbers(text: string): string {
334
+ // Negative lookbehind: reject digits that are part of a version/decimal
335
+ // (`v1.2`, `3.14`) or a word (`v2`, `5k`).
336
+ // Negative lookahead: reject digits followed by a word char (`100ms`)
337
+ // or by `.<digit>` (decimal/version) but ALLOW sentence-ending `.`.
338
+ return text.replace(/(?<![\w.])(\d{1,7})(?![\w]|\.\d)/g, (match, digits: string) => {
339
+ const n = parseInt(digits, 10);
340
+ // Years pass through unchanged — TTS engines handle them well.
341
+ if (digits.length === 4 && n >= 1900 && n <= 2099) return match;
342
+ return numberToWords(n);
343
+ });
344
+ }
345
+
346
+ /**
347
+ * Lightweight version for the manual `/voice-speak <text>` path. Trims
348
+ * and collapses whitespace runs, but leaves code/links/ANSI alone — the
349
+ * user typed exactly what they want spoken.
350
+ */
351
+ export function lightNormalize(input: string): string {
352
+ if (typeof input !== "string") return "";
353
+ return input.replace(/[ \t]+/g, " ").trim();
354
+ }
355
+
356
+ // ─── BCP-47 normalization ─────────────────────────────────────────────────────
357
+
358
+ /**
359
+ * Canonicalize a BCP-47-ish language tag.
360
+ *
361
+ * Subtag handling per RFC 5646 casing convention:
362
+ * - language (2-3 letters): lowercase — `en`, `zh`
363
+ * - script (4 letters): Title-case — `Hant`, `Latn`
364
+ * - region (2 letters or 3 digits): UPPERCASE — `US`, `BR`, `419`
365
+ * - variant (5+ letters or starts with digit): kept as-is, lowercase
366
+ *
367
+ * Inputs we accept: `en`, `en-US`, `en_US`, `EN-us`, `pt-br`, `zh_CN`,
368
+ * `zh-Hant-TW`, `sl-rozaj`. Output preserves all subtags in canonical
369
+ * casing; we never drop information.
370
+ *
371
+ * This is the single canonical form used by every TTS code path —
372
+ * comparing two tags after passing both through this function is the
373
+ * only safe way to check equality.
374
+ */
375
+ export function normalizeBCP47(tag: string): string {
376
+ if (typeof tag !== "string" || !tag) return "";
377
+ const parts = tag.replace(/_/g, "-").split("-").filter(Boolean);
378
+ if (parts.length === 0) return "";
379
+ const out: string[] = [];
380
+ for (let i = 0; i < parts.length; i++) {
381
+ const sub = parts[i]!;
382
+ if (i === 0) {
383
+ // Primary language: 2-3 letter code, lowercase
384
+ out.push(sub.toLowerCase());
385
+ } else if (sub.length === 4 && /^[A-Za-z]{4}$/.test(sub)) {
386
+ // Script subtag: Title case (e.g. Hant, Latn, Cyrl)
387
+ out.push(sub.charAt(0).toUpperCase() + sub.slice(1).toLowerCase());
388
+ } else if (/^[A-Za-z]{2}$/.test(sub) || /^\d{3}$/.test(sub)) {
389
+ // Region subtag: 2-letter alpha or 3-digit UN M.49 → uppercase
390
+ out.push(sub.toUpperCase());
391
+ } else {
392
+ // Variant / extension subtag: keep lowercase
393
+ out.push(sub.toLowerCase());
394
+ }
395
+ }
396
+ return out.join("-");
397
+ }
398
+
399
+ /** Extract the base language code from a BCP-47 tag. `en-US` → `en`. */
400
+ export function baseLanguage(tag: string): string {
401
+ const norm = normalizeBCP47(tag);
402
+ const idx = norm.indexOf("-");
403
+ return idx === -1 ? norm : norm.slice(0, idx);
404
+ }
@@ -0,0 +1,272 @@
1
+ /**
2
+ * "Auroral" visual language — v7.2 world-class polish.
3
+ *
4
+ * Three primitives that elevate pi-listen's visual identity from
5
+ * "minimal CLI" to "premium application":
6
+ *
7
+ * 1. Liquid Braille waveform — sub-cell vertical bars at 4-level
8
+ * resolution per column, packed two-per-cell via braille glyphs.
9
+ * Eight effective vertical levels per CELL vs the current ▁▂▃▄▅▆▇█
10
+ * 8-step block scale, and TWO samples per cell width (so a 16-cell
11
+ * waveform resolves 32 audio samples — 2× the current density).
12
+ *
13
+ * 2. Aurora truecolor gradient — Catppuccin-inspired ramp from
14
+ * cool lavender at the edges through warm peach at the peak. RGB
15
+ * interpolated across 64 stops; per-cell color reflects local
16
+ * amplitude so loud peaks "burn" toward red while soft tails stay
17
+ * ethereal blue. Pure ANSI 24-bit escape (`\x1b[38;2;R;G;Bm`),
18
+ * no theme dependency, falls back gracefully on legacy terminals
19
+ * (ANSI escape just renders as nothing on non-truecolor TTYs).
20
+ *
21
+ * 3. Floating Island chrome — `╭─╮│╰╯` bordered 3-line "card" with
22
+ * title in bold accent, content row, and dim keybind footer.
23
+ * Establishes "voice mode" spatial authority on screen rather
24
+ * than blending in as a single inline status line.
25
+ *
26
+ * References (Gemini world-class design recommendation, derived from):
27
+ * - Charm Bracelet lipgloss/gum nested borders + truecolor:
28
+ * https://github.com/charmbracelet/lipgloss
29
+ * - Atuin command palette layout: https://github.com/atuinsh/atuin
30
+ * - Lazygit pane focus + dim non-active: https://github.com/jesseduffield/lazygit
31
+ * - K9s status density: https://github.com/derailed/k9s
32
+ */
33
+
34
+ import { ICON } from "./ui-icons";
35
+ import { visualWidth, padRightVisual } from "./ui-width";
36
+
37
+ // ─── Liquid Braille ───────────────────────────────────────────────────────────
38
+
39
+ /**
40
+ * Encode (leftLevel, rightLevel) ∈ [0..4]² as a single braille glyph.
41
+ * Each cell visually holds TWO column-bars (left + right), each
42
+ * up to 4 dots tall — so one row of N cells = 2N audio samples.
43
+ *
44
+ * Braille bit layout (Unicode U+2800 + 8-bit pattern):
45
+ * 1 4
46
+ * 2 5
47
+ * 3 6
48
+ * 7 8 (rows top-down; 7,8 are bottom row)
49
+ *
50
+ * Vertical bars rise from the bottom up. Left column dots in order
51
+ * 7→3→2→1 = 0x40→0x44→0x46→0x47. Right column 8→6→5→4 = 0x80→0xA0
52
+ * →0xB0→0xB8.
53
+ */
54
+ const BRAILLE_LEFT_BITS = [0, 0x40, 0x44, 0x46, 0x47] as const;
55
+ const BRAILLE_RIGHT_BITS = [0, 0x80, 0xa0, 0xb0, 0xb8] as const;
56
+
57
+ export function brailleBar(left: number, right: number): string {
58
+ const l = Math.max(0, Math.min(4, Math.round(left)));
59
+ const r = Math.max(0, Math.min(4, Math.round(right)));
60
+ return String.fromCodePoint(0x2800 + BRAILLE_LEFT_BITS[l]! + BRAILLE_RIGHT_BITS[r]!);
61
+ }
62
+
63
+ /**
64
+ * Render a "Liquid Braille" waveform from a sample array. Each cell
65
+ * encodes the LEFT and RIGHT sample as 0..4 bar levels, so `cells = N/2`.
66
+ *
67
+ * Pass an array of samples ∈ [0..1]. Sample 0 → leftmost cell's left
68
+ * bar; sample 1 → leftmost cell's right bar; etc.
69
+ *
70
+ * Apply `colorFn(level)` per CELL using the cell's max(left, right)
71
+ * to pick a gradient stop — produces the auroral effect where
72
+ * peaks "burn" warmer.
73
+ */
74
+ export function liquidBraille(samples: number[], colorFn?: (level: number) => string): string {
75
+ const cells = Math.ceil(samples.length / 2);
76
+ let out = "";
77
+ for (let i = 0; i < cells; i++) {
78
+ const leftSample = samples[i * 2] ?? 0;
79
+ const rightSample = samples[i * 2 + 1] ?? 0;
80
+ const left = leftSample * 4; // 0..1 → 0..4
81
+ const right = rightSample * 4;
82
+ const glyph = brailleBar(left, right);
83
+ if (colorFn) {
84
+ const peak = Math.max(leftSample, rightSample);
85
+ out += colorFn(peak) + glyph;
86
+ } else {
87
+ out += glyph;
88
+ }
89
+ }
90
+ if (colorFn) out += "\x1b[0m"; // reset at end of run
91
+ return out;
92
+ }
93
+
94
+ // ─── Aurora Truecolor Gradient ────────────────────────────────────────────────
95
+
96
+ /** Catppuccin-Mocha-inspired ramp: cool lavender → warm peach. */
97
+ const AURORA_STOPS: ReadonlyArray<readonly [number, number, number]> = [
98
+ [180, 190, 254], // lavender (low)
99
+ [137, 180, 250], // blue
100
+ [203, 166, 247], // mauve
101
+ [245, 194, 231], // pink
102
+ [250, 179, 135], // peach
103
+ [243, 139, 168], // red (peak)
104
+ ];
105
+
106
+ /**
107
+ * Return a 24-bit ANSI foreground escape for `level` ∈ [0..1].
108
+ * Linearly interpolates across the AURORA_STOPS palette. The escape
109
+ * is `\x1b[38;2;R;G;Bm` — no reset (caller must emit `\x1b[0m`
110
+ * at end of the colored run).
111
+ */
112
+ export function auroraColor(level: number): string {
113
+ const t = Math.max(0, Math.min(1, level));
114
+ const segments = AURORA_STOPS.length - 1;
115
+ const pos = t * segments;
116
+ const idx = Math.min(segments - 1, Math.floor(pos));
117
+ const f = pos - idx;
118
+ const a = AURORA_STOPS[idx]!;
119
+ const b = AURORA_STOPS[idx + 1]!;
120
+ const r = Math.round(a[0] + (b[0] - a[0]) * f);
121
+ const g = Math.round(a[1] + (b[1] - a[1]) * f);
122
+ const blue = Math.round(a[2] + (b[2] - a[2]) * f);
123
+ return `\x1b[38;2;${r};${g};${blue}m`;
124
+ }
125
+
126
+ /**
127
+ * Slow "breathing" color for static titles. Returns a truecolor ANSI
128
+ * escape that smoothly cycles through a narrow band of the aurora
129
+ * palette (mauve ↔ pink) on a ~4s cycle. Time `tickMs` should be
130
+ * `Date.now()` from the caller so all widgets breathe in phase.
131
+ *
132
+ * The cycle is intentionally narrow (3 stops, indices 2–4) so the
133
+ * title stays accent-coloured at all times; only the saturation
134
+ * subtly shifts. Combined with bold, reads as a premium "alive but
135
+ * not distracting" indicator.
136
+ */
137
+ export function titleBreathe(tickMs: number): string {
138
+ // 4-second cycle: phase ∈ [0, 1).
139
+ const phase = (((tickMs / 4000) % 1) + 1) % 1;
140
+ // sin-shaped modulation: 0 → mauve (idx 2), 1 → pink (idx 3).
141
+ const sinT = (Math.sin(phase * Math.PI * 2) + 1) / 2; // 0..1
142
+ // Map to aurora stops 2..4 (mauve → pink → peach edge).
143
+ const t = 0.4 + sinT * 0.25; // narrow band centered around mauve/pink
144
+ return auroraColor(t);
145
+ }
146
+
147
+ /**
148
+ * Live audio-activity badge — a small chip-style indicator that
149
+ * reflects the current RMS level. Replaces silent timers with a
150
+ * one-glance "is anything happening?" signal.
151
+ *
152
+ * level < 0.05 : ▁ quiet (dim)
153
+ * level < 0.20 : ▃ voice (cool aurora)
154
+ * level < 0.50 : ▅ active (mid aurora)
155
+ * level ≥ 0.50 : ▇ loud (hot aurora)
156
+ *
157
+ * Returns the chip pre-styled with truecolor ANSI escapes; no
158
+ * theme dependency. Caller wraps in any spacing they like.
159
+ */
160
+ export function activityTag(level: number, dim: (s: string) => string): string {
161
+ if (level < 0.05) return dim("▁ quiet");
162
+ if (level < 0.2) return auroraColor(0.1) + "▃ voice" + "\x1b[0m";
163
+ if (level < 0.5) return auroraColor(0.45) + "▅ active" + "\x1b[0m";
164
+ return auroraColor(0.85) + "▇ loud" + "\x1b[0m";
165
+ }
166
+
167
+ /** Hex-color string version (for callers that want to mix into rgb fonts). */
168
+ export function auroraHex(level: number): string {
169
+ const t = Math.max(0, Math.min(1, level));
170
+ const segments = AURORA_STOPS.length - 1;
171
+ const pos = t * segments;
172
+ const idx = Math.min(segments - 1, Math.floor(pos));
173
+ const f = pos - idx;
174
+ const a = AURORA_STOPS[idx]!;
175
+ const b = AURORA_STOPS[idx + 1]!;
176
+ const r = Math.round(a[0] + (b[0] - a[0]) * f);
177
+ const g = Math.round(a[1] + (b[1] - a[1]) * f);
178
+ const blue = Math.round(a[2] + (b[2] - a[2]) * f);
179
+ const hex = (n: number) => n.toString(16).padStart(2, "0");
180
+ return `#${hex(r)}${hex(g)}${hex(blue)}`;
181
+ }
182
+
183
+ // ─── Floating Island chrome ────────────────────────────────────────────────────
184
+
185
+ /** ANSI escape stripper — required before any visual-width math on
186
+ * styled strings, since `visualWidth` counts each ESC byte as 1 col. */
187
+ const ANSI_RE = /\x1b\[[\d;]*[A-Za-z]/g;
188
+ const stripAnsi = (s: string): string => s.replace(ANSI_RE, "");
189
+ /** Visual width of a pre-styled string (ANSI escapes excluded). */
190
+ function trueWidth(s: string): number {
191
+ return visualWidth(stripAnsi(s));
192
+ }
193
+
194
+ /**
195
+ * Render a 3-line "floating island" with rounded borders.
196
+ *
197
+ * ╭─ {title} ─────────────────────╮
198
+ * │ {content} │
199
+ * ╰─ {footer} ────────────────────╯
200
+ *
201
+ * The title sits inline on the top border, footer inline on the
202
+ * bottom border — the box reads as a "card" rather than a block.
203
+ * All three lines use rounded corners (Material 3 sheet aesthetic).
204
+ *
205
+ * Pass pre-styled (already ANSI-coloured) strings; this helper
206
+ * doesn't apply theming itself, just chrome.
207
+ *
208
+ * `width` is the OUTER width including the two `│` borders. All
209
+ * width math is ANSI-stripped — the right edge always lands on the
210
+ * `╮` / `╯` even when content is full of color escapes.
211
+ */
212
+ export function island(opts: {
213
+ width: number;
214
+ title: string; // pre-styled or plain
215
+ content: string; // pre-styled or plain
216
+ footer?: string; // pre-styled or plain (optional 3rd line)
217
+ dim: (s: string) => string; // theme dim wrapper
218
+ bold?: (s: string) => string; // unused (retained for API stability)
219
+ }): string[] {
220
+ const { width, title, content, footer, dim } = opts;
221
+ const innerW = Math.max(4, width - 2);
222
+
223
+ // Top border with title inlaid: `╭─ TITLE ────╮`
224
+ // Layout: corner + boxH + space + title + space + filler + corner.
225
+ // All widths stripped of ANSI before counting.
226
+ const titleW = trueWidth(title);
227
+ const topFillerCount = Math.max(0, innerW - 1 - 1 - titleW - 1); // -1 leading boxH, -1+1 spaces, -1 trailing boxH... fixed:
228
+ // Layout cells:
229
+ // [boxH][ ][title][ ][boxH × topFillerCount] = innerW cells total
230
+ // 1 1 titleW 1 topFillerCount = innerW
231
+ // → topFillerCount = innerW - titleW - 3
232
+ const topFill = Math.max(0, innerW - titleW - 3);
233
+ const top =
234
+ dim(ICON.boxRoundedTL) +
235
+ dim(ICON.boxH) +
236
+ " " +
237
+ title +
238
+ " " +
239
+ dim(ICON.boxH.repeat(topFill)) +
240
+ dim(ICON.boxRoundedTR);
241
+
242
+ // Content row — pad to fill innerW visually (with ANSI-stripped width)
243
+ const contentW = trueWidth(content);
244
+ const contentPadded = contentW < innerW ? content + " ".repeat(Math.max(0, innerW - contentW)) : content;
245
+ const middle = dim(ICON.boxV) + contentPadded + dim(ICON.boxV);
246
+
247
+ // Bottom border with optional footer inlaid (same math as top).
248
+ let bottom: string;
249
+ if (footer) {
250
+ const fW = trueWidth(footer);
251
+ const botFill = Math.max(0, innerW - fW - 3);
252
+ bottom =
253
+ dim(ICON.boxRoundedBL) +
254
+ dim(ICON.boxH) +
255
+ " " +
256
+ footer +
257
+ " " +
258
+ dim(ICON.boxH.repeat(botFill)) +
259
+ dim(ICON.boxRoundedBR);
260
+ } else {
261
+ bottom = dim(ICON.boxRoundedBL) + dim(ICON.boxH.repeat(innerW)) + dim(ICON.boxRoundedBR);
262
+ }
263
+
264
+ return [top, middle, bottom];
265
+ }
266
+
267
+ /** Pad-right helper that respects pre-styled (ANSI-escape-laden) strings. */
268
+ export function padToWidth(s: string, width: number): string {
269
+ const w = trueWidth(s);
270
+ if (w >= width) return s;
271
+ return s + " ".repeat(width - w);
272
+ }