@juicesharp/rpiv-voice 1.4.2
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/CHANGELOG.md +45 -0
- package/LICENSE +21 -0
- package/README.md +116 -0
- package/audio/error-log.ts +37 -0
- package/audio/hallucination-filter.ts +71 -0
- package/audio/mic-source.ts +38 -0
- package/audio/model-download.ts +268 -0
- package/audio/pcm.ts +45 -0
- package/audio/sherpa-onnx-node.d.ts +55 -0
- package/audio/stt-engine.ts +117 -0
- package/command/pipeline-runner.ts +238 -0
- package/command/splash-runner.ts +72 -0
- package/command/voice-command.ts +251 -0
- package/config/voice-config.ts +80 -0
- package/docs/cover.png +0 -0
- package/docs/cover.svg +173 -0
- package/docs/equalizer.svg +86 -0
- package/docs/overlay.jpg +0 -0
- package/docs/overlay.png +0 -0
- package/docs/vertical-cover.png +0 -0
- package/docs/vertical-cover.svg +239 -0
- package/index.ts +66 -0
- package/locales/de.json +39 -0
- package/locales/en.json +42 -0
- package/locales/es.json +39 -0
- package/locales/fr.json +39 -0
- package/locales/pt-BR.json +39 -0
- package/locales/pt.json +39 -0
- package/locales/ru.json +39 -0
- package/locales/uk.json +39 -0
- package/package.json +94 -0
- package/state/i18n-bridge.ts +51 -0
- package/state/key-router.ts +46 -0
- package/state/screen-intent.ts +27 -0
- package/state/selectors/contract.ts +13 -0
- package/state/selectors/derivations.ts +9 -0
- package/state/selectors/focus.ts +6 -0
- package/state/selectors/projections.ts +112 -0
- package/state/state-reducer.ts +197 -0
- package/state/state.ts +48 -0
- package/state/status-intent.ts +23 -0
- package/state/voice-session.ts +176 -0
- package/view/component-binding.ts +24 -0
- package/view/components/equalizer-view.ts +237 -0
- package/view/components/settings-field-view.ts +77 -0
- package/view/components/settings-form-view.ts +26 -0
- package/view/components/splash-view.ts +98 -0
- package/view/components/status-bar-view.ts +112 -0
- package/view/components/transcript-view.ts +50 -0
- package/view/overlay-view.ts +82 -0
- package/view/props-adapter.ts +29 -0
- package/view/screen-content-strategy.ts +58 -0
- package/view/stateful-view.ts +7 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { RecordingStatus } from "../../state/state.js";
|
|
4
|
+
import { STATUS_META } from "../../state/status-intent.js";
|
|
5
|
+
import type { StatefulView } from "../stateful-view.js";
|
|
6
|
+
|
|
7
|
+
const GAP = " ";
|
|
8
|
+
const TRUNCATE_ELLIPSIS = "…";
|
|
9
|
+
|
|
10
|
+
const COLOR_ACCENT = "accent";
|
|
11
|
+
const COLOR_DIM = "dim";
|
|
12
|
+
const COLOR_MUTED = "muted";
|
|
13
|
+
|
|
14
|
+
const RECORDING_PULSE_COLORS = ["error", "error", "error", "dim"] as const;
|
|
15
|
+
export const STATUS_BAR_PULSE_FRAME_INTERVAL_MS = 200;
|
|
16
|
+
|
|
17
|
+
// Plain text key names instead of glyphs (`⏎ ␣ ⇥ ⎋`): most terminal fonts
|
|
18
|
+
// substitute lookalikes whose vertical metrics don't match the surrounding
|
|
19
|
+
// letters, producing a wobbly baseline. Latin word labels always sit cleanly.
|
|
20
|
+
const HINT_SEP = " · ";
|
|
21
|
+
|
|
22
|
+
// Splits each i18n string on its first space so the leading key word ("Enter",
|
|
23
|
+
// "Space", …) renders in `muted` while the rest stays `dim`. The split is
|
|
24
|
+
// locale-stable as long as translations preserve the "Key <verb-phrase>"
|
|
25
|
+
// shape — e.g. "Enter to paste", "Введите для вставки" both split cleanly.
|
|
26
|
+
function splitHint(literal: string): { key: string; action: string } {
|
|
27
|
+
const sp = literal.indexOf(" ");
|
|
28
|
+
if (sp <= 0) return { key: literal, action: "" };
|
|
29
|
+
return { key: literal.slice(0, sp), action: literal.slice(sp + 1) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface StatusBarViewProps {
|
|
33
|
+
status: RecordingStatus;
|
|
34
|
+
/** Pre-resolved i18n strings ("Enter to paste", "Esc to go back"…). The
|
|
35
|
+
* selector decides which set to pass based on the current screen. */
|
|
36
|
+
hints: readonly string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class StatusBarView implements StatefulView<StatusBarViewProps> {
|
|
40
|
+
private props: StatusBarViewProps = { status: "recording", hints: [] };
|
|
41
|
+
private pulseFrame = 0;
|
|
42
|
+
private readonly startedAtMs: number;
|
|
43
|
+
private pausedAtMs: number | undefined;
|
|
44
|
+
private pausedAccumMs = 0;
|
|
45
|
+
|
|
46
|
+
constructor(private readonly theme: Theme) {
|
|
47
|
+
this.startedAtMs = Date.now();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
setProps(props: StatusBarViewProps): void {
|
|
51
|
+
const wasPaused = this.props.status === "paused";
|
|
52
|
+
const isPaused = props.status === "paused";
|
|
53
|
+
if (!wasPaused && isPaused) {
|
|
54
|
+
this.pausedAtMs = Date.now();
|
|
55
|
+
} else if (wasPaused && !isPaused && this.pausedAtMs !== undefined) {
|
|
56
|
+
this.pausedAccumMs += Date.now() - this.pausedAtMs;
|
|
57
|
+
this.pausedAtMs = undefined;
|
|
58
|
+
}
|
|
59
|
+
this.props = props;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
handleInput(_data: string): void {}
|
|
63
|
+
|
|
64
|
+
invalidate(): void {}
|
|
65
|
+
|
|
66
|
+
tickPulse(): void {
|
|
67
|
+
this.pulseFrame = (this.pulseFrame + 1) % RECORDING_PULSE_COLORS.length;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
render(width: number): string[] {
|
|
71
|
+
const meta = STATUS_META[this.props.status];
|
|
72
|
+
const glyphColor =
|
|
73
|
+
this.props.status === "recording"
|
|
74
|
+
? RECORDING_PULSE_COLORS[this.pulseFrame % RECORDING_PULSE_COLORS.length]
|
|
75
|
+
: meta.glyphColorKey;
|
|
76
|
+
const glyph = this.theme.fg(glyphColor, meta.glyph);
|
|
77
|
+
const timerColor = this.props.status === "recording" ? COLOR_ACCENT : COLOR_MUTED;
|
|
78
|
+
const timer = this.theme.fg(timerColor, formatElapsed(this.elapsedMs()));
|
|
79
|
+
const hints = this.props.hints
|
|
80
|
+
.map((literal) => {
|
|
81
|
+
const { key, action } = splitHint(literal);
|
|
82
|
+
return `${this.theme.fg(COLOR_MUTED, key)} ${this.theme.fg(COLOR_DIM, action)}`;
|
|
83
|
+
})
|
|
84
|
+
.join(this.theme.fg(COLOR_DIM, HINT_SEP));
|
|
85
|
+
|
|
86
|
+
const line = `${glyph} ${timer}${GAP}${hints}`;
|
|
87
|
+
return [truncateToWidth(line, width, TRUNCATE_ELLIPSIS, false)];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private elapsedMs(): number {
|
|
91
|
+
const now = Date.now();
|
|
92
|
+
const livePauseMs = this.pausedAtMs !== undefined ? now - this.pausedAtMs : 0;
|
|
93
|
+
return Math.max(0, now - this.startedAtMs - this.pausedAccumMs - livePauseMs);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const MS_PER_SECOND = 1000;
|
|
98
|
+
const SECONDS_PER_MINUTE = 60;
|
|
99
|
+
const SECONDS_PADDING_DIGITS = 2;
|
|
100
|
+
|
|
101
|
+
function decomposeElapsed(ms: number): { minutes: number; seconds: number } {
|
|
102
|
+
const totalSeconds = Math.floor(ms / MS_PER_SECOND);
|
|
103
|
+
return {
|
|
104
|
+
minutes: Math.floor(totalSeconds / SECONDS_PER_MINUTE),
|
|
105
|
+
seconds: totalSeconds % SECONDS_PER_MINUTE,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function formatElapsed(ms: number): string {
|
|
110
|
+
const { minutes, seconds } = decomposeElapsed(ms);
|
|
111
|
+
return `${minutes}:${seconds.toString().padStart(SECONDS_PADDING_DIGITS, "0")}`;
|
|
112
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { StatefulView } from "../stateful-view.js";
|
|
4
|
+
|
|
5
|
+
const COLOR_MUTED = "muted";
|
|
6
|
+
const COLOR_DIM = "dim";
|
|
7
|
+
|
|
8
|
+
export interface TranscriptViewProps {
|
|
9
|
+
/** Committed (final-decoded) transcript — what gets pasted on Enter. */
|
|
10
|
+
text: string;
|
|
11
|
+
/** In-progress reading of the still-active utterance. Rendered in dim
|
|
12
|
+
* immediately after `text`, separated by a single space when both are
|
|
13
|
+
* non-empty. Replaced wholesale on each rolling decode and cleared the
|
|
14
|
+
* moment the final commit lands. */
|
|
15
|
+
partial?: string;
|
|
16
|
+
placeholder: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class TranscriptView implements StatefulView<TranscriptViewProps> {
|
|
20
|
+
private props: TranscriptViewProps = { text: "", partial: "", placeholder: "Listening..." };
|
|
21
|
+
|
|
22
|
+
constructor(private readonly theme: Theme) {}
|
|
23
|
+
|
|
24
|
+
setProps(props: TranscriptViewProps): void {
|
|
25
|
+
this.props = props;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
handleInput(_data: string): void {}
|
|
29
|
+
|
|
30
|
+
invalidate(): void {}
|
|
31
|
+
|
|
32
|
+
render(width: number): string[] {
|
|
33
|
+
const committed = this.props.text;
|
|
34
|
+
const partial = this.props.partial ?? "";
|
|
35
|
+
if (!committed && !partial) {
|
|
36
|
+
return [this.theme.fg(COLOR_MUTED, this.props.placeholder)];
|
|
37
|
+
}
|
|
38
|
+
// Concatenate committed + partial with a separating space so wrapping
|
|
39
|
+
// treats them as one paragraph; the partial portion is themed dim.
|
|
40
|
+
const partialColored = partial ? this.theme.fg(COLOR_DIM, partial) : "";
|
|
41
|
+
const merged = committed && partialColored ? `${committed} ${partialColored}` : `${committed}${partialColored}`;
|
|
42
|
+
|
|
43
|
+
const lines: string[] = [];
|
|
44
|
+
for (const ln of merged.split("\n")) {
|
|
45
|
+
const src = ln.length === 0 ? " " : ln;
|
|
46
|
+
lines.push(...wrapTextWithAnsi(src, width));
|
|
47
|
+
}
|
|
48
|
+
return lines;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Container } from "@earendil-works/pi-tui";
|
|
2
|
+
import { clipToTerminalHeight } from "../state/selectors/derivations.js";
|
|
3
|
+
import type { VoiceState } from "../state/state.js";
|
|
4
|
+
import type { ScreenContentStrategy } from "./screen-content-strategy.js";
|
|
5
|
+
import type { StatefulView } from "./stateful-view.js";
|
|
6
|
+
|
|
7
|
+
const FALLBACK_TERMINAL_ROWS = 24;
|
|
8
|
+
|
|
9
|
+
// Bottom chrome rows depend on whether the equalizer is enabled. With the
|
|
10
|
+
// equalizer ON, the chrome is `[divider, eq-top, eq-bottom, statusBar]` (4
|
|
11
|
+
// rows); with it OFF the equalizer renders zero rows and the chrome collapses
|
|
12
|
+
// to `[divider, statusBar]` (2 rows). Subtracting the chrome count from total
|
|
13
|
+
// rows gives the body row count that needs height equalization.
|
|
14
|
+
const BOTTOM_CHROME_ROWS_WITH_EQ = 4;
|
|
15
|
+
const BOTTOM_CHROME_ROWS_WITHOUT_EQ = 2;
|
|
16
|
+
|
|
17
|
+
export interface OverlayViewProps {
|
|
18
|
+
state: VoiceState;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface OverlayViewConfig {
|
|
22
|
+
tui: { terminal: { rows?: number; columns: number } };
|
|
23
|
+
dictation: ScreenContentStrategy;
|
|
24
|
+
settings: ScreenContentStrategy;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* OverlayView keeps the divider + status-bar pinned at the same vertical
|
|
29
|
+
* offset across screen flips. Each render:
|
|
30
|
+
* 1. Renders both strategies (cheap — components are pure renderers).
|
|
31
|
+
* 2. Updates a high-water mark of the body height (rows above the bottom
|
|
32
|
+
* chrome) across both. Once dictation has grown to N rows, every
|
|
33
|
+
* subsequent settings render pads up to N too — so the user never sees
|
|
34
|
+
* the chrome jump after switching screens.
|
|
35
|
+
* 3. Top-pads the active strategy's rows with empty lines to match the
|
|
36
|
+
* target.
|
|
37
|
+
* 4. Top-clips to ~85% of terminal height (handled by clipToTerminalHeight).
|
|
38
|
+
*/
|
|
39
|
+
export class OverlayView implements StatefulView<OverlayViewProps> {
|
|
40
|
+
private liveState: VoiceState | undefined;
|
|
41
|
+
private targetBodyHeight = 0;
|
|
42
|
+
|
|
43
|
+
constructor(private readonly config: OverlayViewConfig) {}
|
|
44
|
+
|
|
45
|
+
setProps(props: OverlayViewProps): void {
|
|
46
|
+
this.liveState = props.state;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
handleInput(_data: string): void {}
|
|
50
|
+
|
|
51
|
+
invalidate(): void {}
|
|
52
|
+
|
|
53
|
+
render(width: number): string[] {
|
|
54
|
+
const state = this.liveState;
|
|
55
|
+
if (!state) return [];
|
|
56
|
+
|
|
57
|
+
const chromeRows = state.settingsDraft.equalizerEnabled
|
|
58
|
+
? BOTTOM_CHROME_ROWS_WITH_EQ
|
|
59
|
+
: BOTTOM_CHROME_ROWS_WITHOUT_EQ;
|
|
60
|
+
const dictRows = this.renderStrategy(this.config.dictation, width);
|
|
61
|
+
const settRows = this.renderStrategy(this.config.settings, width);
|
|
62
|
+
const dictBody = Math.max(0, dictRows.length - chromeRows);
|
|
63
|
+
const settBody = Math.max(0, settRows.length - chromeRows);
|
|
64
|
+
this.targetBodyHeight = Math.max(this.targetBodyHeight, dictBody, settBody);
|
|
65
|
+
|
|
66
|
+
const activeRows = state.currentScreen === "settings" ? settRows : dictRows;
|
|
67
|
+
const currentBody = Math.max(0, activeRows.length - chromeRows);
|
|
68
|
+
const padNeeded = this.targetBodyHeight - currentBody;
|
|
69
|
+
const padded = padNeeded > 0 ? [...new Array<string>(padNeeded).fill(""), ...activeRows] : activeRows;
|
|
70
|
+
return clipToTerminalHeight(padded, this.terminalRows());
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private renderStrategy(strategy: ScreenContentStrategy, width: number): string[] {
|
|
74
|
+
const container = new Container();
|
|
75
|
+
for (const child of strategy.children()) container.addChild(child);
|
|
76
|
+
return container.render(width);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private terminalRows(): number {
|
|
80
|
+
return this.config.tui.terminal.rows ?? FALLBACK_TERMINAL_ROWS;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { BindingContext } from "../state/selectors/contract.js";
|
|
2
|
+
import { selectActiveView } from "../state/selectors/focus.js";
|
|
3
|
+
import type { VoiceState } from "../state/state.js";
|
|
4
|
+
import type { BoundGlobalBinding } from "./component-binding.js";
|
|
5
|
+
|
|
6
|
+
export interface VoiceOverlayPropsAdapterConfig {
|
|
7
|
+
tui: { requestRender(): void };
|
|
8
|
+
bindings: ReadonlyArray<BoundGlobalBinding>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class VoiceOverlayPropsAdapter {
|
|
12
|
+
private readonly tui: VoiceOverlayPropsAdapterConfig["tui"];
|
|
13
|
+
private readonly bindings: ReadonlyArray<BoundGlobalBinding>;
|
|
14
|
+
|
|
15
|
+
constructor(config: VoiceOverlayPropsAdapterConfig) {
|
|
16
|
+
this.tui = config.tui;
|
|
17
|
+
this.bindings = config.bindings;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
apply(state: VoiceState): void {
|
|
21
|
+
const ctx: BindingContext = { activeView: selectActiveView(state) };
|
|
22
|
+
for (const b of this.bindings) b.apply(state, ctx);
|
|
23
|
+
this.tui.requestRender();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
invalidate(): void {
|
|
27
|
+
for (const b of this.bindings) b.invalidate();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type Component, Spacer } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { EqualizerViewProps } from "./components/equalizer-view.js";
|
|
3
|
+
import type { SettingsFormViewProps } from "./components/settings-form-view.js";
|
|
4
|
+
import type { StatusBarViewProps } from "./components/status-bar-view.js";
|
|
5
|
+
import type { TranscriptViewProps } from "./components/transcript-view.js";
|
|
6
|
+
import type { StatefulView } from "./stateful-view.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Per-screen layout. Returns the ordered list of pi-tui Components that the
|
|
10
|
+
* overlay container renders top-down. Both screens share the same bottom
|
|
11
|
+
* chrome (mint divider + equalizer + status row) so the equalizer column and
|
|
12
|
+
* key hints stay vertically pinned when flipping between dictation and
|
|
13
|
+
* settings.
|
|
14
|
+
*/
|
|
15
|
+
export interface ScreenContentStrategy {
|
|
16
|
+
readonly kind: "dictation" | "settings";
|
|
17
|
+
children(): readonly Component[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface DictationScreenStrategyConfig {
|
|
21
|
+
transcript: StatefulView<TranscriptViewProps>;
|
|
22
|
+
divider: Component;
|
|
23
|
+
equalizer: StatefulView<EqualizerViewProps>;
|
|
24
|
+
statusBar: StatefulView<StatusBarViewProps>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class DictationScreenStrategy implements ScreenContentStrategy {
|
|
28
|
+
readonly kind = "dictation" as const;
|
|
29
|
+
|
|
30
|
+
constructor(private readonly config: DictationScreenStrategyConfig) {}
|
|
31
|
+
|
|
32
|
+
children(): readonly Component[] {
|
|
33
|
+
return [new Spacer(1), this.config.transcript, this.config.divider, this.config.equalizer, this.config.statusBar];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface SettingsScreenStrategyConfig {
|
|
38
|
+
settingsForm: StatefulView<SettingsFormViewProps>;
|
|
39
|
+
divider: Component;
|
|
40
|
+
equalizer: StatefulView<EqualizerViewProps>;
|
|
41
|
+
statusBar: StatefulView<StatusBarViewProps>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class SettingsScreenStrategy implements ScreenContentStrategy {
|
|
45
|
+
readonly kind = "settings" as const;
|
|
46
|
+
|
|
47
|
+
constructor(private readonly config: SettingsScreenStrategyConfig) {}
|
|
48
|
+
|
|
49
|
+
children(): readonly Component[] {
|
|
50
|
+
return [
|
|
51
|
+
new Spacer(1),
|
|
52
|
+
this.config.settingsForm,
|
|
53
|
+
this.config.divider,
|
|
54
|
+
this.config.equalizer,
|
|
55
|
+
this.config.statusBar,
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
}
|