nixamp 0.36.3 → 0.37.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 (62) hide show
  1. package/dist/ai-keys.d.ts +9 -0
  2. package/dist/ai-keys.js +46 -0
  3. package/dist/ai-radio.d.ts +148 -0
  4. package/dist/ai-radio.js +477 -0
  5. package/dist/ai-routes.d.ts +31 -0
  6. package/dist/ai-routes.js +191 -0
  7. package/dist/ai-text.d.ts +23 -0
  8. package/dist/ai-text.js +102 -0
  9. package/dist/library.d.ts +4 -0
  10. package/dist/library.js +2 -0
  11. package/dist/listener-shares.d.ts +136 -0
  12. package/dist/listener-shares.js +486 -0
  13. package/dist/server.d.ts +8 -0
  14. package/dist/server.js +63 -2
  15. package/dist/station-cli.js +166 -0
  16. package/dist/station-guide.d.ts +4 -1
  17. package/dist/station-guide.js +118 -21
  18. package/dist/station-mcp.d.ts +291 -20
  19. package/dist/station-mcp.js +66 -0
  20. package/dist/station-metering.d.ts +1 -1
  21. package/dist/station-metering.js +21 -9
  22. package/dist/station-routes.d.ts +4 -0
  23. package/dist/station-routes.js +8 -1
  24. package/dist/station-usage.d.ts +1 -1
  25. package/dist/station-usage.js +19 -0
  26. package/package.json +1 -1
  27. package/src/ai-keys.ts +49 -0
  28. package/src/ai-radio.ts +536 -0
  29. package/src/ai-routes.ts +220 -0
  30. package/src/ai-text.ts +117 -0
  31. package/src/library.ts +6 -0
  32. package/src/listener-shares.ts +528 -0
  33. package/src/server.ts +70 -2
  34. package/src/station-cli.ts +153 -0
  35. package/src/station-guide.ts +122 -21
  36. package/src/station-mcp.ts +62 -0
  37. package/src/station-metering.ts +19 -9
  38. package/src/station-routes.ts +11 -2
  39. package/src/station-usage.ts +19 -0
  40. package/web/dist/assets/{ar-BD2UUquk.js → ar-B2m5eyoV.js} +1 -1
  41. package/web/dist/assets/{cs-BAnlPO0G.js → cs-Cc5pOkWw.js} +1 -1
  42. package/web/dist/assets/{da-B3Tu9hTo.js → da-10XVgmQy.js} +1 -1
  43. package/web/dist/assets/{de-BnhC08J6.js → de-DIBX68fo.js} +1 -1
  44. package/web/dist/assets/{es-C6f8XeOz.js → es-B5wXCbD_.js} +1 -1
  45. package/web/dist/assets/{fi-c8nSqL6Y.js → fi-CfqtJU25.js} +1 -1
  46. package/web/dist/assets/{fr-CCQqbzd3.js → fr-DVpDbDWM.js} +1 -1
  47. package/web/dist/assets/{hi-oWq0nqXG.js → hi-iQgB7g2O.js} +1 -1
  48. package/web/dist/assets/{hls-3VKVEQE3-D4IS0V1s.js → hls-3VKVEQE3-Hp3aQLSm.js} +1 -1
  49. package/web/dist/assets/{hu-DXCXa0U6.js → hu-cL8uAJW0.js} +1 -1
  50. package/web/dist/assets/{id-i3qTkeDv.js → id-DAEQSrx8.js} +1 -1
  51. package/web/dist/assets/{it-CHb1I7qg.js → it-DARHJ2-F.js} +1 -1
  52. package/web/dist/assets/{main-B8hCuIBx.js → main-Bdbkg8vi.js} +2 -2
  53. package/web/dist/assets/{mpegts-LO6RVLD6-DKSrU2jg.js → mpegts-LO6RVLD6-Be6JzNGd.js} +1 -1
  54. package/web/dist/assets/{mpegts-DSR-qG9w.js → mpegts-vNdAhc1w.js} +1 -1
  55. package/web/dist/assets/{nl-iCv9pkTl.js → nl-BNskTvYD.js} +1 -1
  56. package/web/dist/assets/{ru-BW2-AWx1.js → ru-PA8keJv_.js} +1 -1
  57. package/web/dist/assets/{sv-DJV6w7__.js → sv-sZu9UVEg.js} +1 -1
  58. package/web/dist/assets/{uk-CCsBeFUE.js → uk-FmHT0lvK.js} +1 -1
  59. package/web/dist/assets/{vi-Cckuuziu.js → vi-BV81HbEM.js} +1 -1
  60. package/web/dist/assets/{zh-Df6AwSwJ.js → zh-CwzApCu7.js} +1 -1
  61. package/web/dist/index.html +1 -1
  62. package/web/dist/sw.js +22 -22
