nixamp 0.48.0 → 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 (56) 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 +8 -3
  16. package/dist/station-mcp.d.ts +247 -20
  17. package/dist/station-mcp.js +36 -0
  18. package/dist/station-metering.js +2 -2
  19. package/dist/station-usage.d.ts +1 -1
  20. package/dist/station-usage.js +15 -1
  21. package/package.json +1 -1
  22. package/src/ai-keys.ts +2 -0
  23. package/src/ai-label.ts +826 -0
  24. package/src/ai-radio.ts +30 -13
  25. package/src/ai-text.ts +6 -2
  26. package/src/label-routes.ts +268 -0
  27. package/src/server.ts +30 -1
  28. package/src/station-cli.ts +102 -0
  29. package/src/station-guide.ts +8 -3
  30. package/src/station-mcp.ts +34 -0
  31. package/src/station-metering.ts +2 -2
  32. package/src/station-usage.ts +15 -1
  33. package/web/dist/assets/{ar-ropwGSSV.js → ar-BEVjqkqM.js} +1 -1
  34. package/web/dist/assets/{cs-Bi7uRYjd.js → cs-DjazXQ15.js} +1 -1
  35. package/web/dist/assets/{da-B8OELPch.js → da-DnyF6SYK.js} +1 -1
  36. package/web/dist/assets/{de-DJOTeXET.js → de--q1ph9Ez.js} +1 -1
  37. package/web/dist/assets/{es-BIupzuzS.js → es-Do3DkBwD.js} +1 -1
  38. package/web/dist/assets/{fi-A6MoWkzZ.js → fi-B7sJTx1N.js} +1 -1
  39. package/web/dist/assets/{fr-DerIDIwl.js → fr-Dy7f5rGi.js} +1 -1
  40. package/web/dist/assets/{hi-CyUtRBH4.js → hi-C9vSnTQ4.js} +1 -1
  41. package/web/dist/assets/{hu-DD1vrIhY.js → hu-h-eueQE8.js} +1 -1
  42. package/web/dist/assets/{id-C6OAMzii.js → id-D8Ffd-Pc.js} +1 -1
  43. package/web/dist/assets/{it-Xy86vmU7.js → it-BKM_2rj1.js} +1 -1
  44. package/web/dist/assets/main-Dl_uRj22.js +22 -0
  45. package/web/dist/assets/{nl-4Q-K2Jf1.js → nl-Cj3ETrcl.js} +1 -1
  46. package/web/dist/assets/radio-zPgyISLf.js +1 -0
  47. package/web/dist/assets/{ru-CpxwOhC4.js → ru-B5zmGSnG.js} +1 -1
  48. package/web/dist/assets/{sv-aRvu7MWa.js → sv-BMtjA1uI.js} +1 -1
  49. package/web/dist/assets/{uk-BCwwfngg.js → uk-B2vuB6Un.js} +1 -1
  50. package/web/dist/assets/{vi-tvWPiAH_.js → vi-CXam3lDh.js} +1 -1
  51. package/web/dist/assets/{zh-Bp2aLEyX.js → zh-DnYhyzqb.js} +1 -1
  52. package/web/dist/index.html +1 -1
  53. package/web/dist/radio.html +33 -1
  54. package/web/dist/sw.js +20 -20
  55. package/web/dist/assets/main-CuG8-ADK.js +0 -22
  56. package/web/dist/assets/radio-DI0H8flN.js +0 -1
