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.
- package/LICENSE +21 -0
- package/README.md +341 -0
- package/extensions/voice/config.ts +395 -0
- package/extensions/voice/deepgram.ts +33 -0
- package/extensions/voice/device.ts +382 -0
- package/extensions/voice/hold-to-talk.ts +69 -0
- package/extensions/voice/local.ts +1143 -0
- package/extensions/voice/model-download.ts +636 -0
- package/extensions/voice/onboarding.ts +739 -0
- package/extensions/voice/release-controller.ts +55 -0
- package/extensions/voice/settings-panel.ts +1602 -0
- package/extensions/voice/sherpa-engine.ts +464 -0
- package/extensions/voice/sherpa-loader.ts +143 -0
- package/extensions/voice/sherpa-onnx-node.d.ts +4 -0
- package/extensions/voice/speak.ts +430 -0
- package/extensions/voice/tts-deepgram.ts +454 -0
- package/extensions/voice/tts-engine.ts +653 -0
- package/extensions/voice/tts-install-progress.ts +257 -0
- package/extensions/voice/tts-local-models.ts +1255 -0
- package/extensions/voice/tts-onboarding-overlay.ts +186 -0
- package/extensions/voice/tts-onboarding.ts +87 -0
- package/extensions/voice/tts-playback-indicator.ts +127 -0
- package/extensions/voice/tts-playback.ts +675 -0
- package/extensions/voice/tts-text-filter.ts +404 -0
- package/extensions/voice/ui-aura.ts +272 -0
- package/extensions/voice/ui-help-overlay.ts +161 -0
- package/extensions/voice/ui-icons.ts +124 -0
- package/extensions/voice/ui-locale-labels.ts +110 -0
- package/extensions/voice/ui-picker.ts +209 -0
- package/extensions/voice/ui-render-ticker.ts +171 -0
- package/extensions/voice/ui-widget-base.ts +219 -0
- package/extensions/voice/ui-width.ts +112 -0
- package/extensions/voice.ts +3644 -0
- package/package.json +75 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rich first-run onboarding overlay for TTS — §9 of the v7.1 plan.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the v7.0 notify-based hint with a focused overlay that
|
|
5
|
+
* shows the recommendation, the install size, and three explicit
|
|
6
|
+
* actions ([enter] try it / [m] pick another / [esc] skip). Per the
|
|
7
|
+
* §9 event-ordering contract:
|
|
8
|
+
*
|
|
9
|
+
* - All three actions mark `ttsOnboardingShown = true` BEFORE any
|
|
10
|
+
* async work so a failed install/cancel never prompts the user
|
|
11
|
+
* again.
|
|
12
|
+
* - `[enter]` returns `{ kind: "test" }` — the caller runs
|
|
13
|
+
* /voice-speak-test which auto-installs the recommended model.
|
|
14
|
+
* - `[m]` returns `{ kind: "pickModel" }` — caller opens settings
|
|
15
|
+
* panel on the Speak tab so the user can browse alternatives.
|
|
16
|
+
* - `[esc]` returns `{ kind: "skip" }` — silent dismissal.
|
|
17
|
+
*
|
|
18
|
+
* Visual design (no emoji per v7.1 hard constraint):
|
|
19
|
+
*
|
|
20
|
+
* ┌─ pi-listen TTS ─────────────────────────────────────────────┐
|
|
21
|
+
* │ │
|
|
22
|
+
* │ Voice output ready. │
|
|
23
|
+
* │ │
|
|
24
|
+
* │ ● Recommended: Kitten Nano v0.2 (25 MB, en) │
|
|
25
|
+
* │ Smallest English TTS — sub-real-time on M-series │
|
|
26
|
+
* │ │
|
|
27
|
+
* │ Status: not installed — press [↵] to download │
|
|
28
|
+
* │ │
|
|
29
|
+
* │ [↵] Try it now [m] Pick another [esc] Skip │
|
|
30
|
+
* └──────────────────────────────────────────────────────────────┘
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { matchesKey, Key } from "@earendil-works/pi-tui";
|
|
34
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
35
|
+
import { recommendDefaultModel, isTtsModelInstalled, getTtsModel } from "./tts-local-models";
|
|
36
|
+
import { ICON } from "./ui-icons";
|
|
37
|
+
import { isPanelTooNarrow, visualWidth, padRightVisual } from "./ui-width";
|
|
38
|
+
|
|
39
|
+
export type OnboardingResult = { kind: "test" } | { kind: "pickModel" } | { kind: "skip" };
|
|
40
|
+
|
|
41
|
+
export interface OnboardingOverlayDeps {
|
|
42
|
+
/** Active locale (e.g. "en", "zh") for recommendation. */
|
|
43
|
+
systemLocale: string | undefined;
|
|
44
|
+
/** Optional Pi theme for color routing. */
|
|
45
|
+
theme?: Theme;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Pi `Component`-shaped class — pass into `ctx.ui.custom()` directly.
|
|
50
|
+
* `done` is provided by the caller's factory closure to resolve the
|
|
51
|
+
* outer promise.
|
|
52
|
+
*/
|
|
53
|
+
export class TtsOnboardingOverlay {
|
|
54
|
+
private readonly deps: OnboardingOverlayDeps;
|
|
55
|
+
private readonly done: (result: OnboardingResult) => void;
|
|
56
|
+
private resolved = false;
|
|
57
|
+
|
|
58
|
+
constructor(deps: OnboardingOverlayDeps, done: (result: OnboardingResult) => void) {
|
|
59
|
+
this.deps = deps;
|
|
60
|
+
this.done = done;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
render(width: number): string[] {
|
|
64
|
+
// v7.1 §10/§13: hard-block below 60 cols. The overlay is
|
|
65
|
+
// pure-content; below that, we render a dim "resize" hint and
|
|
66
|
+
// expose the same three actions as keys without the chrome.
|
|
67
|
+
if (isPanelTooNarrow(width)) {
|
|
68
|
+
return this.renderNarrow();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const w = Math.max(60, Math.min(width - 2, 80));
|
|
72
|
+
const innerW = w - 4;
|
|
73
|
+
const t = this.deps.theme;
|
|
74
|
+
const dim = (s: string) => (t ? t.fg("dim", s) : s);
|
|
75
|
+
const accent = (s: string) => (t ? t.fg("accent", s) : s);
|
|
76
|
+
const bold = (s: string) => (t ? t.fg("accent", s) : s);
|
|
77
|
+
const success = (s: string) => (t ? t.fg("success", s) : s);
|
|
78
|
+
const warning = (s: string) => (t ? t.fg("warning", s) : s);
|
|
79
|
+
|
|
80
|
+
const recommendation = recommendDefaultModel(this.deps.systemLocale ?? "en");
|
|
81
|
+
let recModel;
|
|
82
|
+
try {
|
|
83
|
+
recModel = getTtsModel(recommendation.modelId);
|
|
84
|
+
} catch {
|
|
85
|
+
recModel = undefined;
|
|
86
|
+
}
|
|
87
|
+
const installed = recModel ? isTtsModelInstalled(recModel.id) : false;
|
|
88
|
+
|
|
89
|
+
const lines: string[] = [];
|
|
90
|
+
// v7.2: rounded corners for modal feel. Title sits inline on the
|
|
91
|
+
// top edge with thin spacing so the box reads as a "sheet"
|
|
92
|
+
// rather than a hard frame (HIG modal aesthetic).
|
|
93
|
+
const top = `${ICON.boxRoundedTL}${ICON.boxH.repeat(2)} ${bold("pi-listen TTS")} ${ICON.boxH.repeat(Math.max(0, innerW - 16))}${ICON.boxRoundedTR}`;
|
|
94
|
+
const bottom = `${ICON.boxRoundedBL}${ICON.boxH.repeat(innerW)}${ICON.boxRoundedBR}`;
|
|
95
|
+
const hr = `${ICON.boxV}${" ".repeat(innerW)}${ICON.boxV}`;
|
|
96
|
+
const row = (s: string): string => {
|
|
97
|
+
// CJK-aware pad-right (Codex final-review nit): use visualWidth
|
|
98
|
+
// not String.length so wide glyphs in Chinese/Japanese/Korean
|
|
99
|
+
// recommendation copy align correctly.
|
|
100
|
+
const w = visualWidth(s);
|
|
101
|
+
if (w <= innerW) return `${ICON.boxV}${padRightVisual(s, innerW)}${ICON.boxV}`;
|
|
102
|
+
return `${ICON.boxV}${s.slice(0, innerW)}${ICON.boxV}`;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
lines.push(top);
|
|
106
|
+
lines.push(hr);
|
|
107
|
+
lines.push(row(` ${accent("Voice output ready.")}`));
|
|
108
|
+
lines.push(hr);
|
|
109
|
+
|
|
110
|
+
if (recModel) {
|
|
111
|
+
const langs = recModel.languages.length > 1 ? `${recModel.languages.length} langs` : recModel.languages[0];
|
|
112
|
+
lines.push(
|
|
113
|
+
row(
|
|
114
|
+
` ${success(ICON.bulletActive)} ${dim("Recommended:")} ${accent(recModel.name)} ${dim(`(${recModel.size}, ${langs})`)}`
|
|
115
|
+
)
|
|
116
|
+
);
|
|
117
|
+
lines.push(row(` ${dim(recModel.notes)}`));
|
|
118
|
+
} else {
|
|
119
|
+
lines.push(row(` ${success(ICON.bulletActive)} ${dim("Recommended:")} ${accent(recommendation.modelId)}`));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
lines.push(hr);
|
|
123
|
+
|
|
124
|
+
const statusLabel = installed
|
|
125
|
+
? success("ready")
|
|
126
|
+
: warning(`not installed ${ICON.middot} press ${accent("[↵]")} to download`);
|
|
127
|
+
lines.push(row(` ${dim("Status:")} ${statusLabel}`));
|
|
128
|
+
|
|
129
|
+
if (recommendation.fallback) {
|
|
130
|
+
lines.push(hr);
|
|
131
|
+
lines.push(
|
|
132
|
+
row(
|
|
133
|
+
` ${dim(`Note: ${this.deps.systemLocale ?? "your locale"} has no native voice — English fallback chosen.`)}`
|
|
134
|
+
)
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
lines.push(hr);
|
|
139
|
+
const hint = ` ${accent("[↵]")} ${dim("Try it now")} ${accent("[m]")} ${dim("Pick another")} ${accent("[esc]")} ${dim("Skip")}`;
|
|
140
|
+
lines.push(row(hint));
|
|
141
|
+
lines.push(bottom);
|
|
142
|
+
return lines;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private renderNarrow(): string[] {
|
|
146
|
+
const t = this.deps.theme;
|
|
147
|
+
const dim = (s: string) => (t ? t.fg("dim", s) : s);
|
|
148
|
+
const accent = (s: string) => (t ? t.fg("accent", s) : s);
|
|
149
|
+
return [
|
|
150
|
+
` ${accent("pi-listen TTS")}`,
|
|
151
|
+
` ${dim("Voice output ready. Resize to ≥60 cols for the full hint.")}`,
|
|
152
|
+
` ${accent("[↵]")} ${dim("test")} ${accent("[m]")} ${dim("pick")} ${accent("[esc]")} ${dim("skip")}`,
|
|
153
|
+
];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
handleInput(data: string): void {
|
|
157
|
+
if (this.resolved) return;
|
|
158
|
+
if (matchesKey(data, Key.enter)) {
|
|
159
|
+
this.resolve({ kind: "test" });
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (matchesKey(data, Key.escape)) {
|
|
163
|
+
this.resolve({ kind: "skip" });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
// Lowercase 'm' or 'M' picks model
|
|
167
|
+
if (data === "m" || data === "M") {
|
|
168
|
+
this.resolve({ kind: "pickModel" });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
invalidate(): void {
|
|
174
|
+
/* render is uncached */
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private resolve(result: OnboardingResult): void {
|
|
178
|
+
if (this.resolved) return;
|
|
179
|
+
this.resolved = true;
|
|
180
|
+
try {
|
|
181
|
+
this.done(result);
|
|
182
|
+
} catch {
|
|
183
|
+
/* never fail closure */
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-run TTS onboarding helper.
|
|
3
|
+
*
|
|
4
|
+
* v7.0.0 ships the lightweight version: when a user enables TTS for the
|
|
5
|
+
* first time (and onboarding hasn't been completed), the orchestrator
|
|
6
|
+
* shows a single notify() with the smart-default recommendation and
|
|
7
|
+
* tells the user how to either accept it (run /voice-speak-test) or
|
|
8
|
+
* customize (run /voice-speak-models).
|
|
9
|
+
*
|
|
10
|
+
* Why not a multi-step picker overlay (the v7 plan's full vision):
|
|
11
|
+
* - The settings panel already exposes every knob with proper UX
|
|
12
|
+
* - A first-run popup that hijacks the editor on every initial enable
|
|
13
|
+
* is annoying for advanced users who already configured things via
|
|
14
|
+
* settings.json
|
|
15
|
+
* - The lightweight surface is honest: "here's the recommendation,
|
|
16
|
+
* here's where to change it" — and it composes with the rest of
|
|
17
|
+
* the v7 surface (Speak tab, /voice-speak-info)
|
|
18
|
+
*
|
|
19
|
+
* If field reports show users want a richer flow, we can swap this
|
|
20
|
+
* notify-based version for a `ctx.ui.custom()` overlay in v7.1
|
|
21
|
+
* without changing any other code path.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
25
|
+
import type { VoiceConfig, VoiceSettingsScope } from "./config";
|
|
26
|
+
import type { DeviceProfile } from "./device";
|
|
27
|
+
import { recommendDefaultModel, isTtsModelInstalled, getTtsModel } from "./tts-local-models";
|
|
28
|
+
|
|
29
|
+
type NotifyContext = ExtensionContext | ExtensionCommandContext;
|
|
30
|
+
|
|
31
|
+
export interface OnboardTtsOpts {
|
|
32
|
+
ctx: NotifyContext;
|
|
33
|
+
config: VoiceConfig;
|
|
34
|
+
device: DeviceProfile;
|
|
35
|
+
cwd: string;
|
|
36
|
+
saveConfig: (config: VoiceConfig, scope: VoiceSettingsScope, cwd: string) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Run the first-run TTS onboarding hint. Idempotent — only shows the
|
|
41
|
+
* hint once per config (`config.onboarding.completed` controls the
|
|
42
|
+
* generic onboarding flag; we co-opt a parallel `ttsOnboardingShown`
|
|
43
|
+
* marker on the config to avoid spamming the hint on every enable).
|
|
44
|
+
*
|
|
45
|
+
* Returns true if the hint was shown this call.
|
|
46
|
+
*/
|
|
47
|
+
export function maybeShowTtsOnboarding(opts: OnboardTtsOpts): boolean {
|
|
48
|
+
const { ctx, config, device, cwd, saveConfig } = opts;
|
|
49
|
+
if (!ctx.hasUI) return false;
|
|
50
|
+
if ((config as any).ttsOnboardingShown) return false;
|
|
51
|
+
|
|
52
|
+
const recommendation = recommendDefaultModel(device.systemLocale ?? "en");
|
|
53
|
+
let recModel;
|
|
54
|
+
try {
|
|
55
|
+
recModel = getTtsModel(recommendation.modelId);
|
|
56
|
+
} catch {
|
|
57
|
+
recModel = undefined;
|
|
58
|
+
}
|
|
59
|
+
const installed = isTtsModelInstalled(recommendation.modelId);
|
|
60
|
+
|
|
61
|
+
const lines = [
|
|
62
|
+
"TTS enabled — voice output for Pi.",
|
|
63
|
+
"",
|
|
64
|
+
` ${recommendation.reason}`,
|
|
65
|
+
"",
|
|
66
|
+
recModel
|
|
67
|
+
? ` Recommended: ${recModel.name} (${recModel.size}, ${recModel.languages.join("/")})`
|
|
68
|
+
: ` Recommended: ${recommendation.modelId}`,
|
|
69
|
+
` Status: ${installed ? "ready ✓" : `not installed — first speak downloads ${recModel?.size ?? "model"}`}`,
|
|
70
|
+
"",
|
|
71
|
+
" Try it: /voice-speak-test",
|
|
72
|
+
" Pick another: /voice-speak-models (or /voice-settings → Speak tab)",
|
|
73
|
+
" Diagnose: /voice-speak-info",
|
|
74
|
+
" Disable: /voice-speak-toggle",
|
|
75
|
+
];
|
|
76
|
+
if (recommendation.fallback) {
|
|
77
|
+
lines.push("");
|
|
78
|
+
lines.push(` Note: ${device.systemLocale ?? "your locale"} has no built-in voice — English fallback chosen.`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
82
|
+
|
|
83
|
+
// Mark the hint as shown (and persist) so subsequent enables are quiet.
|
|
84
|
+
(config as any).ttsOnboardingShown = true;
|
|
85
|
+
saveConfig(config, config.scope === "project" ? "project" : "global", cwd);
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Honest playback state indicator — §6 of v7.1 plan.
|
|
3
|
+
*
|
|
4
|
+
* v1 of the plan proposed a fake amplitude meter; both Codex and Gemini
|
|
5
|
+
* flagged it as misleading (a bouncing meter would imply audio is
|
|
6
|
+
* playing even when the player is muted, stuck, or failed). v7.1 ships
|
|
7
|
+
* a state spinner only:
|
|
8
|
+
*
|
|
9
|
+
* ◓ Synthesizing · 1s
|
|
10
|
+
* ◑ Playing · 4s · [esc] stop
|
|
11
|
+
* (mounted only while state ∈ {synthesizing, playing}; dismissed on idle)
|
|
12
|
+
*
|
|
13
|
+
* Rotating spinner phase comes from the shared `RenderTicker`, so it
|
|
14
|
+
* stays in phase with any concurrently-mounted install bar — no
|
|
15
|
+
* tearing.
|
|
16
|
+
*
|
|
17
|
+
* v7.2 may add a real PCM amplitude bar ALONGSIDE the state word once
|
|
18
|
+
* the streaming-playback path emits real levels; until then the
|
|
19
|
+
* label-only design keeps every signal honest.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { BaseDisposableWidget, type WidgetRegistry, WIDGET_KEY } from "./ui-widget-base";
|
|
23
|
+
import type { RenderTicker } from "./ui-render-ticker";
|
|
24
|
+
import { spinnerFrame } from "./ui-icons";
|
|
25
|
+
import type { InstallWidgetUI } from "./tts-install-progress";
|
|
26
|
+
|
|
27
|
+
export type PlaybackState = "idle" | "synthesizing" | "playing";
|
|
28
|
+
|
|
29
|
+
export interface TtsPlaybackIndicatorOpts {
|
|
30
|
+
readonly ui: InstallWidgetUI;
|
|
31
|
+
readonly registry: WidgetRegistry;
|
|
32
|
+
readonly ticker: RenderTicker;
|
|
33
|
+
readonly onStop?: () => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class TtsPlaybackIndicator extends BaseDisposableWidget {
|
|
37
|
+
readonly key = WIDGET_KEY.ttsPlayback;
|
|
38
|
+
private readonly ui: InstallWidgetUI;
|
|
39
|
+
private readonly onStop?: () => void;
|
|
40
|
+
private state: PlaybackState = "idle";
|
|
41
|
+
private startedAt: number | null = null;
|
|
42
|
+
private spinnerTick = 0;
|
|
43
|
+
|
|
44
|
+
constructor(opts: TtsPlaybackIndicatorOpts) {
|
|
45
|
+
super(opts.registry, () => opts.ui.setWidget(WIDGET_KEY.ttsPlayback, undefined));
|
|
46
|
+
this.ui = opts.ui;
|
|
47
|
+
this.onStop = opts.onStop;
|
|
48
|
+
this.unsubTicker = opts.ticker.subscribe({
|
|
49
|
+
tick: () => this.onTick(),
|
|
50
|
+
dispose: () => this.dispose(),
|
|
51
|
+
label: "tts-playback",
|
|
52
|
+
});
|
|
53
|
+
opts.registry.register(this);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Caller flips state through (synthesizing → playing → idle). */
|
|
57
|
+
setState(s: PlaybackState): void {
|
|
58
|
+
if (this.disposed) return;
|
|
59
|
+
if (s === this.state) return;
|
|
60
|
+
this.state = s;
|
|
61
|
+
if (s === "idle") {
|
|
62
|
+
// Idle dismisses the widget — render an empty slot, then
|
|
63
|
+
// dispose so the registry/ticker drop us cleanly.
|
|
64
|
+
this.dispose();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
// Re-arm timestamp on each non-idle transition so the elapsed
|
|
68
|
+
// counter resets between synthesize and play.
|
|
69
|
+
this.startedAt = Date.now();
|
|
70
|
+
this.renderFrame();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Caller invokes this from [esc] when the indicator owns escape. */
|
|
74
|
+
stop(): void {
|
|
75
|
+
try {
|
|
76
|
+
this.onStop?.();
|
|
77
|
+
} catch {
|
|
78
|
+
/* never fail caller */
|
|
79
|
+
}
|
|
80
|
+
this.dispose();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private onTick(): void {
|
|
84
|
+
if (this.disposed) return;
|
|
85
|
+
this.spinnerTick++;
|
|
86
|
+
this.renderFrame();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private renderFrame(): void {
|
|
90
|
+
const state = this.state;
|
|
91
|
+
const startedAt = this.startedAt ?? Date.now();
|
|
92
|
+
const tick = this.spinnerTick;
|
|
93
|
+
this.ui.setWidget(
|
|
94
|
+
this.key,
|
|
95
|
+
(_tui: any, theme: any) => ({
|
|
96
|
+
invalidate() {},
|
|
97
|
+
render: (width: number): string[] => renderPlaybackLine({ theme, width, state, startedAt, tick }),
|
|
98
|
+
}),
|
|
99
|
+
{ placement: "belowEditor" }
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface RenderInput {
|
|
105
|
+
theme: any;
|
|
106
|
+
width: number;
|
|
107
|
+
state: PlaybackState;
|
|
108
|
+
startedAt: number;
|
|
109
|
+
tick: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Pure render — exported for tests. */
|
|
113
|
+
export function renderPlaybackLine(input: RenderInput): string[] {
|
|
114
|
+
const fg = (role: string, s: string): string => (input.theme?.fg ? input.theme.fg(role, s) : s);
|
|
115
|
+
const dim = (s: string) => fg("dim", s);
|
|
116
|
+
const accent = (s: string) => fg("accent", s);
|
|
117
|
+
|
|
118
|
+
if (input.state === "idle") return [""];
|
|
119
|
+
|
|
120
|
+
const spinner = spinnerFrame(input.tick);
|
|
121
|
+
const word = input.state === "synthesizing" ? "Synthesizing" : "Playing";
|
|
122
|
+
const elapsed = Math.max(0, Math.round((Date.now() - input.startedAt) / 1000));
|
|
123
|
+
const elapsedStr = `${elapsed}s`;
|
|
124
|
+
const showHint = input.width >= 60;
|
|
125
|
+
const hint = showHint ? ` ${dim("·")} ${dim("[esc] stop")}` : "";
|
|
126
|
+
return [` ${accent(spinner)} ${accent(word)} ${dim("·")} ${dim(elapsedStr)}${hint}`];
|
|
127
|
+
}
|