@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 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.4.2",
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
- // Lower-block glyph ladder: each cell fills from its baseline upward in
10
- // eighths. Used for BOTH cell rows now — the bottom row fills first, the top
11
- // row continues filling above it, giving us 16 distinct amplitude steps
12
- // (vs. 8 in the old mirrored design) for the same two-row chrome height.
13
- const BAR_GLYPHS = [" ", "", "", "", "▄", "▅", "▆", "▇", "█"] as const;
14
- const SUB_LEVELS_PER_ROW = BAR_GLYPHS.length - 1; // 8 — the highest non-empty index
15
- const MAX_AMP = SUB_LEVELS_PER_ROW * 2; // 16 sub-levels stacked across two rows
16
-
17
- // Perceptual gain on the live RMS reading. RMS for normal speech sits around
18
- // 0.02–0.15; sqrt(level * 5) reaches roughly 0.7 at quiet talking volume and
19
- // saturates on loud peaks.
20
- const PERCEPTUAL_GAIN = 5;
21
-
22
- // Single-pole low-pass on `level`. Half-life ~25 ms at 10 Hz updates — fast
23
- // enough that loud peaks visibly punch through, slow enough to suppress the
24
- // glyph-quantization shimmer.
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
- // Trapezoid envelope: flat plateau across the middle PLATEAU_HALF_WIDTH × 2 of
28
- // the row, linear fade to zero over FADE_WIDTH on each side, hard zero past
29
- // that. This is the cover-art silhouette: confident centred bell with tails
30
- // that go fully blank, so the equalizer never reads as filling the whole row.
31
- const PLATEAU_HALF_WIDTH = 0.4;
32
- const FADE_WIDTH = 0.08;
33
-
34
- // Per-column noise floor on the static envelope. Keeps adjacent bars at
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 smoothedLevel = 0;
72
- private tuning: ColumnTuning[] = [];
73
- private level: Float64Array = new Float64Array(0);
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
- if (this.tuning.length !== width) {
106
- this.tuning = buildTuning(width);
107
- this.level = new Float64Array(width);
108
- this.holdLeft = new Uint8Array(width);
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
- const recording = this.props.status === "recording";
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
- const amps = new Uint8Array(width);
119
- let topRow = "";
120
- let botRow = "";
121
- for (let i = 0; i < width; i++) {
122
- const amp = quantize(this.level[i] ?? 0);
123
- amps[i] = amp;
124
- // Bottom row holds the first SUB_LEVELS_PER_ROW eighths; once it's
125
- // saturated, the top row picks up the rest. Both rows draw from the
126
- // same lower-block ladder so the bar visibly grows from the bottom.
127
- const botIdx = amp >= SUB_LEVELS_PER_ROW ? SUB_LEVELS_PER_ROW : amp;
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
- return [this.paint(topRow, amps, recording), this.paint(botRow, amps, recording)];
134
- }
135
-
136
- // Run-length encode the row into theme.fg() segments one segment per
137
- // contiguous run of cells that share the same shade tier. Paused state
138
- // collapses to a single dim segment, matching the "frozen, gone quiet"
139
- // semantic the rest of the overlay uses.
140
- private paint(row: string, amps: Uint8Array, recording: boolean): string {
141
- if (!recording) return this.theme.fg(COLOR_DIM, row);
142
- const width = row.length;
143
- if (width === 0) return "";
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
- // Three-tier shade picker over the 0..MAX_AMP amplitude range. Splits roughly
162
- // thirds: lower bottom-row eighths render dim, the upper bottom-row + lower
163
- // top-row eighths render muted, anything in the upper top-row burns accent.
164
- function pickShade(amp: number): "dim" | "muted" | "accent" {
165
- if (amp <= 5) return COLOR_DIM;
166
- if (amp <= 10) return COLOR_MUTED;
167
- return COLOR_ACCENT;
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 buildTuning(width: number): ColumnTuning[] {
171
- const out: ColumnTuning[] = new Array(width);
172
- for (let i = 0; i < width; i++) {
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
- // Rise-fast / hold / fall-slow per column. The latch refreshes on every
182
- // rising-or-stable tick so sustained loud audio keeps the column pinned to
183
- // peak; only when target drops below prev does the HOLD_TICKS countdown
184
- // start, and only after it expires does the column-specific gravity (release
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
- // Trapezoid centered at t=0.5: flat 1.0 across [0.5±PLATEAU_HALF_WIDTH], linear
205
- // fade to 0 over the next FADE_WIDTH, then hard zero at the very edges.
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
- // Deterministic per-column multiplier in [SHAPE_FLOOR, 1]. Two hash phases are
215
- // summed so adjacent columns vary like a frozen audio snapshot — dense peaks
216
- // and dips, no obvious periodicity.
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
- // Independent per-column [0, 1) hash for fall rates — different sine phases
227
- // from columnShape so envelope-tall columns don't all share a single fall
228
- // speed.
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 {
@@ -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, 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;
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 { type Component, Spacer } from "@earendil-works/pi-tui";
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. 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.
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 [new Spacer(1), this.config.transcript, this.config.divider, this.config.equalizer, this.config.statusBar];
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
  }