@juicesharp/rpiv-voice 1.4.2 → 1.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/CHANGELOG.md +5 -0
- package/package.json +1 -1
- package/view/components/equalizer-view.ts +143 -171
- package/view/overlay-view.ts +5 -5
- package/view/screen-content-strategy.ts +8 -13
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,11 @@ All notable changes to `@juicesharp/rpiv-voice` are documented here.
|
|
|
5
5
|
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
6
6
|
Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.5.0] - 2026-05-12
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Redesigned equalizer visualization with a centered bell silhouette, truecolor accent gradient, and audio-driven animation.
|
|
12
|
+
|
|
8
13
|
## [1.4.2] - 2026-05-11
|
|
9
14
|
|
|
10
15
|
### Changed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juicesharp/rpiv-voice",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Pi extension. Voice dictation via /voice — local on-device STT with sherpa-onnx Whisper (base multilingual int8), microphone capture via decibri.",
|
|
6
6
|
"keywords": [
|
|
@@ -3,61 +3,66 @@ import type { RecordingStatus } from "../../state/state.js";
|
|
|
3
3
|
import type { StatefulView } from "../stateful-view.js";
|
|
4
4
|
|
|
5
5
|
const COLOR_ACCENT = "accent";
|
|
6
|
-
const COLOR_MUTED = "muted";
|
|
7
6
|
const COLOR_DIM = "dim";
|
|
8
7
|
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
8
|
+
// Vertical gradient: parse the theme's `accent` truecolor SGR and scale each
|
|
9
|
+
// channel per ring outward from the centerline. Falls back to Pi's discrete
|
|
10
|
+
// shade keys when the accent isn't truecolor (256/8-color themes, test mocks).
|
|
11
|
+
const GRADIENT_BRIGHTNESS = [1.0, 0.65, 0.4, 0.22] as const;
|
|
12
|
+
const SHADE_FALLBACK = ["accent", "borderAccent", "muted", "dim"] as const;
|
|
13
|
+
const ANSI_FG_RESET = "\x1b[39m";
|
|
14
|
+
const TRUECOLOR_FG_REGEX = /\x1b\[38;2;(\d+);(\d+);(\d+)m/;
|
|
15
|
+
|
|
16
|
+
// Vertical lattice geometry. Amp counts rings outward from CENTER_ROW so
|
|
17
|
+
// every bar is mirror-symmetric (amp=1 lights only the centerline, amp=MAX
|
|
18
|
+
// fills the whole column).
|
|
19
|
+
const HALF_SPAN = 3;
|
|
20
|
+
const CENTER_ROW = HALF_SPAN;
|
|
21
|
+
const ROW_COUNT = HALF_SPAN * 2 + 1;
|
|
22
|
+
const MAX_AMP = HALF_SPAN + 1;
|
|
23
|
+
|
|
24
|
+
// Bars sit on even column indices; odd columns are mandatory spacing so
|
|
25
|
+
// adjacent strokes never visually merge.
|
|
26
|
+
const BAR_GLYPH = "█";
|
|
27
|
+
const SPACE_GLYPH = " ";
|
|
28
|
+
const BAR_STRIDE = 2;
|
|
29
|
+
|
|
30
|
+
// fBm noise drives the silhouette pattern. Three octaves at decreasing
|
|
31
|
+
// spatial scale + decorrelated time drift give an organic, non-periodic
|
|
32
|
+
// waveform that still clusters smoothly (adjacent bars share a trend).
|
|
33
|
+
const NOISE_OCTAVES = [
|
|
34
|
+
{ spacing: 10, weight: 0.55, drift: 0.04, seed: 13.7 },
|
|
35
|
+
{ spacing: 5, weight: 0.3, drift: 0.07, seed: 29.3 },
|
|
36
|
+
{ spacing: 2.5, weight: 0.15, drift: 0.11, seed: 47.1 },
|
|
37
|
+
] as const;
|
|
38
|
+
|
|
39
|
+
// Standard fract(sin) shader hash. Multiplier and offset are arbitrary but
|
|
40
|
+
// well-tested for irrational-looking distribution.
|
|
41
|
+
const HASH_FREQ = 12.9898;
|
|
42
|
+
const HASH_AMP = 43758.5453;
|
|
43
|
+
|
|
44
|
+
// Mild over-gain on noise so constructive peaks saturate the quantizer at
|
|
45
|
+
// MAX_AMP — keeps the top of the lattice in use without flattening every
|
|
46
|
+
// cluster into a mesa.
|
|
47
|
+
const NOISE_PEAK_GAIN = 1.15;
|
|
48
|
+
|
|
49
|
+
// Perceptual mapping from RMS to display gain. sqrt(level * 15) saturates
|
|
50
|
+
// around level≈0.067 so normal speaking volume reaches full bars without
|
|
51
|
+
// projecting.
|
|
52
|
+
const PERCEPTUAL_GAIN = 15;
|
|
53
|
+
|
|
54
|
+
// Single-pole smoother on the live RMS — ~1 s natural decay to blank during
|
|
55
|
+
// silences, fast enough that onsets still punch through.
|
|
25
56
|
const SMOOTHING = 0.3;
|
|
26
57
|
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
// different heights even when held at peak so the silhouette has texture.
|
|
36
|
-
const SHAPE_FLOOR = 0.15;
|
|
37
|
-
|
|
38
|
-
// Per-tick fall range. Each column draws a deterministic decay rate from
|
|
39
|
-
// [FALL_MIN, FALL_MAX] — that's what makes adjacent bars fall back at
|
|
40
|
-
// different speeds after a loud onset, so the lattice dances instead of
|
|
41
|
-
// pulsing in lockstep. Range is tuned to the canonical voice-meter release
|
|
42
|
-
// window: at 10 Hz update rate, FALL_MIN=0.10 ⇒ ~1.0 s tail, FALL_MAX=0.22 ⇒
|
|
43
|
-
// ~450 ms. Audio-engineering literature (IEC 60268-17 / BS 6840 PPM) puts the
|
|
44
|
-
// "feels alive on speech" sweet spot at 300–500 ms release, faster than VU's
|
|
45
|
-
// 300 ms symmetric and slower than PPM's 1.5 s decay-to-−20 dB.
|
|
46
|
-
const FALL_MIN = 0.1;
|
|
47
|
-
const FALL_MAX = 0.22;
|
|
48
|
-
|
|
49
|
-
// Peak-hold latency: every new local maximum latches the column at its peak
|
|
50
|
-
// height for HOLD_TICKS frames before the per-column gravity kicks in. At
|
|
51
|
-
// 10 Hz tick that is ~600 ms — the recognisable "memory of the loudest recent
|
|
52
|
-
// moment" you see in Winamp / classic VU plug-ins. We can't dedicate a
|
|
53
|
-
// separate cap row in two-row chrome, so the ballistic lives inside the bar
|
|
54
|
-
// itself: the bar freezes briefly at its peak, then resumes its asymmetric
|
|
55
|
-
// release.
|
|
56
|
-
const HOLD_TICKS = 6;
|
|
57
|
-
|
|
58
|
-
interface ColumnTuning {
|
|
59
|
-
envelope: number;
|
|
60
|
-
fall: number;
|
|
58
|
+
// Blended Hann window: 0.5·rc² + 0.5·rc⁵. The rc² term keeps shoulders broad
|
|
59
|
+
// (smooth taper through every amp bucket); the rc⁵ term sharpens the centre
|
|
60
|
+
// tip (few slots reach MAX_AMP). Produces a parabolic bell silhouette.
|
|
61
|
+
function bellEnvelope(t: number): number {
|
|
62
|
+
const rc = 0.5 - 0.5 * Math.cos(2 * Math.PI * t);
|
|
63
|
+
const rc2 = rc * rc;
|
|
64
|
+
const rc5 = rc2 * rc2 * rc;
|
|
65
|
+
return 0.5 * rc2 + 0.5 * rc5;
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
export interface EqualizerViewProps {
|
|
@@ -68,25 +73,17 @@ export interface EqualizerViewProps {
|
|
|
68
73
|
|
|
69
74
|
export class EqualizerView implements StatefulView<EqualizerViewProps> {
|
|
70
75
|
private props: EqualizerViewProps = { level: 0, status: "recording", enabled: false };
|
|
71
|
-
private
|
|
72
|
-
private
|
|
73
|
-
private
|
|
74
|
-
// Ticks remaining in each column's peak-hold latch. Reset to HOLD_TICKS
|
|
75
|
-
// every time level[i] rises to a new local max; counts down on every
|
|
76
|
-
// non-rise tick. While > 0 the column's gravity is suppressed.
|
|
77
|
-
private holdLeft: Uint8Array = new Uint8Array(0);
|
|
78
|
-
// Each setProps tick that should advance the lattice queues one step here;
|
|
79
|
-
// render() consumes them. Decoupling keeps render() idempotent for the same
|
|
80
|
-
// audio frame even if the TUI calls it more than once.
|
|
76
|
+
private envelope: Float64Array = new Float64Array(0);
|
|
77
|
+
private currentBarCount = 0;
|
|
78
|
+
private phase = 0;
|
|
81
79
|
private pendingTicks = 0;
|
|
80
|
+
private smoothedLevel = 0;
|
|
81
|
+
private gradient: readonly string[] | null = null;
|
|
82
|
+
private gradientResolved = false;
|
|
82
83
|
|
|
83
84
|
constructor(private readonly theme: Theme) {}
|
|
84
85
|
|
|
85
86
|
setProps(props: EqualizerViewProps): void {
|
|
86
|
-
// Live RMS only feeds the smoother + lattice while recording AND enabled.
|
|
87
|
-
// Pausing freezes the silhouette at its last shape (the dim colour carries
|
|
88
|
-
// the paused state). Disabling drops to zero rows from render() so the
|
|
89
|
-
// dictation pane reclaims the space.
|
|
90
87
|
if (props.enabled && props.status === "recording") {
|
|
91
88
|
this.smoothedLevel = (1 - SMOOTHING) * this.smoothedLevel + SMOOTHING * props.level;
|
|
92
89
|
this.pendingTicks += 1;
|
|
@@ -100,135 +97,110 @@ export class EqualizerView implements StatefulView<EqualizerViewProps> {
|
|
|
100
97
|
|
|
101
98
|
render(width: number): string[] {
|
|
102
99
|
if (!this.props.enabled) return [];
|
|
103
|
-
if (width <= 0) return
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
this.
|
|
108
|
-
|
|
100
|
+
if (width <= 0) return new Array<string>(ROW_COUNT).fill("");
|
|
101
|
+
|
|
102
|
+
const nBars = Math.ceil(width / BAR_STRIDE);
|
|
103
|
+
if (this.currentBarCount !== nBars) {
|
|
104
|
+
this.envelope = new Float64Array(nBars);
|
|
105
|
+
for (let i = 0; i < nBars; i++) {
|
|
106
|
+
const t = nBars === 1 ? 0.5 : i / (nBars - 1);
|
|
107
|
+
this.envelope[i] = bellEnvelope(t);
|
|
108
|
+
}
|
|
109
|
+
this.currentBarCount = nBars;
|
|
109
110
|
}
|
|
110
111
|
|
|
111
|
-
|
|
112
|
-
const gain = recording ? Math.min(1, Math.sqrt(this.smoothedLevel * PERCEPTUAL_GAIN)) : 0;
|
|
113
|
-
// Drain queued ticks; while paused pendingTicks is always 0, so the level
|
|
114
|
-
// array holds whatever shape it had at the moment of pause.
|
|
115
|
-
for (let n = 0; n < this.pendingTicks; n++) advanceLevels(this.level, this.holdLeft, this.tuning, gain);
|
|
112
|
+
this.phase += this.pendingTicks;
|
|
116
113
|
this.pendingTicks = 0;
|
|
117
114
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const topIdx = amp > SUB_LEVELS_PER_ROW ? amp - SUB_LEVELS_PER_ROW : 0;
|
|
129
|
-
botRow += BAR_GLYPHS[botIdx]!;
|
|
130
|
-
topRow += BAR_GLYPHS[topIdx]!;
|
|
115
|
+
// smoothedLevel only advances while recording, so freezing it during
|
|
116
|
+
// pause naturally preserves the last bar heights.
|
|
117
|
+
const audioGain = Math.min(1, Math.sqrt(this.smoothedLevel * PERCEPTUAL_GAIN));
|
|
118
|
+
// Fold the noise lookup around the centerline so slot i and its mirror
|
|
119
|
+
// see identical noise — silhouette stays centred every frame.
|
|
120
|
+
const center = (nBars - 1) / 2;
|
|
121
|
+
const amps = new Uint8Array(nBars);
|
|
122
|
+
for (let i = 0; i < nBars; i++) {
|
|
123
|
+
const shape = fbmShape(Math.abs(i - center), this.phase);
|
|
124
|
+
amps[i] = quantize(shape * this.envelope[i]! * audioGain);
|
|
131
125
|
}
|
|
132
126
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const cells = [...row];
|
|
145
|
-
let out = "";
|
|
146
|
-
let runStart = 0;
|
|
147
|
-
let currentShade = pickShade(amps[0] ?? 0);
|
|
148
|
-
for (let i = 1; i < width; i++) {
|
|
149
|
-
const shade = pickShade(amps[i] ?? 0);
|
|
150
|
-
if (shade !== currentShade) {
|
|
151
|
-
out += this.theme.fg(currentShade, cells.slice(runStart, i).join(""));
|
|
152
|
-
runStart = i;
|
|
153
|
-
currentShade = shade;
|
|
127
|
+
this.ensureGradient();
|
|
128
|
+
const recording = this.props.status === "recording";
|
|
129
|
+
const out: string[] = new Array(ROW_COUNT);
|
|
130
|
+
for (let r = 0; r < ROW_COUNT; r++) {
|
|
131
|
+
let raw = "";
|
|
132
|
+
for (let c = 0; c < width; c++) {
|
|
133
|
+
if (c % BAR_STRIDE !== 0) {
|
|
134
|
+
raw += SPACE_GLYPH;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
raw += rowLit(amps[c / BAR_STRIDE]!, r) ? BAR_GLYPH : SPACE_GLYPH;
|
|
154
138
|
}
|
|
139
|
+
out[r] = this.paintRow(raw, r, recording);
|
|
155
140
|
}
|
|
156
|
-
out += this.theme.fg(currentShade, cells.slice(runStart).join(""));
|
|
157
141
|
return out;
|
|
158
142
|
}
|
|
143
|
+
|
|
144
|
+
private ensureGradient(): void {
|
|
145
|
+
if (this.gradientResolved) return;
|
|
146
|
+
this.gradientResolved = true;
|
|
147
|
+
const themeWithAnsi = this.theme as { getFgAnsi?: (key: string) => string };
|
|
148
|
+
if (typeof themeWithAnsi.getFgAnsi !== "function") return;
|
|
149
|
+
const accentAnsi = themeWithAnsi.getFgAnsi(COLOR_ACCENT);
|
|
150
|
+
const match = accentAnsi.match(TRUECOLOR_FG_REGEX);
|
|
151
|
+
if (!match) return;
|
|
152
|
+
const r = Number(match[1]);
|
|
153
|
+
const g = Number(match[2]);
|
|
154
|
+
const b = Number(match[3]);
|
|
155
|
+
this.gradient = GRADIENT_BRIGHTNESS.map((factor) => rgbAnsi(r * factor, g * factor, b * factor));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private paintRow(raw: string, row: number, recording: boolean): string {
|
|
159
|
+
if (!recording) return this.theme.fg(COLOR_DIM, raw);
|
|
160
|
+
const dist = Math.abs(row - CENTER_ROW);
|
|
161
|
+
if (this.gradient) {
|
|
162
|
+
const idx = Math.min(dist, this.gradient.length - 1);
|
|
163
|
+
return `${this.gradient[idx]}${raw}${ANSI_FG_RESET}`;
|
|
164
|
+
}
|
|
165
|
+
const fallbackIdx = Math.min(dist, SHADE_FALLBACK.length - 1);
|
|
166
|
+
return this.theme.fg(SHADE_FALLBACK[fallbackIdx]!, raw);
|
|
167
|
+
}
|
|
159
168
|
}
|
|
160
169
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
return
|
|
170
|
+
function valueNoise(x: number, seed: number): number {
|
|
171
|
+
const xi = Math.floor(x);
|
|
172
|
+
const xf = x - xi;
|
|
173
|
+
const u = xf * xf * (3 - 2 * xf);
|
|
174
|
+
const a = valueHash(xi, seed);
|
|
175
|
+
const b = valueHash(xi + 1, seed);
|
|
176
|
+
return a + (b - a) * u;
|
|
168
177
|
}
|
|
169
178
|
|
|
170
|
-
function
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
const t = width === 1 ? 0.5 : i / (width - 1);
|
|
174
|
-
const envelope = trapezoidEnvelope(t) * columnShape(i);
|
|
175
|
-
const fall = FALL_MIN + columnHash(i) * (FALL_MAX - FALL_MIN);
|
|
176
|
-
out[i] = { envelope, fall };
|
|
177
|
-
}
|
|
178
|
-
return out;
|
|
179
|
+
function valueHash(x: number, seed: number): number {
|
|
180
|
+
const v = Math.sin(x * HASH_FREQ + seed) * HASH_AMP;
|
|
181
|
+
return v - Math.floor(v);
|
|
179
182
|
}
|
|
180
183
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
// time) take over. End result: bars freeze at the loudest recent moment for
|
|
186
|
-
// ~600 ms, then start dropping at their per-column rates — the classic
|
|
187
|
-
// peak-hold ballistic of Winamp-class meters.
|
|
188
|
-
function advanceLevels(level: Float64Array, holdLeft: Uint8Array, tuning: ColumnTuning[], gain: number): void {
|
|
189
|
-
for (let i = 0; i < level.length; i++) {
|
|
190
|
-
const t = tuning[i]!;
|
|
191
|
-
const target = gain * t.envelope;
|
|
192
|
-
const prev = level[i]!;
|
|
193
|
-
if (target >= prev) {
|
|
194
|
-
level[i] = target;
|
|
195
|
-
holdLeft[i] = HOLD_TICKS;
|
|
196
|
-
} else if (holdLeft[i]! > 0) {
|
|
197
|
-
holdLeft[i] = holdLeft[i]! - 1;
|
|
198
|
-
} else {
|
|
199
|
-
level[i] = Math.max(0, prev - t.fall);
|
|
200
|
-
}
|
|
184
|
+
function fbmShape(i: number, phase: number): number {
|
|
185
|
+
let sum = 0;
|
|
186
|
+
for (const o of NOISE_OCTAVES) {
|
|
187
|
+
sum += valueNoise(i / o.spacing + phase * o.drift, o.seed) * o.weight;
|
|
201
188
|
}
|
|
189
|
+
return sum * NOISE_PEAK_GAIN;
|
|
202
190
|
}
|
|
203
191
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
function trapezoidEnvelope(t: number): number {
|
|
207
|
-
const d = Math.abs(t - 0.5);
|
|
208
|
-
if (d <= PLATEAU_HALF_WIDTH) return 1;
|
|
209
|
-
const fadeT = (d - PLATEAU_HALF_WIDTH) / FADE_WIDTH;
|
|
210
|
-
if (fadeT >= 1) return 0;
|
|
211
|
-
return 1 - fadeT;
|
|
192
|
+
function rgbAnsi(r: number, g: number, b: number): string {
|
|
193
|
+
return `\x1b[38;2;${clamp8(r)};${clamp8(g)};${clamp8(b)}m`;
|
|
212
194
|
}
|
|
213
195
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
function columnShape(i: number): number {
|
|
218
|
-
const a = Math.sin(i * 12.9898 + 78.233) * 43758.5453;
|
|
219
|
-
const b = Math.sin(i * 7.523 + 41.31) * 13371.337;
|
|
220
|
-
const fa = a - Math.floor(a);
|
|
221
|
-
const fb = b - Math.floor(b);
|
|
222
|
-
const f = (fa + fb) / 2;
|
|
223
|
-
return SHAPE_FLOOR + (1 - SHAPE_FLOOR) * f;
|
|
196
|
+
function clamp8(v: number): number {
|
|
197
|
+
const rounded = Math.round(v);
|
|
198
|
+
return rounded < 0 ? 0 : rounded > 255 ? 255 : rounded;
|
|
224
199
|
}
|
|
225
200
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
function columnHash(i: number): number {
|
|
230
|
-
const a = Math.sin(i * 97.13 + 12.345) * 43758.5453;
|
|
231
|
-
return a - Math.floor(a);
|
|
201
|
+
function rowLit(amp: number, row: number): boolean {
|
|
202
|
+
if (amp <= 0) return false;
|
|
203
|
+
return Math.abs(row - CENTER_ROW) < amp;
|
|
232
204
|
}
|
|
233
205
|
|
|
234
206
|
function quantize(level: number): number {
|
package/view/overlay-view.ts
CHANGED
|
@@ -7,11 +7,11 @@ import type { StatefulView } from "./stateful-view.js";
|
|
|
7
7
|
const FALLBACK_TERMINAL_ROWS = 24;
|
|
8
8
|
|
|
9
9
|
// Bottom chrome rows depend on whether the equalizer is enabled. With the
|
|
10
|
-
// equalizer ON, the chrome is `[divider,
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
const BOTTOM_CHROME_ROWS_WITH_EQ =
|
|
10
|
+
// equalizer ON, the chrome is `[divider, eq×7, statusBar]` (9 rows); with it
|
|
11
|
+
// OFF the equalizer renders zero rows and the chrome collapses to `[divider,
|
|
12
|
+
// statusBar]` (2 rows). Subtracting the chrome count from total rows gives
|
|
13
|
+
// the body row count that needs height equalization.
|
|
14
|
+
const BOTTOM_CHROME_ROWS_WITH_EQ = 9;
|
|
15
15
|
const BOTTOM_CHROME_ROWS_WITHOUT_EQ = 2;
|
|
16
16
|
|
|
17
17
|
export interface OverlayViewProps {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
2
2
|
import type { EqualizerViewProps } from "./components/equalizer-view.js";
|
|
3
3
|
import type { SettingsFormViewProps } from "./components/settings-form-view.js";
|
|
4
4
|
import type { StatusBarViewProps } from "./components/status-bar-view.js";
|
|
@@ -7,10 +7,11 @@ import type { StatefulView } from "./stateful-view.js";
|
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Per-screen layout. Returns the ordered list of pi-tui Components that the
|
|
10
|
-
* overlay container renders top-down
|
|
11
|
-
*
|
|
12
|
-
* key hints stay vertically pinned when flipping between dictation
|
|
13
|
-
* settings
|
|
10
|
+
* overlay container renders top-down: body component → divider → equalizer →
|
|
11
|
+
* status row. Both screens share the same bottom chrome so the equalizer
|
|
12
|
+
* column and key hints stay vertically pinned when flipping between dictation
|
|
13
|
+
* and settings; nothing is inserted above the body or between the equalizer
|
|
14
|
+
* and the status row, so the overlay reads flush against both edges.
|
|
14
15
|
*/
|
|
15
16
|
export interface ScreenContentStrategy {
|
|
16
17
|
readonly kind: "dictation" | "settings";
|
|
@@ -30,7 +31,7 @@ export class DictationScreenStrategy implements ScreenContentStrategy {
|
|
|
30
31
|
constructor(private readonly config: DictationScreenStrategyConfig) {}
|
|
31
32
|
|
|
32
33
|
children(): readonly Component[] {
|
|
33
|
-
return [
|
|
34
|
+
return [this.config.transcript, this.config.divider, this.config.equalizer, this.config.statusBar];
|
|
34
35
|
}
|
|
35
36
|
}
|
|
36
37
|
|
|
@@ -47,12 +48,6 @@ export class SettingsScreenStrategy implements ScreenContentStrategy {
|
|
|
47
48
|
constructor(private readonly config: SettingsScreenStrategyConfig) {}
|
|
48
49
|
|
|
49
50
|
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
|
-
];
|
|
51
|
+
return [this.config.settingsForm, this.config.divider, this.config.equalizer, this.config.statusBar];
|
|
57
52
|
}
|
|
58
53
|
}
|