package/src/ai-keys.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The keys AI radio uses, and where they come from.
3
+ *
4
+ * The environment first, as everywhere else; then `ai.json` beside the
5
+ * server's own keys (0600), which `nixamp station ai key NAME` fills from
6
+ * stdin so a daemon restarted by `nixamp update` still has them. A key is
7
+ * never shown back: the owner view says which ones are set, not what they are.
8
+ */
9
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import { stateDir } from "./daemon.ts";
12
+
13
+ export const AI_KEY_NAMES = ["ELEVENLABS_API_KEY", "GEMINI_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY"] as const;
14
+ export type AiKeyName = (typeof AI_KEY_NAMES)[number];
15
+
16
+ export function aiKeysFile(): string {
17
+ return join(stateDir(), "ai.json");
18
+ }
19
+
20
+ function saved(file = aiKeysFile()): Record<string, string> {
21
+ try {
22
+ const parsed = JSON.parse(readFileSync(file, "utf8")) as unknown;
23
+ if (typeof parsed !== "object" || parsed === null) return {};
24
+ return Object.fromEntries(Object.entries(parsed).filter((pair): pair is [string, string] => typeof pair[1] === "string"));
25
+ } catch {
26
+ return {};
27
+ }
28
+ }
29
+
30
+ /** The key, from the environment or ai.json; "" when neither has it. */
31
+ export function aiKey(name: AiKeyName, env: Record<string, string | undefined> = process.env, file = aiKeysFile()): string {
32
+ return (env[name] ?? "").trim() || (saved(file)[name] ?? "").trim();
33
+ }
34
+
35
+ /** Which keys are set, never their values. */
36
+ export function aiKeysSet(env: Record<string, string | undefined> = process.env, file = aiKeysFile()): Record<AiKeyName, boolean> {
37
+ return Object.fromEntries(AI_KEY_NAMES.map((name) => [name, aiKey(name, env, file) !== ""])) as Record<AiKeyName, boolean>;
38
+ }
39
+
40
+ /** Keep (or with "" forget) a key in ai.json. */
41
+ export function saveAiKey(name: AiKeyName, value: string, file = aiKeysFile()): void {
42
+ const next = saved(file);
43
+ if (value.trim()) next[name] = value.trim();
44
+ else delete next[name];
45
+ mkdirSync(dirname(file), { recursive: true });
46
+ const temporary = `${file}.${process.pid}.tmp`;
47
+ writeFileSync(temporary, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
48
+ renameSync(temporary, file);
49
+ }
@@ -0,0 +1,536 @@
1
+ /**
2
+ * AI radio: stations whose music is composed for them, a few tracks a day.
3
+ *
4
+ * An AI station is a style the owner wrote ("late-night lo-fi, dusty drums,
5
+ * Rhodes"). On its schedule a language model writes the next track's title
6
+ * and brief from that style and the titles already made (so it does not
7
+ * repeat itself), a music model composes it, ffmpeg writes the tags, and the
8
+ * file lands in a folder of Nixamp's own -- a managed library source -- and
9
+ * joins the station's rotation on its next track. The station is an ordinary
10
+ * station: same link, same runner, same breaks, same guide.
11
+ *
12
+ * Composition costs money, so it is metered in seconds of music per calendar
13
+ * month across every AI station on this server, and stops for the month at
14
+ * the cap rather than at the provider's bill. A provider that says the
15
+ * account is out of credit stops it too, until the owner says go again.
16
+ *
17
+ * The music is the owner's to broadcast (a paid plan's commercial licence,
18
+ * or a model of their own via `command`), so a station made with `publish`
19
+ * is public and listed on the guide with the basis `owned`, and the note
20
+ * says how it was made. Nobody else's music is ever asked for: the brief
21
+ * names no artist and quotes no lyric.
22
+ */
23
+ import { execFile } from "node:child_process";
24
+ import { randomBytes } from "node:crypto";
25
+ import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
26
+ import { dirname, join } from "node:path";
27
+ import { aiKey } from "./ai-keys.ts";
28
+ import { NoModel, askJson, hasModel } from "./ai-text.ts";
29
+ import type { LibraryIndex, Probed } from "./library-index.ts";
30
+ import type { StationRunner } from "./station-runner.ts";
31
+ import { StationError, type StationStore } from "./stations.ts";
32
+
33
+ export interface AiTrack {
34
+ file: string;
35
+ title: string;
36
+ /** The brief the music model was given. */
37
+ prompt: string;
38
+ seconds: number;
39
+ /** Who wrote the brief and who composed it, e.g. "gemini-flash-latest + elevenlabs/music_v1". */
40
+ made: string;
41
+ createdAt: number;
42
+ }
43
+
44
+ export interface AiStation {
45
+ key: string;
46
+ name: string;
47
+ /** The owner's words for the music. Every brief starts from these. */
48
+ style: string;
49
+ description: string;
50
+ vocals: boolean;
51
+ /** Length of each track, 30-300 s. */
52
+ trackSeconds: number;
53
+ /** New tracks a day once the first few exist. */
54
+ perDay: number;
55
+ enabled: boolean;
56
+ /** Public, listed, basis owned, as soon as it has a track. */
57
+ publish: boolean;
58
+ /** The station it plays on; "" until the first track exists. */
59
+ stationId: string;
60
+ tracks: AiTrack[];
61
+ lastAttemptAt: number;
62
+ lastError: string;
63
+ /** Failures in a row; backs off after each. */
64
+ failures: number;
65
+ createdAt: number;
66
+ }
67
+
68
+ export interface Preset {
69
+ name: string;
70
+ style: string;
71
+ description: string;
72
+ vocals: boolean;
73
+ }
74
+
75
+ /** Styles to start from: `nixamp station ai create --preset lofi`. */
76
+ export const PRESETS: Record<string, Preset> = {
77
+ lofi: { name: "AI Lo-Fi Beats", style: "mellow lo-fi hip hop, dusty swung drums, warm Rhodes and jazz guitar chords, vinyl crackle, 70-85 BPM, relaxed and nostalgic", description: "Lo-fi beats to study and drift to, composed by AI.", vocals: false },
78
+ synthwave: { name: "AI Synthwave", style: "retro 80s synthwave, gated reverb drums, analog bass arpeggios, neon pads, soaring lead synth, 95-118 BPM, night-drive mood", description: "Neon night drives, composed by AI.", vocals: false },
79
+ ambient: { name: "AI Ambient", style: "slow evolving ambient, soft drones, granular textures, distant piano, field-recording air, no drums, calm and spacious", description: "Slow, spacious ambient, composed by AI.", vocals: false },
80
+ chiptune: { name: "AI Chiptune", style: "8-bit and 16-bit video game chiptune, square-wave leads, noise-channel drums, fast arpeggios, upbeat and playful, 130-160 BPM", description: "Chiptune for an imaginary console, composed by AI.", vocals: false },
81
+ punk: { name: "AI Punk", style: "fast loud garage punk rock, distorted power chords, driving bass, pounding drums, shouted gang vocals, raw and energetic, 170-200 BPM", description: "Three chords and the truth, composed by AI.", vocals: true },
82
+ jazz: { name: "AI Late Night Jazz", style: "smoky late-night jazz trio and quartet, upright bass, brushed drums, piano comping, muted trumpet or tenor sax solos, swing and ballads", description: "A jazz club that never closes, composed by AI.", vocals: false },
83
+ };
84
+
85
+ export const DEFAULTS = { trackSeconds: 120, perDay: 2, monthlySeconds: 900, seedTracks: 3 };
86
+ const MAX_STYLE = 600;
87
+ const MAX_TRACKS = 5_000;
88
+ /** Between tries after a failure: 5 min, doubling, at most 6 h. */
89
+ const BACKOFF_MS = [5 * 60_000, 10 * 60_000, 20 * 60_000, 40 * 60_000, 80 * 60_000, 6 * 3600_000];
90
+
91
+ export interface Composer {
92
+ /** e.g. "elevenlabs/music_v1". */
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>;
96
+ }
97
+
98
+ export class OutOfCredit extends Error {}
99
+
100
+ /** ElevenLabs Music: a paid plan's music may be broadcast. */
101
+ export function elevenLabsComposer(key: string, fetcher: typeof fetch = fetch): Composer {
102
+ return {
103
+ name: "elevenlabs/music_v1",
104
+ async compose({ prompt, seconds, vocals, out }) {
105
+ const ask = async (text: string): Promise<Response> => fetcher("https://api.elevenlabs.io/v1/music?output_format=mp3_44100_192", {
106
+ method: "POST",
107
+ signal: AbortSignal.timeout(10 * 60_000),
108
+ 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 }),
110
+ });
111
+ let response = await ask(prompt);
112
+ if (response.status === 400) {
113
+ // A brief it will not make (an artist's name, a lyric) comes back
114
+ // with one it will; take it once.
115
+ const body = await response.json().catch(() => ({})) as { detail?: { status?: string; data?: { prompt_suggestion?: string } } };
116
+ const suggestion = body.detail?.data?.prompt_suggestion;
117
+ if (!suggestion) throw new Error(`elevenlabs refused the brief (${body.detail?.status ?? "HTTP 400"})`);
118
+ response = await ask(suggestion);
119
+ }
120
+ if (response.status === 401 || response.status === 402) throw new OutOfCredit(`elevenlabs answered HTTP ${response.status}: the key or the plan's credit`);
121
+ if (!response.ok) {
122
+ const text = await response.text().catch(() => "");
123
+ if (/quota|credit/i.test(text)) throw new OutOfCredit(`elevenlabs is out of credit: ${text.slice(0, 200)}`);
124
+ throw new Error(`elevenlabs answered HTTP ${response.status}`);
125
+ }
126
+ const bytes = Buffer.from(await response.arrayBuffer());
127
+ if (bytes.length < 1024) throw new Error("elevenlabs answered with no music");
128
+ writeFileSync(out, bytes);
129
+ },
130
+ };
131
+ }
132
+
133
+ /**
134
+ * 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}.
137
+ */
138
+ export function commandComposer(template: string): Composer {
139
+ const words = template.trim().split(/\s+/);
140
+ return {
141
+ 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));
144
+ execFile(argv[0]!, argv.slice(1), { timeout: 30 * 60_000, maxBuffer: 4 * 1024 * 1024 }, (error) => {
145
+ if (error) return reject(new Error(`the music command failed: ${error.message}`));
146
+ try {
147
+ if (statSync(out).size < 1024) return reject(new Error("the music command left no music"));
148
+ } catch {
149
+ return reject(new Error("the music command left no file at {out}"));
150
+ }
151
+ resolve();
152
+ });
153
+ }),
154
+ };
155
+ }
156
+
157
+ /** The composer this server can use, or null with the reason. */
158
+ export function composerFromEnv(env: Record<string, string | undefined> = process.env): Composer | null {
159
+ const command = (env["NIXAMP_AI_MUSIC_COMMAND"] ?? "").trim();
160
+ if (command) return commandComposer(command);
161
+ const key = aiKey("ELEVENLABS_API_KEY", env);
162
+ return key ? elevenLabsComposer(key) : null;
163
+ }
164
+
165
+ const MOODS = ["dreamy", "driving", "melancholy", "bright", "hazy", "tense", "playful", "warm", "nocturnal", "triumphant"];
166
+ const WORDS = ["Neon", "Harbor", "Velvet", "Static", "Paper", "Lantern", "Satellite", "Ember", "Glass", "Tide", "Orchard", "Signal"];
167
+ const PLACES = ["Avenue", "Hours", "Drift", "Motel", "Rooftops", "Weather", "Lines", "Garden", "Frequency", "Afterglow", "Window", "Run"];
168
+
169
+ /** A brief without a language model: the style, with a mood and a name drawn for it. */
170
+ export function plainBrief(station: Pick<AiStation, "style" | "tracks">, random: () => number = Math.random): { title: string; prompt: string } {
171
+ const pick = <T>(list: T[]): T => list[Math.floor(random() * list.length)]!;
172
+ const taken = new Set(station.tracks.map((one) => one.title.toLowerCase()));
173
+ let title = "";
174
+ for (let tries = 0; tries < 20 && (title === "" || taken.has(title.toLowerCase())); tries++) title = `${pick(WORDS)} ${pick(PLACES)}`;
175
+ if (taken.has(title.toLowerCase())) title = `${title} ${station.tracks.length + 1}`;
176
+ return { title, prompt: `${station.style}. A ${pick(MOODS)} piece called "${title}", with a clear intro, development and ending.` };
177
+ }
178
+
179
+ const SYSTEM = [
180
+ "You write briefs for an AI music generator that composes original tracks for an internet radio station.",
181
+ "Each brief is one paragraph of at most 400 characters: genre, tempo, key or mode, instruments, structure, mood.",
182
+ "Never name a real artist, band, song, album or label, and never quote existing lyrics.",
183
+ "Answer as JSON: {\"title\": \"a fresh 1-5 word track title\", \"prompt\": \"the brief\"}.",
184
+ ].join(" ");
185
+
186
+ export async function writeBrief(
187
+ station: Pick<AiStation, "name" | "style" | "vocals" | "tracks">,
188
+ ask: typeof askJson = askJson,
189
+ ): Promise<{ title: string; prompt: string; model: string }> {
190
+ const recent = station.tracks.slice(-15).map((one) => one.title);
191
+ const answer = await ask({
192
+ system: SYSTEM,
193
+ prompt: [
194
+ `Station: ${station.name}`,
195
+ `Style: ${station.style}`,
196
+ station.vocals ? "The track has sung vocals; describe the voice and what the song is about (the generator writes the words)." : "Instrumental: no vocals.",
197
+ recent.length ? `Titles already on the station (do not reuse or echo them; vary tempo, mood and instruments within the style): ${recent.join("; ")}` : "This is the station's first track.",
198
+ ].join("\n"),
199
+ });
200
+ const title = String(answer.value["title"] ?? "").replace(/[\r\n"]/g, " ").trim().slice(0, 80);
201
+ const prompt = String(answer.value["prompt"] ?? "").replace(/[\r\n]/g, " ").trim().slice(0, 600);
202
+ if (!title || !prompt) throw new Error("the model wrote no title or brief");
203
+ return { title, prompt, model: answer.model };
204
+ }
205
+
206
+ function slug(text: string): string {
207
+ return text.normalize("NFKD").replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, " ").slice(0, 80) || "track";
208
+ }
209
+
210
+ export interface AiRadioDeps {
211
+ index: LibraryIndex;
212
+ store: StationStore;
213
+ runner: StationRunner;
214
+ probe: (path: string) => Promise<Probed>;
215
+ /** ffmpeg argv prefix, for the tags. */
216
+ ffmpeg: string[];
217
+ /** The folder the music goes in: a managed library source. */
218
+ root: string;
219
+ owner: string;
220
+ /** Whether another station may be started now (the server's running limit). */
221
+ mayStart: () => boolean;
222
+ composer?: () => Composer | null;
223
+ /** Writes `from` with these tags to `to`; ffmpeg's stream copy by default. */
224
+ tag?: (from: string, to: string, tags: Record<string, string>) => Promise<void>;
225
+ ask?: typeof askJson;
226
+ canAsk?: () => boolean;
227
+ now?: () => number;
228
+ log?: (line: string) => void;
229
+ }
230
+
231
+ export interface AiRadioSettings {
232
+ /** Seconds of music a month across every AI station; 0 for no cap. */
233
+ monthlySeconds: number;
234
+ /** Set by a provider that is out of credit; cleared by `resume`. */
235
+ pausedReason: string;
236
+ }
237
+
238
+ function monthOf(at: number): string {
239
+ return new Date(at).toISOString().slice(0, 7);
240
+ }
241
+
242
+ /** Write title/artist/album/genre into the file, so every player shows them. */
243
+ function tag(ffmpeg: string[], from: string, to: string, tags: Record<string, string>): Promise<void> {
244
+ const args = [...ffmpeg.slice(1), "-hide_banner", "-loglevel", "error", "-y", "-i", from, "-map", "0:a", "-c", "copy", "-id3v2_version", "3"];
245
+ for (const [name, value] of Object.entries(tags)) args.push("-metadata", `${name}=${value}`);
246
+ args.push(to);
247
+ return new Promise((resolve, reject) => {
248
+ execFile(ffmpeg[0]!, args, { timeout: 120_000 }, (error) => (error ? reject(new Error(`tagging failed: ${error.message}`)) : resolve()));
249
+ });
250
+ }
251
+
252
+ export class AiRadio {
253
+ private stations = new Map<string, AiStation>();
254
+ private settings: AiRadioSettings = { monthlySeconds: DEFAULTS.monthlySeconds, pausedReason: "" };
255
+ private usage: Record<string, number> = {};
256
+ private busy: string | null = null;
257
+ private sourceId = "";
258
+ private timer: ReturnType<typeof setInterval> | null = null;
259
+
260
+ constructor(private readonly file: string, private readonly deps: AiRadioDeps) {
261
+ try {
262
+ const parsed = JSON.parse(readFileSync(file, "utf8")) as { stations?: AiStation[]; settings?: Partial<AiRadioSettings>; usage?: Record<string, number> };
263
+ for (const one of parsed.stations ?? []) if (one?.key) this.stations.set(one.key, { ...one, tracks: one.tracks ?? [] });
264
+ this.settings = { ...this.settings, ...(parsed.settings ?? {}) };
265
+ this.usage = parsed.usage ?? {};
266
+ } catch {
267
+ // Nothing yet.
268
+ }
269
+ }
270
+
271
+ static fileFor(stateDir: string, port: number): string {
272
+ return join(stateDir, `ai-radio-${port}.json`);
273
+ }
274
+
275
+ private now(): number {
276
+ return this.deps.now?.() ?? Date.now();
277
+ }
278
+
279
+ private save(): void {
280
+ mkdirSync(dirname(this.file), { recursive: true });
281
+ const temporary = `${this.file}.${process.pid}.tmp`;
282
+ writeFileSync(temporary, `${JSON.stringify({ version: 1, settings: this.settings, usage: this.usage, stations: [...this.stations.values()] }, null, 2)}\n`, { mode: 0o600 });
283
+ renameSync(temporary, this.file);
284
+ }
285
+
286
+ private source(): string {
287
+ if (this.sourceId && this.deps.index.source(this.sourceId)) return this.sourceId;
288
+ mkdirSync(this.deps.root, { recursive: true });
289
+ this.sourceId = this.deps.index.addManagedSource(this.deps.root).id;
290
+ return this.sourceId;
291
+ }
292
+
293
+ private composer(): Composer | null {
294
+ return this.deps.composer ? this.deps.composer() : composerFromEnv();
295
+ }
296
+
297
+ list(): AiStation[] {
298
+ return [...this.stations.values()].sort((a, b) => a.createdAt - b.createdAt);
299
+ }
300
+
301
+ get(key: string): AiStation | undefined {
302
+ return this.stations.get(key);
303
+ }
304
+
305
+ /** What the owner sees: the stations, this month's seconds, and why nothing is being made. */
306
+ status(): Record<string, unknown> {
307
+ const month = monthOf(this.now());
308
+ const composer = this.composer();
309
+ return {
310
+ composer: composer?.name ?? null,
311
+ writer: (this.deps.canAsk ?? hasModel)() ? "language model" : "plain (no model key)",
312
+ busy: this.busy,
313
+ month,
314
+ secondsThisMonth: this.usage[month] ?? 0,
315
+ monthlySeconds: this.settings.monthlySeconds,
316
+ paused: this.settings.pausedReason || (composer ? "" : "no composer: set ELEVENLABS_API_KEY (nixamp station ai key ELEVENLABS_API_KEY) or NIXAMP_AI_MUSIC_COMMAND"),
317
+ presets: Object.keys(PRESETS),
318
+ stations: this.list().map((one) => ({ ...one, tracks: one.tracks.length, latest: one.tracks.slice(-5).map(({ title, seconds, made, createdAt }) => ({ title, seconds, made, createdAt })), nextAt: this.nextAt(one) })),
319
+ };
320
+ }
321
+
322
+ /** Change the month's cap; `resume` clears a stop for lack of credit. */
323
+ configure(input: Record<string, unknown>): AiRadioSettings {
324
+ if (input["monthlySeconds"] !== undefined) {
325
+ const value = Number(input["monthlySeconds"]);
326
+ if (!Number.isInteger(value) || value < 0 || value > 1_000_000) throw new StationError("invalid_budget", "monthlySeconds is whole seconds, 0 for no cap");
327
+ this.settings.monthlySeconds = value;
328
+ }
329
+ if (input["resume"] === true) this.settings.pausedReason = "";
330
+ this.save();
331
+ return this.settings;
332
+ }
333
+
334
+ create(input: Record<string, unknown>): AiStation {
335
+ const preset = typeof input["preset"] === "string" ? PRESETS[input["preset"]] : undefined;
336
+ if (input["preset"] !== undefined && !preset) throw new StationError("unknown_preset", `presets: ${Object.keys(PRESETS).join(", ")}`);
337
+ const name = String(input["name"] ?? preset?.name ?? "").trim().slice(0, 120);
338
+ const style = String(input["style"] ?? preset?.style ?? "").replace(/[\r\n]+/g, " ").trim().slice(0, MAX_STYLE);
339
+ if (!name) throw new StationError("name_required", "an AI station needs a name (or a preset)");
340
+ if (style.length < 10) throw new StationError("style_required", "describe the music in a few words: genre, instruments, mood, tempo");
341
+ const seconds = input["trackSeconds"] === undefined ? DEFAULTS.trackSeconds : Number(input["trackSeconds"]);
342
+ if (!Number.isInteger(seconds) || seconds < 30 || seconds > 300) throw new StationError("invalid_length", "trackSeconds is 30 to 300");
343
+ const perDay = input["perDay"] === undefined ? DEFAULTS.perDay : Number(input["perDay"]);
344
+ if (!Number.isInteger(perDay) || perDay < 0 || perDay > 48) throw new StationError("invalid_rate", "perDay is 0 to 48 tracks");
345
+ const station: AiStation = {
346
+ key: randomBytes(5).toString("hex"),
347
+ name,
348
+ style,
349
+ description: String(input["description"] ?? preset?.description ?? "").trim().slice(0, 500),
350
+ vocals: input["vocals"] === undefined ? preset?.vocals ?? false : input["vocals"] === true,
351
+ trackSeconds: seconds,
352
+ perDay,
353
+ enabled: input["enabled"] !== false,
354
+ publish: input["publish"] !== false,
355
+ stationId: "",
356
+ tracks: [],
357
+ lastAttemptAt: 0,
358
+ lastError: "",
359
+ failures: 0,
360
+ createdAt: this.now(),
361
+ };
362
+ this.stations.set(station.key, station);
363
+ this.save();
364
+ void this.tick();
365
+ return station;
366
+ }
367
+
368
+ update(key: string, input: Record<string, unknown>): AiStation {
369
+ const station = this.require(key);
370
+ if (typeof input["style"] === "string" && input["style"].trim().length >= 10) station.style = input["style"].replace(/[\r\n]+/g, " ").trim().slice(0, MAX_STYLE);
371
+ if (typeof input["name"] === "string" && input["name"].trim()) station.name = input["name"].trim().slice(0, 120);
372
+ if (typeof input["description"] === "string") station.description = input["description"].trim().slice(0, 500);
373
+ if (input["vocals"] !== undefined) station.vocals = input["vocals"] === true;
374
+ if (input["enabled"] !== undefined) station.enabled = input["enabled"] !== false;
375
+ if (input["publish"] !== undefined) station.publish = input["publish"] !== false;
376
+ if (input["perDay"] !== undefined) {
377
+ const perDay = Number(input["perDay"]);
378
+ if (!Number.isInteger(perDay) || perDay < 0 || perDay > 48) throw new StationError("invalid_rate", "perDay is 0 to 48 tracks");
379
+ station.perDay = perDay;
380
+ }
381
+ if (input["trackSeconds"] !== undefined) {
382
+ const seconds = Number(input["trackSeconds"]);
383
+ if (!Number.isInteger(seconds) || seconds < 30 || seconds > 300) throw new StationError("invalid_length", "trackSeconds is 30 to 300");
384
+ station.trackSeconds = seconds;
385
+ }
386
+ station.failures = 0;
387
+ station.lastError = "";
388
+ if (station.stationId && this.deps.store.get(station.stationId)) {
389
+ this.deps.store.update(station.stationId, { name: station.name, description: station.description }, null);
390
+ }
391
+ this.save();
392
+ return station;
393
+ }
394
+
395
+ /** Forget an AI station. Its music and its station stay unless `files` is true. */
396
+ remove(key: string, files = false): void {
397
+ const station = this.require(key);
398
+ if (files) {
399
+ if (station.stationId) this.deps.runner.remove(station.stationId);
400
+ rmSync(join(this.deps.root, station.key), { recursive: true, force: true });
401
+ void this.deps.index.scan(this.source(), this.deps.probe).done;
402
+ }
403
+ this.stations.delete(key);
404
+ this.save();
405
+ }
406
+
407
+ /** When this station's next track is due; 0 when now, -1 when never. */
408
+ nextAt(station: AiStation): number {
409
+ if (!station.enabled) return -1;
410
+ if (station.failures > 0) return station.lastAttemptAt + BACKOFF_MS[Math.min(station.failures, BACKOFF_MS.length) - 1]!;
411
+ if (station.tracks.length < DEFAULTS.seedTracks) return 0;
412
+ if (station.perDay === 0) return -1;
413
+ const last = station.tracks[station.tracks.length - 1]!.createdAt;
414
+ return last + Math.floor(86_400_000 / station.perDay);
415
+ }
416
+
417
+ /** Every 5 minutes: the station most overdue gets its next track. One at a time. */
418
+ startSchedule(everyMs = 5 * 60_000): void {
419
+ if (this.timer) return;
420
+ this.timer = setInterval(() => void this.tick(), everyMs);
421
+ this.timer.unref?.();
422
+ void this.tick();
423
+ }
424
+
425
+ stopSchedule(): void {
426
+ if (this.timer) clearInterval(this.timer);
427
+ this.timer = null;
428
+ }
429
+
430
+ private budgetLeft(): number {
431
+ if (this.settings.monthlySeconds === 0) return Number.POSITIVE_INFINITY;
432
+ return this.settings.monthlySeconds - (this.usage[monthOf(this.now())] ?? 0);
433
+ }
434
+
435
+ async tick(): Promise<void> {
436
+ if (this.busy || this.settings.pausedReason) return;
437
+ const now = this.now();
438
+ const due = this.list()
439
+ .map((one) => ({ one, at: this.nextAt(one) }))
440
+ .filter(({ one, at }) => at !== -1 && at <= now && one.tracks.length < MAX_TRACKS && one.trackSeconds <= this.budgetLeft())
441
+ .sort((a, b) => a.at - b.at)[0];
442
+ if (!due || !this.composer()) return;
443
+ await this.generate(due.one.key).catch(() => undefined);
444
+ }
445
+
446
+ /** Compose one track for this station now (the owner's "generate"), within the month's cap. */
447
+ async generate(key: string): Promise<AiTrack> {
448
+ const station = this.require(key);
449
+ if (this.busy) throw new StationError("busy", `already composing for ${this.busy}; one track at a time`, 409);
450
+ if (this.settings.pausedReason) throw new StationError("paused", this.settings.pausedReason, 409);
451
+ if (station.trackSeconds > this.budgetLeft()) throw new StationError("budget_used", `this month's ${this.settings.monthlySeconds} s of AI music is used; raise it with monthlySeconds`, 429);
452
+ const composer = this.composer();
453
+ if (!composer) throw new StationError("no_composer", "set ELEVENLABS_API_KEY (nixamp station ai key ELEVENLABS_API_KEY) or NIXAMP_AI_MUSIC_COMMAND", 409);
454
+ this.busy = key;
455
+ station.lastAttemptAt = this.now();
456
+ const log = this.deps.log ?? (() => undefined);
457
+ const folder = join(this.deps.root, station.key);
458
+ const raw = join(folder, `.${randomBytes(4).toString("hex")}.raw.mp3`);
459
+ try {
460
+ let brief: { title: string; prompt: string; model: string };
461
+ try {
462
+ brief = await writeBrief(station, this.deps.ask ?? askJson);
463
+ } catch (error) {
464
+ // No model, or one that failed: the station still gets music.
465
+ if (!(error instanceof NoModel)) log(` ai radio: ${station.name}: brief from the model failed (${(error as Error).message}); using the style as it is`);
466
+ brief = { ...plainBrief(station), model: "plain" };
467
+ }
468
+ mkdirSync(folder, { recursive: true });
469
+ log(` ai radio: composing "${brief.title}" for ${station.name} (${station.trackSeconds} s, ${composer.name})`);
470
+ await composer.compose({ prompt: brief.prompt, seconds: station.trackSeconds, vocals: station.vocals, out: raw });
471
+ const month = monthOf(this.now());
472
+ this.usage[month] = (this.usage[month] ?? 0) + station.trackSeconds;
473
+ const file = join(folder, `${String(station.tracks.length + 1).padStart(4, "0")} ${slug(brief.title)}.mp3`);
474
+ await (this.deps.tag ?? ((from, to, tags) => tag(this.deps.ffmpeg, from, to, tags)))(raw, file, {
475
+ title: brief.title,
476
+ artist: `${station.name} (AI)`,
477
+ album: station.name,
478
+ genre: "AI",
479
+ comment: `Composed by ${composer.name} from a brief by ${brief.model}`,
480
+ date: new Date(this.now()).getUTCFullYear().toString(),
481
+ });
482
+ const track: AiTrack = { file, title: brief.title, prompt: brief.prompt, seconds: station.trackSeconds, made: `${brief.model} + ${composer.name}`, createdAt: this.now() };
483
+ station.tracks.push(track);
484
+ station.failures = 0;
485
+ station.lastError = "";
486
+ this.save();
487
+ await this.place(station);
488
+ log(` ai radio: "${brief.title}" is on ${station.name} (${station.tracks.length} tracks)`);
489
+ return track;
490
+ } catch (error) {
491
+ station.failures += 1;
492
+ station.lastError = (error as Error).message;
493
+ if (error instanceof OutOfCredit) {
494
+ this.settings.pausedReason = `${(error as Error).message}; resume with \`nixamp station ai resume\` once there is credit`;
495
+ }
496
+ this.save();
497
+ log(` ai radio: ${station.name}: ${station.lastError}`);
498
+ throw error instanceof StationError ? error : new StationError("compose_failed", station.lastError, 502);
499
+ } finally {
500
+ rmSync(raw, { force: true });
501
+ this.busy = null;
502
+ }
503
+ }
504
+
505
+ /** The station gets its tracks: made with the first one, then kept in step. */
506
+ private async place(station: AiStation): Promise<void> {
507
+ await this.deps.index.scan(this.source(), this.deps.probe).done;
508
+ const files = station.tracks.map((one) => one.file).filter((file) => this.deps.index.itemAt(file)?.state === "ready");
509
+ if (files.length === 0) return;
510
+ const existing = station.stationId ? this.deps.store.get(station.stationId) : undefined;
511
+ if (existing) {
512
+ this.deps.store.update(existing.id, {}, files);
513
+ return;
514
+ }
515
+ const made = this.deps.store.create({ name: station.name, description: station.description, shuffle: true, repeat: true }, files, this.deps.owner);
516
+ this.deps.store.markAuto(made.id, `ai:${station.key}`);
517
+ if (station.publish) {
518
+ this.deps.store.setAccess(made.id, {
519
+ visibility: "public",
520
+ listed: true,
521
+ rights: { basis: "owned", note: `Original music composed for this station by AI (${this.composer()?.name ?? "a music model"}); no third-party recordings.` },
522
+ }, this.deps.owner || "ai-radio");
523
+ } else {
524
+ this.deps.store.setAccess(made.id, { visibility: "unlisted" }, this.deps.owner || "ai-radio");
525
+ }
526
+ station.stationId = made.id;
527
+ this.save();
528
+ if (this.deps.mayStart()) await this.deps.runner.start(made.id).catch(() => undefined);
529
+ }
530
+
531
+ private require(key: string): AiStation {
532
+ const station = this.stations.get(key);
533
+ if (!station) throw new StationError("not_found", "no such AI station", 404);
534
+ return station;
535
+ }
536
+ }