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/src/manage.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
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
|
+
|
|
13
|
+
/** What the installer recorded about this install. */
|
|
14
|
+
export interface Manifest {
|
|
15
|
+
version: string;
|
|
16
|
+
method: string;
|
|
17
|
+
installer: string;
|
|
18
|
+
installedAt: string;
|
|
19
|
+
prefix: string;
|
|
20
|
+
desktop: boolean;
|
|
21
|
+
paths: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const SITE = "https://nixamp.com";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Where the installer put things. `NIXAMP_HOME` is exported by the shim it
|
|
28
|
+
* wrote, which is the only thing that knows for certain; the walk up from this
|
|
29
|
+
* file covers a shim from an older install that did not set it.
|
|
30
|
+
*/
|
|
31
|
+
export function installRoot(from = new URL(".", import.meta.url).pathname): string | null {
|
|
32
|
+
const declared = process.env["NIXAMP_HOME"];
|
|
33
|
+
if (declared && existsSync(join(declared, "manifest.json"))) return declared;
|
|
34
|
+
|
|
35
|
+
// dist/ -> the CLI directory -> share/nixamp for a CLI-only install, or the
|
|
36
|
+
// app bundle's resources for a desktop one.
|
|
37
|
+
let dir = resolve(from);
|
|
38
|
+
for (let i = 0; i < 6; i++) {
|
|
39
|
+
if (existsSync(join(dir, "manifest.json"))) return dir;
|
|
40
|
+
const share = join(dir, "share", "nixamp");
|
|
41
|
+
if (existsSync(join(share, "manifest.json"))) return share;
|
|
42
|
+
const parent = dirname(dir);
|
|
43
|
+
if (parent === dir) break;
|
|
44
|
+
dir = parent;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function readManifest(root: string): Manifest | null {
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(readFileSync(join(root, "manifest.json"), "utf8")) as Manifest;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The message for a copy that no installer put here: a checkout, an npx run, or
|
|
59
|
+
* a package manager's own install. Telling someone to run `rm -rf` on a
|
|
60
|
+
* directory we did not create would be worse than saying so.
|
|
61
|
+
*/
|
|
62
|
+
function notInstalled(what: string): number {
|
|
63
|
+
console.error(`nixamp: this copy was not put here by the installer, so there is nothing to ${what}.`);
|
|
64
|
+
console.error("");
|
|
65
|
+
console.error(" Installed with npm or bun: npm uninstall -g nixamp");
|
|
66
|
+
console.error(" Running from a checkout: delete the checkout");
|
|
67
|
+
console.error(` Wanted the installed one: curl -fsSL ${SITE}/install.sh | sh`);
|
|
68
|
+
return 69;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Update by re-running the installer with the same choices. It is the one
|
|
73
|
+
* place that knows how to lay an install out, so an update gets every fix the
|
|
74
|
+
* installer has had since, rather than only a newer tarball.
|
|
75
|
+
*/
|
|
76
|
+
export function update(argv: string[]): number {
|
|
77
|
+
const root = installRoot();
|
|
78
|
+
const manifest = root ? readManifest(root) : null;
|
|
79
|
+
if (!root || !manifest) return notInstalled("update");
|
|
80
|
+
|
|
81
|
+
const installer = manifest.installer || `${SITE}/install.sh`;
|
|
82
|
+
const args = ["-s", "--", manifest.desktop ? "--desktop" : "--cli-only", "--prefix", manifest.prefix];
|
|
83
|
+
const wanted = argv.find((a) => !a.startsWith("-"));
|
|
84
|
+
if (wanted) args.push("--version", wanted);
|
|
85
|
+
|
|
86
|
+
console.log(`nixamp ${manifest.version} is installed. Fetching the installer...`);
|
|
87
|
+
|
|
88
|
+
const fetcher = which("curl") ? ["curl", "-fsSL", installer] : which("wget") ? ["wget", "-qO-", installer] : null;
|
|
89
|
+
if (!fetcher) {
|
|
90
|
+
console.error("nixamp: curl or wget is required to update.");
|
|
91
|
+
return 69;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Piping the script into sh is what the documented install line does, so an
|
|
95
|
+
// update takes exactly the path a fresh install takes.
|
|
96
|
+
const script = spawnSync(fetcher[0] as string, fetcher.slice(1), { encoding: "utf8" });
|
|
97
|
+
if (script.status !== 0 || !script.stdout) {
|
|
98
|
+
console.error(`nixamp: could not fetch ${installer}`);
|
|
99
|
+
return 1;
|
|
100
|
+
}
|
|
101
|
+
const run = spawnSync("sh", args, { input: script.stdout, stdio: ["pipe", "inherit", "inherit"] });
|
|
102
|
+
return run.status ?? 1;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Run the uninstall script the installer left beside the manifest. */
|
|
106
|
+
export function uninstall(argv: string[]): number {
|
|
107
|
+
const root = installRoot();
|
|
108
|
+
const manifest = root ? readManifest(root) : null;
|
|
109
|
+
if (!root || !manifest) return notInstalled("uninstall");
|
|
110
|
+
|
|
111
|
+
// Saying what would go needs only the manifest, so it comes first: a missing
|
|
112
|
+
// script is a problem for removing, not for describing.
|
|
113
|
+
if (!argv.includes("--yes") && !argv.includes("-y")) {
|
|
114
|
+
console.log(`This removes nixamp ${manifest.version} and everything the installer created:`);
|
|
115
|
+
for (const path of manifest.paths) console.log(` ${path}`);
|
|
116
|
+
console.log("");
|
|
117
|
+
console.log("Your music is not touched. Run `nixamp uninstall --yes` to go ahead.");
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const script = join(root, "uninstall.sh");
|
|
122
|
+
if (!existsSync(script)) {
|
|
123
|
+
console.error(`nixamp: ${script} is missing, so removal cannot be exact.`);
|
|
124
|
+
console.error(` The manifest lists: ${manifest.paths.join(", ")}`);
|
|
125
|
+
return 1;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const run = spawnSync("sh", [script], { stdio: "inherit" });
|
|
129
|
+
return run.status ?? 1;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function which(command: string): boolean {
|
|
133
|
+
return spawnSync("sh", ["-c", `command -v ${command}`], { stdio: "ignore" }).status === 0;
|
|
134
|
+
}
|
package/src/meta.ts
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
|
+
|
|
4
|
+
/** The version we were installed as, or 0.0.0 when the manifest is missing. */
|
|
5
|
+
export function version(): string {
|
|
6
|
+
try {
|
|
7
|
+
const raw = readFileSync(new URL("../package.json", import.meta.url), "utf8");
|
|
8
|
+
return (JSON.parse(raw) as { version?: string }).version ?? "0.0.0";
|
|
9
|
+
} catch {
|
|
10
|
+
return "0.0.0";
|
|
11
|
+
}
|
|
12
|
+
}
|
package/src/playlist.ts
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, type Tools, type Track } from "./audio.ts";
|
|
5
|
+
|
|
6
|
+
export const AUDIO_EXTENSIONS = new Set([
|
|
7
|
+
".mp3", ".flac", ".ogg", ".oga", ".opus", ".m4a", ".aac",
|
|
8
|
+
".wav", ".wma", ".aiff", ".aif", ".alac", ".mp4", ".webm",
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
export function isAudio(path: string): boolean {
|
|
12
|
+
const dot = path.lastIndexOf(".");
|
|
13
|
+
return dot > 0 && AUDIO_EXTENSIONS.has(path.slice(dot).toLowerCase());
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Every audio file under `root`, depth first. A single file is a playlist of one. */
|
|
17
|
+
export function findAudio(root: string): string[] {
|
|
18
|
+
const out: string[] = [];
|
|
19
|
+
let stats;
|
|
20
|
+
try {
|
|
21
|
+
stats = statSync(root);
|
|
22
|
+
} catch {
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
if (stats.isFile()) return isAudio(root) ? [root] : out;
|
|
26
|
+
|
|
27
|
+
const walk = (dir: string): void => {
|
|
28
|
+
let entries: string[];
|
|
29
|
+
try {
|
|
30
|
+
entries = readdirSync(dir).sort();
|
|
31
|
+
} catch {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
for (const entry of entries) {
|
|
35
|
+
if (entry.startsWith(".")) continue;
|
|
36
|
+
const full = join(dir, entry);
|
|
37
|
+
let s;
|
|
38
|
+
try {
|
|
39
|
+
s = statSync(full);
|
|
40
|
+
} catch {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (s.isDirectory()) walk(full);
|
|
44
|
+
else if (isAudio(full)) out.push(full);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
walk(root);
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Reading tags means an ffprobe per file, which is slow for a large library, so
|
|
53
|
+
* the caller decides when to pay for it. Untagged entries still play.
|
|
54
|
+
*/
|
|
55
|
+
export function loadPlaylist(tools: Tools, root: string, probeTags = true): Track[] {
|
|
56
|
+
return findAudio(root).map((path) =>
|
|
57
|
+
probeTags ? probe(tools, path) : {
|
|
58
|
+
path,
|
|
59
|
+
title: path.split("/").pop() ?? path,
|
|
60
|
+
artist: "", album: "", duration: 0,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function displayName(track: Track): string {
|
|
65
|
+
return track.artist ? `${track.artist} — ${track.title}` : track.title;
|
|
66
|
+
}
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
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
|
+
|
|
9
|
+
/** A track as a remote sees it — no filesystem path leaves the machine. */
|
|
10
|
+
export interface RemoteTrack {
|
|
11
|
+
title: string;
|
|
12
|
+
artist: string;
|
|
13
|
+
album: string;
|
|
14
|
+
/** Seconds; 0 when ffprobe could not tell us. */
|
|
15
|
+
duration: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Everything a remote needs to draw the player. */
|
|
19
|
+
export interface Snapshot {
|
|
20
|
+
/** Bumped on every push so a client can drop an out-of-order frame. */
|
|
21
|
+
revision: number;
|
|
22
|
+
tracks: RemoteTrack[];
|
|
23
|
+
index: number;
|
|
24
|
+
playing: boolean;
|
|
25
|
+
position: number;
|
|
26
|
+
/** Analyser bands, 0..1, one per bar. */
|
|
27
|
+
bars: number[];
|
|
28
|
+
/** Left and right peak levels, 0..1. */
|
|
29
|
+
levels: [number, number];
|
|
30
|
+
/** Whether this machine can actually make sound. */
|
|
31
|
+
silent: boolean;
|
|
32
|
+
note: string;
|
|
33
|
+
root: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type Command =
|
|
37
|
+
| { type: "play"; index?: number }
|
|
38
|
+
| { type: "toggle" }
|
|
39
|
+
| { type: "stop" }
|
|
40
|
+
| { type: "next" }
|
|
41
|
+
| { type: "prev" }
|
|
42
|
+
| { type: "select"; index: number };
|
|
43
|
+
|
|
44
|
+
export const COMMAND_TYPES = ["play", "toggle", "stop", "next", "prev", "select"] as const;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Commands arrive as untrusted JSON from a browser on the LAN, so nothing is
|
|
48
|
+
* assumed: an unknown type or a non-integer index is a null, not a throw.
|
|
49
|
+
*/
|
|
50
|
+
export function parseCommand(input: unknown): Command | null {
|
|
51
|
+
if (typeof input !== "object" || input === null) return null;
|
|
52
|
+
const record = input as Record<string, unknown>;
|
|
53
|
+
const type = record.type;
|
|
54
|
+
if (typeof type !== "string") return null;
|
|
55
|
+
const index = record.index;
|
|
56
|
+
const validIndex = typeof index === "number" && Number.isInteger(index) && index >= 0;
|
|
57
|
+
|
|
58
|
+
switch (type) {
|
|
59
|
+
case "toggle":
|
|
60
|
+
case "stop":
|
|
61
|
+
case "next":
|
|
62
|
+
case "prev":
|
|
63
|
+
return { type };
|
|
64
|
+
case "play":
|
|
65
|
+
return validIndex ? { type: "play", index: index as number } : { type: "play" };
|
|
66
|
+
case "select":
|
|
67
|
+
return validIndex ? { type: "select", index: index as number } : null;
|
|
68
|
+
default:
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The name a remote shows for a track. */
|
|
74
|
+
export function remoteName(track: RemoteTrack): string {
|
|
75
|
+
return track.artist ? `${track.artist} — ${track.title}` : track.title;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function emptySnapshot(): Snapshot {
|
|
79
|
+
return {
|
|
80
|
+
revision: 0,
|
|
81
|
+
tracks: [],
|
|
82
|
+
index: 0,
|
|
83
|
+
playing: false,
|
|
84
|
+
position: 0,
|
|
85
|
+
bars: [],
|
|
86
|
+
levels: [0, 0],
|
|
87
|
+
silent: true,
|
|
88
|
+
note: "",
|
|
89
|
+
root: "",
|
|
90
|
+
};
|
|
91
|
+
}
|
package/src/serve.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The server on its own, with no terminal UI attached.
|
|
3
|
+
*
|
|
4
|
+
* `nixamp serve` reaches the same code through main.ts, but the desktop app
|
|
5
|
+
* spawns this file directly: it pulls in no TUI, so what it has to load is the
|
|
6
|
+
* four modules that decode and serve and nothing else.
|
|
7
|
+
*/
|
|
8
|
+
import { version } from "./meta.ts";
|
|
9
|
+
import { serve } from "./server.ts";
|
|
10
|
+
|
|
11
|
+
await serve(process.argv.slice(2), version());
|