nixamp 0.48.1 → 0.49.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.
Files changed (53) hide show
  1. package/dist/ai-keys.d.ts +1 -1
  2. package/dist/ai-keys.js +2 -0
  3. package/dist/ai-label.d.ts +210 -0
  4. package/dist/ai-label.js +725 -0
  5. package/dist/ai-radio.d.ts +11 -5
  6. package/dist/ai-radio.js +27 -11
  7. package/dist/ai-text.d.ts +2 -0
  8. package/dist/ai-text.js +4 -2
  9. package/dist/label-routes.d.ts +15 -0
  10. package/dist/label-routes.js +255 -0
  11. package/dist/server.d.ts +3 -0
  12. package/dist/server.js +28 -1
  13. package/dist/station-cli.js +109 -0
  14. package/dist/station-guide.d.ts +1 -1
  15. package/dist/station-guide.js +5 -1
  16. package/dist/station-mcp.d.ts +247 -20
  17. package/dist/station-mcp.js +36 -0
  18. package/dist/station-usage.d.ts +1 -1
  19. package/dist/station-usage.js +15 -1
  20. package/package.json +1 -1
  21. package/src/ai-keys.ts +2 -0
  22. package/src/ai-label.ts +826 -0
  23. package/src/ai-radio.ts +30 -13
  24. package/src/ai-text.ts +6 -2
  25. package/src/label-routes.ts +268 -0
  26. package/src/server.ts +30 -1
  27. package/src/station-cli.ts +102 -0
  28. package/src/station-guide.ts +5 -1
  29. package/src/station-mcp.ts +34 -0
  30. package/src/station-usage.ts +15 -1
  31. package/web/dist/assets/{ar-BvPTr_2x.js → ar-BEVjqkqM.js} +1 -1
  32. package/web/dist/assets/{cs-g4ii7Tkl.js → cs-DjazXQ15.js} +1 -1
  33. package/web/dist/assets/{da-D9vYCFH7.js → da-DnyF6SYK.js} +1 -1
  34. package/web/dist/assets/{de-lYElbebQ.js → de--q1ph9Ez.js} +1 -1
  35. package/web/dist/assets/{es-CiDbfxFy.js → es-Do3DkBwD.js} +1 -1
  36. package/web/dist/assets/{fi-BWlqMMWx.js → fi-B7sJTx1N.js} +1 -1
  37. package/web/dist/assets/{fr-D2n5nXQv.js → fr-Dy7f5rGi.js} +1 -1
  38. package/web/dist/assets/{hi-DM9Nh04K.js → hi-C9vSnTQ4.js} +1 -1
  39. package/web/dist/assets/{hu-Bzb0X4rk.js → hu-h-eueQE8.js} +1 -1
  40. package/web/dist/assets/{id-j0QazySW.js → id-D8Ffd-Pc.js} +1 -1
  41. package/web/dist/assets/{it-BVvva3a6.js → it-BKM_2rj1.js} +1 -1
  42. package/web/dist/assets/{main-DPfBhzsz.js → main-Dl_uRj22.js} +2 -2
  43. package/web/dist/assets/{nl-CrJQ6obW.js → nl-Cj3ETrcl.js} +1 -1
  44. package/web/dist/assets/radio-zPgyISLf.js +1 -0
  45. package/web/dist/assets/{ru-DFIjjKvG.js → ru-B5zmGSnG.js} +1 -1
  46. package/web/dist/assets/{sv-BO9QPL4K.js → sv-BMtjA1uI.js} +1 -1
  47. package/web/dist/assets/{uk-DwmG3Owv.js → uk-B2vuB6Un.js} +1 -1
  48. package/web/dist/assets/{vi-C-2EzgMe.js → vi-CXam3lDh.js} +1 -1
  49. package/web/dist/assets/{zh-BlnCenyZ.js → zh-DnYhyzqb.js} +1 -1
  50. package/web/dist/index.html +1 -1
  51. package/web/dist/radio.html +33 -1
  52. package/web/dist/sw.js +20 -20
  53. package/web/dist/assets/radio-DI0H8flN.js +0 -1
