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/dist/main.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
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 { type BrailleCanvas, type Container, type Theme } from "@profullstack/hqtui";
|
|
10
|
+
import { type Track } from "./audio.ts";
|
|
11
|
+
export declare const BAND_COUNT = 24;
|
|
12
|
+
export interface State {
|
|
13
|
+
tracks: Track[];
|
|
14
|
+
index: number;
|
|
15
|
+
offset: number;
|
|
16
|
+
playing: boolean;
|
|
17
|
+
position: number;
|
|
18
|
+
bars: number[];
|
|
19
|
+
peakHold: number[];
|
|
20
|
+
levels: [number, number];
|
|
21
|
+
note: string;
|
|
22
|
+
silent: boolean;
|
|
23
|
+
root: string;
|
|
24
|
+
}
|
|
25
|
+
export declare function createState(tracks: Track[], root: string, silent: boolean): State;
|
|
26
|
+
export declare function current(state: State): Track | undefined;
|
|
27
|
+
export declare function barGlyph(value: number): string;
|
|
28
|
+
/**
|
|
29
|
+
* The whole CLI, as a function. `bin/nixamp.mjs` imports and calls it: relying
|
|
30
|
+
* on `import.meta.main` there would leave the installed binary doing nothing,
|
|
31
|
+
* because the flag is false in a module that was imported rather than run.
|
|
32
|
+
*/
|
|
33
|
+
export declare function main(): Promise<void>;
|
|
34
|
+
/**
|
|
35
|
+
* The bars, on a braille canvas: four vertical pixels per cell, so a bar moves
|
|
36
|
+
* smoothly instead of stepping through eight block glyphs.
|
|
37
|
+
*
|
|
38
|
+
* Each band gets a column of pixels with a one-pixel gap, and its peak is held
|
|
39
|
+
* as a single floating pixel that sinks — the detail that made Winamp's
|
|
40
|
+
* analyser readable rather than just busy.
|
|
41
|
+
*/
|
|
42
|
+
export declare function drawSpectrum(canvas: BrailleCanvas, state: State): void;
|
|
43
|
+
export declare function view({ ui, theme, height }: {
|
|
44
|
+
ui: Container;
|
|
45
|
+
theme: Theme;
|
|
46
|
+
height: number;
|
|
47
|
+
}, state: State): void;
|
package/dist/main.js
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
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 } from "@profullstack/hqtui";
|
|
10
|
+
import { resolve } from "node:path";
|
|
11
|
+
import { detectTools, formatTime, peaks, RATE, Stream, toMono, } from "./audio.js";
|
|
12
|
+
import { Analyser, bandEdges, bands, decay } from "./fft.js";
|
|
13
|
+
import { version } from "./meta.js";
|
|
14
|
+
import { displayName, loadPlaylist } from "./playlist.js";
|
|
15
|
+
import { DEFAULT_PORT } from "./server.js";
|
|
16
|
+
const FFT_SIZE = 2048;
|
|
17
|
+
export const BAND_COUNT = 24;
|
|
18
|
+
export function createState(tracks, root, silent) {
|
|
19
|
+
return {
|
|
20
|
+
tracks,
|
|
21
|
+
index: 0,
|
|
22
|
+
offset: 0,
|
|
23
|
+
playing: false,
|
|
24
|
+
position: 0,
|
|
25
|
+
bars: new Array(BAND_COUNT).fill(0),
|
|
26
|
+
peakHold: new Array(BAND_COUNT).fill(0),
|
|
27
|
+
levels: [0, 0],
|
|
28
|
+
note: silent ? "No audio output found (install ffplay) — analyser only." : "",
|
|
29
|
+
silent,
|
|
30
|
+
root,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export function current(state) {
|
|
34
|
+
return state.tracks[state.index];
|
|
35
|
+
}
|
|
36
|
+
/** The classic block ramp, low to high. */
|
|
37
|
+
const RAMP = "▁▂▃▄▅▆▇█";
|
|
38
|
+
export function barGlyph(value) {
|
|
39
|
+
const i = Math.max(0, Math.min(RAMP.length - 1, Math.round(value * (RAMP.length - 1))));
|
|
40
|
+
return RAMP[i];
|
|
41
|
+
}
|
|
42
|
+
const HELP = `nixamp — it really whips the terminal's ass.
|
|
43
|
+
|
|
44
|
+
nixamp [path] play a directory or a file in the terminal
|
|
45
|
+
nixamp serve [path] [options] play here, and hand out a browser remote
|
|
46
|
+
nixamp update [version] re-run the installer, keeping your choices
|
|
47
|
+
nixamp uninstall [--yes] remove everything the installer created
|
|
48
|
+
|
|
49
|
+
Options for serve:
|
|
50
|
+
-p, --port N port to listen on (default ${DEFAULT_PORT})
|
|
51
|
+
-h, --host HOST address to bind (default 127.0.0.1; 0.0.0.0 for the LAN)
|
|
52
|
+
--web DIR directory of built PWA files to serve at /
|
|
53
|
+
--no-media do not stream the library's bytes to remotes
|
|
54
|
+
|
|
55
|
+
-v, --version print the version
|
|
56
|
+
--help print this
|
|
57
|
+
`;
|
|
58
|
+
/**
|
|
59
|
+
* The whole CLI, as a function. `bin/nixamp.mjs` imports and calls it: relying
|
|
60
|
+
* on `import.meta.main` there would leave the installed binary doing nothing,
|
|
61
|
+
* because the flag is false in a module that was imported rather than run.
|
|
62
|
+
*/
|
|
63
|
+
export async function main() {
|
|
64
|
+
const [first, ...rest] = process.argv.slice(2);
|
|
65
|
+
if (first === "serve") {
|
|
66
|
+
const { serve } = await import("./server.js");
|
|
67
|
+
await serve(rest, version());
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (first === "update" || first === "uninstall") {
|
|
71
|
+
const manage = await import("./manage.js");
|
|
72
|
+
process.exitCode = first === "update" ? manage.update(rest) : manage.uninstall(rest);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (first === "--version" || first === "-v") {
|
|
76
|
+
console.log(version());
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (first === "--help") {
|
|
80
|
+
console.log(HELP);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const target = resolve(first ?? ".");
|
|
84
|
+
const tools = detectTools();
|
|
85
|
+
const tracks = loadPlaylist(tools, target);
|
|
86
|
+
if (tracks.length === 0) {
|
|
87
|
+
console.error(`nixamp: no audio files under ${target}`);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
const state = createState(tracks, target, tools.play === null);
|
|
91
|
+
const app = await createApp({ theme: themes.matrix, title: "nixamp", quitKeys: ["ctrl+c"] });
|
|
92
|
+
const analyser = new Analyser(FFT_SIZE, RATE);
|
|
93
|
+
const edges = bandEdges(BAND_COUNT, RATE, FFT_SIZE);
|
|
94
|
+
// Samples accumulate until there are enough for one transform.
|
|
95
|
+
let pending = new Float32Array(0);
|
|
96
|
+
const stream = new Stream(tools, {
|
|
97
|
+
onSamples: (pcm) => {
|
|
98
|
+
state.levels = peaks(pcm);
|
|
99
|
+
state.position = stream.position;
|
|
100
|
+
const mono = toMono(pcm);
|
|
101
|
+
const joined = new Float32Array(pending.length + mono.length);
|
|
102
|
+
joined.set(pending);
|
|
103
|
+
joined.set(mono, pending.length);
|
|
104
|
+
let at = 0;
|
|
105
|
+
while (joined.length - at >= FFT_SIZE) {
|
|
106
|
+
analyser.run(joined.subarray(at, at + FFT_SIZE));
|
|
107
|
+
state.bars = decay(state.bars, bands(analyser.magnitudes, edges));
|
|
108
|
+
state.peakHold = state.peakHold.map((p, i) => Math.max(state.bars[i], p - 0.02));
|
|
109
|
+
at += FFT_SIZE;
|
|
110
|
+
}
|
|
111
|
+
pending = joined.subarray(at);
|
|
112
|
+
app.invalidate();
|
|
113
|
+
},
|
|
114
|
+
onEnd: (error) => {
|
|
115
|
+
if (error) {
|
|
116
|
+
state.note = error;
|
|
117
|
+
state.playing = false;
|
|
118
|
+
app.invalidate();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
next(1);
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
const play = () => {
|
|
125
|
+
const track = current(state);
|
|
126
|
+
if (!track)
|
|
127
|
+
return;
|
|
128
|
+
pending = new Float32Array(0);
|
|
129
|
+
state.position = 0;
|
|
130
|
+
state.playing = true;
|
|
131
|
+
state.note = state.silent ? "No audio output found (install ffplay) — analyser only." : "";
|
|
132
|
+
stream.start(track);
|
|
133
|
+
app.invalidate();
|
|
134
|
+
};
|
|
135
|
+
const next = (delta) => {
|
|
136
|
+
if (state.tracks.length === 0)
|
|
137
|
+
return;
|
|
138
|
+
state.index = (state.index + delta + state.tracks.length) % state.tracks.length;
|
|
139
|
+
if (state.playing)
|
|
140
|
+
play();
|
|
141
|
+
else {
|
|
142
|
+
state.position = 0;
|
|
143
|
+
app.invalidate();
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
const stopAll = () => {
|
|
147
|
+
stream.stop();
|
|
148
|
+
state.playing = false;
|
|
149
|
+
state.bars = new Array(BAND_COUNT).fill(0);
|
|
150
|
+
state.peakHold = new Array(BAND_COUNT).fill(0);
|
|
151
|
+
state.levels = [0, 0];
|
|
152
|
+
state.position = 0;
|
|
153
|
+
app.invalidate();
|
|
154
|
+
};
|
|
155
|
+
app.on("key", (event) => {
|
|
156
|
+
switch (event.key) {
|
|
157
|
+
case "q":
|
|
158
|
+
stream.stop();
|
|
159
|
+
app.quit();
|
|
160
|
+
return;
|
|
161
|
+
case "space":
|
|
162
|
+
state.playing ? stopAll() : play();
|
|
163
|
+
return;
|
|
164
|
+
case "enter":
|
|
165
|
+
play();
|
|
166
|
+
return;
|
|
167
|
+
case "s":
|
|
168
|
+
stopAll();
|
|
169
|
+
return;
|
|
170
|
+
case "n":
|
|
171
|
+
case "right":
|
|
172
|
+
next(1);
|
|
173
|
+
return;
|
|
174
|
+
case "p":
|
|
175
|
+
case "left":
|
|
176
|
+
next(-1);
|
|
177
|
+
return;
|
|
178
|
+
case "up":
|
|
179
|
+
state.index = Math.max(0, state.index - 1);
|
|
180
|
+
if (state.playing)
|
|
181
|
+
play();
|
|
182
|
+
else
|
|
183
|
+
app.invalidate();
|
|
184
|
+
return;
|
|
185
|
+
case "down":
|
|
186
|
+
state.index = Math.min(state.tracks.length - 1, state.index + 1);
|
|
187
|
+
if (state.playing)
|
|
188
|
+
play();
|
|
189
|
+
else
|
|
190
|
+
app.invalidate();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
app.on("exit", () => stream.stop());
|
|
195
|
+
app.render((args) => view(args, state));
|
|
196
|
+
await app.start();
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* The bars, on a braille canvas: four vertical pixels per cell, so a bar moves
|
|
200
|
+
* smoothly instead of stepping through eight block glyphs.
|
|
201
|
+
*
|
|
202
|
+
* Each band gets a column of pixels with a one-pixel gap, and its peak is held
|
|
203
|
+
* as a single floating pixel that sinks — the detail that made Winamp's
|
|
204
|
+
* analyser readable rather than just busy.
|
|
205
|
+
*/
|
|
206
|
+
export function drawSpectrum(canvas, state) {
|
|
207
|
+
const high = canvas.height;
|
|
208
|
+
const wide = canvas.width;
|
|
209
|
+
if (high <= 0 || wide <= 0)
|
|
210
|
+
return;
|
|
211
|
+
const perBand = Math.max(1, Math.floor(wide / state.bars.length));
|
|
212
|
+
state.bars.forEach((value, i) => {
|
|
213
|
+
const x0 = i * perBand;
|
|
214
|
+
const top = Math.round((1 - value) * (high - 1));
|
|
215
|
+
for (let x = x0; x < x0 + Math.max(1, perBand - 1) && x < wide; x++) {
|
|
216
|
+
canvas.vline(x, top, high - 1);
|
|
217
|
+
canvas.pixel(x, Math.round((1 - state.peakHold[i]) * (high - 1)));
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
export function view({ ui, theme, height }, state) {
|
|
222
|
+
const track = current(state);
|
|
223
|
+
const duration = track?.duration ?? 0;
|
|
224
|
+
const progress = duration > 0 ? Math.min(1, state.position / duration) : 0;
|
|
225
|
+
ui.row({ size: 1 }, (header) => {
|
|
226
|
+
header.text(" ⣿ NIXAMP", { fg: theme.title, bold: true, size: 11 });
|
|
227
|
+
header.text(state.playing ? "▶ PLAYING" : "■ STOPPED", {
|
|
228
|
+
fg: state.playing ? theme.success : theme.muted,
|
|
229
|
+
size: 12,
|
|
230
|
+
});
|
|
231
|
+
header.text(`${state.tracks.length} tracks ${state.root} `, { fg: theme.muted, align: "right" });
|
|
232
|
+
});
|
|
233
|
+
ui.panel({ title: "Now Playing", size: 6 }, (p) => {
|
|
234
|
+
if (!track) {
|
|
235
|
+
p.label("Nothing loaded.");
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
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
|
+
ui.row({ size: height - 10, gap: 1 }, (row) => {
|
|
249
|
+
row.panel({ title: "Spectrum Analyser", width: "1.3fr" }, (p) => {
|
|
250
|
+
// Braille gives four vertical pixels per cell, so the bars move smoothly
|
|
251
|
+
// rather than stepping through eight block glyphs.
|
|
252
|
+
p.canvas((canvas) => {
|
|
253
|
+
drawSpectrum(canvas, state);
|
|
254
|
+
}, { color: theme.success });
|
|
255
|
+
p.row({ size: 1 }, (r) => {
|
|
256
|
+
r.text(state.bars.map(barGlyph).join(""), { fg: theme.success });
|
|
257
|
+
r.text(`L${"▮".repeat(Math.round(state.levels[0] * 6)).padEnd(6, "·")} ` +
|
|
258
|
+
`R${"▮".repeat(Math.round(state.levels[1] * 6)).padEnd(6, "·")}`, { fg: theme.accent, align: "right" });
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
row.panel({ title: `Playlist (${state.tracks.length})`, width: "1fr" }, (p) => {
|
|
262
|
+
if (state.tracks.length === 0) {
|
|
263
|
+
p.label("Empty.");
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
p.table({
|
|
267
|
+
rows: state.tracks.map((t, i) => ({
|
|
268
|
+
n: String(i + 1).padStart(2, " "),
|
|
269
|
+
name: displayName(t),
|
|
270
|
+
time: t.duration > 0 ? formatTime(t.duration) : "--:--",
|
|
271
|
+
playing: i === state.index && state.playing,
|
|
272
|
+
})),
|
|
273
|
+
selected: state.index,
|
|
274
|
+
offset: state.offset,
|
|
275
|
+
followSelection: true,
|
|
276
|
+
scrollbar: true,
|
|
277
|
+
onScroll: (d) => { state.offset = Math.max(0, state.offset + d); },
|
|
278
|
+
header: false,
|
|
279
|
+
columns: [
|
|
280
|
+
{ key: "n", title: "", width: 3, color: theme.muted },
|
|
281
|
+
{
|
|
282
|
+
key: "name", title: "", min: 8,
|
|
283
|
+
color: (row) => (row.playing ? theme.success : theme.foreground),
|
|
284
|
+
},
|
|
285
|
+
{ key: "time", title: "", width: 6, align: "right", color: theme.muted },
|
|
286
|
+
],
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
if (state.note !== "")
|
|
291
|
+
ui.text(state.note, { fg: theme.warning, size: 1 });
|
|
292
|
+
ui.statusBar({
|
|
293
|
+
items: [
|
|
294
|
+
{ key: "Space", label: state.playing ? "Stop" : "Play", active: state.playing },
|
|
295
|
+
{ key: "↑↓", label: "Select" },
|
|
296
|
+
{ key: "n/p", label: "Next/Prev" },
|
|
297
|
+
{ key: "Enter", label: "Play" },
|
|
298
|
+
{ key: "q", label: "Quit" },
|
|
299
|
+
],
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
if (import.meta.main) {
|
|
303
|
+
main().catch((error) => {
|
|
304
|
+
console.error(error);
|
|
305
|
+
process.exit(1);
|
|
306
|
+
});
|
|
307
|
+
}
|
package/dist/manage.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** What the installer recorded about this install. */
|
|
2
|
+
export interface Manifest {
|
|
3
|
+
version: string;
|
|
4
|
+
method: string;
|
|
5
|
+
installer: string;
|
|
6
|
+
installedAt: string;
|
|
7
|
+
prefix: string;
|
|
8
|
+
desktop: boolean;
|
|
9
|
+
paths: string[];
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Where the installer put things. `NIXAMP_HOME` is exported by the shim it
|
|
13
|
+
* wrote, which is the only thing that knows for certain; the walk up from this
|
|
14
|
+
* file covers a shim from an older install that did not set it.
|
|
15
|
+
*/
|
|
16
|
+
export declare function installRoot(from?: string): string | null;
|
|
17
|
+
export declare function readManifest(root: string): Manifest | null;
|
|
18
|
+
/**
|
|
19
|
+
* Update by re-running the installer with the same choices. It is the one
|
|
20
|
+
* place that knows how to lay an install out, so an update gets every fix the
|
|
21
|
+
* installer has had since, rather than only a newer tarball.
|
|
22
|
+
*/
|
|
23
|
+
export declare function update(argv: string[]): number;
|
|
24
|
+
/** Run the uninstall script the installer left beside the manifest. */
|
|
25
|
+
export declare function uninstall(argv: string[]): number;
|
package/dist/manage.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nixamp update` and `nixamp uninstall`.
|
|
3
|
+
*
|
|
4
|
+
* Both belong on the CLI rather than in a second script the user has to find.
|
|
5
|
+
* Removal reads the manifest the installer wrote, so it is exact and works
|
|
6
|
+
* offline: a tool that needs the network to uninstall itself is one you cannot
|
|
7
|
+
* remove on a plane.
|
|
8
|
+
*/
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import { dirname, join, resolve } from "node:path";
|
|
12
|
+
const SITE = "https://nixamp.com";
|
|
13
|
+
/**
|
|
14
|
+
* Where the installer put things. `NIXAMP_HOME` is exported by the shim it
|
|
15
|
+
* wrote, which is the only thing that knows for certain; the walk up from this
|
|
16
|
+
* file covers a shim from an older install that did not set it.
|
|
17
|
+
*/
|
|
18
|
+
export function installRoot(from = new URL(".", import.meta.url).pathname) {
|
|
19
|
+
const declared = process.env["NIXAMP_HOME"];
|
|
20
|
+
if (declared && existsSync(join(declared, "manifest.json")))
|
|
21
|
+
return declared;
|
|
22
|
+
// dist/ -> the CLI directory -> share/nixamp for a CLI-only install, or the
|
|
23
|
+
// app bundle's resources for a desktop one.
|
|
24
|
+
let dir = resolve(from);
|
|
25
|
+
for (let i = 0; i < 6; i++) {
|
|
26
|
+
if (existsSync(join(dir, "manifest.json")))
|
|
27
|
+
return dir;
|
|
28
|
+
const share = join(dir, "share", "nixamp");
|
|
29
|
+
if (existsSync(join(share, "manifest.json")))
|
|
30
|
+
return share;
|
|
31
|
+
const parent = dirname(dir);
|
|
32
|
+
if (parent === dir)
|
|
33
|
+
break;
|
|
34
|
+
dir = parent;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
export function readManifest(root) {
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(readFileSync(join(root, "manifest.json"), "utf8"));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The message for a copy that no installer put here: a checkout, an npx run, or
|
|
48
|
+
* a package manager's own install. Telling someone to run `rm -rf` on a
|
|
49
|
+
* directory we did not create would be worse than saying so.
|
|
50
|
+
*/
|
|
51
|
+
function notInstalled(what) {
|
|
52
|
+
console.error(`nixamp: this copy was not put here by the installer, so there is nothing to ${what}.`);
|
|
53
|
+
console.error("");
|
|
54
|
+
console.error(" Installed with npm or bun: npm uninstall -g nixamp");
|
|
55
|
+
console.error(" Running from a checkout: delete the checkout");
|
|
56
|
+
console.error(` Wanted the installed one: curl -fsSL ${SITE}/install.sh | sh`);
|
|
57
|
+
return 69;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Update by re-running the installer with the same choices. It is the one
|
|
61
|
+
* place that knows how to lay an install out, so an update gets every fix the
|
|
62
|
+
* installer has had since, rather than only a newer tarball.
|
|
63
|
+
*/
|
|
64
|
+
export function update(argv) {
|
|
65
|
+
const root = installRoot();
|
|
66
|
+
const manifest = root ? readManifest(root) : null;
|
|
67
|
+
if (!root || !manifest)
|
|
68
|
+
return notInstalled("update");
|
|
69
|
+
const installer = manifest.installer || `${SITE}/install.sh`;
|
|
70
|
+
const args = ["-s", "--", manifest.desktop ? "--desktop" : "--cli-only", "--prefix", manifest.prefix];
|
|
71
|
+
const wanted = argv.find((a) => !a.startsWith("-"));
|
|
72
|
+
if (wanted)
|
|
73
|
+
args.push("--version", wanted);
|
|
74
|
+
console.log(`nixamp ${manifest.version} is installed. Fetching the installer...`);
|
|
75
|
+
const fetcher = which("curl") ? ["curl", "-fsSL", installer] : which("wget") ? ["wget", "-qO-", installer] : null;
|
|
76
|
+
if (!fetcher) {
|
|
77
|
+
console.error("nixamp: curl or wget is required to update.");
|
|
78
|
+
return 69;
|
|
79
|
+
}
|
|
80
|
+
// Piping the script into sh is what the documented install line does, so an
|
|
81
|
+
// update takes exactly the path a fresh install takes.
|
|
82
|
+
const script = spawnSync(fetcher[0], fetcher.slice(1), { encoding: "utf8" });
|
|
83
|
+
if (script.status !== 0 || !script.stdout) {
|
|
84
|
+
console.error(`nixamp: could not fetch ${installer}`);
|
|
85
|
+
return 1;
|
|
86
|
+
}
|
|
87
|
+
const run = spawnSync("sh", args, { input: script.stdout, stdio: ["pipe", "inherit", "inherit"] });
|
|
88
|
+
return run.status ?? 1;
|
|
89
|
+
}
|
|
90
|
+
/** Run the uninstall script the installer left beside the manifest. */
|
|
91
|
+
export function uninstall(argv) {
|
|
92
|
+
const root = installRoot();
|
|
93
|
+
const manifest = root ? readManifest(root) : null;
|
|
94
|
+
if (!root || !manifest)
|
|
95
|
+
return notInstalled("uninstall");
|
|
96
|
+
// Saying what would go needs only the manifest, so it comes first: a missing
|
|
97
|
+
// script is a problem for removing, not for describing.
|
|
98
|
+
if (!argv.includes("--yes") && !argv.includes("-y")) {
|
|
99
|
+
console.log(`This removes nixamp ${manifest.version} and everything the installer created:`);
|
|
100
|
+
for (const path of manifest.paths)
|
|
101
|
+
console.log(` ${path}`);
|
|
102
|
+
console.log("");
|
|
103
|
+
console.log("Your music is not touched. Run `nixamp uninstall --yes` to go ahead.");
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
const script = join(root, "uninstall.sh");
|
|
107
|
+
if (!existsSync(script)) {
|
|
108
|
+
console.error(`nixamp: ${script} is missing, so removal cannot be exact.`);
|
|
109
|
+
console.error(` The manifest lists: ${manifest.paths.join(", ")}`);
|
|
110
|
+
return 1;
|
|
111
|
+
}
|
|
112
|
+
const run = spawnSync("sh", [script], { stdio: "inherit" });
|
|
113
|
+
return run.status ?? 1;
|
|
114
|
+
}
|
|
115
|
+
function which(command) {
|
|
116
|
+
return spawnSync("sh", ["-c", `command -v ${command}`], { stdio: "ignore" }).status === 0;
|
|
117
|
+
}
|
package/dist/meta.d.ts
ADDED
package/dist/meta.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Facts about this install that both the terminal app and the server want. */
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
/** The version we were installed as, or 0.0.0 when the manifest is missing. */
|
|
4
|
+
export function version() {
|
|
5
|
+
try {
|
|
6
|
+
const raw = readFileSync(new URL("../package.json", import.meta.url), "utf8");
|
|
7
|
+
return JSON.parse(raw).version ?? "0.0.0";
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return "0.0.0";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type Tools, type Track } from "./audio.ts";
|
|
2
|
+
export declare const AUDIO_EXTENSIONS: Set<string>;
|
|
3
|
+
export declare function isAudio(path: string): boolean;
|
|
4
|
+
/** Every audio file under `root`, depth first. A single file is a playlist of one. */
|
|
5
|
+
export declare function findAudio(root: string): string[];
|
|
6
|
+
/**
|
|
7
|
+
* Reading tags means an ffprobe per file, which is slow for a large library, so
|
|
8
|
+
* the caller decides when to pay for it. Untagged entries still play.
|
|
9
|
+
*/
|
|
10
|
+
export declare function loadPlaylist(tools: Tools, root: string, probeTags?: boolean): Track[];
|
|
11
|
+
export declare function displayName(track: Track): string;
|
package/dist/playlist.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** The playlist: audio files found on disk, in a stable order. */
|
|
2
|
+
import { readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { probe } from "./audio.js";
|
|
5
|
+
export const AUDIO_EXTENSIONS = new Set([
|
|
6
|
+
".mp3", ".flac", ".ogg", ".oga", ".opus", ".m4a", ".aac",
|
|
7
|
+
".wav", ".wma", ".aiff", ".aif", ".alac", ".mp4", ".webm",
|
|
8
|
+
]);
|
|
9
|
+
export function isAudio(path) {
|
|
10
|
+
const dot = path.lastIndexOf(".");
|
|
11
|
+
return dot > 0 && AUDIO_EXTENSIONS.has(path.slice(dot).toLowerCase());
|
|
12
|
+
}
|
|
13
|
+
/** Every audio file under `root`, depth first. A single file is a playlist of one. */
|
|
14
|
+
export function findAudio(root) {
|
|
15
|
+
const out = [];
|
|
16
|
+
let stats;
|
|
17
|
+
try {
|
|
18
|
+
stats = statSync(root);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
if (stats.isFile())
|
|
24
|
+
return isAudio(root) ? [root] : out;
|
|
25
|
+
const walk = (dir) => {
|
|
26
|
+
let entries;
|
|
27
|
+
try {
|
|
28
|
+
entries = readdirSync(dir).sort();
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
if (entry.startsWith("."))
|
|
35
|
+
continue;
|
|
36
|
+
const full = join(dir, entry);
|
|
37
|
+
let s;
|
|
38
|
+
try {
|
|
39
|
+
s = statSync(full);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (s.isDirectory())
|
|
45
|
+
walk(full);
|
|
46
|
+
else if (isAudio(full))
|
|
47
|
+
out.push(full);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
walk(root);
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Reading tags means an ffprobe per file, which is slow for a large library, so
|
|
55
|
+
* the caller decides when to pay for it. Untagged entries still play.
|
|
56
|
+
*/
|
|
57
|
+
export function loadPlaylist(tools, root, probeTags = true) {
|
|
58
|
+
return findAudio(root).map((path) => probeTags ? probe(tools, path) : {
|
|
59
|
+
path,
|
|
60
|
+
title: path.split("/").pop() ?? path,
|
|
61
|
+
artist: "", album: "", duration: 0,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
export function displayName(track) {
|
|
65
|
+
return track.artist ? `${track.artist} — ${track.title}` : track.title;
|
|
66
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire format between a running nixamp and any remote that drives it.
|
|
3
|
+
*
|
|
4
|
+
* Kept free of node imports on purpose: the browser client type-checks against
|
|
5
|
+
* this same file, so a change to the protocol breaks both sides at once rather
|
|
6
|
+
* than one of them at runtime.
|
|
7
|
+
*/
|
|
8
|
+
/** A track as a remote sees it — no filesystem path leaves the machine. */
|
|
9
|
+
export interface RemoteTrack {
|
|
10
|
+
title: string;
|
|
11
|
+
artist: string;
|
|
12
|
+
album: string;
|
|
13
|
+
/** Seconds; 0 when ffprobe could not tell us. */
|
|
14
|
+
duration: number;
|
|
15
|
+
}
|
|
16
|
+
/** Everything a remote needs to draw the player. */
|
|
17
|
+
export interface Snapshot {
|
|
18
|
+
/** Bumped on every push so a client can drop an out-of-order frame. */
|
|
19
|
+
revision: number;
|
|
20
|
+
tracks: RemoteTrack[];
|
|
21
|
+
index: number;
|
|
22
|
+
playing: boolean;
|
|
23
|
+
position: number;
|
|
24
|
+
/** Analyser bands, 0..1, one per bar. */
|
|
25
|
+
bars: number[];
|
|
26
|
+
/** Left and right peak levels, 0..1. */
|
|
27
|
+
levels: [number, number];
|
|
28
|
+
/** Whether this machine can actually make sound. */
|
|
29
|
+
silent: boolean;
|
|
30
|
+
note: string;
|
|
31
|
+
root: string;
|
|
32
|
+
}
|
|
33
|
+
export type Command = {
|
|
34
|
+
type: "play";
|
|
35
|
+
index?: number;
|
|
36
|
+
} | {
|
|
37
|
+
type: "toggle";
|
|
38
|
+
} | {
|
|
39
|
+
type: "stop";
|
|
40
|
+
} | {
|
|
41
|
+
type: "next";
|
|
42
|
+
} | {
|
|
43
|
+
type: "prev";
|
|
44
|
+
} | {
|
|
45
|
+
type: "select";
|
|
46
|
+
index: number;
|
|
47
|
+
};
|
|
48
|
+
export declare const COMMAND_TYPES: readonly ["play", "toggle", "stop", "next", "prev", "select"];
|
|
49
|
+
/**
|
|
50
|
+
* Commands arrive as untrusted JSON from a browser on the LAN, so nothing is
|
|
51
|
+
* assumed: an unknown type or a non-integer index is a null, not a throw.
|
|
52
|
+
*/
|
|
53
|
+
export declare function parseCommand(input: unknown): Command | null;
|
|
54
|
+
/** The name a remote shows for a track. */
|
|
55
|
+
export declare function remoteName(track: RemoteTrack): string;
|
|
56
|
+
export declare function emptySnapshot(): Snapshot;
|