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.
- package/dist/ai-keys.d.ts +9 -0
- package/dist/ai-keys.js +46 -0
- package/dist/ai-radio.d.ts +148 -0
- package/dist/ai-radio.js +477 -0
- package/dist/ai-routes.d.ts +31 -0
- package/dist/ai-routes.js +191 -0
- package/dist/ai-text.d.ts +23 -0
- package/dist/ai-text.js +102 -0
- package/dist/library.d.ts +4 -0
- package/dist/library.js +2 -0
- package/dist/listener-shares.d.ts +136 -0
- package/dist/listener-shares.js +486 -0
- package/dist/server.d.ts +8 -0
- package/dist/server.js +63 -2
- package/dist/station-cli.js +166 -0
- package/dist/station-guide.d.ts +4 -1
- package/dist/station-guide.js +118 -21
- package/dist/station-mcp.d.ts +291 -20
- package/dist/station-mcp.js +66 -0
- package/dist/station-metering.d.ts +1 -1
- package/dist/station-metering.js +21 -9
- package/dist/station-routes.d.ts +4 -0
- package/dist/station-routes.js +8 -1
- package/dist/station-usage.d.ts +1 -1
- package/dist/station-usage.js +19 -0
- package/package.json +1 -1
- package/src/ai-keys.ts +49 -0
- package/src/ai-radio.ts +536 -0
- package/src/ai-routes.ts +220 -0
- package/src/ai-text.ts +117 -0
- package/src/library.ts +6 -0
- package/src/listener-shares.ts +528 -0
- package/src/server.ts +70 -2
- package/src/station-cli.ts +153 -0
- package/src/station-guide.ts +122 -21
- package/src/station-mcp.ts +62 -0
- package/src/station-metering.ts +19 -9
- package/src/station-routes.ts +11 -2
- package/src/station-usage.ts +19 -0
- package/web/dist/assets/{ar-BD2UUquk.js → ar-B2m5eyoV.js} +1 -1
- package/web/dist/assets/{cs-BAnlPO0G.js → cs-Cc5pOkWw.js} +1 -1
- package/web/dist/assets/{da-B3Tu9hTo.js → da-10XVgmQy.js} +1 -1
- package/web/dist/assets/{de-BnhC08J6.js → de-DIBX68fo.js} +1 -1
- package/web/dist/assets/{es-C6f8XeOz.js → es-B5wXCbD_.js} +1 -1
- package/web/dist/assets/{fi-c8nSqL6Y.js → fi-CfqtJU25.js} +1 -1
- package/web/dist/assets/{fr-CCQqbzd3.js → fr-DVpDbDWM.js} +1 -1
- package/web/dist/assets/{hi-oWq0nqXG.js → hi-iQgB7g2O.js} +1 -1
- package/web/dist/assets/{hls-3VKVEQE3-D4IS0V1s.js → hls-3VKVEQE3-Hp3aQLSm.js} +1 -1
- package/web/dist/assets/{hu-DXCXa0U6.js → hu-cL8uAJW0.js} +1 -1
- package/web/dist/assets/{id-i3qTkeDv.js → id-DAEQSrx8.js} +1 -1
- package/web/dist/assets/{it-CHb1I7qg.js → it-DARHJ2-F.js} +1 -1
- package/web/dist/assets/{main-B8hCuIBx.js → main-Bdbkg8vi.js} +2 -2
- package/web/dist/assets/{mpegts-LO6RVLD6-DKSrU2jg.js → mpegts-LO6RVLD6-Be6JzNGd.js} +1 -1
- package/web/dist/assets/{mpegts-DSR-qG9w.js → mpegts-vNdAhc1w.js} +1 -1
- package/web/dist/assets/{nl-iCv9pkTl.js → nl-BNskTvYD.js} +1 -1
- package/web/dist/assets/{ru-BW2-AWx1.js → ru-PA8keJv_.js} +1 -1
- package/web/dist/assets/{sv-DJV6w7__.js → sv-sZu9UVEg.js} +1 -1
- package/web/dist/assets/{uk-CCsBeFUE.js → uk-FmHT0lvK.js} +1 -1
- package/web/dist/assets/{vi-Cckuuziu.js → vi-BV81HbEM.js} +1 -1
- package/web/dist/assets/{zh-Df6AwSwJ.js → zh-CwzApCu7.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +22 -22
package/dist/ai-radio.js
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
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.js";
|
|
28
|
+
import { NoModel, askJson, hasModel } from "./ai-text.js";
|
|
29
|
+
import { StationError } from "./stations.js";
|
|
30
|
+
/** Styles to start from: `nixamp station ai create --preset lofi`. */
|
|
31
|
+
export const PRESETS = {
|
|
32
|
+
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 },
|
|
33
|
+
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 },
|
|
34
|
+
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 },
|
|
35
|
+
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 },
|
|
36
|
+
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 },
|
|
37
|
+
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 },
|
|
38
|
+
};
|
|
39
|
+
export const DEFAULTS = { trackSeconds: 120, perDay: 2, monthlySeconds: 900, seedTracks: 3 };
|
|
40
|
+
const MAX_STYLE = 600;
|
|
41
|
+
const MAX_TRACKS = 5_000;
|
|
42
|
+
/** Between tries after a failure: 5 min, doubling, at most 6 h. */
|
|
43
|
+
const BACKOFF_MS = [5 * 60_000, 10 * 60_000, 20 * 60_000, 40 * 60_000, 80 * 60_000, 6 * 3600_000];
|
|
44
|
+
export class OutOfCredit extends Error {
|
|
45
|
+
}
|
|
46
|
+
/** ElevenLabs Music: a paid plan's music may be broadcast. */
|
|
47
|
+
export function elevenLabsComposer(key, fetcher = fetch) {
|
|
48
|
+
return {
|
|
49
|
+
name: "elevenlabs/music_v1",
|
|
50
|
+
async compose({ prompt, seconds, vocals, out }) {
|
|
51
|
+
const ask = async (text) => fetcher("https://api.elevenlabs.io/v1/music?output_format=mp3_44100_192", {
|
|
52
|
+
method: "POST",
|
|
53
|
+
signal: AbortSignal.timeout(10 * 60_000),
|
|
54
|
+
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 }),
|
|
56
|
+
});
|
|
57
|
+
let response = await ask(prompt);
|
|
58
|
+
if (response.status === 400) {
|
|
59
|
+
// A brief it will not make (an artist's name, a lyric) comes back
|
|
60
|
+
// with one it will; take it once.
|
|
61
|
+
const body = await response.json().catch(() => ({}));
|
|
62
|
+
const suggestion = body.detail?.data?.prompt_suggestion;
|
|
63
|
+
if (!suggestion)
|
|
64
|
+
throw new Error(`elevenlabs refused the brief (${body.detail?.status ?? "HTTP 400"})`);
|
|
65
|
+
response = await ask(suggestion);
|
|
66
|
+
}
|
|
67
|
+
if (response.status === 401 || response.status === 402)
|
|
68
|
+
throw new OutOfCredit(`elevenlabs answered HTTP ${response.status}: the key or the plan's credit`);
|
|
69
|
+
if (!response.ok) {
|
|
70
|
+
const text = await response.text().catch(() => "");
|
|
71
|
+
if (/quota|credit/i.test(text))
|
|
72
|
+
throw new OutOfCredit(`elevenlabs is out of credit: ${text.slice(0, 200)}`);
|
|
73
|
+
throw new Error(`elevenlabs answered HTTP ${response.status}`);
|
|
74
|
+
}
|
|
75
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
76
|
+
if (bytes.length < 1024)
|
|
77
|
+
throw new Error("elevenlabs answered with no music");
|
|
78
|
+
writeFileSync(out, bytes);
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* 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}.
|
|
86
|
+
*/
|
|
87
|
+
export function commandComposer(template) {
|
|
88
|
+
const words = template.trim().split(/\s+/);
|
|
89
|
+
return {
|
|
90
|
+
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));
|
|
93
|
+
execFile(argv[0], argv.slice(1), { timeout: 30 * 60_000, maxBuffer: 4 * 1024 * 1024 }, (error) => {
|
|
94
|
+
if (error)
|
|
95
|
+
return reject(new Error(`the music command failed: ${error.message}`));
|
|
96
|
+
try {
|
|
97
|
+
if (statSync(out).size < 1024)
|
|
98
|
+
return reject(new Error("the music command left no music"));
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return reject(new Error("the music command left no file at {out}"));
|
|
102
|
+
}
|
|
103
|
+
resolve();
|
|
104
|
+
});
|
|
105
|
+
}),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/** The composer this server can use, or null with the reason. */
|
|
109
|
+
export function composerFromEnv(env = process.env) {
|
|
110
|
+
const command = (env["NIXAMP_AI_MUSIC_COMMAND"] ?? "").trim();
|
|
111
|
+
if (command)
|
|
112
|
+
return commandComposer(command);
|
|
113
|
+
const key = aiKey("ELEVENLABS_API_KEY", env);
|
|
114
|
+
return key ? elevenLabsComposer(key) : null;
|
|
115
|
+
}
|
|
116
|
+
const MOODS = ["dreamy", "driving", "melancholy", "bright", "hazy", "tense", "playful", "warm", "nocturnal", "triumphant"];
|
|
117
|
+
const WORDS = ["Neon", "Harbor", "Velvet", "Static", "Paper", "Lantern", "Satellite", "Ember", "Glass", "Tide", "Orchard", "Signal"];
|
|
118
|
+
const PLACES = ["Avenue", "Hours", "Drift", "Motel", "Rooftops", "Weather", "Lines", "Garden", "Frequency", "Afterglow", "Window", "Run"];
|
|
119
|
+
/** A brief without a language model: the style, with a mood and a name drawn for it. */
|
|
120
|
+
export function plainBrief(station, random = Math.random) {
|
|
121
|
+
const pick = (list) => list[Math.floor(random() * list.length)];
|
|
122
|
+
const taken = new Set(station.tracks.map((one) => one.title.toLowerCase()));
|
|
123
|
+
let title = "";
|
|
124
|
+
for (let tries = 0; tries < 20 && (title === "" || taken.has(title.toLowerCase())); tries++)
|
|
125
|
+
title = `${pick(WORDS)} ${pick(PLACES)}`;
|
|
126
|
+
if (taken.has(title.toLowerCase()))
|
|
127
|
+
title = `${title} ${station.tracks.length + 1}`;
|
|
128
|
+
return { title, prompt: `${station.style}. A ${pick(MOODS)} piece called "${title}", with a clear intro, development and ending.` };
|
|
129
|
+
}
|
|
130
|
+
const SYSTEM = [
|
|
131
|
+
"You write briefs for an AI music generator that composes original tracks for an internet radio station.",
|
|
132
|
+
"Each brief is one paragraph of at most 400 characters: genre, tempo, key or mode, instruments, structure, mood.",
|
|
133
|
+
"Never name a real artist, band, song, album or label, and never quote existing lyrics.",
|
|
134
|
+
"Answer as JSON: {\"title\": \"a fresh 1-5 word track title\", \"prompt\": \"the brief\"}.",
|
|
135
|
+
].join(" ");
|
|
136
|
+
export async function writeBrief(station, ask = askJson) {
|
|
137
|
+
const recent = station.tracks.slice(-15).map((one) => one.title);
|
|
138
|
+
const answer = await ask({
|
|
139
|
+
system: SYSTEM,
|
|
140
|
+
prompt: [
|
|
141
|
+
`Station: ${station.name}`,
|
|
142
|
+
`Style: ${station.style}`,
|
|
143
|
+
station.vocals ? "The track has sung vocals; describe the voice and what the song is about (the generator writes the words)." : "Instrumental: no vocals.",
|
|
144
|
+
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.",
|
|
145
|
+
].join("\n"),
|
|
146
|
+
});
|
|
147
|
+
const title = String(answer.value["title"] ?? "").replace(/[\r\n"]/g, " ").trim().slice(0, 80);
|
|
148
|
+
const prompt = String(answer.value["prompt"] ?? "").replace(/[\r\n]/g, " ").trim().slice(0, 600);
|
|
149
|
+
if (!title || !prompt)
|
|
150
|
+
throw new Error("the model wrote no title or brief");
|
|
151
|
+
return { title, prompt, model: answer.model };
|
|
152
|
+
}
|
|
153
|
+
function slug(text) {
|
|
154
|
+
return text.normalize("NFKD").replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, " ").slice(0, 80) || "track";
|
|
155
|
+
}
|
|
156
|
+
function monthOf(at) {
|
|
157
|
+
return new Date(at).toISOString().slice(0, 7);
|
|
158
|
+
}
|
|
159
|
+
/** Write title/artist/album/genre into the file, so every player shows them. */
|
|
160
|
+
function tag(ffmpeg, from, to, tags) {
|
|
161
|
+
const args = [...ffmpeg.slice(1), "-hide_banner", "-loglevel", "error", "-y", "-i", from, "-map", "0:a", "-c", "copy", "-id3v2_version", "3"];
|
|
162
|
+
for (const [name, value] of Object.entries(tags))
|
|
163
|
+
args.push("-metadata", `${name}=${value}`);
|
|
164
|
+
args.push(to);
|
|
165
|
+
return new Promise((resolve, reject) => {
|
|
166
|
+
execFile(ffmpeg[0], args, { timeout: 120_000 }, (error) => (error ? reject(new Error(`tagging failed: ${error.message}`)) : resolve()));
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
export class AiRadio {
|
|
170
|
+
file;
|
|
171
|
+
deps;
|
|
172
|
+
stations = new Map();
|
|
173
|
+
settings = { monthlySeconds: DEFAULTS.monthlySeconds, pausedReason: "" };
|
|
174
|
+
usage = {};
|
|
175
|
+
busy = null;
|
|
176
|
+
sourceId = "";
|
|
177
|
+
timer = null;
|
|
178
|
+
constructor(file, deps) {
|
|
179
|
+
this.file = file;
|
|
180
|
+
this.deps = deps;
|
|
181
|
+
try {
|
|
182
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
183
|
+
for (const one of parsed.stations ?? [])
|
|
184
|
+
if (one?.key)
|
|
185
|
+
this.stations.set(one.key, { ...one, tracks: one.tracks ?? [] });
|
|
186
|
+
this.settings = { ...this.settings, ...(parsed.settings ?? {}) };
|
|
187
|
+
this.usage = parsed.usage ?? {};
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// Nothing yet.
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
static fileFor(stateDir, port) {
|
|
194
|
+
return join(stateDir, `ai-radio-${port}.json`);
|
|
195
|
+
}
|
|
196
|
+
now() {
|
|
197
|
+
return this.deps.now?.() ?? Date.now();
|
|
198
|
+
}
|
|
199
|
+
save() {
|
|
200
|
+
mkdirSync(dirname(this.file), { recursive: true });
|
|
201
|
+
const temporary = `${this.file}.${process.pid}.tmp`;
|
|
202
|
+
writeFileSync(temporary, `${JSON.stringify({ version: 1, settings: this.settings, usage: this.usage, stations: [...this.stations.values()] }, null, 2)}\n`, { mode: 0o600 });
|
|
203
|
+
renameSync(temporary, this.file);
|
|
204
|
+
}
|
|
205
|
+
source() {
|
|
206
|
+
if (this.sourceId && this.deps.index.source(this.sourceId))
|
|
207
|
+
return this.sourceId;
|
|
208
|
+
mkdirSync(this.deps.root, { recursive: true });
|
|
209
|
+
this.sourceId = this.deps.index.addManagedSource(this.deps.root).id;
|
|
210
|
+
return this.sourceId;
|
|
211
|
+
}
|
|
212
|
+
composer() {
|
|
213
|
+
return this.deps.composer ? this.deps.composer() : composerFromEnv();
|
|
214
|
+
}
|
|
215
|
+
list() {
|
|
216
|
+
return [...this.stations.values()].sort((a, b) => a.createdAt - b.createdAt);
|
|
217
|
+
}
|
|
218
|
+
get(key) {
|
|
219
|
+
return this.stations.get(key);
|
|
220
|
+
}
|
|
221
|
+
/** What the owner sees: the stations, this month's seconds, and why nothing is being made. */
|
|
222
|
+
status() {
|
|
223
|
+
const month = monthOf(this.now());
|
|
224
|
+
const composer = this.composer();
|
|
225
|
+
return {
|
|
226
|
+
composer: composer?.name ?? null,
|
|
227
|
+
writer: (this.deps.canAsk ?? hasModel)() ? "language model" : "plain (no model key)",
|
|
228
|
+
busy: this.busy,
|
|
229
|
+
month,
|
|
230
|
+
secondsThisMonth: this.usage[month] ?? 0,
|
|
231
|
+
monthlySeconds: this.settings.monthlySeconds,
|
|
232
|
+
paused: this.settings.pausedReason || (composer ? "" : "no composer: set ELEVENLABS_API_KEY (nixamp station ai key ELEVENLABS_API_KEY) or NIXAMP_AI_MUSIC_COMMAND"),
|
|
233
|
+
presets: Object.keys(PRESETS),
|
|
234
|
+
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) })),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
/** Change the month's cap; `resume` clears a stop for lack of credit. */
|
|
238
|
+
configure(input) {
|
|
239
|
+
if (input["monthlySeconds"] !== undefined) {
|
|
240
|
+
const value = Number(input["monthlySeconds"]);
|
|
241
|
+
if (!Number.isInteger(value) || value < 0 || value > 1_000_000)
|
|
242
|
+
throw new StationError("invalid_budget", "monthlySeconds is whole seconds, 0 for no cap");
|
|
243
|
+
this.settings.monthlySeconds = value;
|
|
244
|
+
}
|
|
245
|
+
if (input["resume"] === true)
|
|
246
|
+
this.settings.pausedReason = "";
|
|
247
|
+
this.save();
|
|
248
|
+
return this.settings;
|
|
249
|
+
}
|
|
250
|
+
create(input) {
|
|
251
|
+
const preset = typeof input["preset"] === "string" ? PRESETS[input["preset"]] : undefined;
|
|
252
|
+
if (input["preset"] !== undefined && !preset)
|
|
253
|
+
throw new StationError("unknown_preset", `presets: ${Object.keys(PRESETS).join(", ")}`);
|
|
254
|
+
const name = String(input["name"] ?? preset?.name ?? "").trim().slice(0, 120);
|
|
255
|
+
const style = String(input["style"] ?? preset?.style ?? "").replace(/[\r\n]+/g, " ").trim().slice(0, MAX_STYLE);
|
|
256
|
+
if (!name)
|
|
257
|
+
throw new StationError("name_required", "an AI station needs a name (or a preset)");
|
|
258
|
+
if (style.length < 10)
|
|
259
|
+
throw new StationError("style_required", "describe the music in a few words: genre, instruments, mood, tempo");
|
|
260
|
+
const seconds = input["trackSeconds"] === undefined ? DEFAULTS.trackSeconds : Number(input["trackSeconds"]);
|
|
261
|
+
if (!Number.isInteger(seconds) || seconds < 30 || seconds > 300)
|
|
262
|
+
throw new StationError("invalid_length", "trackSeconds is 30 to 300");
|
|
263
|
+
const perDay = input["perDay"] === undefined ? DEFAULTS.perDay : Number(input["perDay"]);
|
|
264
|
+
if (!Number.isInteger(perDay) || perDay < 0 || perDay > 48)
|
|
265
|
+
throw new StationError("invalid_rate", "perDay is 0 to 48 tracks");
|
|
266
|
+
const station = {
|
|
267
|
+
key: randomBytes(5).toString("hex"),
|
|
268
|
+
name,
|
|
269
|
+
style,
|
|
270
|
+
description: String(input["description"] ?? preset?.description ?? "").trim().slice(0, 500),
|
|
271
|
+
vocals: input["vocals"] === undefined ? preset?.vocals ?? false : input["vocals"] === true,
|
|
272
|
+
trackSeconds: seconds,
|
|
273
|
+
perDay,
|
|
274
|
+
enabled: input["enabled"] !== false,
|
|
275
|
+
publish: input["publish"] !== false,
|
|
276
|
+
stationId: "",
|
|
277
|
+
tracks: [],
|
|
278
|
+
lastAttemptAt: 0,
|
|
279
|
+
lastError: "",
|
|
280
|
+
failures: 0,
|
|
281
|
+
createdAt: this.now(),
|
|
282
|
+
};
|
|
283
|
+
this.stations.set(station.key, station);
|
|
284
|
+
this.save();
|
|
285
|
+
void this.tick();
|
|
286
|
+
return station;
|
|
287
|
+
}
|
|
288
|
+
update(key, input) {
|
|
289
|
+
const station = this.require(key);
|
|
290
|
+
if (typeof input["style"] === "string" && input["style"].trim().length >= 10)
|
|
291
|
+
station.style = input["style"].replace(/[\r\n]+/g, " ").trim().slice(0, MAX_STYLE);
|
|
292
|
+
if (typeof input["name"] === "string" && input["name"].trim())
|
|
293
|
+
station.name = input["name"].trim().slice(0, 120);
|
|
294
|
+
if (typeof input["description"] === "string")
|
|
295
|
+
station.description = input["description"].trim().slice(0, 500);
|
|
296
|
+
if (input["vocals"] !== undefined)
|
|
297
|
+
station.vocals = input["vocals"] === true;
|
|
298
|
+
if (input["enabled"] !== undefined)
|
|
299
|
+
station.enabled = input["enabled"] !== false;
|
|
300
|
+
if (input["publish"] !== undefined)
|
|
301
|
+
station.publish = input["publish"] !== false;
|
|
302
|
+
if (input["perDay"] !== undefined) {
|
|
303
|
+
const perDay = Number(input["perDay"]);
|
|
304
|
+
if (!Number.isInteger(perDay) || perDay < 0 || perDay > 48)
|
|
305
|
+
throw new StationError("invalid_rate", "perDay is 0 to 48 tracks");
|
|
306
|
+
station.perDay = perDay;
|
|
307
|
+
}
|
|
308
|
+
if (input["trackSeconds"] !== undefined) {
|
|
309
|
+
const seconds = Number(input["trackSeconds"]);
|
|
310
|
+
if (!Number.isInteger(seconds) || seconds < 30 || seconds > 300)
|
|
311
|
+
throw new StationError("invalid_length", "trackSeconds is 30 to 300");
|
|
312
|
+
station.trackSeconds = seconds;
|
|
313
|
+
}
|
|
314
|
+
station.failures = 0;
|
|
315
|
+
station.lastError = "";
|
|
316
|
+
if (station.stationId && this.deps.store.get(station.stationId)) {
|
|
317
|
+
this.deps.store.update(station.stationId, { name: station.name, description: station.description }, null);
|
|
318
|
+
}
|
|
319
|
+
this.save();
|
|
320
|
+
return station;
|
|
321
|
+
}
|
|
322
|
+
/** Forget an AI station. Its music and its station stay unless `files` is true. */
|
|
323
|
+
remove(key, files = false) {
|
|
324
|
+
const station = this.require(key);
|
|
325
|
+
if (files) {
|
|
326
|
+
if (station.stationId)
|
|
327
|
+
this.deps.runner.remove(station.stationId);
|
|
328
|
+
rmSync(join(this.deps.root, station.key), { recursive: true, force: true });
|
|
329
|
+
void this.deps.index.scan(this.source(), this.deps.probe).done;
|
|
330
|
+
}
|
|
331
|
+
this.stations.delete(key);
|
|
332
|
+
this.save();
|
|
333
|
+
}
|
|
334
|
+
/** When this station's next track is due; 0 when now, -1 when never. */
|
|
335
|
+
nextAt(station) {
|
|
336
|
+
if (!station.enabled)
|
|
337
|
+
return -1;
|
|
338
|
+
if (station.failures > 0)
|
|
339
|
+
return station.lastAttemptAt + BACKOFF_MS[Math.min(station.failures, BACKOFF_MS.length) - 1];
|
|
340
|
+
if (station.tracks.length < DEFAULTS.seedTracks)
|
|
341
|
+
return 0;
|
|
342
|
+
if (station.perDay === 0)
|
|
343
|
+
return -1;
|
|
344
|
+
const last = station.tracks[station.tracks.length - 1].createdAt;
|
|
345
|
+
return last + Math.floor(86_400_000 / station.perDay);
|
|
346
|
+
}
|
|
347
|
+
/** Every 5 minutes: the station most overdue gets its next track. One at a time. */
|
|
348
|
+
startSchedule(everyMs = 5 * 60_000) {
|
|
349
|
+
if (this.timer)
|
|
350
|
+
return;
|
|
351
|
+
this.timer = setInterval(() => void this.tick(), everyMs);
|
|
352
|
+
this.timer.unref?.();
|
|
353
|
+
void this.tick();
|
|
354
|
+
}
|
|
355
|
+
stopSchedule() {
|
|
356
|
+
if (this.timer)
|
|
357
|
+
clearInterval(this.timer);
|
|
358
|
+
this.timer = null;
|
|
359
|
+
}
|
|
360
|
+
budgetLeft() {
|
|
361
|
+
if (this.settings.monthlySeconds === 0)
|
|
362
|
+
return Number.POSITIVE_INFINITY;
|
|
363
|
+
return this.settings.monthlySeconds - (this.usage[monthOf(this.now())] ?? 0);
|
|
364
|
+
}
|
|
365
|
+
async tick() {
|
|
366
|
+
if (this.busy || this.settings.pausedReason)
|
|
367
|
+
return;
|
|
368
|
+
const now = this.now();
|
|
369
|
+
const due = this.list()
|
|
370
|
+
.map((one) => ({ one, at: this.nextAt(one) }))
|
|
371
|
+
.filter(({ one, at }) => at !== -1 && at <= now && one.tracks.length < MAX_TRACKS && one.trackSeconds <= this.budgetLeft())
|
|
372
|
+
.sort((a, b) => a.at - b.at)[0];
|
|
373
|
+
if (!due || !this.composer())
|
|
374
|
+
return;
|
|
375
|
+
await this.generate(due.one.key).catch(() => undefined);
|
|
376
|
+
}
|
|
377
|
+
/** Compose one track for this station now (the owner's "generate"), within the month's cap. */
|
|
378
|
+
async generate(key) {
|
|
379
|
+
const station = this.require(key);
|
|
380
|
+
if (this.busy)
|
|
381
|
+
throw new StationError("busy", `already composing for ${this.busy}; one track at a time`, 409);
|
|
382
|
+
if (this.settings.pausedReason)
|
|
383
|
+
throw new StationError("paused", this.settings.pausedReason, 409);
|
|
384
|
+
if (station.trackSeconds > this.budgetLeft())
|
|
385
|
+
throw new StationError("budget_used", `this month's ${this.settings.monthlySeconds} s of AI music is used; raise it with monthlySeconds`, 429);
|
|
386
|
+
const composer = this.composer();
|
|
387
|
+
if (!composer)
|
|
388
|
+
throw new StationError("no_composer", "set ELEVENLABS_API_KEY (nixamp station ai key ELEVENLABS_API_KEY) or NIXAMP_AI_MUSIC_COMMAND", 409);
|
|
389
|
+
this.busy = key;
|
|
390
|
+
station.lastAttemptAt = this.now();
|
|
391
|
+
const log = this.deps.log ?? (() => undefined);
|
|
392
|
+
const folder = join(this.deps.root, station.key);
|
|
393
|
+
const raw = join(folder, `.${randomBytes(4).toString("hex")}.raw.mp3`);
|
|
394
|
+
try {
|
|
395
|
+
let brief;
|
|
396
|
+
try {
|
|
397
|
+
brief = await writeBrief(station, this.deps.ask ?? askJson);
|
|
398
|
+
}
|
|
399
|
+
catch (error) {
|
|
400
|
+
// No model, or one that failed: the station still gets music.
|
|
401
|
+
if (!(error instanceof NoModel))
|
|
402
|
+
log(` ai radio: ${station.name}: brief from the model failed (${error.message}); using the style as it is`);
|
|
403
|
+
brief = { ...plainBrief(station), model: "plain" };
|
|
404
|
+
}
|
|
405
|
+
mkdirSync(folder, { recursive: true });
|
|
406
|
+
log(` ai radio: composing "${brief.title}" for ${station.name} (${station.trackSeconds} s, ${composer.name})`);
|
|
407
|
+
await composer.compose({ prompt: brief.prompt, seconds: station.trackSeconds, vocals: station.vocals, out: raw });
|
|
408
|
+
const month = monthOf(this.now());
|
|
409
|
+
this.usage[month] = (this.usage[month] ?? 0) + station.trackSeconds;
|
|
410
|
+
const file = join(folder, `${String(station.tracks.length + 1).padStart(4, "0")} ${slug(brief.title)}.mp3`);
|
|
411
|
+
await (this.deps.tag ?? ((from, to, tags) => tag(this.deps.ffmpeg, from, to, tags)))(raw, file, {
|
|
412
|
+
title: brief.title,
|
|
413
|
+
artist: `${station.name} (AI)`,
|
|
414
|
+
album: station.name,
|
|
415
|
+
genre: "AI",
|
|
416
|
+
comment: `Composed by ${composer.name} from a brief by ${brief.model}`,
|
|
417
|
+
date: new Date(this.now()).getUTCFullYear().toString(),
|
|
418
|
+
});
|
|
419
|
+
const track = { file, title: brief.title, prompt: brief.prompt, seconds: station.trackSeconds, made: `${brief.model} + ${composer.name}`, createdAt: this.now() };
|
|
420
|
+
station.tracks.push(track);
|
|
421
|
+
station.failures = 0;
|
|
422
|
+
station.lastError = "";
|
|
423
|
+
this.save();
|
|
424
|
+
await this.place(station);
|
|
425
|
+
log(` ai radio: "${brief.title}" is on ${station.name} (${station.tracks.length} tracks)`);
|
|
426
|
+
return track;
|
|
427
|
+
}
|
|
428
|
+
catch (error) {
|
|
429
|
+
station.failures += 1;
|
|
430
|
+
station.lastError = error.message;
|
|
431
|
+
if (error instanceof OutOfCredit) {
|
|
432
|
+
this.settings.pausedReason = `${error.message}; resume with \`nixamp station ai resume\` once there is credit`;
|
|
433
|
+
}
|
|
434
|
+
this.save();
|
|
435
|
+
log(` ai radio: ${station.name}: ${station.lastError}`);
|
|
436
|
+
throw error instanceof StationError ? error : new StationError("compose_failed", station.lastError, 502);
|
|
437
|
+
}
|
|
438
|
+
finally {
|
|
439
|
+
rmSync(raw, { force: true });
|
|
440
|
+
this.busy = null;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
/** The station gets its tracks: made with the first one, then kept in step. */
|
|
444
|
+
async place(station) {
|
|
445
|
+
await this.deps.index.scan(this.source(), this.deps.probe).done;
|
|
446
|
+
const files = station.tracks.map((one) => one.file).filter((file) => this.deps.index.itemAt(file)?.state === "ready");
|
|
447
|
+
if (files.length === 0)
|
|
448
|
+
return;
|
|
449
|
+
const existing = station.stationId ? this.deps.store.get(station.stationId) : undefined;
|
|
450
|
+
if (existing) {
|
|
451
|
+
this.deps.store.update(existing.id, {}, files);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
const made = this.deps.store.create({ name: station.name, description: station.description, shuffle: true, repeat: true }, files, this.deps.owner);
|
|
455
|
+
this.deps.store.markAuto(made.id, `ai:${station.key}`);
|
|
456
|
+
if (station.publish) {
|
|
457
|
+
this.deps.store.setAccess(made.id, {
|
|
458
|
+
visibility: "public",
|
|
459
|
+
listed: true,
|
|
460
|
+
rights: { basis: "owned", note: `Original music composed for this station by AI (${this.composer()?.name ?? "a music model"}); no third-party recordings.` },
|
|
461
|
+
}, this.deps.owner || "ai-radio");
|
|
462
|
+
}
|
|
463
|
+
else {
|
|
464
|
+
this.deps.store.setAccess(made.id, { visibility: "unlisted" }, this.deps.owner || "ai-radio");
|
|
465
|
+
}
|
|
466
|
+
station.stationId = made.id;
|
|
467
|
+
this.save();
|
|
468
|
+
if (this.deps.mayStart())
|
|
469
|
+
await this.deps.runner.start(made.id).catch(() => undefined);
|
|
470
|
+
}
|
|
471
|
+
require(key) {
|
|
472
|
+
const station = this.stations.get(key);
|
|
473
|
+
if (!station)
|
|
474
|
+
throw new StationError("not_found", "no such AI station", 404);
|
|
475
|
+
return station;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI radio and listener shares over HTTP.
|
|
3
|
+
*
|
|
4
|
+
* Public, no key, because sharing and reporting are what the guide offers
|
|
5
|
+
* anybody: `/api/v1/share` (what may be shared, and a link to share),
|
|
6
|
+
* `/api/v1/share/upload` (the file as the body), `/api/v1/share/:id?t=`
|
|
7
|
+
* (how it went; DELETE takes it back) and `/api/v1/share/report` (take what
|
|
8
|
+
* is playing off the air). Everything else is the owner's, under
|
|
9
|
+
* /api/v1/stations/ai and /api/v1/stations/shares.
|
|
10
|
+
*/
|
|
11
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
12
|
+
import type { AiRadio } from "./ai-radio.ts";
|
|
13
|
+
import { type ListenerShares } from "./listener-shares.ts";
|
|
14
|
+
/** Whether this path is one anybody may use without the server's key. */
|
|
15
|
+
export declare function isPublicSharePath(path: string): boolean;
|
|
16
|
+
export interface AiRoutesContext {
|
|
17
|
+
ai?: AiRadio;
|
|
18
|
+
shares?: ListenerShares;
|
|
19
|
+
canControl: boolean;
|
|
20
|
+
/** Who is asking, for per-sharer limits (an address; hashed before it is kept). */
|
|
21
|
+
caller: string;
|
|
22
|
+
json: (response: ServerResponse, status: number, body: unknown) => void;
|
|
23
|
+
readBody: (request: IncomingMessage) => Promise<string>;
|
|
24
|
+
headers: Record<string, string>;
|
|
25
|
+
/** Null to go ahead, or how long to wait: a public limit on top of the per-sharer one. */
|
|
26
|
+
throttle?: () => {
|
|
27
|
+
retryAfter: number;
|
|
28
|
+
} | null;
|
|
29
|
+
}
|
|
30
|
+
/** Answers the paths above; false when the path is not one of them. */
|
|
31
|
+
export declare function handleAiRoutes(request: IncomingMessage, response: ServerResponse, path: string, url: URL, ctx: AiRoutesContext): Promise<boolean>;
|