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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Profullstack, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,180 @@
1
+ # nixamp
2
+
3
+ It really whips the terminal's ass.
4
+
5
+ ```
6
+ curl -fsSL https://nixamp.com/install.sh | sh
7
+ ```
8
+
9
+ ```
10
+ nixamp ~/Music
11
+ nixamp track.flac
12
+ ```
13
+
14
+ ```
15
+ ⣿ NIXAMP ▶ PLAYING 1 tracks ~/Music
16
+ ╭─ Now Playing ────────────────────────────────────────────────────────────────────────────────────╮
17
+ │ Meshuggah — Bleed │
18
+ │ obZen │
19
+ │ 00:41 █████████████████████████───────────────────────────────────────────────────── 32% 07:27 │
20
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
21
+ ╭─ Spectrum Analyser ─────────────────────────────────╮ ╭─ Playlist (12) ──────────────────────────╮
22
+ │ ⠉⠁⠉⠁⠤⠄⣀⡀ │ │ 1 Meshuggah — Bleed 07:27 │
23
+ │ ⣶⡆⣶⡆⣀⡀ ⠒⠂⠉⠁ │ │ 2 SOAD — Aerials 03:55 │
24
+ │ ⣿⡇⣿⡇⣿⡇⣿⡇⣤⡄⣶⡆⣀⡀⠒⠂⠉⠁ ⠤⠄⣀⡀ │ │ │
25
+ │ ⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣀⡀⣿⡇⣿⡇⣤⡄⣤⡄⣀⡀ ⠉⠁⠤⠄⠒⠂ ⣀⡀ │ │ │
26
+ │ ⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇⣿⡇ │ │ │
27
+ │ ▆▆▅▅▅▅▄▄▅▄▄▄▄▄▄▃▃▃▃▃▃▃▃▂ L▮▮▮▮·· R▮▮▮▮·· │ │ │
28
+ ╰─────────────────────────────────────────────────────╯ ╰──────────────────────────────────────────╯
29
+ Space Stop ↑↓ Select n/p Next/Prev Enter Play q Quit
30
+ ```
31
+
32
+ ## Installing
33
+
34
+ ```
35
+ curl -fsSL https://nixamp.com/install.sh | sh
36
+ ```
37
+
38
+ Everything lands under `~/.local`. No root, no package manager, no system files
39
+ touched. On a machine with a desktop session it installs the app and the CLI
40
+ together, and the CLI then runs on the Node inside the app, so there is no
41
+ system Node to keep in step. Over SSH it detects that there is no desktop and
42
+ installs the CLI alone.
43
+
44
+ ```
45
+ curl -fsSL https://nixamp.com/install.sh | sh -s -- --cli-only
46
+ curl -fsSL https://nixamp.com/install.sh | sh -s -- --desktop
47
+ curl -fsSL https://nixamp.com/install.sh | sh -s -- --version 0.1.0
48
+ curl -fsSL https://nixamp.com/install.sh | sh -s -- --prefix ~/opt
49
+ ```
50
+
51
+ Then:
52
+
53
+ ```
54
+ nixamp update re-runs the installer, keeping the choices you made
55
+ nixamp update 0.2.0 or pins a version
56
+ nixamp uninstall says what would go
57
+ nixamp uninstall --yes removes exactly what the installer created
58
+ ```
59
+
60
+ Removal reads a manifest the installer wrote, so it is exact and works with no
61
+ network. Your music is never touched.
62
+
63
+ If you would rather not pipe a script into a shell, `npm i -g nixamp` and
64
+ `bunx nixamp ~/Music` both work; that route needs Node 24 or newer.
65
+
66
+ ## How it works
67
+
68
+ One decode feeds both your speakers and the display. `ffmpeg` writes raw 32-bit float samples to a pipe; nixamp reads every sample on its way past, runs an FFT over it, and hands the same bytes to `ffplay`.
69
+
70
+ Running a second decoder just for the visualiser would be simpler and wrong: the two would drift apart within seconds and the bars would stop matching what you hear.
71
+
72
+ The analyser is a radix-2 Cooley-Tukey FFT with a Hann window, about 120 lines and no dependency. Bands are spaced logarithmically because hearing is, and they are scaled in decibels for the same reason — linear bins put almost every bar above 10 kHz where there is nothing to see. Bars rise instantly and fall gradually, with a peak marker that sinks. That decay is what made Winamp's analyser readable rather than merely busy.
73
+
74
+ Braille gives four vertical pixels per character cell, so a bar moves smoothly instead of stepping through eight block glyphs.
75
+
76
+ ## Three ways to run it
77
+
78
+ The terminal player is the original and still the point, but the same engine
79
+ now drives two more surfaces.
80
+
81
+ ### Server mode, and the browser remote
82
+
83
+ ```
84
+ nixamp serve ~/Music --host 0.0.0.0
85
+ ```
86
+
87
+ nixamp keeps playing through your speakers and hands out a remote: open the
88
+ address it prints on your phone and you get the playlist, the transport and the
89
+ same spectrum, pushed as it happens. State goes out over Server-Sent Events
90
+ rather than a WebSocket, because SSE is plain HTTP — no dependency, and it
91
+ reconnects by itself when the phone goes to sleep.
92
+
93
+ Tick **Listen on this device** and the browser streams the track's bytes and
94
+ plays it there instead, with its own analyser drawing the same picture.
95
+
96
+ `--host 127.0.0.1` is the default, so nothing is reachable until you say so.
97
+ Track paths never leave the machine; the remote sees titles.
98
+
99
+ | Endpoint | Does |
100
+ |---|---|
101
+ | `GET /api/state` | one snapshot |
102
+ | `GET /api/events` | snapshots, pushed |
103
+ | `POST /api/command` | `play` `toggle` `stop` `next` `prev` `select` |
104
+ | `GET /api/media/:n` | the track's bytes, with ranges (`--no-media` turns it off) |
105
+
106
+ ### The PWA — [nixamp.com](https://nixamp.com)
107
+
108
+ ```
109
+ bun run web:dev # or: bun run web:build && bun run serve
110
+ ```
111
+
112
+ A player in the browser, installable, and the remote client above. It opens
113
+ your own files — nothing is uploaded; the browser decodes them where they are —
114
+ and it plays video as well as audio. Vanilla TypeScript and Vite, one 16 kB
115
+ bundle, and a service worker that precaches the shell so the app opens with no
116
+ network at all.
117
+
118
+ The icons are drawn from source (`web/scripts/icons.ts`) rather than committed
119
+ as opaque binaries, which is how the 192 and the 512 stay in step.
120
+
121
+ ### The desktop app
122
+
123
+ ```
124
+ bun run desktop:dev
125
+ bun run desktop:build # AppImage + deb into desktop/release
126
+ ```
127
+
128
+ Electron around the same PWA, with a real `nixamp serve` running as a child
129
+ process — so the window is the browser player, the terminal player's engine and
130
+ the remote-control server at once.
131
+
132
+ The CLI travels inside the bundle and is run by Electron's own Node, which is
133
+ the point: installing the app installs a working nixamp with no system Node
134
+ anywhere near it. **Copy Bundled CLI Path** in the menu tells you how to call
135
+ it.
136
+
137
+ Building is unsigned on purpose — no code signing, no notarisation.
138
+
139
+ ## Requirements
140
+
141
+ **ffmpeg** and **ffprobe** to decode, **ffplay** to make sound. All three ship together.
142
+
143
+ ```
144
+ sudo apt install ffmpeg # or: brew install ffmpeg
145
+ ```
146
+
147
+ Without `ffplay`, nixamp still runs and still draws the spectrum — it just says so rather than pretending to play.
148
+
149
+ A bare `ffmpeg` on `PATH` is used when there is one; `mise` shims are detected and invoked through `mise exec`, because the shim itself fails when no version is pinned.
150
+
151
+ ## Keys
152
+
153
+ | Key | Does |
154
+ |---|---|
155
+ | `Space` | Play or stop |
156
+ | `Enter` | Play the selected track from the start |
157
+ | `↑` `↓` | Move through the playlist |
158
+ | `n` `p` (or `→` `←`) | Next and previous track |
159
+ | `s` | Stop |
160
+ | `q` | Quit |
161
+
162
+ ## Formats
163
+
164
+ Whatever your ffmpeg was built with: mp3, flac, ogg, opus, m4a, aac, wav, wma, aiff, alac, and the audio track of mp4 and webm.
165
+
166
+ ## Status
167
+
168
+ Early. It plays a directory, shows tags and timings, and draws what it hears —
169
+ in a terminal, in a browser and in a window, from one engine. The browser
170
+ player seeks and has a volume slider; the terminal one still does not. Not yet,
171
+ anywhere: shuffle, repeat, m3u playlists, or the visualiser presets that would
172
+ make the name honest.
173
+
174
+ ## Built with
175
+
176
+ [hqtui](https://hqtui.com) — the terminal UI library. Like [r3q](https://github.com/profullstack/r3q) and [g1tz](https://github.com/profullstack/g1tz), nixamp exists partly to keep hqtui honest: a real application finds the gaps a widget gallery does not.
177
+
178
+ ## Licence
179
+
180
+ MIT
package/bin/nixamp.mjs ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "../dist/main.js";
3
+
4
+ main().catch((error) => {
5
+ console.error(error);
6
+ process.exit(1);
7
+ });
@@ -0,0 +1,60 @@
1
+ export declare const RATE = 44100;
2
+ export declare const CHANNELS = 2;
3
+ export interface Track {
4
+ path: string;
5
+ title: string;
6
+ artist: string;
7
+ album: string;
8
+ /** Seconds; 0 when ffprobe could not tell us. */
9
+ duration: number;
10
+ }
11
+ export interface Tools {
12
+ ffmpeg: string[];
13
+ ffprobe: string[];
14
+ /** Argv prefix for the player, or null when nothing can make sound here. */
15
+ play: string[] | null;
16
+ }
17
+ /**
18
+ * Find the tools. A bare `ffmpeg` on PATH is tried first; mise shims are common
19
+ * on developer machines and need `mise exec` because the shim itself fails when
20
+ * no version is pinned.
21
+ */
22
+ export declare function detectTools(): Tools;
23
+ export declare function probe(tools: Tools, path: string): Track;
24
+ export interface StreamHandlers {
25
+ /** Interleaved stereo f32 samples, as decoded. */
26
+ onSamples: (pcm: Float32Array) => void;
27
+ onEnd: (error?: string) => void;
28
+ }
29
+ /**
30
+ * A playing track: one ffmpeg decoding, one player consuming, and us in the
31
+ * middle reading every sample on its way past.
32
+ */
33
+ export declare class Stream {
34
+ private readonly tools;
35
+ private readonly handlers;
36
+ private decoder;
37
+ private output;
38
+ private stopped;
39
+ /**
40
+ * Which start each callback belongs to. A killed ffmpeg still fires `close`,
41
+ * and without this its "exited null" lands on the track that replaced it —
42
+ * so skipping a track would report an error and stop the player.
43
+ */
44
+ private generation;
45
+ /** Samples handed to the output so far, per channel. */
46
+ private framesOut;
47
+ /** Leftover bytes when a chunk does not divide into whole f32 samples. */
48
+ private tail;
49
+ constructor(tools: Tools, handlers: StreamHandlers);
50
+ /** Seconds of audio delivered so far. */
51
+ get position(): number;
52
+ get silent(): boolean;
53
+ start(track: Track, from?: number): void;
54
+ stop(): void;
55
+ }
56
+ /** Left and right peak levels from an interleaved stereo frame, 0..1. */
57
+ export declare function peaks(pcm: Float32Array): [number, number];
58
+ /** Interleaved stereo down to mono, for the analyser. */
59
+ export declare function toMono(pcm: Float32Array): Float32Array;
60
+ export declare function formatTime(seconds: number): string;
package/dist/audio.js ADDED
@@ -0,0 +1,206 @@
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 } from "node:child_process";
10
+ export const RATE = 44100;
11
+ export const CHANNELS = 2;
12
+ function works(argv) {
13
+ const [cmd, ...rest] = argv;
14
+ if (!cmd)
15
+ return false;
16
+ const r = spawnSync(cmd, [...rest, "-version"], { encoding: "utf8", timeout: 10_000 });
17
+ return !r.error && r.status === 0;
18
+ }
19
+ /**
20
+ * Find the tools. A bare `ffmpeg` on PATH is tried first; mise shims are common
21
+ * on developer machines and need `mise exec` because the shim itself fails when
22
+ * no version is pinned.
23
+ */
24
+ export function detectTools() {
25
+ const candidates = (name) => [
26
+ [name],
27
+ ["mise", "exec", `ffmpeg@latest`, "--", name],
28
+ ];
29
+ const pick = (name) => candidates(name).find((argv) => works(argv)) ?? null;
30
+ const ffmpeg = pick("ffmpeg");
31
+ const ffprobe = pick("ffprobe");
32
+ const play = pick("ffplay");
33
+ return {
34
+ ffmpeg: ffmpeg ?? ["ffmpeg"],
35
+ ffprobe: ffprobe ?? ["ffprobe"],
36
+ play,
37
+ };
38
+ }
39
+ export function probe(tools, path) {
40
+ const [cmd, ...rest] = tools.ffprobe;
41
+ const fallback = {
42
+ path,
43
+ title: path.split("/").pop() ?? path,
44
+ artist: "",
45
+ album: "",
46
+ duration: 0,
47
+ };
48
+ if (!cmd)
49
+ return fallback;
50
+ const result = spawnSync(cmd, [
51
+ ...rest,
52
+ "-v", "quiet", "-print_format", "json",
53
+ "-show_format", "-show_entries", "format_tags=title,artist,album",
54
+ path,
55
+ ], { encoding: "utf8", timeout: 20_000, maxBuffer: 4 * 1024 * 1024 });
56
+ if (result.error || result.status !== 0)
57
+ return fallback;
58
+ try {
59
+ const parsed = JSON.parse(result.stdout);
60
+ const tags = parsed.format?.tags ?? {};
61
+ const lower = {};
62
+ for (const [k, v] of Object.entries(tags))
63
+ lower[k.toLowerCase()] = v;
64
+ return {
65
+ path,
66
+ title: lower.title || fallback.title,
67
+ artist: lower.artist ?? "",
68
+ album: lower.album ?? "",
69
+ duration: Number(parsed.format?.duration ?? 0) || 0,
70
+ };
71
+ }
72
+ catch {
73
+ return fallback;
74
+ }
75
+ }
76
+ /**
77
+ * A playing track: one ffmpeg decoding, one player consuming, and us in the
78
+ * middle reading every sample on its way past.
79
+ */
80
+ export class Stream {
81
+ tools;
82
+ handlers;
83
+ decoder = null;
84
+ output = null;
85
+ stopped = false;
86
+ /**
87
+ * Which start each callback belongs to. A killed ffmpeg still fires `close`,
88
+ * and without this its "exited null" lands on the track that replaced it —
89
+ * so skipping a track would report an error and stop the player.
90
+ */
91
+ generation = 0;
92
+ /** Samples handed to the output so far, per channel. */
93
+ framesOut = 0;
94
+ /** Leftover bytes when a chunk does not divide into whole f32 samples. */
95
+ tail = Buffer.alloc(0);
96
+ constructor(tools, handlers) {
97
+ this.tools = tools;
98
+ this.handlers = handlers;
99
+ }
100
+ /** Seconds of audio delivered so far. */
101
+ get position() {
102
+ return this.framesOut / RATE;
103
+ }
104
+ get silent() {
105
+ return this.tools.play === null;
106
+ }
107
+ start(track, from = 0) {
108
+ this.stop();
109
+ this.stopped = false;
110
+ const generation = ++this.generation;
111
+ this.framesOut = Math.round(from * RATE);
112
+ const [ff, ...ffRest] = this.tools.ffmpeg;
113
+ if (!ff) {
114
+ this.handlers.onEnd("ffmpeg not found");
115
+ return;
116
+ }
117
+ this.decoder = spawn(ff, [
118
+ ...ffRest,
119
+ "-hide_banner", "-loglevel", "error",
120
+ ...(from > 0 ? ["-ss", String(from)] : []),
121
+ "-i", track.path,
122
+ "-f", "f32le", "-ac", String(CHANNELS), "-ar", String(RATE), "-",
123
+ ], { stdio: ["ignore", "pipe", "pipe"] });
124
+ if (this.tools.play) {
125
+ const [player, ...playerRest] = this.tools.play;
126
+ this.output = spawn(player, [
127
+ ...playerRest,
128
+ "-hide_banner", "-loglevel", "quiet",
129
+ "-nodisp", "-autoexit",
130
+ "-f", "f32le", "-ac", String(CHANNELS), "-ar", String(RATE), "-i", "-",
131
+ ], { stdio: ["pipe", "ignore", "ignore"] });
132
+ // The player exiting first must not kill us with EPIPE.
133
+ this.output.stdin?.on("error", () => { });
134
+ }
135
+ let stderr = "";
136
+ this.decoder.stderr?.on("data", (c) => { stderr += c.toString(); });
137
+ this.decoder.stdout?.on("data", (chunk) => {
138
+ if (this.stopped || generation !== this.generation)
139
+ return;
140
+ this.output?.stdin?.write(chunk);
141
+ const joined = this.tail.length ? Buffer.concat([this.tail, chunk]) : chunk;
142
+ const usable = joined.length - (joined.length % 4);
143
+ this.tail = usable === joined.length ? Buffer.alloc(0) : joined.subarray(usable);
144
+ if (usable === 0)
145
+ return;
146
+ // Copy rather than view: the underlying buffer is reused by the stream.
147
+ const samples = new Float32Array(usable / 4);
148
+ for (let i = 0; i < samples.length; i++)
149
+ samples[i] = joined.readFloatLE(i * 4);
150
+ this.framesOut += samples.length / CHANNELS;
151
+ this.handlers.onSamples(samples);
152
+ });
153
+ this.decoder.on("close", (code) => {
154
+ if (this.stopped || generation !== this.generation)
155
+ return;
156
+ this.output?.stdin?.end();
157
+ this.handlers.onEnd(code === 0 ? undefined : stderr.trim() || `ffmpeg exited ${code}`);
158
+ });
159
+ this.decoder.on("error", (error) => {
160
+ if (this.stopped || generation !== this.generation)
161
+ return;
162
+ this.handlers.onEnd(error.message);
163
+ });
164
+ }
165
+ stop() {
166
+ this.stopped = true;
167
+ // Anything still in flight from the last start belongs to nobody now.
168
+ this.generation++;
169
+ this.decoder?.kill("SIGKILL");
170
+ this.output?.stdin?.end();
171
+ this.output?.kill("SIGKILL");
172
+ this.decoder = null;
173
+ this.output = null;
174
+ this.tail = Buffer.alloc(0);
175
+ }
176
+ }
177
+ /** Left and right peak levels from an interleaved stereo frame, 0..1. */
178
+ export function peaks(pcm) {
179
+ let left = 0;
180
+ let right = 0;
181
+ for (let i = 0; i + 1 < pcm.length; i += 2) {
182
+ const l = Math.abs(pcm[i]);
183
+ const r = Math.abs(pcm[i + 1]);
184
+ if (l > left)
185
+ left = l;
186
+ if (r > right)
187
+ right = r;
188
+ }
189
+ return [Math.min(1, left), Math.min(1, right)];
190
+ }
191
+ /** Interleaved stereo down to mono, for the analyser. */
192
+ export function toMono(pcm) {
193
+ const mono = new Float32Array(Math.floor(pcm.length / CHANNELS));
194
+ for (let i = 0; i < mono.length; i++) {
195
+ mono[i] = ((pcm[i * 2] + pcm[i * 2 + 1]) / 2);
196
+ }
197
+ return mono;
198
+ }
199
+ export function formatTime(seconds) {
200
+ if (!Number.isFinite(seconds) || seconds < 0)
201
+ return "--:--";
202
+ const total = Math.floor(seconds);
203
+ const m = Math.floor(total / 60);
204
+ const s = total % 60;
205
+ return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
206
+ }
package/dist/fft.d.ts ADDED
@@ -0,0 +1,52 @@
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
+ export declare function isPowerOfTwo(n: number): boolean;
9
+ /**
10
+ * A Hann window, precomputed.
11
+ *
12
+ * Without one, a tone that does not complete a whole number of cycles in the
13
+ * frame leaks across every bin and the display turns to mush.
14
+ */
15
+ export declare function hann(n: number): Float32Array;
16
+ export declare class Analyser {
17
+ readonly size: number;
18
+ readonly sampleRate: number;
19
+ private readonly re;
20
+ private readonly im;
21
+ private readonly rev;
22
+ private readonly window;
23
+ /** Magnitudes for bins 0..size/2, reused between frames. */
24
+ readonly magnitudes: Float32Array;
25
+ constructor(size?: number, sampleRate?: number);
26
+ /** Frequency at the centre of a bin. */
27
+ frequencyOf(bin: number): number;
28
+ /**
29
+ * Transform one frame of mono samples. Shorter input is zero padded; longer
30
+ * is truncated, so a partial final frame still draws rather than throwing.
31
+ */
32
+ run(samples: Float32Array | number[]): Float32Array;
33
+ /** The loudest bin, ignoring DC. Useful for tests and for a tuner readout. */
34
+ peakBin(): number;
35
+ }
36
+ /**
37
+ * Edges of `count` logarithmically spaced bands between two frequencies.
38
+ *
39
+ * Linear bins would put nearly every bar above 10 kHz, where there is little to
40
+ * see; hearing is roughly logarithmic and the display should match it.
41
+ */
42
+ export declare function bandEdges(count: number, sampleRate: number, size: number, low?: number, high?: number): number[];
43
+ /** Peak magnitude in each band, in decibels, normalised to 0..1. */
44
+ export declare function bands(magnitudes: Float32Array, edges: number[], floorDb?: number): number[];
45
+ /**
46
+ * Bars fall smoothly and rise instantly.
47
+ *
48
+ * A spectrum drawn straight from each frame flickers badly at 30fps. Winamp's
49
+ * analyser rose immediately and decayed, which is both prettier and easier to
50
+ * read; this is that, as one pass over the previous frame.
51
+ */
52
+ export declare function decay(previous: number[], next: number[], fall?: number): number[];
package/dist/fft.js ADDED
@@ -0,0 +1,154 @@
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
+ /** Bit-reversal permutation table for a transform of size n (a power of two). */
9
+ function reversalTable(n) {
10
+ const bits = Math.log2(n);
11
+ const table = new Uint32Array(n);
12
+ for (let i = 0; i < n; i++) {
13
+ let reversed = 0;
14
+ for (let b = 0; b < bits; b++)
15
+ if (i & (1 << b))
16
+ reversed |= 1 << (bits - 1 - b);
17
+ table[i] = reversed;
18
+ }
19
+ return table;
20
+ }
21
+ export function isPowerOfTwo(n) {
22
+ return n > 0 && (n & (n - 1)) === 0;
23
+ }
24
+ /**
25
+ * A Hann window, precomputed.
26
+ *
27
+ * Without one, a tone that does not complete a whole number of cycles in the
28
+ * frame leaks across every bin and the display turns to mush.
29
+ */
30
+ export function hann(n) {
31
+ const w = new Float32Array(n);
32
+ for (let i = 0; i < n; i++)
33
+ w[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / (n - 1)));
34
+ return w;
35
+ }
36
+ export class Analyser {
37
+ size;
38
+ sampleRate;
39
+ re;
40
+ im;
41
+ rev;
42
+ window;
43
+ /** Magnitudes for bins 0..size/2, reused between frames. */
44
+ magnitudes;
45
+ constructor(size = 2048, sampleRate = 44100) {
46
+ if (!isPowerOfTwo(size))
47
+ throw new Error(`FFT size must be a power of two, got ${size}`);
48
+ this.size = size;
49
+ this.sampleRate = sampleRate;
50
+ this.re = new Float64Array(size);
51
+ this.im = new Float64Array(size);
52
+ this.rev = reversalTable(size);
53
+ this.window = hann(size);
54
+ this.magnitudes = new Float32Array(size / 2 + 1);
55
+ }
56
+ /** Frequency at the centre of a bin. */
57
+ frequencyOf(bin) {
58
+ return (bin * this.sampleRate) / this.size;
59
+ }
60
+ /**
61
+ * Transform one frame of mono samples. Shorter input is zero padded; longer
62
+ * is truncated, so a partial final frame still draws rather than throwing.
63
+ */
64
+ run(samples) {
65
+ const { size, re, im, rev, window } = this;
66
+ for (let i = 0; i < size; i++) {
67
+ const s = samples[rev[i]] ?? 0;
68
+ re[i] = s * window[rev[i]];
69
+ im[i] = 0;
70
+ }
71
+ for (let len = 2; len <= size; len <<= 1) {
72
+ const step = (-2 * Math.PI) / len;
73
+ const half = len >> 1;
74
+ for (let i = 0; i < size; i += len) {
75
+ for (let j = 0; j < half; j++) {
76
+ const angle = step * j;
77
+ const wr = Math.cos(angle);
78
+ const wi = Math.sin(angle);
79
+ const a = i + j;
80
+ const b = a + half;
81
+ const tr = re[b] * wr - im[b] * wi;
82
+ const ti = re[b] * wi + im[b] * wr;
83
+ re[b] = re[a] - tr;
84
+ im[b] = im[a] - ti;
85
+ re[a] = re[a] + tr;
86
+ im[a] = im[a] + ti;
87
+ }
88
+ }
89
+ }
90
+ const half = size / 2;
91
+ // 2/size normalises a full-scale sine to 1.0 in its bin; DC and Nyquist
92
+ // appear once rather than twice, so they are not doubled.
93
+ for (let k = 0; k <= half; k++) {
94
+ const scale = k === 0 || k === half ? 1 / size : 2 / size;
95
+ this.magnitudes[k] = Math.hypot(re[k], im[k]) * scale;
96
+ }
97
+ return this.magnitudes;
98
+ }
99
+ /** The loudest bin, ignoring DC. Useful for tests and for a tuner readout. */
100
+ peakBin() {
101
+ let best = 1;
102
+ for (let k = 2; k < this.magnitudes.length; k++) {
103
+ if (this.magnitudes[k] > this.magnitudes[best])
104
+ best = k;
105
+ }
106
+ return best;
107
+ }
108
+ }
109
+ /**
110
+ * Edges of `count` logarithmically spaced bands between two frequencies.
111
+ *
112
+ * Linear bins would put nearly every bar above 10 kHz, where there is little to
113
+ * see; hearing is roughly logarithmic and the display should match it.
114
+ */
115
+ export function bandEdges(count, sampleRate, size, low = 40, high = 16000) {
116
+ const nyquist = sampleRate / 2;
117
+ const top = Math.min(high, nyquist);
118
+ const edges = [];
119
+ for (let i = 0; i <= count; i++) {
120
+ const hz = low * (top / low) ** (i / count);
121
+ edges.push(Math.min(size / 2, Math.max(1, Math.round((hz * size) / sampleRate))));
122
+ }
123
+ return edges;
124
+ }
125
+ /** Peak magnitude in each band, in decibels, normalised to 0..1. */
126
+ export function bands(magnitudes, edges, floorDb = -70) {
127
+ const out = [];
128
+ for (let i = 0; i < edges.length - 1; i++) {
129
+ const lo = edges[i];
130
+ const hi = Math.max(lo + 1, edges[i + 1]);
131
+ let peak = 0;
132
+ for (let k = lo; k < hi && k < magnitudes.length; k++) {
133
+ const m = magnitudes[k];
134
+ if (m > peak)
135
+ peak = m;
136
+ }
137
+ const db = 20 * Math.log10(Math.max(peak, 1e-9));
138
+ out.push(Math.max(0, Math.min(1, (db - floorDb) / -floorDb)));
139
+ }
140
+ return out;
141
+ }
142
+ /**
143
+ * Bars fall smoothly and rise instantly.
144
+ *
145
+ * A spectrum drawn straight from each frame flickers badly at 30fps. Winamp's
146
+ * analyser rose immediately and decayed, which is both prettier and easier to
147
+ * read; this is that, as one pass over the previous frame.
148
+ */
149
+ export function decay(previous, next, fall = 0.12) {
150
+ return next.map((value, i) => {
151
+ const was = previous[i] ?? 0;
152
+ return value >= was ? value : Math.max(value, was - fall);
153
+ });
154
+ }