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 +21 -0
- package/README.md +180 -0
- package/bin/nixamp.mjs +7 -0
- package/dist/audio.d.ts +60 -0
- package/dist/audio.js +206 -0
- package/dist/fft.d.ts +52 -0
- package/dist/fft.js +154 -0
- package/dist/main.d.ts +47 -0
- package/dist/main.js +307 -0
- package/dist/manage.d.ts +25 -0
- package/dist/manage.js +117 -0
- package/dist/meta.d.ts +2 -0
- package/dist/meta.js +12 -0
- package/dist/playlist.d.ts +11 -0
- package/dist/playlist.js +66 -0
- package/dist/protocol.d.ts +56 -0
- package/dist/protocol.js +53 -0
- package/dist/serve.d.ts +1 -0
- package/dist/serve.js +10 -0
- package/dist/server.d.ts +105 -0
- package/dist/server.js +617 -0
- package/package.json +65 -0
- package/src/audio.ts +233 -0
- package/src/fft.ts +165 -0
- package/src/main.ts +311 -0
- package/src/manage.ts +134 -0
- package/src/meta.ts +12 -0
- package/src/playlist.ts +66 -0
- package/src/protocol.ts +91 -0
- package/src/serve.ts +11 -0
- package/src/server.ts +649 -0
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/index-BGKWWaIx.css +1 -0
- package/web/dist/assets/index-Dhja5wxB.js +1 -0
- package/web/dist/icons/icon-192-maskable.png +0 -0
- package/web/dist/icons/icon-192.png +0 -0
- package/web/dist/icons/icon-512-maskable.png +0 -0
- package/web/dist/icons/icon-512.png +0 -0
- package/web/dist/index.html +110 -0
- package/web/dist/install.sh +299 -0
- package/web/dist/manifest.webmanifest +50 -0
- package/web/dist/sw.js +91 -0
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
package/dist/audio.d.ts
ADDED
|
@@ -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
|
+
}
|