@@ -52,22 +52,26 @@ export declare const DEFAULTS: {
52
52
  export interface Composer {
53
53
  /** e.g. "elevenlabs/music_v1". */
54
54
  name: string;
55
- /** Writes an MP3 to `out`. Throws OutOfCredit when the account cannot pay. */
55
+ /** Writes an MP3 to `out`. Throws OutOfCredit when the account cannot pay. `lyrics` are the words to sing, when there are any. */
56
56
  compose(input: {
57
57
  prompt: string;
58
58
  seconds: number;
59
59
  vocals: boolean;
60
60
  out: string;
61
+ lyrics?: string;
61
62
  }): Promise<void>;
62
63
  }
63
64
  export declare class OutOfCredit extends Error {
64
65
  }
65
- /** ElevenLabs Music: a paid plan's music may be broadcast. */
66
- export declare function elevenLabsComposer(key: string, fetcher?: typeof fetch): Composer;
66
+ /** ElevenLabs' music models; NIXAMP_MUSIC_MODEL picks one, the newest by default. */
67
+ export declare const MUSIC_MODELS: readonly ["music_v1", "music_v2", "music_v2_5"];
68
+ /** ElevenLabs Music: a paid plan's music may be broadcast. Given lyrics, it sings those words. */
69
+ export declare function elevenLabsComposer(key: string, fetcher?: typeof fetch, model?: string): Composer;
67
70
  /**
68
71
  * A model of the owner's own (MusicGen, ACE-Step...): NIXAMP_AI_MUSIC_COMMAND
69
- * is run with {prompt}, {seconds}, {vocals} and {out} replaced, each as one
70
- * argument, and must leave an MP3 at {out}.
72
+ * is run with {prompt}, {seconds}, {vocals}, {lyrics} (a file of the words to
73
+ * sing, "" when there are none) and {out} replaced, each as one argument, and
74
+ * must leave an MP3 at {out}.
71
75
  */
72
76
  export declare function commandComposer(template: string): Composer;
73
77
  /** The composer this server can use, or null with the reason. */
@@ -92,6 +96,8 @@ export interface AiRadioDeps {
92
96
  /** The folder the music goes in: a managed library source. */
93
97
  root: string;
94
98
  owner: string;
99
+ /** Who holds the rights in what is made ("Profullstack, Inc."), written as publisher and copyright; "" for none. */
100
+ holder?: () => string;
95
101
  /** Whether another station may be started now (the server's running limit). */
96
102
  mayStart: () => boolean;
97
103
  composer?: () => Composer | null;
package/dist/ai-radio.js CHANGED
@@ -43,16 +43,22 @@ const MAX_TRACKS = 5_000;
43
43
  const BACKOFF_MS = [5 * 60_000, 10 * 60_000, 20 * 60_000, 40 * 60_000, 80 * 60_000, 6 * 3600_000];
44
44
  export class OutOfCredit extends Error {
45
45
  }
46
- /** ElevenLabs Music: a paid plan's music may be broadcast. */
47
- export function elevenLabsComposer(key, fetcher = fetch) {
46
+ /** ElevenLabs' music models; NIXAMP_MUSIC_MODEL picks one, the newest by default. */
47
+ export const MUSIC_MODELS = ["music_v1", "music_v2", "music_v2_5"];
48
+ /** ElevenLabs Music: a paid plan's music may be broadcast. Given lyrics, it sings those words. */
49
+ export function elevenLabsComposer(key, fetcher = fetch, model = "music_v2_5") {
48
50
  return {
49
- name: "elevenlabs/music_v1",
50
- async compose({ prompt, seconds, vocals, out }) {
51
+ name: `elevenlabs/${model}`,
52
+ async compose({ prompt, seconds, vocals, out, lyrics }) {
53
+ const words = vocals && lyrics?.trim() ? lyrics.trim() : "";
51
54
  const ask = async (text) => fetcher("https://api.elevenlabs.io/v1/music?output_format=mp3_44100_192", {
52
55
  method: "POST",
53
56
  signal: AbortSignal.timeout(10 * 60_000),
54
57
  headers: { "content-type": "application/json", "xi-api-key": key },
55
- body: JSON.stringify({ prompt: text, music_length_ms: Math.round(seconds * 1000), model_id: "music_v1", force_instrumental: !vocals }),
58
+ body: JSON.stringify({
59
+ prompt: text, music_length_ms: Math.round(seconds * 1000), model_id: model,
60
+ ...(words ? { lyrics_text: words } : { force_instrumental: !vocals }),
61
+ }),
56
62
  });
57
63
  let response = await ask(prompt);
58
64
  if (response.status === 400) {
@@ -81,16 +87,22 @@ export function elevenLabsComposer(key, fetcher = fetch) {
81
87
  }
82
88
  /**
83
89
  * A model of the owner's own (MusicGen, ACE-Step...): NIXAMP_AI_MUSIC_COMMAND
84
- * is run with {prompt}, {seconds}, {vocals} and {out} replaced, each as one
85
- * argument, and must leave an MP3 at {out}.
90
+ * is run with {prompt}, {seconds}, {vocals}, {lyrics} (a file of the words to
91
+ * sing, "" when there are none) and {out} replaced, each as one argument, and
92
+ * must leave an MP3 at {out}.
86
93
  */
87
94
  export function commandComposer(template) {
88
95
  const words = template.trim().split(/\s+/);
89
96
  return {
90
97
  name: `command/${words[0]?.split("/").pop() ?? "music"}`,
91
- compose: ({ prompt, seconds, vocals, out }) => new Promise((resolve, reject) => {
92
- const argv = words.map((one) => one.replace("{prompt}", prompt).replace("{seconds}", String(seconds)).replace("{vocals}", vocals ? "1" : "0").replace("{out}", out));
98
+ compose: ({ prompt, seconds, vocals, out, lyrics }) => new Promise((resolve, reject) => {
99
+ const lyricsFile = lyrics?.trim() ? `${out}.lyrics.txt` : "";
100
+ if (lyricsFile)
101
+ writeFileSync(lyricsFile, `${lyrics.trim()}\n`);
102
+ const argv = words.map((one) => one.replace("{prompt}", prompt).replace("{seconds}", String(seconds)).replace("{vocals}", vocals ? "1" : "0").replace("{lyrics}", lyricsFile).replace("{out}", out));
93
103
  execFile(argv[0], argv.slice(1), { timeout: 30 * 60_000, maxBuffer: 4 * 1024 * 1024 }, (error) => {
104
+ if (lyricsFile)
105
+ rmSync(lyricsFile, { force: true });
94
106
  if (error)
95
107
  return reject(new Error(`the music command failed: ${error.message}`));
96
108
  try {
@@ -111,7 +123,8 @@ export function composerFromEnv(env = process.env) {
111
123
  if (command)
112
124
  return commandComposer(command);
113
125
  const key = aiKey("ELEVENLABS_API_KEY", env);
114
- return key ? elevenLabsComposer(key) : null;
126
+ const model = aiKey("NIXAMP_MUSIC_MODEL", env);
127
+ return key ? elevenLabsComposer(key, fetch, MUSIC_MODELS.includes(model) ? model : "music_v2_5") : null;
115
128
  }
116
129
  const MOODS = ["dreamy", "driving", "melancholy", "bright", "hazy", "tense", "playful", "warm", "nocturnal", "triumphant"];
117
130
  const WORDS = ["Neon", "Harbor", "Velvet", "Static", "Paper", "Lantern", "Satellite", "Ember", "Glass", "Tide", "Orchard", "Signal"];
@@ -417,13 +430,16 @@ export class AiRadio {
417
430
  const month = monthOf(this.now());
418
431
  this.usage[month] = (this.usage[month] ?? 0) + station.trackSeconds;
419
432
  const file = join(folder, `${String(station.tracks.length + 1).padStart(4, "0")} ${slug(brief.title)}.mp3`);
433
+ const year = new Date(this.now()).getUTCFullYear().toString();
434
+ const holder = this.deps.holder?.() ?? "";
420
435
  await (this.deps.tag ?? ((from, to, tags) => tag(this.deps.ffmpeg, from, to, tags)))(raw, file, {
421
436
  title: brief.title,
422
437
  artist: `${station.name} (AI)`,
423
438
  album: station.name,
424
439
  genre: "AI",
425
440
  comment: `Composed by ${composer.name} from a brief by ${brief.model}`,
426
- date: new Date(this.now()).getUTCFullYear().toString(),
441
+ date: year,
442
+ ...(holder ? { publisher: holder, copyright: `℗ ${year} ${holder}` } : {}),
427
443
  });
428
444
  const track = { file, title: brief.title, prompt: brief.prompt, seconds: station.trackSeconds, made: `${brief.model} + ${composer.name}`, createdAt: this.now() };
429
445
  station.tracks.push(track);
package/dist/ai-text.d.ts CHANGED
@@ -5,6 +5,8 @@ export interface AskOptions {
5
5
  prompt: string;
6
6
  /** Milliseconds before giving up (default 30 s). */
7
7
  timeoutMs?: number;
8
+ /** The longest answer, in tokens (default 1024): a song's words need more than a title does. */
9
+ maxTokens?: number;
8
10
  fetcher?: typeof fetch;
9
11
  env?: Record<string, string | undefined>;
10
12
  }
package/dist/ai-text.js CHANGED
@@ -58,7 +58,7 @@ export async function askJson(options) {
58
58
  body: JSON.stringify({
59
59
  systemInstruction: { parts: [{ text: options.system }] },
60
60
  contents: [{ role: "user", parts: [{ text: options.prompt }] }],
61
- generationConfig: { responseMimeType: "application/json", temperature: 0.9 },
61
+ generationConfig: { responseMimeType: "application/json", temperature: 0.9, maxOutputTokens: options.maxTokens ?? 1024 },
62
62
  }),
63
63
  });
64
64
  if (!response.ok) {
@@ -82,6 +82,8 @@ export async function askJson(options) {
82
82
  headers: { "content-type": "application/json", authorization: `Bearer ${openai}` },
83
83
  body: JSON.stringify({
84
84
  model,
85
+ // Workers AI stops at 256 tokens unless it is told more.
86
+ max_tokens: options.maxTokens ?? 1024,
85
87
  // Not every compatible server knows json_object; the answer is read for its JSON either way.
86
88
  ...(official ? { response_format: { type: "json_object" } } : {}),
87
89
  messages: [{ role: "system", content: options.system }, { role: "user", content: options.prompt }],
@@ -101,7 +103,7 @@ export async function askJson(options) {
101
103
  headers: { "content-type": "application/json", "x-api-key": anthropic, "anthropic-version": "2023-06-01" },
102
104
  body: JSON.stringify({
103
105
  model,
104
- max_tokens: 1024,
106
+ max_tokens: options.maxTokens ?? 1024,
105
107
  system: `${options.system}\nAnswer with one JSON object and nothing else.`,
106
108
  messages: [{ role: "user", content: options.prompt }],
107
109
  }),
@@ -0,0 +1,15 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { type AiLabel } from "./ai-label.ts";
3
+ /** Whether this path is the label's public face. */
4
+ export declare function isPublicLabelPath(path: string): boolean;
5
+ export interface LabelRoutesContext {
6
+ label: AiLabel;
7
+ canControl: boolean;
8
+ json: (response: ServerResponse, status: number, body: unknown) => void;
9
+ readBody: (request: IncomingMessage) => Promise<string>;
10
+ }
11
+ /** Answers the paths above and the owner's; false when the path is neither. */
12
+ export declare function handleLabelRoutes(request: IncomingMessage, response: ServerResponse, path: string, ctx: LabelRoutesContext): Promise<boolean>;
13
+ export declare const LABEL_CSP: string;
14
+ /** The label's catalogue as a page: no script, plays in any browser. */
15
+ export declare function renderLabel(view: ReturnType<AiLabel["publicView"]>): string;
@@ -0,0 +1,255 @@
1
+ /**
2
+ * The AI label over HTTP (ai-label.ts).
3
+ *
4
+ * Public, no key: `/label` (the catalogue as a page: every artist, album and
5
+ * song, playable, with its words), `/api/v1/label` (the same as JSON) and
6
+ * `/api/v1/label/<artist>/<album>/<n>.mp3|<n>.txt|cover.jpg` (the released
7
+ * files themselves). The owner's are under /api/v1/stations/label.
8
+ */
9
+ import { createHash } from "node:crypto";
10
+ import { createReadStream, statSync } from "node:fs";
11
+ import { ROSTER } from "./ai-label.js";
12
+ import { StationError } from "./stations.js";
13
+ // Listening is free on the live stations (with their breaks); the songs as
14
+ // files are sold, so only the words and the cover are public here.
15
+ const FILE = /^\/api\/v1\/label\/([a-f0-9]{10})\/([a-f0-9]{8})\/(\d{1,3}\.txt|cover\.jpg)$/;
16
+ /** Whether this path is the label's public face. */
17
+ export function isPublicLabelPath(path) {
18
+ return path === "/label" || path === "/api/v1/label" || FILE.test(path);
19
+ }
20
+ async function jsonBody(ctx, request) {
21
+ const raw = await ctx.readBody(request);
22
+ if (raw.trim() === "")
23
+ return {};
24
+ let parsed;
25
+ try {
26
+ parsed = JSON.parse(raw);
27
+ }
28
+ catch {
29
+ throw new StationError("bad_json", "the body is not JSON");
30
+ }
31
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
32
+ throw new StationError("bad_json", "the body is not a JSON object");
33
+ return parsed;
34
+ }
35
+ /** A file, whole or the byte range asked for, so a player can seek. */
36
+ function sendFile(request, response, path, type, download) {
37
+ let size = 0;
38
+ try {
39
+ size = statSync(path).size;
40
+ }
41
+ catch {
42
+ response.writeHead(404, { "content-type": "text/plain" }).end("gone");
43
+ return;
44
+ }
45
+ const headers = {
46
+ "content-type": type,
47
+ "accept-ranges": "bytes",
48
+ "cache-control": "public, max-age=3600",
49
+ "access-control-allow-origin": "*",
50
+ ...(download ? { "content-disposition": `inline; filename*=UTF-8''${encodeURIComponent(download)}` } : {}),
51
+ };
52
+ const range = /^bytes=(\d*)-(\d*)$/.exec(String(request.headers.range ?? ""));
53
+ if (range && size > 0) {
54
+ const start = range[1] ? Number(range[1]) : Math.max(0, size - Number(range[2] || 0));
55
+ const end = range[1] && range[2] ? Math.min(Number(range[2]), size - 1) : size - 1;
56
+ if (start > end || start >= size) {
57
+ response.writeHead(416, { "content-range": `bytes */${size}` }).end();
58
+ return;
59
+ }
60
+ response.writeHead(206, { ...headers, "content-range": `bytes ${start}-${end}/${size}`, "content-length": String(end - start + 1) });
61
+ if (request.method === "HEAD")
62
+ return void response.end();
63
+ createReadStream(path, { start, end }).pipe(response);
64
+ return;
65
+ }
66
+ response.writeHead(200, { ...headers, "content-length": String(size) });
67
+ if (request.method === "HEAD")
68
+ return void response.end();
69
+ createReadStream(path).pipe(response);
70
+ }
71
+ /** Answers the paths above and the owner's; false when the path is neither. */
72
+ export async function handleLabelRoutes(request, response, path, ctx) {
73
+ const method = request.method ?? "GET";
74
+ const label = ctx.label;
75
+ try {
76
+ if (isPublicLabelPath(path)) {
77
+ if (method !== "GET" && method !== "HEAD") {
78
+ ctx.json(response, 405, { error: "not like that", code: "method_not_allowed" });
79
+ return true;
80
+ }
81
+ if (path === "/label") {
82
+ const page = renderLabel(label.publicView());
83
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8", "content-security-policy": LABEL_CSP, "cache-control": "public, max-age=300" });
84
+ response.end(method === "HEAD" ? undefined : page);
85
+ return true;
86
+ }
87
+ if (path === "/api/v1/label") {
88
+ ctx.json(response, 200, label.publicView());
89
+ return true;
90
+ }
91
+ const [, artist, album, what] = FILE.exec(path);
92
+ const found = label.publicFile(artist, album, what);
93
+ if (!found) {
94
+ ctx.json(response, 404, { error: "no such file on the label", code: "not_found" });
95
+ return true;
96
+ }
97
+ sendFile(request, response, found.path, found.type, what === "cover.jpg" ? "" : found.path.split("/").pop() ?? "");
98
+ return true;
99
+ }
100
+ if (path !== "/api/v1/stations/label" && !path.startsWith("/api/v1/stations/label/"))
101
+ return false;
102
+ if (!ctx.canControl) {
103
+ ctx.json(response, 403, { error: "only this server's owner runs the label", code: "forbidden" });
104
+ return true;
105
+ }
106
+ if (path === "/api/v1/stations/label") {
107
+ if (method === "GET") {
108
+ ctx.json(response, 200, label.status());
109
+ return true;
110
+ }
111
+ if (method === "POST") {
112
+ ctx.json(response, 200, { settings: label.configure(await jsonBody(ctx, request)) });
113
+ return true;
114
+ }
115
+ }
116
+ if (path === "/api/v1/stations/label/run" && method === "POST") {
117
+ void label.tick();
118
+ ctx.json(response, 202, label.status());
119
+ return true;
120
+ }
121
+ if (path === "/api/v1/stations/label/artists" && method === "POST") {
122
+ const input = await jsonBody(ctx, request);
123
+ if (input["roster"] === true) {
124
+ // The whole first roster at once, skipping whoever is already signed.
125
+ const signed = Object.keys(ROSTER).flatMap((preset) => {
126
+ try {
127
+ return [label.sign({ preset })];
128
+ }
129
+ catch (error) {
130
+ if (error instanceof StationError && error.code === "already_signed")
131
+ return [];
132
+ throw error;
133
+ }
134
+ });
135
+ ctx.json(response, 201, { artists: signed });
136
+ return true;
137
+ }
138
+ ctx.json(response, 201, { artist: label.sign(input) });
139
+ return true;
140
+ }
141
+ const match = /^\/api\/v1\/stations\/label\/artists\/([a-f0-9]{10})(?:\/(album))?$/.exec(path);
142
+ if (match) {
143
+ const [, key, action] = match;
144
+ if (!action && method === "GET") {
145
+ const artist = label.get(key);
146
+ if (!artist)
147
+ throw new StationError("not_found", "no such artist on the label", 404);
148
+ ctx.json(response, 200, { artist });
149
+ return true;
150
+ }
151
+ if (!action && method === "PATCH") {
152
+ ctx.json(response, 200, { artist: label.update(key, await jsonBody(ctx, request)) });
153
+ return true;
154
+ }
155
+ if (!action && method === "DELETE") {
156
+ await label.drop(key, new URL(request.url ?? "/", "http://x").searchParams.get("files") === "1");
157
+ ctx.json(response, 200, { deleted: key });
158
+ return true;
159
+ }
160
+ if (action === "album" && method === "POST") {
161
+ const album = await label.plan(key);
162
+ void label.tick();
163
+ ctx.json(response, 201, { album });
164
+ return true;
165
+ }
166
+ }
167
+ ctx.json(response, 404, { error: "no such label route", code: "not_found" });
168
+ return true;
169
+ }
170
+ catch (error) {
171
+ if (error instanceof StationError)
172
+ ctx.json(response, error.status, { error: error.message, code: error.code });
173
+ else
174
+ ctx.json(response, 500, { error: error instanceof Error ? error.message : String(error), code: "internal" });
175
+ return true;
176
+ }
177
+ }
178
+ function escape(text) {
179
+ return text.replace(/[&<>"']/g, (one) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[one]);
180
+ }
181
+ function minutes(seconds) {
182
+ return `${Math.floor(seconds / 60)}:${String(Math.round(seconds % 60)).padStart(2, "0")}`;
183
+ }
184
+ const STYLE = `
185
+ :root{color-scheme:light dark;--bg:#f6f7f9;--fg:#15171a;--muted:#5d6570;--card:#fff;--line:#dfe3e8;--accent:#0f7b4f}
186
+ @media (prefers-color-scheme:dark){:root{--bg:#0d0f12;--fg:#e8eaee;--muted:#9aa3ad;--card:#161a1f;--line:#262c33;--accent:#3ddc84}}
187
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
188
+ main{max-width:960px;margin:0 auto;padding:32px 16px 48px}h1{margin:0 0 4px;font-size:1.8rem}
189
+ .lead{color:var(--muted);margin:0 0 24px}a{color:var(--accent)}
190
+ nav.genres{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 8px}nav.genres a{border:1px solid var(--line);border-radius:999px;padding:4px 12px;text-decoration:none;color:var(--fg);background:var(--card)}
191
+ section.genre{margin-top:36px}section.genre>h2{display:flex;flex-wrap:wrap;align-items:baseline;gap:12px;margin:0 0 12px;font-size:1.4rem}
192
+ section.genre>h2 a{font-size:.9rem;font-weight:600}
193
+ .artist{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px;margin:12px 0}
194
+ .artist h3{margin:0;font-size:1.2rem}.bio{color:var(--muted);margin:4px 0 0}
195
+ .album{display:grid;grid-template-columns:160px 1fr;gap:16px;margin-top:18px}
196
+ @media (max-width:560px){.album{grid-template-columns:1fr}}
197
+ .cover{width:160px;height:160px;border-radius:10px;object-fit:cover;background:var(--line);display:block}
198
+ .blank{width:160px;height:160px;border-radius:10px;background:linear-gradient(135deg,var(--accent),var(--line))}
199
+ .album h4{margin:0;font-size:1.05rem}.concept{color:var(--muted);font-size:.9rem;margin:2px 0 8px}
200
+ ol.songs{list-style:none;margin:0;padding:0}ol.songs li{border-top:1px solid var(--line);padding:8px 0}
201
+ .song{display:flex;flex-wrap:wrap;gap:8px 12px;align-items:center}.song .t{flex:1 1 200px;font-weight:600;overflow-wrap:anywhere}
202
+ .song .n,.song .d{color:var(--muted);font-variant-numeric:tabular-nums}
203
+ audio{width:100%;max-width:360px;height:36px}
204
+ details{margin-top:4px}summary{cursor:pointer;color:var(--muted);font-size:.9rem}
205
+ pre.lyrics{white-space:pre-wrap;font:inherit;font-size:.95rem;margin:8px 0 0;padding:12px;border-radius:8px;background:var(--bg)}
206
+ .dl{font-size:.85rem}.soon{color:var(--muted);font-size:.85rem}
207
+ footer{margin-top:48px;color:var(--muted);font-size:.85rem}footer a{color:inherit}
208
+ `;
209
+ function hash(text) {
210
+ return `'sha256-${createHash("sha256").update(text).digest("base64")}'`;
211
+ }
212
+ export const LABEL_CSP = [
213
+ "default-src 'none'", `style-src ${hash(STYLE)}`, "img-src 'self'",
214
+ "base-uri 'none'", "form-action 'none'", "frame-ancestors 'none'",
215
+ ].join("; ");
216
+ function renderAlbum(artist, album) {
217
+ const base = `/api/v1/label/${artist.key}/${album.key}`;
218
+ const cover = album.cover ? `<img class="cover" src="${base}/cover.jpg" alt="Cover of ${escape(album.title)}" width="160" height="160" loading="lazy">` : `<div class="blank" aria-hidden="true"></div>`;
219
+ const songs = album.songs.map((song) => `<li><div class="song"><span class="n">${song.n}.</span><span class="t">${escape(song.title)}</span><span class="d">${minutes(song.seconds)}</span></div>`
220
+ + `<details><summary>Lyrics</summary><pre class="lyrics">${escape(song.lyrics)}</pre></details>`
221
+ + `<div class="dl"><a href="${base}/${song.n}.txt">lyrics.txt</a></div></li>`).join("");
222
+ return `<div class="album">${cover}<div><h4>${escape(album.title)} <span class="d">(${album.year})</span></h4>`
223
+ + `${album.concept ? `<p class="concept">${escape(album.concept)}</p>` : ""}`
224
+ + `${album.complete ? "" : `<p class="soon">Still being recorded: new songs land here as they are finished.</p>`}`
225
+ + `<ol class="songs">${songs}</ol></div></div>`;
226
+ }
227
+ /** The label's catalogue as a page: no script, plays in any browser. */
228
+ export function renderLabel(view) {
229
+ const label = view.holder.replace(/,?\s+(inc|llc|ltd|gmbh|co)\.?$/i, "");
230
+ const nav = view.genres.length > 1 ? `<nav class="genres" aria-label="Genres">${view.genres.map((one) => `<a href="#${escape(one.genre.toLowerCase().replace(/[^a-z0-9]+/g, "-"))}">${escape(one.genre)}</a>`).join("")}</nav>` : "";
231
+ const sections = view.genres.map((genre) => `<section class="genre" id="${escape(genre.genre.toLowerCase().replace(/[^a-z0-9]+/g, "-"))}" aria-labelledby="g-${escape(genre.genre.toLowerCase().replace(/[^a-z0-9]+/g, "-"))}">`
232
+ + `<h2 id="g-${escape(genre.genre.toLowerCase().replace(/[^a-z0-9]+/g, "-"))}">${escape(genre.genre)}${genre.station ? ` <a href="/watch/${escape(genre.station)}">Listen live, free →</a>` : ""}</h2>`
233
+ + genre.artists.map((artist) => `<article class="artist"><h3>${escape(artist.name)}</h3>${artist.bio ? `<p class="bio">${escape(artist.bio)}</p>` : ""}${artist.albums.map((album) => renderAlbum(artist, album)).join("")}</article>`).join("")
234
+ + `</section>`).join("");
235
+ return `<!doctype html>
236
+ <html lang="en">
237
+ <head>
238
+ <meta charset="utf-8">
239
+ <meta name="viewport" content="width=device-width, initial-scale=1">
240
+ <title>${escape(label)} AI Label</title>
241
+ <meta name="description" content="Original music by ${escape(label)}'s AI artists: every album, every song, with the words. New albums every month.">
242
+ <link rel="icon" href="data:,">
243
+ <style>${STYLE}</style>
244
+ </head>
245
+ <body>
246
+ <main>
247
+ <h1>${escape(label)} AI Label</h1>
248
+ <p class="lead">Original artists, written and sung by AI, released by ${escape(view.holder)}. Every album, every song, with the words. New albums land every month. Listening on the live stations is always free.</p>
249
+ ${nav}
250
+ ${sections || `<p class="soon">The first albums are being recorded. Check back soon.</p>`}
251
+ <footer>℗ ${new Date().getUTCFullYear()} ${escape(view.holder)}. Music and words made with AI. Played by <a href="https://nixamp.com">nixamp</a>.</footer>
252
+ </main>
253
+ </body>
254
+ </html>`;
255
+ }
package/dist/server.d.ts CHANGED
@@ -24,6 +24,7 @@ import { HlsPackagers } from "./hls.ts";
24
24
  import { LibraryIndex, type Probed } from "./library-index.ts";
25
25
  import { ListenerStore } from "./library-user.ts";
26
26
  import { Importer } from "./handoff.ts";
27
+ import { AiLabel } from "./ai-label.ts";
27
28
  import { AiRadio } from "./ai-radio.ts";
28
29
  import { ListenerShares } from "./listener-shares.ts";
29
30
  import { Support, SupportSubscriptions } from "./support.ts";
@@ -599,6 +600,8 @@ export interface HandlerOptions {
599
600
  publicBase?: () => string;
600
601
  /** Stations whose music is composed by AI. */
601
602
  ai?: AiRadio;
603
+ /** AI artists and their albums, a genre station each, and the catalogue at /label. */
604
+ label?: AiLabel;
602
605
  /** Tracks anybody may share, sorted onto the air by AI. */
603
606
  shares?: ListenerShares;
604
607
  /** What a listener may know about the track on the air. */
package/dist/server.js CHANGED
@@ -61,6 +61,8 @@ import { ListenerStore } from "./library-user.js";
61
61
  import { Importer } from "./handoff.js";
62
62
  import { handleHandoffs } from "./handoff-routes.js";
63
63
  import { handleAiRoutes, isPublicSharePath } from "./ai-routes.js";
64
+ import { AiLabel } from "./ai-label.js";
65
+ import { handleLabelRoutes, isPublicLabelPath } from "./label-routes.js";
64
66
  import { AiRadio } from "./ai-radio.js";
65
67
  import { ListenerShares } from "./listener-shares.js";
66
68
  import { HolderStore, TagReader, factsFor } from "./track-facts.js";
@@ -2177,6 +2179,8 @@ export function createHandler(engine, options) {
2177
2179
  !isStationListenPath(path) &&
2178
2180
  // Sharing a track and reporting one are for anybody the guide reaches.
2179
2181
  !isPublicSharePath(path) &&
2182
+ // The AI label's catalogue, its songs and their words.
2183
+ !(isPublicLabelPath(path) && Boolean(options.stations?.label)) &&
2180
2184
  // What is on, as MCP, for anybody.
2181
2185
  path !== "/mcp/public" &&
2182
2186
  // Supporting a copyright holder is for anybody listening.
@@ -2368,7 +2372,8 @@ export function createHandler(engine, options) {
2368
2372
  ? await roomEntries(options.stations.rooms.store, options.stations.rooms.probe, watchBase(options.stations.publicBase?.() || `${encrypted ? "https" : "http"}://${request.headers.host ?? "localhost"}`))
2369
2373
  : [];
2370
2374
  const ads = guideAds();
2371
- const page = renderGuide([...rooms, ...entries, ...others], options.stations.publicBase?.() ?? "", ads);
2375
+ const label = options.stations.label;
2376
+ const page = renderGuide([...rooms, ...entries, ...others], options.stations.publicBase?.() ?? "", ads, label && label.publicView().genres.length > 0 ? "/label" : "");
2372
2377
  response.writeHead(200, {
2373
2378
  "content-type": "text/html; charset=utf-8",
2374
2379
  "cache-control": "no-cache",
@@ -2523,6 +2528,11 @@ export function createHandler(engine, options) {
2523
2528
  });
2524
2529
  return;
2525
2530
  }
2531
+ if (options.stations?.label && (isPublicLabelPath(path) || path === "/api/v1/stations/label" || path.startsWith("/api/v1/stations/label/"))) {
2532
+ const control = isPublicLabelPath(path) ? { allowed: false } : await controlOf();
2533
+ if (await handleLabelRoutes(request, response, path, { label: options.stations.label, canControl: control.allowed, json, readBody }))
2534
+ return;
2535
+ }
2526
2536
  if (options.stations && (isPublicSharePath(path) || path.startsWith("/api/v1/stations/ai") || path.startsWith("/api/v1/stations/shares") || path.startsWith("/api/v1/stations/holders"))) {
2527
2537
  const control = await controlOf();
2528
2538
  // Behind a proxy we trust (dev2 for live.streaming), the sharer is the
@@ -6771,10 +6781,27 @@ export async function serve(argv, version = "0.1.0") {
6771
6781
  ffmpeg: tools.ffmpeg,
6772
6782
  root: join(stateDir(), "ai-music"),
6773
6783
  owner: options.owner || "",
6784
+ holder: () => aiLabel.holder(),
6785
+ mayStart: mayStartStation,
6786
+ log: (line) => console.log(line),
6787
+ });
6788
+ // The AI label: artists who put out albums on their own, filed under the
6789
+ // library as ai/<genre>/<artist>/<year>-<album>/ (NIXAMP_LABEL_ROOT moves it).
6790
+ const labelHome = libraryIndex.sources().find((one) => one.enabled && !one.managed);
6791
+ const aiLabel = new AiLabel(AiLabel.fileFor(stateDir(), options.port), {
6792
+ index: libraryIndex,
6793
+ store: stationStore,
6794
+ runner: stationRunner,
6795
+ probe: probeFile,
6796
+ ffmpeg: tools.ffmpeg,
6797
+ root: process.env["NIXAMP_LABEL_ROOT"] || (labelHome ? join(labelHome.root, "ai") : join(stateDir(), "ai-label")),
6798
+ owner: options.owner || "",
6774
6799
  mayStart: mayStartStation,
6775
6800
  log: (line) => console.log(line),
6776
6801
  });
6777
6802
  aiRadio.startSchedule();
6803
+ aiLabel.startSchedule();
6804
+ stations.label = aiLabel;
6778
6805
  // Tracks anybody shares from the guide, checked by AI before they air.
6779
6806
  const listenerShares = new ListenerShares(ListenerShares.fileFor(stateDir(), options.port), {
6780
6807
  index: libraryIndex,