package/src/ai-radio.ts CHANGED
@@ -91,22 +91,29 @@ const BACKOFF_MS = [5 * 60_000, 10 * 60_000, 20 * 60_000, 40 * 60_000, 80 * 60_0
91
91
  export interface Composer {
92
92
  /** e.g. "elevenlabs/music_v1". */
93
93
  name: string;
94
- /** Writes an MP3 to `out`. Throws OutOfCredit when the account cannot pay. */
95
- compose(input: { prompt: string; seconds: number; vocals: boolean; out: string }): Promise<void>;
94
+ /** Writes an MP3 to `out`. Throws OutOfCredit when the account cannot pay. `lyrics` are the words to sing, when there are any. */
95
+ compose(input: { prompt: string; seconds: number; vocals: boolean; out: string; lyrics?: string }): Promise<void>;
96
96
  }
97
97
 
98
98
  export class OutOfCredit extends Error {}
99
99
 
100
- /** ElevenLabs Music: a paid plan's music may be broadcast. */
101
- export function elevenLabsComposer(key: string, fetcher: typeof fetch = fetch): Composer {
100
+ /** ElevenLabs' music models; NIXAMP_MUSIC_MODEL picks one, the newest by default. */
101
+ export const MUSIC_MODELS = ["music_v1", "music_v2", "music_v2_5"] as const;
102
+
103
+ /** ElevenLabs Music: a paid plan's music may be broadcast. Given lyrics, it sings those words. */
104
+ export function elevenLabsComposer(key: string, fetcher: typeof fetch = fetch, model = "music_v2_5"): Composer {
102
105
  return {
103
- name: "elevenlabs/music_v1",
104
- async compose({ prompt, seconds, vocals, out }) {
106
+ name: `elevenlabs/${model}`,
107
+ async compose({ prompt, seconds, vocals, out, lyrics }) {
108
+ const words = vocals && lyrics?.trim() ? lyrics.trim() : "";
105
109
  const ask = async (text: string): Promise<Response> => fetcher("https://api.elevenlabs.io/v1/music?output_format=mp3_44100_192", {
106
110
  method: "POST",
107
111
  signal: AbortSignal.timeout(10 * 60_000),
108
112
  headers: { "content-type": "application/json", "xi-api-key": key },
109
- body: JSON.stringify({ prompt: text, music_length_ms: Math.round(seconds * 1000), model_id: "music_v1", force_instrumental: !vocals }),
113
+ body: JSON.stringify({
114
+ prompt: text, music_length_ms: Math.round(seconds * 1000), model_id: model,
115
+ ...(words ? { lyrics_text: words } : { force_instrumental: !vocals }),
116
+ }),
110
117
  });
111
118
  let response = await ask(prompt);
112
119
  if (response.status === 400) {
@@ -132,16 +139,20 @@ export function elevenLabsComposer(key: string, fetcher: typeof fetch = fetch):
132
139
 
133
140
  /**
134
141
  * A model of the owner's own (MusicGen, ACE-Step...): NIXAMP_AI_MUSIC_COMMAND
135
- * is run with {prompt}, {seconds}, {vocals} and {out} replaced, each as one
136
- * argument, and must leave an MP3 at {out}.
142
+ * is run with {prompt}, {seconds}, {vocals}, {lyrics} (a file of the words to
143
+ * sing, "" when there are none) and {out} replaced, each as one argument, and
144
+ * must leave an MP3 at {out}.
137
145
  */
138
146
  export function commandComposer(template: string): Composer {
139
147
  const words = template.trim().split(/\s+/);
140
148
  return {
141
149
  name: `command/${words[0]?.split("/").pop() ?? "music"}`,
142
- compose: ({ prompt, seconds, vocals, out }) => new Promise((resolve, reject) => {
143
- const argv = words.map((one) => one.replace("{prompt}", prompt).replace("{seconds}", String(seconds)).replace("{vocals}", vocals ? "1" : "0").replace("{out}", out));
150
+ compose: ({ prompt, seconds, vocals, out, lyrics }) => new Promise((resolve, reject) => {
151
+ const lyricsFile = lyrics?.trim() ? `${out}.lyrics.txt` : "";
152
+ if (lyricsFile) writeFileSync(lyricsFile, `${lyrics!.trim()}\n`);
153
+ const argv = words.map((one) => one.replace("{prompt}", prompt).replace("{seconds}", String(seconds)).replace("{vocals}", vocals ? "1" : "0").replace("{lyrics}", lyricsFile).replace("{out}", out));
144
154
  execFile(argv[0]!, argv.slice(1), { timeout: 30 * 60_000, maxBuffer: 4 * 1024 * 1024 }, (error) => {
155
+ if (lyricsFile) rmSync(lyricsFile, { force: true });
145
156
  if (error) return reject(new Error(`the music command failed: ${error.message}`));
146
157
  try {
147
158
  if (statSync(out).size < 1024) return reject(new Error("the music command left no music"));
@@ -159,7 +170,8 @@ export function composerFromEnv(env: Record<string, string | undefined> = proces
159
170
  const command = (env["NIXAMP_AI_MUSIC_COMMAND"] ?? "").trim();
160
171
  if (command) return commandComposer(command);
161
172
  const key = aiKey("ELEVENLABS_API_KEY", env);
162
- return key ? elevenLabsComposer(key) : null;
173
+ const model = aiKey("NIXAMP_MUSIC_MODEL", env);
174
+ return key ? elevenLabsComposer(key, fetch, (MUSIC_MODELS as readonly string[]).includes(model) ? model : "music_v2_5") : null;
163
175
  }
164
176
 
165
177
  const MOODS = ["dreamy", "driving", "melancholy", "bright", "hazy", "tense", "playful", "warm", "nocturnal", "triumphant"];
@@ -217,6 +229,8 @@ export interface AiRadioDeps {
217
229
  /** The folder the music goes in: a managed library source. */
218
230
  root: string;
219
231
  owner: string;
232
+ /** Who holds the rights in what is made ("Profullstack, Inc."), written as publisher and copyright; "" for none. */
233
+ holder?: () => string;
220
234
  /** Whether another station may be started now (the server's running limit). */
221
235
  mayStart: () => boolean;
222
236
  composer?: () => Composer | null;
@@ -480,13 +494,16 @@ export class AiRadio {
480
494
  const month = monthOf(this.now());
481
495
  this.usage[month] = (this.usage[month] ?? 0) + station.trackSeconds;
482
496
  const file = join(folder, `${String(station.tracks.length + 1).padStart(4, "0")} ${slug(brief.title)}.mp3`);
497
+ const year = new Date(this.now()).getUTCFullYear().toString();
498
+ const holder = this.deps.holder?.() ?? "";
483
499
  await (this.deps.tag ?? ((from, to, tags) => tag(this.deps.ffmpeg, from, to, tags)))(raw, file, {
484
500
  title: brief.title,
485
501
  artist: `${station.name} (AI)`,
486
502
  album: station.name,
487
503
  genre: "AI",
488
504
  comment: `Composed by ${composer.name} from a brief by ${brief.model}`,
489
- date: new Date(this.now()).getUTCFullYear().toString(),
505
+ date: year,
506
+ ...(holder ? { publisher: holder, copyright: `℗ ${year} ${holder}` } : {}),
490
507
  });
491
508
  const track: AiTrack = { file, title: brief.title, prompt: brief.prompt, seconds: station.trackSeconds, made: `${brief.model} + ${composer.name}`, createdAt: this.now() };
492
509
  station.tracks.push(track);
package/src/ai-text.ts CHANGED
@@ -17,6 +17,8 @@ export interface AskOptions {
17
17
  prompt: string;
18
18
  /** Milliseconds before giving up (default 30 s). */
19
19
  timeoutMs?: number;
20
+ /** The longest answer, in tokens (default 1024): a song's words need more than a title does. */
21
+ maxTokens?: number;
20
22
  fetcher?: typeof fetch;
21
23
  env?: Record<string, string | undefined>;
22
24
  }
@@ -75,7 +77,7 @@ export async function askJson(options: AskOptions): Promise<Answer> {
75
77
  body: JSON.stringify({
76
78
  systemInstruction: { parts: [{ text: options.system }] },
77
79
  contents: [{ role: "user", parts: [{ text: options.prompt }] }],
78
- generationConfig: { responseMimeType: "application/json", temperature: 0.9 },
80
+ generationConfig: { responseMimeType: "application/json", temperature: 0.9, maxOutputTokens: options.maxTokens ?? 1024 },
79
81
  }),
80
82
  });
81
83
  if (!response.ok) {
@@ -98,6 +100,8 @@ export async function askJson(options: AskOptions): Promise<Answer> {
98
100
  headers: { "content-type": "application/json", authorization: `Bearer ${openai}` },
99
101
  body: JSON.stringify({
100
102
  model,
103
+ // Workers AI stops at 256 tokens unless it is told more.
104
+ max_tokens: options.maxTokens ?? 1024,
101
105
  // Not every compatible server knows json_object; the answer is read for its JSON either way.
102
106
  ...(official ? { response_format: { type: "json_object" } } : {}),
103
107
  messages: [{ role: "system", content: options.system }, { role: "user", content: options.prompt }],
@@ -115,7 +119,7 @@ export async function askJson(options: AskOptions): Promise<Answer> {
115
119
  headers: { "content-type": "application/json", "x-api-key": anthropic, "anthropic-version": "2023-06-01" },
116
120
  body: JSON.stringify({
117
121
  model,
118
- max_tokens: 1024,
122
+ max_tokens: options.maxTokens ?? 1024,
119
123
  system: `${options.system}\nAnswer with one JSON object and nothing else.`,
120
124
  messages: [{ role: "user", content: options.prompt }],
121
125
  }),
@@ -0,0 +1,268 @@
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 type { IncomingMessage, ServerResponse } from "node:http";
12
+ import { ROSTER, type AiLabel, type PublicArtist } from "./ai-label.ts";
13
+ import { StationError } from "./stations.ts";
14
+
15
+ // Listening is free on the live stations (with their breaks); the songs as
16
+ // files are sold, so only the words and the cover are public here.
17
+ const FILE = /^\/api\/v1\/label\/([a-f0-9]{10})\/([a-f0-9]{8})\/(\d{1,3}\.txt|cover\.jpg)$/;
18
+
19
+ /** Whether this path is the label's public face. */
20
+ export function isPublicLabelPath(path: string): boolean {
21
+ return path === "/label" || path === "/api/v1/label" || FILE.test(path);
22
+ }
23
+
24
+ export interface LabelRoutesContext {
25
+ label: AiLabel;
26
+ canControl: boolean;
27
+ json: (response: ServerResponse, status: number, body: unknown) => void;
28
+ readBody: (request: IncomingMessage) => Promise<string>;
29
+ }
30
+
31
+ async function jsonBody(ctx: LabelRoutesContext, request: IncomingMessage): Promise<Record<string, unknown>> {
32
+ const raw = await ctx.readBody(request);
33
+ if (raw.trim() === "") return {};
34
+ let parsed: unknown;
35
+ try {
36
+ parsed = JSON.parse(raw);
37
+ } catch {
38
+ throw new StationError("bad_json", "the body is not JSON");
39
+ }
40
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new StationError("bad_json", "the body is not a JSON object");
41
+ return parsed as Record<string, unknown>;
42
+ }
43
+
44
+ /** A file, whole or the byte range asked for, so a player can seek. */
45
+ function sendFile(request: IncomingMessage, response: ServerResponse, path: string, type: string, download: string): void {
46
+ let size = 0;
47
+ try {
48
+ size = statSync(path).size;
49
+ } catch {
50
+ response.writeHead(404, { "content-type": "text/plain" }).end("gone");
51
+ return;
52
+ }
53
+ const headers: Record<string, string> = {
54
+ "content-type": type,
55
+ "accept-ranges": "bytes",
56
+ "cache-control": "public, max-age=3600",
57
+ "access-control-allow-origin": "*",
58
+ ...(download ? { "content-disposition": `inline; filename*=UTF-8''${encodeURIComponent(download)}` } : {}),
59
+ };
60
+ const range = /^bytes=(\d*)-(\d*)$/.exec(String(request.headers.range ?? ""));
61
+ if (range && size > 0) {
62
+ const start = range[1] ? Number(range[1]) : Math.max(0, size - Number(range[2] || 0));
63
+ const end = range[1] && range[2] ? Math.min(Number(range[2]), size - 1) : size - 1;
64
+ if (start > end || start >= size) {
65
+ response.writeHead(416, { "content-range": `bytes */${size}` }).end();
66
+ return;
67
+ }
68
+ response.writeHead(206, { ...headers, "content-range": `bytes ${start}-${end}/${size}`, "content-length": String(end - start + 1) });
69
+ if (request.method === "HEAD") return void response.end();
70
+ createReadStream(path, { start, end }).pipe(response);
71
+ return;
72
+ }
73
+ response.writeHead(200, { ...headers, "content-length": String(size) });
74
+ if (request.method === "HEAD") return void response.end();
75
+ createReadStream(path).pipe(response);
76
+ }
77
+
78
+ /** Answers the paths above and the owner's; false when the path is neither. */
79
+ export async function handleLabelRoutes(
80
+ request: IncomingMessage,
81
+ response: ServerResponse,
82
+ path: string,
83
+ ctx: LabelRoutesContext,
84
+ ): Promise<boolean> {
85
+ const method = request.method ?? "GET";
86
+ const label = ctx.label;
87
+ try {
88
+ if (isPublicLabelPath(path)) {
89
+ if (method !== "GET" && method !== "HEAD") {
90
+ ctx.json(response, 405, { error: "not like that", code: "method_not_allowed" });
91
+ return true;
92
+ }
93
+ if (path === "/label") {
94
+ const page = renderLabel(label.publicView());
95
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8", "content-security-policy": LABEL_CSP, "cache-control": "public, max-age=300" });
96
+ response.end(method === "HEAD" ? undefined : page);
97
+ return true;
98
+ }
99
+ if (path === "/api/v1/label") {
100
+ ctx.json(response, 200, label.publicView());
101
+ return true;
102
+ }
103
+ const [, artist, album, what] = FILE.exec(path)!;
104
+ const found = label.publicFile(artist!, album!, what!);
105
+ if (!found) {
106
+ ctx.json(response, 404, { error: "no such file on the label", code: "not_found" });
107
+ return true;
108
+ }
109
+ sendFile(request, response, found.path, found.type, what === "cover.jpg" ? "" : found.path.split("/").pop() ?? "");
110
+ return true;
111
+ }
112
+
113
+ if (path !== "/api/v1/stations/label" && !path.startsWith("/api/v1/stations/label/")) return false;
114
+ if (!ctx.canControl) {
115
+ ctx.json(response, 403, { error: "only this server's owner runs the label", code: "forbidden" });
116
+ return true;
117
+ }
118
+ if (path === "/api/v1/stations/label") {
119
+ if (method === "GET") {
120
+ ctx.json(response, 200, label.status());
121
+ return true;
122
+ }
123
+ if (method === "POST") {
124
+ ctx.json(response, 200, { settings: label.configure(await jsonBody(ctx, request)) });
125
+ return true;
126
+ }
127
+ }
128
+ if (path === "/api/v1/stations/label/run" && method === "POST") {
129
+ void label.tick();
130
+ ctx.json(response, 202, label.status());
131
+ return true;
132
+ }
133
+ if (path === "/api/v1/stations/label/artists" && method === "POST") {
134
+ const input = await jsonBody(ctx, request);
135
+ if (input["roster"] === true) {
136
+ // The whole first roster at once, skipping whoever is already signed.
137
+ const signed = Object.keys(ROSTER).flatMap((preset) => {
138
+ try {
139
+ return [label.sign({ preset })];
140
+ } catch (error) {
141
+ if (error instanceof StationError && error.code === "already_signed") return [];
142
+ throw error;
143
+ }
144
+ });
145
+ ctx.json(response, 201, { artists: signed });
146
+ return true;
147
+ }
148
+ ctx.json(response, 201, { artist: label.sign(input) });
149
+ return true;
150
+ }
151
+ const match = /^\/api\/v1\/stations\/label\/artists\/([a-f0-9]{10})(?:\/(album))?$/.exec(path);
152
+ if (match) {
153
+ const [, key, action] = match;
154
+ if (!action && method === "GET") {
155
+ const artist = label.get(key!);
156
+ if (!artist) throw new StationError("not_found", "no such artist on the label", 404);
157
+ ctx.json(response, 200, { artist });
158
+ return true;
159
+ }
160
+ if (!action && method === "PATCH") {
161
+ ctx.json(response, 200, { artist: label.update(key!, await jsonBody(ctx, request)) });
162
+ return true;
163
+ }
164
+ if (!action && method === "DELETE") {
165
+ await label.drop(key!, new URL(request.url ?? "/", "http://x").searchParams.get("files") === "1");
166
+ ctx.json(response, 200, { deleted: key });
167
+ return true;
168
+ }
169
+ if (action === "album" && method === "POST") {
170
+ const album = await label.plan(key!);
171
+ void label.tick();
172
+ ctx.json(response, 201, { album });
173
+ return true;
174
+ }
175
+ }
176
+ ctx.json(response, 404, { error: "no such label route", code: "not_found" });
177
+ return true;
178
+ } catch (error) {
179
+ if (error instanceof StationError) ctx.json(response, error.status, { error: error.message, code: error.code });
180
+ else ctx.json(response, 500, { error: error instanceof Error ? error.message : String(error), code: "internal" });
181
+ return true;
182
+ }
183
+ }
184
+
185
+ function escape(text: string): string {
186
+ return text.replace(/[&<>"']/g, (one) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[one]!);
187
+ }
188
+
189
+ function minutes(seconds: number): string {
190
+ return `${Math.floor(seconds / 60)}:${String(Math.round(seconds % 60)).padStart(2, "0")}`;
191
+ }
192
+
193
+ const STYLE = `
194
+ :root{color-scheme:light dark;--bg:#f6f7f9;--fg:#15171a;--muted:#5d6570;--card:#fff;--line:#dfe3e8;--accent:#0f7b4f}
195
+ @media (prefers-color-scheme:dark){:root{--bg:#0d0f12;--fg:#e8eaee;--muted:#9aa3ad;--card:#161a1f;--line:#262c33;--accent:#3ddc84}}
196
+ *{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}
197
+ main{max-width:960px;margin:0 auto;padding:32px 16px 48px}h1{margin:0 0 4px;font-size:1.8rem}
198
+ .lead{color:var(--muted);margin:0 0 24px}a{color:var(--accent)}
199
+ 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)}
200
+ 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}
201
+ section.genre>h2 a{font-size:.9rem;font-weight:600}
202
+ .artist{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px;margin:12px 0}
203
+ .artist h3{margin:0;font-size:1.2rem}.bio{color:var(--muted);margin:4px 0 0}
204
+ .album{display:grid;grid-template-columns:160px 1fr;gap:16px;margin-top:18px}
205
+ @media (max-width:560px){.album{grid-template-columns:1fr}}
206
+ .cover{width:160px;height:160px;border-radius:10px;object-fit:cover;background:var(--line);display:block}
207
+ .blank{width:160px;height:160px;border-radius:10px;background:linear-gradient(135deg,var(--accent),var(--line))}
208
+ .album h4{margin:0;font-size:1.05rem}.concept{color:var(--muted);font-size:.9rem;margin:2px 0 8px}
209
+ ol.songs{list-style:none;margin:0;padding:0}ol.songs li{border-top:1px solid var(--line);padding:8px 0}
210
+ .song{display:flex;flex-wrap:wrap;gap:8px 12px;align-items:center}.song .t{flex:1 1 200px;font-weight:600;overflow-wrap:anywhere}
211
+ .song .n,.song .d{color:var(--muted);font-variant-numeric:tabular-nums}
212
+ audio{width:100%;max-width:360px;height:36px}
213
+ details{margin-top:4px}summary{cursor:pointer;color:var(--muted);font-size:.9rem}
214
+ pre.lyrics{white-space:pre-wrap;font:inherit;font-size:.95rem;margin:8px 0 0;padding:12px;border-radius:8px;background:var(--bg)}
215
+ .dl{font-size:.85rem}.soon{color:var(--muted);font-size:.85rem}
216
+ footer{margin-top:48px;color:var(--muted);font-size:.85rem}footer a{color:inherit}
217
+ `;
218
+
219
+ function hash(text: string): string {
220
+ return `'sha256-${createHash("sha256").update(text).digest("base64")}'`;
221
+ }
222
+
223
+ export const LABEL_CSP = [
224
+ "default-src 'none'", `style-src ${hash(STYLE)}`, "img-src 'self'",
225
+ "base-uri 'none'", "form-action 'none'", "frame-ancestors 'none'",
226
+ ].join("; ");
227
+
228
+ function renderAlbum(artist: PublicArtist, album: PublicArtist["albums"][number]): string {
229
+ const base = `/api/v1/label/${artist.key}/${album.key}`;
230
+ 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>`;
231
+ 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>`
232
+ + `<details><summary>Lyrics</summary><pre class="lyrics">${escape(song.lyrics)}</pre></details>`
233
+ + `<div class="dl"><a href="${base}/${song.n}.txt">lyrics.txt</a></div></li>`).join("");
234
+ return `<div class="album">${cover}<div><h4>${escape(album.title)} <span class="d">(${album.year})</span></h4>`
235
+ + `${album.concept ? `<p class="concept">${escape(album.concept)}</p>` : ""}`
236
+ + `${album.complete ? "" : `<p class="soon">Still being recorded: new songs land here as they are finished.</p>`}`
237
+ + `<ol class="songs">${songs}</ol></div></div>`;
238
+ }
239
+
240
+ /** The label's catalogue as a page: no script, plays in any browser. */
241
+ export function renderLabel(view: ReturnType<AiLabel["publicView"]>): string {
242
+ const label = view.holder.replace(/,?\s+(inc|llc|ltd|gmbh|co)\.?$/i, "");
243
+ 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>` : "";
244
+ 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, "-"))}">`
245
+ + `<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>`
246
+ + 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("")
247
+ + `</section>`).join("");
248
+ return `<!doctype html>
249
+ <html lang="en">
250
+ <head>
251
+ <meta charset="utf-8">
252
+ <meta name="viewport" content="width=device-width, initial-scale=1">
253
+ <title>${escape(label)} AI Label</title>
254
+ <meta name="description" content="Original music by ${escape(label)}'s AI artists: every album, every song, with the words. New albums every month.">
255
+ <link rel="icon" href="data:,">
256
+ <style>${STYLE}</style>
257
+ </head>
258
+ <body>
259
+ <main>
260
+ <h1>${escape(label)} AI Label</h1>
261
+ <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>
262
+ ${nav}
263
+ ${sections || `<p class="soon">The first albums are being recorded. Check back soon.</p>`}
264
+ <footer>℗ ${new Date().getUTCFullYear()} ${escape(view.holder)}. Music and words made with AI. Played by <a href="https://nixamp.com">nixamp</a>.</footer>
265
+ </main>
266
+ </body>
267
+ </html>`;
268
+ }
package/src/server.ts CHANGED
@@ -78,6 +78,8 @@ import { ListenerStore } from "./library-user.ts";
78
78
  import { Importer } from "./handoff.ts";
79
79
  import { handleHandoffs } from "./handoff-routes.ts";
80
80
  import { handleAiRoutes, isPublicSharePath } from "./ai-routes.ts";
81
+ import { AiLabel } from "./ai-label.ts";
82
+ import { handleLabelRoutes, isPublicLabelPath } from "./label-routes.ts";
81
83
  import { AiRadio } from "./ai-radio.ts";
82
84
  import { ListenerShares } from "./listener-shares.ts";
83
85
  import { HolderStore, TagReader, factsFor } from "./track-facts.ts";
@@ -1746,6 +1748,8 @@ export interface HandlerOptions {
1746
1748
  publicBase?: () => string;
1747
1749
  /** Stations whose music is composed by AI. */
1748
1750
  ai?: AiRadio;
1751
+ /** AI artists and their albums, a genre station each, and the catalogue at /label. */
1752
+ label?: AiLabel;
1749
1753
  /** Tracks anybody may share, sorted onto the air by AI. */
1750
1754
  shares?: ListenerShares;
1751
1755
  /** What a listener may know about the track on the air. */
@@ -2722,6 +2726,8 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2722
2726
  !isStationListenPath(path) &&
2723
2727
  // Sharing a track and reporting one are for anybody the guide reaches.
2724
2728
  !isPublicSharePath(path) &&
2729
+ // The AI label's catalogue, its songs and their words.
2730
+ !(isPublicLabelPath(path) && Boolean(options.stations?.label)) &&
2725
2731
  // What is on, as MCP, for anybody.
2726
2732
  path !== "/mcp/public" &&
2727
2733
  // Supporting a copyright holder is for anybody listening.
@@ -2905,7 +2911,8 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2905
2911
  ? await roomEntries(options.stations!.rooms.store, options.stations!.rooms.probe, watchBase(options.stations!.publicBase?.() || `${encrypted ? "https" : "http"}://${request.headers.host ?? "localhost"}`))
2906
2912
  : [];
2907
2913
  const ads = guideAds();
2908
- const page = renderGuide([...rooms, ...entries, ...others] as GuideEntry[], options.stations!.publicBase?.() ?? "", ads);
2914
+ const label = options.stations!.label;
2915
+ const page = renderGuide([...rooms, ...entries, ...others] as GuideEntry[], options.stations!.publicBase?.() ?? "", ads, label && label.publicView().genres.length > 0 ? "/label" : "");
2909
2916
  response.writeHead(200, {
2910
2917
  "content-type": "text/html; charset=utf-8",
2911
2918
  "cache-control": "no-cache",
@@ -3067,6 +3074,11 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
3067
3074
  return;
3068
3075
  }
3069
3076
 
3077
+ if (options.stations?.label && (isPublicLabelPath(path) || path === "/api/v1/stations/label" || path.startsWith("/api/v1/stations/label/"))) {
3078
+ const control = isPublicLabelPath(path) ? { allowed: false } : await controlOf();
3079
+ if (await handleLabelRoutes(request, response, path, { label: options.stations.label, canControl: control.allowed, json, readBody })) return;
3080
+ }
3081
+
3070
3082
  if (options.stations && (isPublicSharePath(path) || path.startsWith("/api/v1/stations/ai") || path.startsWith("/api/v1/stations/shares") || path.startsWith("/api/v1/stations/holders"))) {
3071
3083
  const control = await controlOf();
3072
3084
  // Behind a proxy we trust (dev2 for live.streaming), the sharer is the
@@ -7279,10 +7291,27 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7279
7291
  ffmpeg: tools.ffmpeg,
7280
7292
  root: join(stateDir(), "ai-music"),
7281
7293
  owner: options.owner || "",
7294
+ holder: () => aiLabel.holder(),
7295
+ mayStart: mayStartStation,
7296
+ log: (line) => console.log(line),
7297
+ });
7298
+ // The AI label: artists who put out albums on their own, filed under the
7299
+ // library as ai/<genre>/<artist>/<year>-<album>/ (NIXAMP_LABEL_ROOT moves it).
7300
+ const labelHome = libraryIndex.sources().find((one) => one.enabled && !one.managed);
7301
+ const aiLabel = new AiLabel(AiLabel.fileFor(stateDir(), options.port), {
7302
+ index: libraryIndex,
7303
+ store: stationStore,
7304
+ runner: stationRunner,
7305
+ probe: probeFile,
7306
+ ffmpeg: tools.ffmpeg,
7307
+ root: process.env["NIXAMP_LABEL_ROOT"] || (labelHome ? join(labelHome.root, "ai") : join(stateDir(), "ai-label")),
7308
+ owner: options.owner || "",
7282
7309
  mayStart: mayStartStation,
7283
7310
  log: (line) => console.log(line),
7284
7311
  });
7285
7312
  aiRadio.startSchedule();
7313
+ aiLabel.startSchedule();
7314
+ stations.label = aiLabel;
7286
7315
  // Tracks anybody shares from the guide, checked by AI before they air.
7287
7316
  const listenerShares = new ListenerShares(ListenerShares.fileFor(stateDir(), options.port), {
7288
7317
  index: libraryIndex,
@@ -272,6 +272,8 @@ export async function station(argv: string[]): Promise<number> {
272
272
  }
273
273
  case "ai":
274
274
  return await aiCommand(argv.slice(1), call, show, json);
275
+ case "label":
276
+ return await labelCommand(argv.slice(1), call, show);
275
277
  case "shares":
276
278
  return await sharesCommand(argv.slice(1), call, show);
277
279
  case "lyrics":
@@ -412,6 +414,106 @@ async function aiCommand(argv: string[], call: Call, show: Show, json: boolean):
412
414
  }
413
415
  }
414
416
 
417
+ /** `nixamp station label ...`: AI artists who put out an album a month (ai-label.ts). */
418
+ async function labelCommand(argv: string[], call: Call, show: Show): Promise<number> {
419
+ const [sub, key] = argv;
420
+ const when = (at: number): string => at === -1 ? "never (off)" : at === 0 ? "now" : new Date(at).toISOString().slice(0, 10);
421
+ const describe = (one: Record<string, unknown>): string => {
422
+ const albums = (one["albums"] as Array<Record<string, unknown>> | undefined) ?? [];
423
+ return [
424
+ `${String(one["name"])} [${String(one["key"])}] ${String(one["genre"])}${one["enabled"] ? "" : " (off)"}`,
425
+ ...albums.map((album) => ` ${String(album["year"])} ${String(album["title"])} ${String(album["composed"])}/${String(album["songs"])} songs${album["doneAt"] ? "" : " (recording)"}`),
426
+ ` next album ${when(Number(one["nextAlbumAt"] ?? -1))}`,
427
+ ...(one["lastError"] ? [` problem ${String(one["lastError"])}`] : []),
428
+ ].join("\n");
429
+ };
430
+ switch (sub) {
431
+ case undefined:
432
+ case "status":
433
+ case "list":
434
+ return show(await call("GET", "/api/v1/stations/label"), (body) => {
435
+ const settings = (body["settings"] ?? {}) as Record<string, unknown>;
436
+ const artists = (body["artists"] as Record<string, unknown>[] | undefined) ?? [];
437
+ const roster = (body["roster"] as Array<Record<string, unknown>> | undefined) ?? [];
438
+ return [
439
+ `holder ${String(settings["holder"])}${settings["enabled"] ? "" : " (off)"}`,
440
+ `files ${String(body["root"])}/<genre>/<artist>/<year>-<album>/`,
441
+ `composer ${String(body["composer"] ?? "none")}; words by ${String(body["writer"] ?? "nobody (no model key)")}`,
442
+ `this month ${String(body["secondsThisMonth"])} of ${Number(settings["monthlySeconds"]) === 0 ? "unlimited" : String(settings["monthlySeconds"])} s${body["busy"] ? `; recording ${String(body["busy"])}` : ""}`,
443
+ `albums ${String(settings["songsPerAlbum"])} songs of ${String(settings["songSeconds"])} s; ${String(settings["seedAlbums"])} to start, then one every ${String(settings["everyDays"])} days`,
444
+ ...(body["waiting"] ? [`waiting ${String(body["waiting"])}`] : []),
445
+ "",
446
+ ...(artists.length ? artists.map(describe) : ["Nobody signed yet. nixamp station label sign --roster"]),
447
+ "",
448
+ `roster ${roster.map((one) => `${String(one["key"])}${one["signed"] ? " (signed)" : ""}`).join(", ")}`,
449
+ ].join("\n");
450
+ });
451
+ case "sign": {
452
+ const body = has(argv, "--roster") ? { roster: true } : {
453
+ ...(flag(argv, "--preset") ? { preset: flag(argv, "--preset") } : {}),
454
+ ...(flag(argv, "--name") ? { name: flag(argv, "--name") } : {}),
455
+ ...(flag(argv, "--genre") ? { genre: flag(argv, "--genre") } : {}),
456
+ ...(flag(argv, "--sound") ? { sound: flag(argv, "--sound") } : {}),
457
+ ...(flag(argv, "--voice") ? { voice: flag(argv, "--voice") } : {}),
458
+ ...(flag(argv, "--bio") ? { bio: flag(argv, "--bio") } : {}),
459
+ ...(flag(argv, "--themes") ? { themes: flag(argv, "--themes") } : {}),
460
+ };
461
+ return show(await call("POST", "/api/v1/stations/label/artists", body), (answer) => {
462
+ const signed = (answer["artists"] as Array<Record<string, unknown>> | undefined) ?? (answer["artist"] ? [answer["artist"] as Record<string, unknown>] : []);
463
+ return signed.length ? `Signed ${signed.map((one) => `${String(one["name"])} (${String(one["genre"])})`).join(", ")}. Their first albums are planned and recorded from here.` : "Everybody on the roster is already signed.";
464
+ });
465
+ }
466
+ case "album": {
467
+ if (!key) { console.error("nixamp station label album needs the artist's key (nixamp station label)"); return 2; }
468
+ return show(await call("POST", `/api/v1/stations/label/artists/${key}/album`, {}), (answer) => {
469
+ const album = answer["album"] as Record<string, unknown>;
470
+ return `Planned "${String(album["title"])}" (${((album["songs"] as unknown[]) ?? []).length} songs); recording now, a song at a time.`;
471
+ });
472
+ }
473
+ case "set": {
474
+ if (!key) { console.error("nixamp station label set needs the artist's key"); return 2; }
475
+ const body = {
476
+ ...(flag(argv, "--sound") ? { sound: flag(argv, "--sound") } : {}),
477
+ ...(flag(argv, "--voice") ? { voice: flag(argv, "--voice") } : {}),
478
+ ...(flag(argv, "--bio") ? { bio: flag(argv, "--bio") } : {}),
479
+ ...(flag(argv, "--themes") ? { themes: flag(argv, "--themes") } : {}),
480
+ ...(has(argv, "--off") ? { enabled: false } : has(argv, "--on") ? { enabled: true } : {}),
481
+ };
482
+ return show(await call("PATCH", `/api/v1/stations/label/artists/${key}`, body), (answer) => `${String((answer["artist"] as Record<string, unknown>)["name"])} updated.`);
483
+ }
484
+ case "drop": {
485
+ if (!key) { console.error("nixamp station label drop needs the artist's key"); return 2; }
486
+ const files = has(argv, "--files");
487
+ return show(await call("DELETE", `/api/v1/stations/label/artists/${key}${files ? "?files=1" : ""}`), () =>
488
+ files ? "Dropped, with their music." : "Dropped. Their albums stay on the air; nothing new will be recorded.");
489
+ }
490
+ case "config": {
491
+ const number = (name: string): number | undefined => (flag(argv, name) === undefined ? undefined : Number(flag(argv, name)));
492
+ const body = Object.fromEntries(Object.entries({
493
+ holder: flag(argv, "--holder"),
494
+ monthlySeconds: number("--budget"),
495
+ everyDays: number("--every-days"),
496
+ seedAlbums: number("--seed-albums"),
497
+ songsPerAlbum: number("--songs"),
498
+ songSeconds: number("--seconds"),
499
+ enabled: has(argv, "--off") ? false : has(argv, "--on") ? true : undefined,
500
+ publish: has(argv, "--unlisted") ? false : has(argv, "--public") ? true : undefined,
501
+ }).filter(([, value]) => value !== undefined));
502
+ return show(await call("POST", "/api/v1/stations/label", body), (answer) => {
503
+ const settings = answer["settings"] as Record<string, unknown>;
504
+ return `Label: ${String(settings["holder"])}, ${String(settings["songsPerAlbum"])} songs of ${String(settings["songSeconds"])} s an album, every ${String(settings["everyDays"])} days, at most ${Number(settings["monthlySeconds"]) === 0 ? "unlimited" : `${String(settings["monthlySeconds"])} s`} a month${settings["enabled"] ? "" : " (off)"}.`;
505
+ });
506
+ }
507
+ case "run":
508
+ return show(await call("POST", "/api/v1/stations/label/run", {}), (body) => body["waiting"] ? `Nothing to record now: ${String(body["waiting"])}.` : "Recording whatever is due.");
509
+ case "resume":
510
+ return show(await call("POST", "/api/v1/stations/label", { resume: true }), () => "Recording again.");
511
+ default:
512
+ console.error(`nixamp station label: no command "${sub}".\n\n${USAGE}`);
513
+ return 2;
514
+ }
515
+ }
516
+
415
517
  /** `nixamp station lyrics ...`: our own songs' words, published back to nichedb.dev. */
416
518
  async function lyricsCommand(argv: string[], call: Call, show: Show, json: boolean): Promise<number> {
417
519
  const [sub, id] = argv;
@@ -32,7 +32,7 @@ button.report:hover{color:var(--warn)}
32
32
  #share .or{color:var(--muted);font-size:.85rem;margin:8px 0 0}
33
33
  #status{margin-top:10px;font-size:.9rem;min-height:1.4em}
34
34
  footer{margin-top:40px;color:var(--muted);font-size:.85rem}footer a{color:inherit}
35
- .ad-unit{margin:28px auto 0;display:flex;justify-content:center}.ad-unit iframe{border:0;display:block;background:transparent}
35
+ .ad-unit{margin:0 auto 16px;display:flex;justify-content:center}.ad-unit iframe{border:0;display:block;background:transparent}
36
36
  .ad-banner_300x250 iframe{width:300px;height:250px;max-width:100%}.ad-text_link iframe{width:100%;height:40px}
37
37
  `;
38
38
 
@@ -170,7 +170,7 @@ const GROUPS = ["rooms", "vod", "ai", "shared", "radio", "tv"] as const;
170
170
  * gateway that cannot run its script against this server (the Moshpit's
171
171
  * /n/<name>), and for a crawler. Everything from a station is escaped.
172
172
  */
173
- export function renderGuide(entries: GuideEntry[], apiBase = "", ads: GuideAds = {}): string {
173
+ export function renderGuide(entries: GuideEntry[], apiBase = "", ads: GuideAds = {}, labelHref = ""): string {
174
174
  const groupOf = (one: GuideEntry): string => one.group ?? (one.kind === "video" ? "tv" : "radio");
175
175
  const cards = (group: string): string => entries
176
176
  .filter((one) => groupOf(one) === group && /^https?:\/\//.test(one.page))
@@ -185,11 +185,16 @@ export function renderGuide(entries: GuideEntry[], apiBase = "", ads: GuideAds =
185
185
  }
186
186
  if (ads.site) page = page.replace("</head>", `<script data-site="${escape(ads.site)}" src="${CRAWLPROOF}/stats.js" async></script>\n</head>`);
187
187
  if (ads.slot) {
188
- const format = ads.format ?? "banner_300x250";
188
+ // One line by default: a 300x250 box above the stations pushed the guide itself off the first screen.
189
+ const format = ads.format ?? "text_link";
189
190
  const slot = ads.slot;
190
191
  // The one ad goes at the top of the content, never against the footer.
191
192
  page = page.replace('<p class="lead">Radio and TV, on the air now. Pick one and press play.</p>\n', (lead) => `${lead} <div class="ad-unit ad-${format}"><iframe src="${CRAWLPROOF}/api/ads/frame?slot=${encodeURIComponent(slot)}&amp;format=${format}" title="Advertisement" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation" scrolling="no"></iframe></div>\n`);
192
193
  }
194
+ // The AI label's artists and albums, under the AI stations they play on.
195
+ if (/^\/[a-z]+$/.test(labelHref)) {
196
+ page = page.replace('<div id="ai" class="grid" aria-live="polite">', `<p class="lead"><a href="${labelHref}">The AI label: every artist, album and song, with the words →</a></p>\n <div id="ai" class="grid" aria-live="polite">`);
197
+ }
193
198
  const api = /^https:\/\/[^\s"<>]+$/.test(apiBase) ? apiBase.replace(/\/+$/, "") : "";
194
199
  return (page.replace('<p id="empty" class="empty">', entries.length ? '<p id="empty" class="empty" hidden>' : '<p id="empty" class="empty">')).replace("<body>", api ? `<body data-api="${escape(api)}">` : "<body>");
195
200
  }