nixamp 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/src/audio.ts ADDED
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Decoding and playback, both by way of ffmpeg.
3
+ *
4
+ * One decode feeds both the speakers and the analyser: ffmpeg writes raw f32
5
+ * samples to our stdout pipe, we compute the spectrum from them and pass the
6
+ * same bytes to the output process. Running two decoders instead would drift
7
+ * apart within seconds and the bars would stop matching what you hear.
8
+ */
9
+ import { spawn, spawnSync, type ChildProcess } from "node:child_process";
10
+
11
+ export const RATE = 44100;
12
+ export const CHANNELS = 2;
13
+
14
+ export interface Track {
15
+ path: string;
16
+ title: string;
17
+ artist: string;
18
+ album: string;
19
+ /** Seconds; 0 when ffprobe could not tell us. */
20
+ duration: number;
21
+ }
22
+
23
+ export interface Tools {
24
+ ffmpeg: string[];
25
+ ffprobe: string[];
26
+ /** Argv prefix for the player, or null when nothing can make sound here. */
27
+ play: string[] | null;
28
+ }
29
+
30
+ function works(argv: string[]): boolean {
31
+ const [cmd, ...rest] = argv;
32
+ if (!cmd) return false;
33
+ const r = spawnSync(cmd, [...rest, "-version"], { encoding: "utf8", timeout: 10_000 });
34
+ return !r.error && r.status === 0;
35
+ }
36
+
37
+ /**
38
+ * Find the tools. A bare `ffmpeg` on PATH is tried first; mise shims are common
39
+ * on developer machines and need `mise exec` because the shim itself fails when
40
+ * no version is pinned.
41
+ */
42
+ export function detectTools(): Tools {
43
+ const candidates = (name: string): string[][] => [
44
+ [name],
45
+ ["mise", "exec", `ffmpeg@latest`, "--", name],
46
+ ];
47
+ const pick = (name: string): string[] | null =>
48
+ candidates(name).find((argv) => works(argv)) ?? null;
49
+
50
+ const ffmpeg = pick("ffmpeg");
51
+ const ffprobe = pick("ffprobe");
52
+ const play = pick("ffplay");
53
+ return {
54
+ ffmpeg: ffmpeg ?? ["ffmpeg"],
55
+ ffprobe: ffprobe ?? ["ffprobe"],
56
+ play,
57
+ };
58
+ }
59
+
60
+ export function probe(tools: Tools, path: string): Track {
61
+ const [cmd, ...rest] = tools.ffprobe;
62
+ const fallback: Track = {
63
+ path,
64
+ title: path.split("/").pop() ?? path,
65
+ artist: "",
66
+ album: "",
67
+ duration: 0,
68
+ };
69
+ if (!cmd) return fallback;
70
+ const result = spawnSync(cmd, [
71
+ ...rest,
72
+ "-v", "quiet", "-print_format", "json",
73
+ "-show_format", "-show_entries", "format_tags=title,artist,album",
74
+ path,
75
+ ], { encoding: "utf8", timeout: 20_000, maxBuffer: 4 * 1024 * 1024 });
76
+ if (result.error || result.status !== 0) return fallback;
77
+ try {
78
+ const parsed = JSON.parse(result.stdout) as {
79
+ format?: { duration?: string; tags?: Record<string, string> };
80
+ };
81
+ const tags = parsed.format?.tags ?? {};
82
+ const lower: Record<string, string> = {};
83
+ for (const [k, v] of Object.entries(tags)) lower[k.toLowerCase()] = v;
84
+ return {
85
+ path,
86
+ title: lower.title || fallback.title,
87
+ artist: lower.artist ?? "",
88
+ album: lower.album ?? "",
89
+ duration: Number(parsed.format?.duration ?? 0) || 0,
90
+ };
91
+ } catch {
92
+ return fallback;
93
+ }
94
+ }
95
+
96
+ export interface StreamHandlers {
97
+ /** Interleaved stereo f32 samples, as decoded. */
98
+ onSamples: (pcm: Float32Array) => void;
99
+ onEnd: (error?: string) => void;
100
+ }
101
+
102
+ /**
103
+ * A playing track: one ffmpeg decoding, one player consuming, and us in the
104
+ * middle reading every sample on its way past.
105
+ */
106
+ export class Stream {
107
+ private decoder: ChildProcess | null = null;
108
+ private output: ChildProcess | null = null;
109
+ private stopped = false;
110
+ /**
111
+ * Which start each callback belongs to. A killed ffmpeg still fires `close`,
112
+ * and without this its "exited null" lands on the track that replaced it —
113
+ * so skipping a track would report an error and stop the player.
114
+ */
115
+ private generation = 0;
116
+ /** Samples handed to the output so far, per channel. */
117
+ private framesOut = 0;
118
+ /** Leftover bytes when a chunk does not divide into whole f32 samples. */
119
+ private tail: Buffer<ArrayBufferLike> = Buffer.alloc(0);
120
+
121
+ constructor(
122
+ private readonly tools: Tools,
123
+ private readonly handlers: StreamHandlers,
124
+ ) {}
125
+
126
+ /** Seconds of audio delivered so far. */
127
+ get position(): number {
128
+ return this.framesOut / RATE;
129
+ }
130
+
131
+ get silent(): boolean {
132
+ return this.tools.play === null;
133
+ }
134
+
135
+ start(track: Track, from = 0): void {
136
+ this.stop();
137
+ this.stopped = false;
138
+ const generation = ++this.generation;
139
+ this.framesOut = Math.round(from * RATE);
140
+
141
+ const [ff, ...ffRest] = this.tools.ffmpeg;
142
+ if (!ff) { this.handlers.onEnd("ffmpeg not found"); return; }
143
+
144
+ this.decoder = spawn(ff, [
145
+ ...ffRest,
146
+ "-hide_banner", "-loglevel", "error",
147
+ ...(from > 0 ? ["-ss", String(from)] : []),
148
+ "-i", track.path,
149
+ "-f", "f32le", "-ac", String(CHANNELS), "-ar", String(RATE), "-",
150
+ ], { stdio: ["ignore", "pipe", "pipe"] });
151
+
152
+ if (this.tools.play) {
153
+ const [player, ...playerRest] = this.tools.play;
154
+ this.output = spawn(player as string, [
155
+ ...playerRest,
156
+ "-hide_banner", "-loglevel", "quiet",
157
+ "-nodisp", "-autoexit",
158
+ "-f", "f32le", "-ac", String(CHANNELS), "-ar", String(RATE), "-i", "-",
159
+ ], { stdio: ["pipe", "ignore", "ignore"] });
160
+ // The player exiting first must not kill us with EPIPE.
161
+ this.output.stdin?.on("error", () => {});
162
+ }
163
+
164
+ let stderr = "";
165
+ this.decoder.stderr?.on("data", (c: Buffer) => { stderr += c.toString(); });
166
+
167
+ this.decoder.stdout?.on("data", (chunk: Buffer) => {
168
+ if (this.stopped || generation !== this.generation) return;
169
+ this.output?.stdin?.write(chunk);
170
+ const joined = this.tail.length ? Buffer.concat([this.tail, chunk]) : chunk;
171
+ const usable = joined.length - (joined.length % 4);
172
+ this.tail = usable === joined.length ? Buffer.alloc(0) : joined.subarray(usable);
173
+ if (usable === 0) return;
174
+ // Copy rather than view: the underlying buffer is reused by the stream.
175
+ const samples = new Float32Array(usable / 4);
176
+ for (let i = 0; i < samples.length; i++) samples[i] = joined.readFloatLE(i * 4);
177
+ this.framesOut += samples.length / CHANNELS;
178
+ this.handlers.onSamples(samples);
179
+ });
180
+
181
+ this.decoder.on("close", (code) => {
182
+ if (this.stopped || generation !== this.generation) return;
183
+ this.output?.stdin?.end();
184
+ this.handlers.onEnd(code === 0 ? undefined : stderr.trim() || `ffmpeg exited ${code}`);
185
+ });
186
+ this.decoder.on("error", (error) => {
187
+ if (this.stopped || generation !== this.generation) return;
188
+ this.handlers.onEnd(error.message);
189
+ });
190
+ }
191
+
192
+ stop(): void {
193
+ this.stopped = true;
194
+ // Anything still in flight from the last start belongs to nobody now.
195
+ this.generation++;
196
+ this.decoder?.kill("SIGKILL");
197
+ this.output?.stdin?.end();
198
+ this.output?.kill("SIGKILL");
199
+ this.decoder = null;
200
+ this.output = null;
201
+ this.tail = Buffer.alloc(0);
202
+ }
203
+ }
204
+
205
+ /** Left and right peak levels from an interleaved stereo frame, 0..1. */
206
+ export function peaks(pcm: Float32Array): [number, number] {
207
+ let left = 0;
208
+ let right = 0;
209
+ for (let i = 0; i + 1 < pcm.length; i += 2) {
210
+ const l = Math.abs(pcm[i] as number);
211
+ const r = Math.abs(pcm[i + 1] as number);
212
+ if (l > left) left = l;
213
+ if (r > right) right = r;
214
+ }
215
+ return [Math.min(1, left), Math.min(1, right)];
216
+ }
217
+
218
+ /** Interleaved stereo down to mono, for the analyser. */
219
+ export function toMono(pcm: Float32Array): Float32Array {
220
+ const mono = new Float32Array(Math.floor(pcm.length / CHANNELS));
221
+ for (let i = 0; i < mono.length; i++) {
222
+ mono[i] = (((pcm[i * 2] as number) + (pcm[i * 2 + 1] as number)) / 2);
223
+ }
224
+ return mono;
225
+ }
226
+
227
+ export function formatTime(seconds: number): string {
228
+ if (!Number.isFinite(seconds) || seconds < 0) return "--:--";
229
+ const total = Math.floor(seconds);
230
+ const m = Math.floor(total / 60);
231
+ const s = total % 60;
232
+ return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
233
+ }
package/src/fft.ts ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * The analyser: real audio in, spectrum bands out.
3
+ *
4
+ * An iterative radix-2 Cooley-Tukey FFT, which is enough for a visualiser and
5
+ * small enough to read. No dependency, because pulling a DSP library in for
6
+ * one transform would be the largest thing in the tree.
7
+ */
8
+
9
+ /** Bit-reversal permutation table for a transform of size n (a power of two). */
10
+ function reversalTable(n: number): Uint32Array {
11
+ const bits = Math.log2(n);
12
+ const table = new Uint32Array(n);
13
+ for (let i = 0; i < n; i++) {
14
+ let reversed = 0;
15
+ for (let b = 0; b < bits; b++) if (i & (1 << b)) reversed |= 1 << (bits - 1 - b);
16
+ table[i] = reversed;
17
+ }
18
+ return table;
19
+ }
20
+
21
+ export function isPowerOfTwo(n: number): boolean {
22
+ return n > 0 && (n & (n - 1)) === 0;
23
+ }
24
+
25
+ /**
26
+ * A Hann window, precomputed.
27
+ *
28
+ * Without one, a tone that does not complete a whole number of cycles in the
29
+ * frame leaks across every bin and the display turns to mush.
30
+ */
31
+ export function hann(n: number): Float32Array {
32
+ const w = new Float32Array(n);
33
+ for (let i = 0; i < n; i++) w[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / (n - 1)));
34
+ return w;
35
+ }
36
+
37
+ export class Analyser {
38
+ readonly size: number;
39
+ readonly sampleRate: number;
40
+ private readonly re: Float64Array;
41
+ private readonly im: Float64Array;
42
+ private readonly rev: Uint32Array;
43
+ private readonly window: Float32Array;
44
+ /** Magnitudes for bins 0..size/2, reused between frames. */
45
+ readonly magnitudes: Float32Array;
46
+
47
+ constructor(size = 2048, sampleRate = 44100) {
48
+ if (!isPowerOfTwo(size)) throw new Error(`FFT size must be a power of two, got ${size}`);
49
+ this.size = size;
50
+ this.sampleRate = sampleRate;
51
+ this.re = new Float64Array(size);
52
+ this.im = new Float64Array(size);
53
+ this.rev = reversalTable(size);
54
+ this.window = hann(size);
55
+ this.magnitudes = new Float32Array(size / 2 + 1);
56
+ }
57
+
58
+ /** Frequency at the centre of a bin. */
59
+ frequencyOf(bin: number): number {
60
+ return (bin * this.sampleRate) / this.size;
61
+ }
62
+
63
+ /**
64
+ * Transform one frame of mono samples. Shorter input is zero padded; longer
65
+ * is truncated, so a partial final frame still draws rather than throwing.
66
+ */
67
+ run(samples: Float32Array | number[]): Float32Array {
68
+ const { size, re, im, rev, window } = this;
69
+ for (let i = 0; i < size; i++) {
70
+ const s = (samples[rev[i] as number] as number | undefined) ?? 0;
71
+ re[i] = s * (window[rev[i] as number] as number);
72
+ im[i] = 0;
73
+ }
74
+
75
+ for (let len = 2; len <= size; len <<= 1) {
76
+ const step = (-2 * Math.PI) / len;
77
+ const half = len >> 1;
78
+ for (let i = 0; i < size; i += len) {
79
+ for (let j = 0; j < half; j++) {
80
+ const angle = step * j;
81
+ const wr = Math.cos(angle);
82
+ const wi = Math.sin(angle);
83
+ const a = i + j;
84
+ const b = a + half;
85
+ const tr = (re[b] as number) * wr - (im[b] as number) * wi;
86
+ const ti = (re[b] as number) * wi + (im[b] as number) * wr;
87
+ re[b] = (re[a] as number) - tr;
88
+ im[b] = (im[a] as number) - ti;
89
+ re[a] = (re[a] as number) + tr;
90
+ im[a] = (im[a] as number) + ti;
91
+ }
92
+ }
93
+ }
94
+
95
+ const half = size / 2;
96
+ // 2/size normalises a full-scale sine to 1.0 in its bin; DC and Nyquist
97
+ // appear once rather than twice, so they are not doubled.
98
+ for (let k = 0; k <= half; k++) {
99
+ const scale = k === 0 || k === half ? 1 / size : 2 / size;
100
+ this.magnitudes[k] = Math.hypot(re[k] as number, im[k] as number) * scale;
101
+ }
102
+ return this.magnitudes;
103
+ }
104
+
105
+ /** The loudest bin, ignoring DC. Useful for tests and for a tuner readout. */
106
+ peakBin(): number {
107
+ let best = 1;
108
+ for (let k = 2; k < this.magnitudes.length; k++) {
109
+ if ((this.magnitudes[k] as number) > (this.magnitudes[best] as number)) best = k;
110
+ }
111
+ return best;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Edges of `count` logarithmically spaced bands between two frequencies.
117
+ *
118
+ * Linear bins would put nearly every bar above 10 kHz, where there is little to
119
+ * see; hearing is roughly logarithmic and the display should match it.
120
+ */
121
+ export function bandEdges(count: number, sampleRate: number, size: number, low = 40, high = 16000): number[] {
122
+ const nyquist = sampleRate / 2;
123
+ const top = Math.min(high, nyquist);
124
+ const edges: number[] = [];
125
+ for (let i = 0; i <= count; i++) {
126
+ const hz = low * (top / low) ** (i / count);
127
+ edges.push(Math.min(size / 2, Math.max(1, Math.round((hz * size) / sampleRate))));
128
+ }
129
+ return edges;
130
+ }
131
+
132
+ /** Peak magnitude in each band, in decibels, normalised to 0..1. */
133
+ export function bands(
134
+ magnitudes: Float32Array,
135
+ edges: number[],
136
+ floorDb = -70,
137
+ ): number[] {
138
+ const out: number[] = [];
139
+ for (let i = 0; i < edges.length - 1; i++) {
140
+ const lo = edges[i] as number;
141
+ const hi = Math.max(lo + 1, edges[i + 1] as number);
142
+ let peak = 0;
143
+ for (let k = lo; k < hi && k < magnitudes.length; k++) {
144
+ const m = magnitudes[k] as number;
145
+ if (m > peak) peak = m;
146
+ }
147
+ const db = 20 * Math.log10(Math.max(peak, 1e-9));
148
+ out.push(Math.max(0, Math.min(1, (db - floorDb) / -floorDb)));
149
+ }
150
+ return out;
151
+ }
152
+
153
+ /**
154
+ * Bars fall smoothly and rise instantly.
155
+ *
156
+ * A spectrum drawn straight from each frame flickers badly at 30fps. Winamp's
157
+ * analyser rose immediately and decayed, which is both prettier and easier to
158
+ * read; this is that, as one pass over the previous frame.
159
+ */
160
+ export function decay(previous: number[], next: number[], fall = 0.12): number[] {
161
+ return next.map((value, i) => {
162
+ const was = previous[i] ?? 0;
163
+ return value >= was ? value : Math.max(value, was - fall);
164
+ });
165
+ }
package/src/main.ts ADDED
@@ -0,0 +1,311 @@
1
+ /**
2
+ * nixamp — it really whips the terminal's ass.
3
+ *
4
+ * bunx nixamp ~/Music
5
+ * bunx nixamp track.flac
6
+ *
7
+ * ffmpeg decodes; we read every sample on its way to the speakers and draw it.
8
+ */
9
+ import { createApp, themes, type BrailleCanvas, type Container, type KeyEvent, type Theme } from "@profullstack/hqtui";
10
+ import { resolve } from "node:path";
11
+ import {
12
+ detectTools, formatTime, peaks, RATE, Stream, toMono,
13
+ type Tools, type Track,
14
+ } from "./audio.ts";
15
+ import { Analyser, bandEdges, bands, decay } from "./fft.ts";
16
+ import { version } from "./meta.ts";
17
+ import { displayName, loadPlaylist } from "./playlist.ts";
18
+ import { DEFAULT_PORT } from "./server.ts";
19
+
20
+ const FFT_SIZE = 2048;
21
+ export const BAND_COUNT = 24;
22
+
23
+ export interface State {
24
+ tracks: Track[];
25
+ index: number;
26
+ offset: number;
27
+ playing: boolean;
28
+ position: number;
29
+ bars: number[];
30
+ peakHold: number[];
31
+ levels: [number, number];
32
+ note: string;
33
+ silent: boolean;
34
+ root: string;
35
+ }
36
+
37
+ export function createState(tracks: Track[], root: string, silent: boolean): State {
38
+ return {
39
+ tracks,
40
+ index: 0,
41
+ offset: 0,
42
+ playing: false,
43
+ position: 0,
44
+ bars: new Array(BAND_COUNT).fill(0),
45
+ peakHold: new Array(BAND_COUNT).fill(0),
46
+ levels: [0, 0],
47
+ note: silent ? "No audio output found (install ffplay) — analyser only." : "",
48
+ silent,
49
+ root,
50
+ };
51
+ }
52
+
53
+ export function current(state: State): Track | undefined {
54
+ return state.tracks[state.index];
55
+ }
56
+
57
+ /** The classic block ramp, low to high. */
58
+ const RAMP = "▁▂▃▄▅▆▇█";
59
+
60
+ export function barGlyph(value: number): string {
61
+ const i = Math.max(0, Math.min(RAMP.length - 1, Math.round(value * (RAMP.length - 1))));
62
+ return RAMP[i] as string;
63
+ }
64
+
65
+ const HELP = `nixamp — it really whips the terminal's ass.
66
+
67
+ nixamp [path] play a directory or a file in the terminal
68
+ nixamp serve [path] [options] play here, and hand out a browser remote
69
+ nixamp update [version] re-run the installer, keeping your choices
70
+ nixamp uninstall [--yes] remove everything the installer created
71
+
72
+ Options for serve:
73
+ -p, --port N port to listen on (default ${DEFAULT_PORT})
74
+ -h, --host HOST address to bind (default 127.0.0.1; 0.0.0.0 for the LAN)
75
+ --web DIR directory of built PWA files to serve at /
76
+ --no-media do not stream the library's bytes to remotes
77
+
78
+ -v, --version print the version
79
+ --help print this
80
+ `;
81
+
82
+ /**
83
+ * The whole CLI, as a function. `bin/nixamp.mjs` imports and calls it: relying
84
+ * on `import.meta.main` there would leave the installed binary doing nothing,
85
+ * because the flag is false in a module that was imported rather than run.
86
+ */
87
+ export async function main(): Promise<void> {
88
+ const [first, ...rest] = process.argv.slice(2);
89
+
90
+ if (first === "serve") {
91
+ const { serve } = await import("./server.ts");
92
+ await serve(rest, version());
93
+ return;
94
+ }
95
+ if (first === "update" || first === "uninstall") {
96
+ const manage = await import("./manage.ts");
97
+ process.exitCode = first === "update" ? manage.update(rest) : manage.uninstall(rest);
98
+ return;
99
+ }
100
+ if (first === "--version" || first === "-v") { console.log(version()); return; }
101
+ if (first === "--help") { console.log(HELP); return; }
102
+
103
+ const target = resolve(first ?? ".");
104
+ const tools = detectTools();
105
+ const tracks = loadPlaylist(tools, target);
106
+ if (tracks.length === 0) {
107
+ console.error(`nixamp: no audio files under ${target}`);
108
+ process.exit(1);
109
+ }
110
+
111
+ const state = createState(tracks, target, tools.play === null);
112
+ const app = await createApp({ theme: themes.matrix, title: "nixamp", quitKeys: ["ctrl+c"] });
113
+
114
+ const analyser = new Analyser(FFT_SIZE, RATE);
115
+ const edges = bandEdges(BAND_COUNT, RATE, FFT_SIZE);
116
+ // Samples accumulate until there are enough for one transform.
117
+ let pending = new Float32Array(0);
118
+
119
+ const stream = new Stream(tools, {
120
+ onSamples: (pcm) => {
121
+ state.levels = peaks(pcm);
122
+ state.position = stream.position;
123
+ const mono = toMono(pcm);
124
+ const joined = new Float32Array(pending.length + mono.length);
125
+ joined.set(pending);
126
+ joined.set(mono, pending.length);
127
+ let at = 0;
128
+ while (joined.length - at >= FFT_SIZE) {
129
+ analyser.run(joined.subarray(at, at + FFT_SIZE));
130
+ state.bars = decay(state.bars, bands(analyser.magnitudes, edges));
131
+ state.peakHold = state.peakHold.map((p, i) =>
132
+ Math.max((state.bars[i] as number), p - 0.02));
133
+ at += FFT_SIZE;
134
+ }
135
+ pending = joined.subarray(at);
136
+ app.invalidate();
137
+ },
138
+ onEnd: (error) => {
139
+ if (error) { state.note = error; state.playing = false; app.invalidate(); return; }
140
+ next(1);
141
+ },
142
+ });
143
+
144
+ const play = (): void => {
145
+ const track = current(state);
146
+ if (!track) return;
147
+ pending = new Float32Array(0);
148
+ state.position = 0;
149
+ state.playing = true;
150
+ state.note = state.silent ? "No audio output found (install ffplay) — analyser only." : "";
151
+ stream.start(track);
152
+ app.invalidate();
153
+ };
154
+
155
+ const next = (delta: number): void => {
156
+ if (state.tracks.length === 0) return;
157
+ state.index = (state.index + delta + state.tracks.length) % state.tracks.length;
158
+ if (state.playing) play(); else { state.position = 0; app.invalidate(); }
159
+ };
160
+
161
+ const stopAll = (): void => {
162
+ stream.stop();
163
+ state.playing = false;
164
+ state.bars = new Array(BAND_COUNT).fill(0);
165
+ state.peakHold = new Array(BAND_COUNT).fill(0);
166
+ state.levels = [0, 0];
167
+ state.position = 0;
168
+ app.invalidate();
169
+ };
170
+
171
+ app.on("key", (event: KeyEvent) => {
172
+ switch (event.key) {
173
+ case "q": stream.stop(); app.quit(); return;
174
+ case "space": state.playing ? stopAll() : play(); return;
175
+ case "enter": play(); return;
176
+ case "s": stopAll(); return;
177
+ case "n": case "right": next(1); return;
178
+ case "p": case "left": next(-1); return;
179
+ case "up":
180
+ state.index = Math.max(0, state.index - 1);
181
+ if (state.playing) play(); else app.invalidate();
182
+ return;
183
+ case "down":
184
+ state.index = Math.min(state.tracks.length - 1, state.index + 1);
185
+ if (state.playing) play(); else app.invalidate();
186
+ return;
187
+ }
188
+ });
189
+
190
+ app.on("exit", () => stream.stop());
191
+ app.render((args) => view(args, state));
192
+ await app.start();
193
+ }
194
+
195
+
196
+ /**
197
+ * The bars, on a braille canvas: four vertical pixels per cell, so a bar moves
198
+ * smoothly instead of stepping through eight block glyphs.
199
+ *
200
+ * Each band gets a column of pixels with a one-pixel gap, and its peak is held
201
+ * as a single floating pixel that sinks — the detail that made Winamp's
202
+ * analyser readable rather than just busy.
203
+ */
204
+ export function drawSpectrum(canvas: BrailleCanvas, state: State): void {
205
+ const high = canvas.height;
206
+ const wide = canvas.width;
207
+ if (high <= 0 || wide <= 0) return;
208
+ const perBand = Math.max(1, Math.floor(wide / state.bars.length));
209
+ state.bars.forEach((value, i) => {
210
+ const x0 = i * perBand;
211
+ const top = Math.round((1 - value) * (high - 1));
212
+ for (let x = x0; x < x0 + Math.max(1, perBand - 1) && x < wide; x++) {
213
+ canvas.vline(x, top, high - 1);
214
+ canvas.pixel(x, Math.round((1 - (state.peakHold[i] as number)) * (high - 1)));
215
+ }
216
+ });
217
+ }
218
+
219
+ export function view(
220
+ { ui, theme, height }: { ui: Container; theme: Theme; height: number },
221
+ state: State,
222
+ ): void {
223
+ const track = current(state);
224
+ const duration = track?.duration ?? 0;
225
+ const progress = duration > 0 ? Math.min(1, state.position / duration) : 0;
226
+
227
+ ui.row({ size: 1 }, (header) => {
228
+ header.text(" ⣿ NIXAMP", { fg: theme.title, bold: true, size: 11 });
229
+ header.text(state.playing ? "▶ PLAYING" : "■ STOPPED", {
230
+ fg: state.playing ? theme.success : theme.muted,
231
+ size: 12,
232
+ });
233
+ header.text(`${state.tracks.length} tracks ${state.root} `, { fg: theme.muted, align: "right" });
234
+ });
235
+
236
+ ui.panel({ title: "Now Playing", size: 6 }, (p) => {
237
+ if (!track) { p.label("Nothing loaded."); return; }
238
+ p.text(displayName(track), { fg: theme.accent, bold: true, size: 1 });
239
+ p.text(track.album || "—", { fg: theme.muted, size: 1 });
240
+ p.row({ size: 1 }, (r) => {
241
+ r.text(formatTime(state.position), { fg: theme.foreground, size: 7 });
242
+ r.progress({ value: progress, color: theme.success });
243
+ r.text(duration > 0 ? formatTime(duration) : "--:--", {
244
+ fg: theme.muted, size: 7, align: "right",
245
+ });
246
+ });
247
+ });
248
+
249
+ ui.row({ size: height - 10, gap: 1 }, (row) => {
250
+ row.panel({ title: "Spectrum Analyser", width: "1.3fr" }, (p) => {
251
+ // Braille gives four vertical pixels per cell, so the bars move smoothly
252
+ // rather than stepping through eight block glyphs.
253
+ p.canvas((canvas) => {
254
+ drawSpectrum(canvas, state);
255
+ }, { color: theme.success });
256
+ p.row({ size: 1 }, (r) => {
257
+ r.text(state.bars.map(barGlyph).join(""), { fg: theme.success });
258
+ r.text(
259
+ `L${"▮".repeat(Math.round(state.levels[0] * 6)).padEnd(6, "·")} ` +
260
+ `R${"▮".repeat(Math.round(state.levels[1] * 6)).padEnd(6, "·")}`,
261
+ { fg: theme.accent, align: "right" },
262
+ );
263
+ });
264
+ });
265
+
266
+ row.panel({ title: `Playlist (${state.tracks.length})`, width: "1fr" }, (p) => {
267
+ if (state.tracks.length === 0) { p.label("Empty."); return; }
268
+ p.table({
269
+ rows: state.tracks.map((t, i) => ({
270
+ n: String(i + 1).padStart(2, " "),
271
+ name: displayName(t),
272
+ time: t.duration > 0 ? formatTime(t.duration) : "--:--",
273
+ playing: i === state.index && state.playing,
274
+ })),
275
+ selected: state.index,
276
+ offset: state.offset,
277
+ followSelection: true,
278
+ scrollbar: true,
279
+ onScroll: (d) => { state.offset = Math.max(0, state.offset + d); },
280
+ header: false,
281
+ columns: [
282
+ { key: "n", title: "", width: 3, color: theme.muted },
283
+ {
284
+ key: "name", title: "", min: 8,
285
+ color: (row) => (row.playing ? theme.success : theme.foreground),
286
+ },
287
+ { key: "time", title: "", width: 6, align: "right", color: theme.muted },
288
+ ],
289
+ });
290
+ });
291
+ });
292
+
293
+ if (state.note !== "") ui.text(state.note, { fg: theme.warning, size: 1 });
294
+
295
+ ui.statusBar({
296
+ items: [
297
+ { key: "Space", label: state.playing ? "Stop" : "Play", active: state.playing },
298
+ { key: "↑↓", label: "Select" },
299
+ { key: "n/p", label: "Next/Prev" },
300
+ { key: "Enter", label: "Play" },
301
+ { key: "q", label: "Quit" },
302
+ ],
303
+ });
304
+ }
305
+
306
+ if (import.meta.main) {
307
+ main().catch((error) => {
308
+ console.error(error);
309
+ process.exit(1);
310
+ });
311
+ }