nixamp 0.48.1 → 0.50.1
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 +1 -1
- package/dist/ai-keys.js +2 -0
- package/dist/ai-label.d.ts +239 -0
- package/dist/ai-label.js +774 -0
- package/dist/ai-radio.d.ts +11 -5
- package/dist/ai-radio.js +27 -11
- package/dist/ai-text.d.ts +2 -0
- package/dist/ai-text.js +4 -2
- package/dist/label-routes.d.ts +28 -0
- package/dist/label-routes.js +314 -0
- package/dist/music-store.d.ts +121 -0
- package/dist/music-store.js +289 -0
- package/dist/server.d.ts +6 -0
- package/dist/server.js +99 -11
- package/dist/station-cli.js +109 -0
- package/dist/station-guide.d.ts +1 -1
- package/dist/station-guide.js +5 -1
- package/dist/station-mcp.d.ts +247 -20
- package/dist/station-mcp.js +36 -0
- package/dist/station-usage.d.ts +1 -1
- package/dist/station-usage.js +15 -1
- package/dist/store-routes.d.ts +16 -0
- package/dist/store-routes.js +189 -0
- package/dist/support.d.ts +14 -0
- package/dist/support.js +41 -0
- package/dist/track-facts.d.ts +8 -0
- package/dist/zip-stream.d.ts +13 -0
- package/dist/zip-stream.js +119 -0
- package/package.json +1 -1
- package/src/ai-keys.ts +2 -0
- package/src/ai-label.ts +873 -0
- package/src/ai-radio.ts +30 -13
- package/src/ai-text.ts +6 -2
- package/src/label-routes.ts +336 -0
- package/src/music-store.ts +333 -0
- package/src/server.ts +91 -3
- package/src/station-cli.ts +102 -0
- package/src/station-guide.ts +5 -1
- package/src/station-mcp.ts +34 -0
- package/src/station-usage.ts +15 -1
- package/src/store-routes.ts +204 -0
- package/src/support.ts +39 -0
- package/src/track-facts.ts +2 -0
- package/src/zip-stream.ts +128 -0
- package/web/dist/assets/{ar-BvPTr_2x.js → ar-CC_uYRlC.js} +1 -1
- package/web/dist/assets/{cs-g4ii7Tkl.js → cs-k5C-jvfX.js} +1 -1
- package/web/dist/assets/{da-D9vYCFH7.js → da-CdBzU_fF.js} +1 -1
- package/web/dist/assets/{de-lYElbebQ.js → de-B-UWa0W7.js} +1 -1
- package/web/dist/assets/{es-CiDbfxFy.js → es-Dod8aCau.js} +1 -1
- package/web/dist/assets/{fi-BWlqMMWx.js → fi-C36bBPFW.js} +1 -1
- package/web/dist/assets/{fr-D2n5nXQv.js → fr-DWUxs2qv.js} +1 -1
- package/web/dist/assets/{hi-DM9Nh04K.js → hi-DjLqhfs8.js} +1 -1
- package/web/dist/assets/{hu-Bzb0X4rk.js → hu-CgsOlcq8.js} +1 -1
- package/web/dist/assets/{id-j0QazySW.js → id-ZA8yTWX9.js} +1 -1
- package/web/dist/assets/{it-BVvva3a6.js → it-DfFhS34g.js} +1 -1
- package/web/dist/assets/{main-DPfBhzsz.js → main-CjgQp9um.js} +2 -2
- package/web/dist/assets/{nl-CrJQ6obW.js → nl-C257i24U.js} +1 -1
- package/web/dist/assets/radio-CDUEoM0y.js +1 -0
- package/web/dist/assets/{ru-DFIjjKvG.js → ru--30I7BNC.js} +1 -1
- package/web/dist/assets/{station-BKmoJ0Sk.css → station-Be-z7jQ2.css} +1 -1
- package/web/dist/assets/{station-X8pffqCW.js → station-D769XVG0.js} +1 -1
- package/web/dist/assets/{sv-BO9QPL4K.js → sv-CxzBeXfk.js} +1 -1
- package/web/dist/assets/{uk-DwmG3Owv.js → uk-DXHpMk4i.js} +1 -1
- package/web/dist/assets/{vi-C-2EzgMe.js → vi-Dg2LT4x3.js} +1 -1
- package/web/dist/assets/{zh-BlnCenyZ.js → zh-D3pvejo6.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/radio.html +44 -1
- package/web/dist/station.html +7 -2
- package/web/dist/sw.js +25 -25
- package/web/dist/vod.html +2 -2
- package/web/dist/watch.html +2 -2
- package/web/dist/assets/radio-DI0H8flN.js +0 -1
- /package/web/dist/assets/{vod-CdfPMa1u.js → vod-bM1DvPEx.js} +0 -0
- /package/web/dist/assets/{watch-hdfnhm3C.js → watch-BbhPO0HS.js} +0 -0
package/dist/ai-label.js
ADDED
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The AI label: a roster of original artists who put out albums on their own.
|
|
3
|
+
*
|
|
4
|
+
* Where AI radio (ai-radio.ts) is a station that grows a track at a time, the
|
|
5
|
+
* label is a record company in miniature. Each artist has a name, a genre, a
|
|
6
|
+
* sound and a voice. When an artist is due -- its first albums at once, then
|
|
7
|
+
* one every `everyDays` -- a language model plans an album (a title, a
|
|
8
|
+
* concept, a running order with a brief per song), writes each song's words
|
|
9
|
+
* in English, and a music model sings them. Every song lands as
|
|
10
|
+
*
|
|
11
|
+
* <root>/<genre>/<artist>/<year>-<album>/001. <title>.mp3
|
|
12
|
+
* <root>/<genre>/<artist>/<year>-<album>/001. <title>.lyrics.txt
|
|
13
|
+
*
|
|
14
|
+
* with the album's cover.jpg and album.json beside them, tagged with the
|
|
15
|
+
* artist, album, track number, the words (so lyrics-publish.ts sends them to
|
|
16
|
+
* nichedb) and the label's rights holder as publisher and copyright. Each
|
|
17
|
+
* genre is one station on the public guide, kept in step as songs land, and
|
|
18
|
+
* the whole catalogue has a page anybody can read and play (label-routes.ts).
|
|
19
|
+
*
|
|
20
|
+
* Planning is saved before anything is composed, and each song is composed on
|
|
21
|
+
* its own, so a restart, a failure or a month's cap resumes where it stopped.
|
|
22
|
+
* Composition is metered in seconds a month like AI radio, and a provider out
|
|
23
|
+
* of credit pauses the label until the owner resumes it.
|
|
24
|
+
*
|
|
25
|
+
* The rights holder's declaration is what makes the music "owned" here; it is
|
|
26
|
+
* not legal clearance, and how much of a machine's work copyright covers is
|
|
27
|
+
* the holder's question to settle.
|
|
28
|
+
*/
|
|
29
|
+
import { execFile } from "node:child_process";
|
|
30
|
+
import { randomBytes } from "node:crypto";
|
|
31
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
32
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
33
|
+
import { aiKey } from "./ai-keys.js";
|
|
34
|
+
import { composerFromEnv, OutOfCredit } from "./ai-radio.js";
|
|
35
|
+
import { askJson, hasModel } from "./ai-text.js";
|
|
36
|
+
import { StationError } from "./stations.js";
|
|
37
|
+
/**
|
|
38
|
+
* The label's first roster: invented names, one genre each, sung in English.
|
|
39
|
+
* `nixamp station label artist add --preset KEY`, or all of them with `--roster`.
|
|
40
|
+
*/
|
|
41
|
+
export const ROSTER = {
|
|
42
|
+
"velvet-ruckus": {
|
|
43
|
+
name: "Velvet Ruckus", genre: "Rock",
|
|
44
|
+
sound: "gritty alternative rock, crunchy overdriven guitars, big room drums, melodic bass, anthemic choruses, 120-150 BPM",
|
|
45
|
+
voice: "raspy, powerful female alto with a wide belt and a little growl",
|
|
46
|
+
bio: "A four-piece that sounds like a basement show that got too big for the basement.",
|
|
47
|
+
themes: "small-town escape, late shifts, loyalty, burning bridges and building better ones",
|
|
48
|
+
},
|
|
49
|
+
"juno-kincaid": {
|
|
50
|
+
name: "Juno Kincaid", genre: "Hip-Hop",
|
|
51
|
+
sound: "modern boom-bap hip hop, dusty sampled-sounding keys, hard swung drums, deep 808s, vinyl texture, 85-95 BPM",
|
|
52
|
+
voice: "confident male rapper, clear diction, warm low register, conversational flow with sung hooks",
|
|
53
|
+
bio: "A night-bus storyteller who raps like he is reading you his diary on the last train home.",
|
|
54
|
+
themes: "ambition, city nights, family, hustle and doubt, coming up from nothing",
|
|
55
|
+
},
|
|
56
|
+
"chrome-orchard": {
|
|
57
|
+
name: "Chrome Orchard", genre: "Synthwave",
|
|
58
|
+
sound: "retro 80s synthwave, gated reverb drums, analog bass arpeggios, lush neon pads, soaring lead synth, 100-118 BPM",
|
|
59
|
+
voice: "airy male tenor, slightly reverbed, wistful and cinematic",
|
|
60
|
+
bio: "Soundtracks for a film about driving that nobody has made yet.",
|
|
61
|
+
themes: "night drives, neon cities, lost summers, the future we were promised",
|
|
62
|
+
},
|
|
63
|
+
"delta-wren": {
|
|
64
|
+
name: "Delta Wren", genre: "Country",
|
|
65
|
+
sound: "modern country and Americana, acoustic and electric guitars, pedal steel, fiddle, warm bass, steady backbeat, 80-110 BPM",
|
|
66
|
+
voice: "clear, twangy female soprano with heart and a little grit",
|
|
67
|
+
bio: "A porch-light songwriter with a pickup truck full of second chances.",
|
|
68
|
+
themes: "back roads, hometowns, heartbreak and healing, working hands, Saturday nights",
|
|
69
|
+
},
|
|
70
|
+
"ashen-cartography": {
|
|
71
|
+
name: "Ashen Cartography", genre: "Metal",
|
|
72
|
+
sound: "melodic metalcore, down-tuned chugging riffs, twin lead guitars, blast beats into half-time breakdowns, cinematic synths, 140-180 BPM",
|
|
73
|
+
voice: "male vocalist alternating fierce harsh screams in the verses with soaring clean singing in the choruses",
|
|
74
|
+
bio: "Maps of places that burned down, drawn at full volume.",
|
|
75
|
+
themes: "ruins, resilience, storms, rebuilding from ashes, defiance",
|
|
76
|
+
},
|
|
77
|
+
"lumi-kestrel": {
|
|
78
|
+
name: "Lumi Kestrel", genre: "Pop",
|
|
79
|
+
sound: "bright modern dance pop, punchy four-on-the-floor drums, glossy synths, plucky guitar, big pre-chorus lifts, 110-124 BPM",
|
|
80
|
+
voice: "bright, agile female pop soprano with breathy verses and a powerful chorus",
|
|
81
|
+
bio: "Glitter on the outside, journal entries on the inside.",
|
|
82
|
+
themes: "first nights out, confidence, crushes, friendship, dancing it off",
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
export const DEFAULTS = {
|
|
86
|
+
holder: "Profullstack, Inc.",
|
|
87
|
+
enabled: true,
|
|
88
|
+
monthlySeconds: 3600,
|
|
89
|
+
everyDays: 30,
|
|
90
|
+
seedAlbums: 2,
|
|
91
|
+
songsPerAlbum: 8,
|
|
92
|
+
songSeconds: 150,
|
|
93
|
+
publish: true,
|
|
94
|
+
};
|
|
95
|
+
const DAY = 86_400_000;
|
|
96
|
+
/** Between tries after a failure: 5 min, doubling, at most 6 h. */
|
|
97
|
+
const BACKOFF_MS = [5 * 60_000, 10 * 60_000, 20 * 60_000, 40 * 60_000, 80 * 60_000, 6 * 3600_000];
|
|
98
|
+
/** A name as a folder or file name: what a filesystem or a zip refuses is dropped. */
|
|
99
|
+
export function fileName(text) {
|
|
100
|
+
return text.normalize("NFC").replace(/[/\\:*?"<>|\u0000-\u001f\u007f]/g, "").replace(/\s+/g, " ").trim().replace(/^\.+/, "").slice(0, 100).trim() || "Untitled";
|
|
101
|
+
}
|
|
102
|
+
/** A genre's folder and station key: "Hip-Hop" is hip-hop, "R&B" is rnb. */
|
|
103
|
+
export function genreSlug(genre) {
|
|
104
|
+
return genre.normalize("NFKD").toLowerCase().replace(/&/g, "n").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "music";
|
|
105
|
+
}
|
|
106
|
+
/** "001. Title", the start of a song's file names. */
|
|
107
|
+
export function songBase(song) {
|
|
108
|
+
return `${String(song.n).padStart(3, "0")}. ${fileName(song.title)}`;
|
|
109
|
+
}
|
|
110
|
+
function clean(value, max) {
|
|
111
|
+
return String(value ?? "").replace(/[\r\n]+/g, " ").replace(/[\u0000-\u001f]/g, " ").trim().slice(0, max);
|
|
112
|
+
}
|
|
113
|
+
/** Words as a singer gets them: section lines kept, the model's own notes and fences dropped. */
|
|
114
|
+
export function cleanLyrics(text) {
|
|
115
|
+
return text
|
|
116
|
+
.replace(/\r\n?/g, "\n")
|
|
117
|
+
.replace(/^```[a-z]*\n?|```$/gm, "")
|
|
118
|
+
.split("\n")
|
|
119
|
+
.map((line) => line.replace(/[\u0000-\u0008\u000b-\u001f]/g, "").trimEnd())
|
|
120
|
+
.join("\n")
|
|
121
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
122
|
+
.trim()
|
|
123
|
+
.slice(0, 3000);
|
|
124
|
+
}
|
|
125
|
+
const PLAN_SYSTEM = [
|
|
126
|
+
"You are the A&R director and lead songwriter of an independent record label whose artists are AI.",
|
|
127
|
+
"Everything is original: never name, quote or imitate a real artist, band, song, album or label. Write in English.",
|
|
128
|
+
"Plan one album. Answer as JSON: {\"title\": \"1-6 words\", \"concept\": \"what the album is about, at most 300 characters\",",
|
|
129
|
+
"\"songs\": [{\"title\": \"1-6 words\", \"style\": \"the production brief for this song, at most 300 characters: tempo in BPM, mood, key or mode, featured instruments, arrangement\"}]}.",
|
|
130
|
+
"Vary tempo, mood and arrangement across the running order, within the artist's sound; open strong, end memorable.",
|
|
131
|
+
].join(" ");
|
|
132
|
+
const LYRICS_SYSTEM = [
|
|
133
|
+
"You write original English song lyrics for an AI singer. Never quote or imitate an existing song and never name a real artist.",
|
|
134
|
+
"Put each section tag on its own line: [Intro], [Verse 1], [Pre-Chorus], [Chorus], [Verse 2], [Bridge], [Outro].",
|
|
135
|
+
"Use two verses of four lines, a four-line chorus sung two or three times, a pre-chorus of two lines and a bridge of two to four lines; vivid, specific, singable lines that rhyme where it sounds natural. Never just one line under a section.",
|
|
136
|
+
"Only the words that are sung, no stage directions or notes. Answer as JSON: {\"lyrics\": \"the lyrics, lines separated by \\n\"}.",
|
|
137
|
+
].join(" ");
|
|
138
|
+
export async function planAlbum(artist, songs, ask = askJson) {
|
|
139
|
+
const before = artist.albums.slice(-4).map((one) => `"${one.title}" (${one.songs.map((song) => song.title).join("; ")})`);
|
|
140
|
+
const answer = await ask({
|
|
141
|
+
system: PLAN_SYSTEM,
|
|
142
|
+
prompt: [
|
|
143
|
+
`Artist: ${artist.name}`,
|
|
144
|
+
`Genre: ${artist.genre}`,
|
|
145
|
+
`Sound: ${artist.sound}`,
|
|
146
|
+
`Voice: ${artist.voice}`,
|
|
147
|
+
artist.bio ? `Who they are: ${artist.bio}` : "",
|
|
148
|
+
artist.themes ? `What they sing about: ${artist.themes}` : "",
|
|
149
|
+
before.length ? `Albums already out (do not reuse their titles or song titles; take the story somewhere new): ${before.join(" | ")}` : "This is the artist's debut album.",
|
|
150
|
+
`The album has exactly ${songs} songs.`,
|
|
151
|
+
].filter(Boolean).join("\n"),
|
|
152
|
+
maxTokens: 2048,
|
|
153
|
+
timeoutMs: 90_000,
|
|
154
|
+
});
|
|
155
|
+
const title = clean(answer.value["title"], 80).replace(/"/g, "");
|
|
156
|
+
const concept = clean(answer.value["concept"], 400);
|
|
157
|
+
const list = Array.isArray(answer.value["songs"]) ? answer.value["songs"] : [];
|
|
158
|
+
const seen = new Set();
|
|
159
|
+
const planned = list
|
|
160
|
+
.map((one) => ({ title: clean(one?.["title"], 80).replace(/"/g, ""), style: clean(one?.["style"], 400) }))
|
|
161
|
+
.filter((one) => one.title && one.style && !seen.has(one.title.toLowerCase()) && seen.add(one.title.toLowerCase()))
|
|
162
|
+
.slice(0, songs);
|
|
163
|
+
if (!title || planned.length < Math.min(3, songs))
|
|
164
|
+
throw new Error("the model planned no album (a title and at least three songs)");
|
|
165
|
+
return { title, concept, songs: planned, model: answer.model };
|
|
166
|
+
}
|
|
167
|
+
export async function writeLyrics(artist, album, song, ask = askJson) {
|
|
168
|
+
const answer = await ask({
|
|
169
|
+
system: LYRICS_SYSTEM,
|
|
170
|
+
prompt: [
|
|
171
|
+
`Artist: ${artist.name} (${artist.genre}); voice: ${artist.voice}`,
|
|
172
|
+
artist.themes ? `What they sing about: ${artist.themes}` : "",
|
|
173
|
+
`Album: "${album.title}"${album.concept ? `: ${album.concept}` : ""}`,
|
|
174
|
+
`Song: "${song.title}": ${song.style}`,
|
|
175
|
+
`The song lasts about ${song.seconds} seconds: keep the words under ${Math.max(600, Math.round(song.seconds * 10))} characters.`,
|
|
176
|
+
].filter(Boolean).join("\n"),
|
|
177
|
+
maxTokens: 1500,
|
|
178
|
+
timeoutMs: 90_000,
|
|
179
|
+
});
|
|
180
|
+
const lyrics = cleanLyrics(String(answer.value["lyrics"] ?? ""));
|
|
181
|
+
if (lyrics.replace(/\[[^\]]*\]/g, "").trim().length < 40)
|
|
182
|
+
throw new Error("the model wrote no lyrics");
|
|
183
|
+
return { lyrics, model: answer.model };
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* A cover from Cloudflare Workers AI (flux-1-schnell), when the language model
|
|
187
|
+
* slot is Workers AI (NIXAMP_OPENAI_URL is an account's /ai/v1 and
|
|
188
|
+
* OPENAI_API_KEY its token); null when there is no image model to ask.
|
|
189
|
+
*/
|
|
190
|
+
export async function workersAiCover(prompt, env = process.env, fetcher = fetch) {
|
|
191
|
+
const account = /^https:\/\/api\.cloudflare\.com\/client\/v4\/accounts\/([A-Za-z0-9]+)\/ai\/v1\/?$/.exec(aiKey("NIXAMP_OPENAI_URL", env))?.[1];
|
|
192
|
+
const token = aiKey("OPENAI_API_KEY", env);
|
|
193
|
+
if (!account || !token)
|
|
194
|
+
return null;
|
|
195
|
+
const response = await fetcher(`https://api.cloudflare.com/client/v4/accounts/${account}/ai/run/@cf/black-forest-labs/flux-1-schnell`, {
|
|
196
|
+
method: "POST",
|
|
197
|
+
signal: AbortSignal.timeout(120_000),
|
|
198
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
199
|
+
body: JSON.stringify({ prompt: prompt.slice(0, 2000), steps: 8 }),
|
|
200
|
+
});
|
|
201
|
+
if (!response.ok)
|
|
202
|
+
throw new Error(`the cover model answered HTTP ${response.status}`);
|
|
203
|
+
const body = await response.json();
|
|
204
|
+
const image = body.result?.image ? Buffer.from(body.result.image, "base64") : null;
|
|
205
|
+
if (!image || image.length < 1024)
|
|
206
|
+
throw new Error("the cover model answered with no picture");
|
|
207
|
+
return image;
|
|
208
|
+
}
|
|
209
|
+
/** Writes the tags (and the cover, as the attached picture) into a copy of the file. */
|
|
210
|
+
function tagFile(ffmpeg, from, to, tags, cover) {
|
|
211
|
+
const args = [...ffmpeg.slice(1), "-hide_banner", "-loglevel", "error", "-y", "-i", from];
|
|
212
|
+
if (cover)
|
|
213
|
+
args.push("-i", cover, "-map", "0:a", "-map", "1:v", "-c", "copy", "-disposition:v", "attached_pic");
|
|
214
|
+
else
|
|
215
|
+
args.push("-map", "0:a", "-c", "copy");
|
|
216
|
+
args.push("-id3v2_version", "3");
|
|
217
|
+
for (const [name, value] of Object.entries(tags))
|
|
218
|
+
args.push("-metadata", `${name}=${value}`);
|
|
219
|
+
args.push(to);
|
|
220
|
+
return new Promise((done, fail) => {
|
|
221
|
+
execFile(ffmpeg[0], args, { timeout: 120_000 }, (error) => (error ? fail(new Error(`tagging failed: ${error.message}`)) : done()));
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
function monthOf(at) {
|
|
225
|
+
return new Date(at).toISOString().slice(0, 7);
|
|
226
|
+
}
|
|
227
|
+
export class AiLabel {
|
|
228
|
+
file;
|
|
229
|
+
deps;
|
|
230
|
+
artists = new Map();
|
|
231
|
+
settings = { ...DEFAULTS, pausedReason: "" };
|
|
232
|
+
usage = {};
|
|
233
|
+
busy = "";
|
|
234
|
+
timer = null;
|
|
235
|
+
constructor(file, deps) {
|
|
236
|
+
this.file = file;
|
|
237
|
+
this.deps = deps;
|
|
238
|
+
try {
|
|
239
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
240
|
+
for (const one of parsed.artists ?? [])
|
|
241
|
+
if (one?.key)
|
|
242
|
+
this.artists.set(one.key, { ...one, albums: one.albums ?? [] });
|
|
243
|
+
this.settings = { ...this.settings, ...(parsed.settings ?? {}) };
|
|
244
|
+
this.usage = parsed.usage ?? {};
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
// Nothing yet.
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
static fileFor(stateDir, port) {
|
|
251
|
+
return join(stateDir, `ai-label-${port}.json`);
|
|
252
|
+
}
|
|
253
|
+
now() {
|
|
254
|
+
return this.deps.now?.() ?? Date.now();
|
|
255
|
+
}
|
|
256
|
+
log(line) {
|
|
257
|
+
this.deps.log?.(` ai label: ${line}`);
|
|
258
|
+
}
|
|
259
|
+
save() {
|
|
260
|
+
mkdirSync(dirname(this.file), { recursive: true });
|
|
261
|
+
const temporary = `${this.file}.${process.pid}.tmp`;
|
|
262
|
+
writeFileSync(temporary, `${JSON.stringify({ version: 1, settings: this.settings, usage: this.usage, artists: [...this.artists.values()] }, null, 2)}\n`, { mode: 0o600 });
|
|
263
|
+
renameSync(temporary, this.file);
|
|
264
|
+
}
|
|
265
|
+
composer() {
|
|
266
|
+
return this.deps.composer ? this.deps.composer() : composerFromEnv();
|
|
267
|
+
}
|
|
268
|
+
canAsk() {
|
|
269
|
+
return (this.deps.canAsk ?? hasModel)();
|
|
270
|
+
}
|
|
271
|
+
/** The library source the root is in: the one that already holds it, else a managed one of its own. */
|
|
272
|
+
source() {
|
|
273
|
+
const root = resolve(this.deps.root);
|
|
274
|
+
mkdirSync(root, { recursive: true });
|
|
275
|
+
const holding = this.deps.index.sources().find((one) => one.enabled && (root === one.root || root.startsWith(one.root + sep)));
|
|
276
|
+
return (holding ?? this.deps.index.addManagedSource(root)).id;
|
|
277
|
+
}
|
|
278
|
+
get root() {
|
|
279
|
+
return resolve(this.deps.root);
|
|
280
|
+
}
|
|
281
|
+
list() {
|
|
282
|
+
return [...this.artists.values()].sort((a, b) => a.createdAt - b.createdAt);
|
|
283
|
+
}
|
|
284
|
+
get(key) {
|
|
285
|
+
return this.artists.get(key);
|
|
286
|
+
}
|
|
287
|
+
holder() {
|
|
288
|
+
return this.settings.holder;
|
|
289
|
+
}
|
|
290
|
+
budgetLeft() {
|
|
291
|
+
if (this.settings.monthlySeconds === 0)
|
|
292
|
+
return Number.POSITIVE_INFINITY;
|
|
293
|
+
return this.settings.monthlySeconds - (this.usage[monthOf(this.now())] ?? 0);
|
|
294
|
+
}
|
|
295
|
+
/** When this artist's next album is due: its first ones at once, then every `everyDays`, staggered by artist. */
|
|
296
|
+
dueAt(artist) {
|
|
297
|
+
if (!artist.enabled)
|
|
298
|
+
return -1;
|
|
299
|
+
if (artist.albums.length < this.settings.seedAlbums)
|
|
300
|
+
return 0;
|
|
301
|
+
const last = artist.albums[artist.albums.length - 1];
|
|
302
|
+
const stagger = (parseInt(artist.key.slice(0, 6), 16) % Math.max(1, Math.floor(this.settings.everyDays / 4))) * DAY;
|
|
303
|
+
return last.plannedAt + this.settings.everyDays * DAY + stagger;
|
|
304
|
+
}
|
|
305
|
+
backingOff(artist, now) {
|
|
306
|
+
return artist.failures > 0 && now < artist.lastAttemptAt + BACKOFF_MS[Math.min(artist.failures, BACKOFF_MS.length) - 1];
|
|
307
|
+
}
|
|
308
|
+
status() {
|
|
309
|
+
const month = monthOf(this.now());
|
|
310
|
+
const composer = this.composer();
|
|
311
|
+
const why = !this.settings.enabled ? "the label is switched off"
|
|
312
|
+
: this.settings.pausedReason
|
|
313
|
+
|| (composer ? "" : "no composer: set ELEVENLABS_API_KEY (nixamp station ai key ELEVENLABS_API_KEY) or NIXAMP_AI_MUSIC_COMMAND")
|
|
314
|
+
|| (this.canAsk() ? "" : "no language model to write the albums: set GEMINI_API_KEY, OPENAI_API_KEY (or NIXAMP_OPENAI_URL) or ANTHROPIC_API_KEY")
|
|
315
|
+
|| (this.budgetLeft() < this.settings.songSeconds ? `this month's ${this.settings.monthlySeconds} s are used` : "");
|
|
316
|
+
return {
|
|
317
|
+
root: this.root,
|
|
318
|
+
composer: composer?.name ?? null,
|
|
319
|
+
writer: this.canAsk() ? "language model" : null,
|
|
320
|
+
busy: this.busy,
|
|
321
|
+
month,
|
|
322
|
+
secondsThisMonth: this.usage[month] ?? 0,
|
|
323
|
+
settings: this.settings,
|
|
324
|
+
waiting: why,
|
|
325
|
+
roster: Object.entries(ROSTER).map(([key, one]) => ({ key, name: one.name, genre: one.genre, signed: this.list().some((artist) => artist.name === one.name) })),
|
|
326
|
+
stations: this.genres().map((genre) => ({ genre, station: this.stationFor(genre)?.id ?? "" })),
|
|
327
|
+
artists: this.list().map((one) => ({
|
|
328
|
+
key: one.key, name: one.name, genre: one.genre, enabled: one.enabled, lastError: one.lastError, failures: one.failures,
|
|
329
|
+
nextAlbumAt: this.dueAt(one),
|
|
330
|
+
albums: one.albums.map((album) => ({
|
|
331
|
+
key: album.key, title: album.title, year: album.year, plannedAt: album.plannedAt, doneAt: album.doneAt,
|
|
332
|
+
songs: album.songs.length, composed: album.songs.filter((song) => song.file).length, cover: Boolean(album.cover),
|
|
333
|
+
})),
|
|
334
|
+
})),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
configure(input) {
|
|
338
|
+
const whole = (name, min, max) => {
|
|
339
|
+
if (input[name] === undefined)
|
|
340
|
+
return undefined;
|
|
341
|
+
const value = Number(input[name]);
|
|
342
|
+
if (!Number.isInteger(value) || value < min || value > max)
|
|
343
|
+
throw new StationError(`invalid_${name}`, `${name} is a whole number from ${min} to ${max}`);
|
|
344
|
+
return value;
|
|
345
|
+
};
|
|
346
|
+
const monthly = whole("monthlySeconds", 0, 1_000_000);
|
|
347
|
+
const every = whole("everyDays", 1, 365);
|
|
348
|
+
const seed = whole("seedAlbums", 0, 10);
|
|
349
|
+
const songs = whole("songsPerAlbum", 3, 20);
|
|
350
|
+
const seconds = whole("songSeconds", 30, 300);
|
|
351
|
+
if (monthly !== undefined)
|
|
352
|
+
this.settings.monthlySeconds = monthly;
|
|
353
|
+
if (every !== undefined)
|
|
354
|
+
this.settings.everyDays = every;
|
|
355
|
+
if (seed !== undefined)
|
|
356
|
+
this.settings.seedAlbums = seed;
|
|
357
|
+
if (songs !== undefined)
|
|
358
|
+
this.settings.songsPerAlbum = songs;
|
|
359
|
+
if (seconds !== undefined)
|
|
360
|
+
this.settings.songSeconds = seconds;
|
|
361
|
+
if (typeof input["holder"] === "string" && input["holder"].trim())
|
|
362
|
+
this.settings.holder = clean(input["holder"], 120);
|
|
363
|
+
if (input["enabled"] !== undefined)
|
|
364
|
+
this.settings.enabled = input["enabled"] !== false;
|
|
365
|
+
if (input["publish"] !== undefined)
|
|
366
|
+
this.settings.publish = input["publish"] !== false;
|
|
367
|
+
if (input["resume"] === true)
|
|
368
|
+
this.settings.pausedReason = "";
|
|
369
|
+
this.save();
|
|
370
|
+
if (this.settings.enabled)
|
|
371
|
+
void this.tick();
|
|
372
|
+
return this.settings;
|
|
373
|
+
}
|
|
374
|
+
/** Sign an artist: a preset from the roster, or a name, genre and sound of the owner's own. */
|
|
375
|
+
sign(input) {
|
|
376
|
+
const preset = typeof input["preset"] === "string" ? ROSTER[input["preset"]] : undefined;
|
|
377
|
+
if (input["preset"] !== undefined && !preset)
|
|
378
|
+
throw new StationError("unknown_preset", `presets: ${Object.keys(ROSTER).join(", ")}`);
|
|
379
|
+
const name = clean(input["name"] ?? preset?.name, 80);
|
|
380
|
+
const genre = clean(input["genre"] ?? preset?.genre, 40);
|
|
381
|
+
const sound = clean(input["sound"] ?? preset?.sound, 600);
|
|
382
|
+
if (!name)
|
|
383
|
+
throw new StationError("name_required", "an artist needs a name (or a preset)");
|
|
384
|
+
if (!genre)
|
|
385
|
+
throw new StationError("genre_required", "an artist needs a genre");
|
|
386
|
+
if (sound.length < 10)
|
|
387
|
+
throw new StationError("sound_required", "describe the music in a few words: instruments, tempo, mood");
|
|
388
|
+
if (this.list().some((one) => one.name.toLowerCase() === name.toLowerCase()))
|
|
389
|
+
throw new StationError("already_signed", `${name} is already on the label`, 409);
|
|
390
|
+
const artist = {
|
|
391
|
+
key: randomBytes(5).toString("hex"),
|
|
392
|
+
name, genre, sound,
|
|
393
|
+
voice: clean(input["voice"] ?? preset?.voice ?? "expressive lead vocal", 300),
|
|
394
|
+
bio: clean(input["bio"] ?? preset?.bio ?? "", 400),
|
|
395
|
+
themes: clean(input["themes"] ?? preset?.themes ?? "", 300),
|
|
396
|
+
enabled: input["enabled"] !== false,
|
|
397
|
+
albums: [],
|
|
398
|
+
createdAt: this.now(),
|
|
399
|
+
lastAttemptAt: 0,
|
|
400
|
+
lastError: "",
|
|
401
|
+
failures: 0,
|
|
402
|
+
};
|
|
403
|
+
this.artists.set(artist.key, artist);
|
|
404
|
+
this.save();
|
|
405
|
+
void this.tick();
|
|
406
|
+
return artist;
|
|
407
|
+
}
|
|
408
|
+
update(key, input) {
|
|
409
|
+
const artist = this.require(key);
|
|
410
|
+
for (const [field, max] of [["sound", 600], ["voice", 300], ["bio", 400], ["themes", 300]]) {
|
|
411
|
+
if (typeof input[field] === "string")
|
|
412
|
+
artist[field] = clean(input[field], max);
|
|
413
|
+
}
|
|
414
|
+
if (input["enabled"] !== undefined)
|
|
415
|
+
artist.enabled = input["enabled"] !== false;
|
|
416
|
+
artist.failures = 0;
|
|
417
|
+
artist.lastError = "";
|
|
418
|
+
this.save();
|
|
419
|
+
return artist;
|
|
420
|
+
}
|
|
421
|
+
/** Let an artist go. Its music stays (and stays on the air) unless `files` is true. */
|
|
422
|
+
async drop(key, files = false) {
|
|
423
|
+
const artist = this.require(key);
|
|
424
|
+
this.artists.delete(key);
|
|
425
|
+
this.save();
|
|
426
|
+
if (files) {
|
|
427
|
+
rmSync(join(this.root, genreSlug(artist.genre), fileName(artist.name)), { recursive: true, force: true });
|
|
428
|
+
await this.place(artist.genre);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
/** Plan the next album for this artist now, whatever its schedule; it is composed song by song from here. */
|
|
432
|
+
async plan(key) {
|
|
433
|
+
const artist = this.require(key);
|
|
434
|
+
if (!this.canAsk())
|
|
435
|
+
throw new StationError("no_writer", "no language model to write the album: set GEMINI_API_KEY, OPENAI_API_KEY (or NIXAMP_OPENAI_URL) or ANTHROPIC_API_KEY", 409);
|
|
436
|
+
const pending = artist.albums.find((one) => !one.doneAt);
|
|
437
|
+
if (pending)
|
|
438
|
+
throw new StationError("album_in_progress", `"${pending.title}" is still being made`, 409);
|
|
439
|
+
const plan = await planAlbum(artist, this.settings.songsPerAlbum, this.deps.ask ?? askJson);
|
|
440
|
+
const year = new Date(this.now()).getUTCFullYear();
|
|
441
|
+
let title = plan.title;
|
|
442
|
+
if (artist.albums.some((one) => one.title.toLowerCase() === title.toLowerCase()))
|
|
443
|
+
title = `${title} II`;
|
|
444
|
+
const album = {
|
|
445
|
+
key: randomBytes(4).toString("hex"),
|
|
446
|
+
title,
|
|
447
|
+
year,
|
|
448
|
+
concept: plan.concept,
|
|
449
|
+
folder: join(this.root, genreSlug(artist.genre), fileName(artist.name), `${year}-${fileName(title)}`),
|
|
450
|
+
cover: "",
|
|
451
|
+
coverTried: false,
|
|
452
|
+
songs: plan.songs.map((one, index) => ({ n: index + 1, title: one.title, style: one.style, lyrics: "", seconds: this.settings.songSeconds, file: "", lyricsFile: "", made: "", composedAt: 0 })),
|
|
453
|
+
writer: plan.model,
|
|
454
|
+
plannedAt: this.now(),
|
|
455
|
+
doneAt: 0,
|
|
456
|
+
};
|
|
457
|
+
artist.albums.push(album);
|
|
458
|
+
this.save();
|
|
459
|
+
this.writeAlbumJson(artist, album);
|
|
460
|
+
this.log(`${artist.name} has a new album planned: "${album.title}" (${album.songs.length} songs)`);
|
|
461
|
+
return album;
|
|
462
|
+
}
|
|
463
|
+
startSchedule(everyMs = 5 * 60_000) {
|
|
464
|
+
if (this.timer)
|
|
465
|
+
return;
|
|
466
|
+
this.timer = setInterval(() => void this.tick(), everyMs);
|
|
467
|
+
this.timer.unref?.();
|
|
468
|
+
void this.tick();
|
|
469
|
+
}
|
|
470
|
+
stopSchedule() {
|
|
471
|
+
if (this.timer)
|
|
472
|
+
clearInterval(this.timer);
|
|
473
|
+
this.timer = null;
|
|
474
|
+
}
|
|
475
|
+
/** Everything that is due, one song at a time, until nothing is or something stops it. */
|
|
476
|
+
async tick(limit = 200) {
|
|
477
|
+
if (this.busy || !this.settings.enabled || this.settings.pausedReason)
|
|
478
|
+
return;
|
|
479
|
+
this.busy = "starting";
|
|
480
|
+
try {
|
|
481
|
+
for (let steps = 0; steps < limit; steps++) {
|
|
482
|
+
if (this.settings.pausedReason)
|
|
483
|
+
break;
|
|
484
|
+
if ((await this.step().catch(() => "idle")) === "idle")
|
|
485
|
+
break;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
finally {
|
|
489
|
+
this.busy = "";
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
async step() {
|
|
493
|
+
const now = this.now();
|
|
494
|
+
const composer = this.composer();
|
|
495
|
+
if (!composer)
|
|
496
|
+
return "idle";
|
|
497
|
+
// A song of an album already planned, oldest album first.
|
|
498
|
+
const making = this.list()
|
|
499
|
+
.filter((one) => one.enabled && !this.backingOff(one, now))
|
|
500
|
+
.flatMap((artist) => artist.albums.filter((album) => !album.doneAt).map((album) => ({ artist, album })))
|
|
501
|
+
.sort((a, b) => a.album.plannedAt - b.album.plannedAt);
|
|
502
|
+
for (const { artist, album } of making) {
|
|
503
|
+
const song = album.songs.find((one) => !one.file);
|
|
504
|
+
if (!song) {
|
|
505
|
+
album.doneAt = now;
|
|
506
|
+
this.save();
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
if (song.seconds > this.budgetLeft())
|
|
510
|
+
return "idle";
|
|
511
|
+
await this.compose(artist, album, song, composer);
|
|
512
|
+
return "composed";
|
|
513
|
+
}
|
|
514
|
+
// Nothing half-made anywhere: the artist most overdue gets its next album.
|
|
515
|
+
if (this.list().some((one) => one.albums.some((album) => !album.doneAt)))
|
|
516
|
+
return "idle";
|
|
517
|
+
if (!this.canAsk() || this.settings.songSeconds > this.budgetLeft())
|
|
518
|
+
return "idle";
|
|
519
|
+
const due = this.list()
|
|
520
|
+
.filter((one) => !this.backingOff(one, now))
|
|
521
|
+
.map((one) => ({ one, at: this.dueAt(one) }))
|
|
522
|
+
.filter(({ at }) => at !== -1 && at <= now)
|
|
523
|
+
.sort((a, b) => a.one.albums.length - b.one.albums.length || a.at - b.at)[0];
|
|
524
|
+
if (!due)
|
|
525
|
+
return "idle";
|
|
526
|
+
try {
|
|
527
|
+
await this.plan(due.one.key);
|
|
528
|
+
due.one.failures = 0;
|
|
529
|
+
due.one.lastError = "";
|
|
530
|
+
this.save();
|
|
531
|
+
return "planned";
|
|
532
|
+
}
|
|
533
|
+
catch (error) {
|
|
534
|
+
this.failed(due.one, error);
|
|
535
|
+
throw error;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
failed(artist, error) {
|
|
539
|
+
artist.lastAttemptAt = this.now();
|
|
540
|
+
artist.failures += 1;
|
|
541
|
+
artist.lastError = error.message;
|
|
542
|
+
if (error instanceof OutOfCredit)
|
|
543
|
+
this.settings.pausedReason = `${artist.lastError}; resume with \`nixamp station label resume\` once there is credit`;
|
|
544
|
+
this.save();
|
|
545
|
+
this.log(`${artist.name}: ${artist.lastError}`);
|
|
546
|
+
}
|
|
547
|
+
async compose(artist, album, song, composer) {
|
|
548
|
+
this.busy = `${artist.name}: ${album.title}: ${song.title}`;
|
|
549
|
+
artist.lastAttemptAt = this.now();
|
|
550
|
+
mkdirSync(album.folder, { recursive: true });
|
|
551
|
+
const raw = join(album.folder, `.${randomBytes(4).toString("hex")}.raw.mp3`);
|
|
552
|
+
try {
|
|
553
|
+
let writer = song.made || album.writer;
|
|
554
|
+
if (!song.lyrics) {
|
|
555
|
+
const written = await writeLyrics(artist, album, song, this.deps.ask ?? askJson);
|
|
556
|
+
song.lyrics = written.lyrics;
|
|
557
|
+
writer = written.model;
|
|
558
|
+
song.made = writer;
|
|
559
|
+
this.save();
|
|
560
|
+
}
|
|
561
|
+
await this.ensureCover(artist, album);
|
|
562
|
+
this.log(`composing "${song.title}" (${artist.name}, ${album.title}, ${song.seconds} s, ${composer.name})`);
|
|
563
|
+
const prompt = `${artist.sound}. ${song.style}. Lead vocal: ${artist.voice}. Sung in English.`.slice(0, 1500);
|
|
564
|
+
await composer.compose({ prompt, seconds: song.seconds, vocals: true, out: raw, lyrics: song.lyrics });
|
|
565
|
+
const month = monthOf(this.now());
|
|
566
|
+
this.usage[month] = (this.usage[month] ?? 0) + song.seconds;
|
|
567
|
+
const base = songBase(song);
|
|
568
|
+
const file = join(album.folder, `${base}.mp3`);
|
|
569
|
+
const lyricsFile = join(album.folder, `${base}.lyrics.txt`);
|
|
570
|
+
writeFileSync(lyricsFile, `${song.lyrics}\n`);
|
|
571
|
+
const holder = this.settings.holder;
|
|
572
|
+
const tags = {
|
|
573
|
+
title: song.title,
|
|
574
|
+
artist: artist.name,
|
|
575
|
+
album_artist: artist.name,
|
|
576
|
+
album: album.title,
|
|
577
|
+
genre: artist.genre,
|
|
578
|
+
track: `${song.n}/${album.songs.length}`,
|
|
579
|
+
date: String(album.year),
|
|
580
|
+
publisher: holder,
|
|
581
|
+
copyright: `℗ ${album.year} ${holder}`,
|
|
582
|
+
comment: `Made with AI for ${holder}: music by ${composer.name}, words by ${writer}`,
|
|
583
|
+
"lyrics-eng": song.lyrics,
|
|
584
|
+
};
|
|
585
|
+
await (this.deps.tag ?? ((from, to, values, cover) => tagFile(this.deps.ffmpeg, from, to, values, cover)))(raw, file, tags, album.cover);
|
|
586
|
+
song.file = file;
|
|
587
|
+
song.lyricsFile = lyricsFile;
|
|
588
|
+
song.made = `${writer} + ${composer.name}`;
|
|
589
|
+
song.composedAt = this.now();
|
|
590
|
+
artist.failures = 0;
|
|
591
|
+
artist.lastError = "";
|
|
592
|
+
if (album.songs.every((one) => one.file)) {
|
|
593
|
+
album.doneAt = this.now();
|
|
594
|
+
this.log(`${artist.name} released "${album.title}"`);
|
|
595
|
+
}
|
|
596
|
+
this.save();
|
|
597
|
+
this.writeAlbumJson(artist, album);
|
|
598
|
+
await this.place(artist.genre);
|
|
599
|
+
}
|
|
600
|
+
catch (error) {
|
|
601
|
+
this.failed(artist, error);
|
|
602
|
+
throw error;
|
|
603
|
+
}
|
|
604
|
+
finally {
|
|
605
|
+
rmSync(raw, { force: true });
|
|
606
|
+
this.busy = "starting";
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
async ensureCover(artist, album) {
|
|
610
|
+
if (album.cover || album.coverTried)
|
|
611
|
+
return;
|
|
612
|
+
album.coverTried = true;
|
|
613
|
+
const prompt = `Album cover art for "${album.title}", a ${artist.genre} album by ${artist.name}. ${album.concept} Style: ${artist.sound}. Striking, original, square composition. No text, no letters, no logos, no watermark.`;
|
|
614
|
+
try {
|
|
615
|
+
const picture = await (this.deps.cover ?? ((text) => workersAiCover(text)))(prompt);
|
|
616
|
+
if (picture) {
|
|
617
|
+
const file = join(album.folder, "cover.jpg");
|
|
618
|
+
writeFileSync(file, picture);
|
|
619
|
+
album.cover = file;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
catch (error) {
|
|
623
|
+
this.log(`no cover for "${album.title}": ${error.message}`);
|
|
624
|
+
}
|
|
625
|
+
this.save();
|
|
626
|
+
}
|
|
627
|
+
/** album.json beside the songs: what the album is, for anybody who copies the folder. */
|
|
628
|
+
writeAlbumJson(artist, album) {
|
|
629
|
+
try {
|
|
630
|
+
mkdirSync(album.folder, { recursive: true });
|
|
631
|
+
writeFileSync(join(album.folder, "album.json"), `${JSON.stringify({
|
|
632
|
+
artist: artist.name, genre: artist.genre, album: album.title, year: album.year, concept: album.concept,
|
|
633
|
+
label: this.settings.holder, copyright: `℗ ${album.year} ${this.settings.holder}`, madeWithAi: true,
|
|
634
|
+
songs: album.songs.map((one) => ({ n: one.n, title: one.title, seconds: one.seconds, file: one.file ? `${songBase(one)}.mp3` : null, lyrics: one.file ? `${songBase(one)}.lyrics.txt` : null })),
|
|
635
|
+
}, null, 2)}\n`);
|
|
636
|
+
}
|
|
637
|
+
catch {
|
|
638
|
+
// The folder may be read-only; the state file still has it all.
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
genres() {
|
|
642
|
+
return [...new Set(this.list().map((one) => one.genre))].sort();
|
|
643
|
+
}
|
|
644
|
+
stationFor(genre) {
|
|
645
|
+
return this.deps.store.byAuto(`ai:label:${genreSlug(genre)}`);
|
|
646
|
+
}
|
|
647
|
+
/** The genre's station gets every song of that genre: made with the first, then kept in step. */
|
|
648
|
+
async place(genre) {
|
|
649
|
+
await this.deps.index.scan(this.source(), this.deps.probe).done;
|
|
650
|
+
const artists = this.list().filter((one) => genreSlug(one.genre) === genreSlug(genre));
|
|
651
|
+
const files = artists
|
|
652
|
+
.flatMap((one) => one.albums.flatMap((album) => album.songs.map((song) => song.file)))
|
|
653
|
+
.filter((file) => file && this.deps.index.itemAt(file)?.state === "ready");
|
|
654
|
+
const existing = this.stationFor(genre);
|
|
655
|
+
if (existing) {
|
|
656
|
+
if (files.length > 0)
|
|
657
|
+
this.deps.store.update(existing.id, {}, files);
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
if (files.length === 0)
|
|
661
|
+
return;
|
|
662
|
+
const holder = this.settings.holder;
|
|
663
|
+
const label = holder.replace(/,?\s+(inc|llc|ltd|gmbh|co)\.?$/i, "");
|
|
664
|
+
const made = this.deps.store.create({
|
|
665
|
+
name: `${label} AI · ${genre}`,
|
|
666
|
+
description: `Original ${genre} by ${label}'s AI artists: ${artists.map((one) => one.name).join(", ")}.`,
|
|
667
|
+
shuffle: true,
|
|
668
|
+
repeat: true,
|
|
669
|
+
bitrate: 192,
|
|
670
|
+
}, files, this.deps.owner);
|
|
671
|
+
this.deps.store.markAuto(made.id, `ai:label:${genreSlug(genre)}`);
|
|
672
|
+
if (this.settings.publish) {
|
|
673
|
+
this.deps.store.setAccess(made.id, {
|
|
674
|
+
visibility: "public",
|
|
675
|
+
listed: true,
|
|
676
|
+
rights: { basis: "owned", note: `Original music by ${holder}'s AI artists, composed for ${holder} by AI (${this.composer()?.name ?? "a music model"}) from words written by AI; no third-party recordings. ℗ ${holder}.` },
|
|
677
|
+
}, this.deps.owner || "ai-label");
|
|
678
|
+
}
|
|
679
|
+
else {
|
|
680
|
+
this.deps.store.setAccess(made.id, { visibility: "unlisted" }, this.deps.owner || "ai-label");
|
|
681
|
+
}
|
|
682
|
+
if (this.deps.mayStart())
|
|
683
|
+
await this.deps.runner.start(made.id).catch(() => undefined);
|
|
684
|
+
}
|
|
685
|
+
/** The catalogue anybody may see: artists, their released songs, the words, and where each genre plays. */
|
|
686
|
+
publicView() {
|
|
687
|
+
const genres = this.genres().map((genre) => ({
|
|
688
|
+
genre,
|
|
689
|
+
station: this.stationFor(genre)?.id ?? "",
|
|
690
|
+
artists: this.list()
|
|
691
|
+
.filter((one) => one.genre === genre)
|
|
692
|
+
.map((one) => ({
|
|
693
|
+
key: one.key, name: one.name, genre: one.genre, bio: one.bio,
|
|
694
|
+
albums: one.albums
|
|
695
|
+
.filter((album) => album.songs.some((song) => song.file))
|
|
696
|
+
.map((album) => ({
|
|
697
|
+
key: album.key, title: album.title, year: album.year, concept: album.concept, cover: Boolean(album.cover), complete: album.doneAt > 0,
|
|
698
|
+
songs: album.songs.filter((song) => song.file).map((song) => ({ n: song.n, title: song.title, seconds: song.seconds, lyrics: song.lyrics })),
|
|
699
|
+
}))
|
|
700
|
+
.reverse(),
|
|
701
|
+
}))
|
|
702
|
+
.filter((one) => one.albums.length > 0),
|
|
703
|
+
})).filter((one) => one.artists.length > 0);
|
|
704
|
+
return { holder: this.settings.holder, genres };
|
|
705
|
+
}
|
|
706
|
+
/** A released file anybody may fetch: a song, its words, or its album's cover. */
|
|
707
|
+
publicFile(artistKey, albumKey, what) {
|
|
708
|
+
const album = this.artists.get(artistKey)?.albums.find((one) => one.key === albumKey);
|
|
709
|
+
if (!album)
|
|
710
|
+
return null;
|
|
711
|
+
if (what === "cover.jpg")
|
|
712
|
+
return album.cover ? { path: album.cover, type: "image/jpeg" } : null;
|
|
713
|
+
const match = /^(\d{1,3})\.(mp3|txt)$/.exec(what);
|
|
714
|
+
const song = match ? album.songs.find((one) => one.n === Number(match[1]) && one.file) : undefined;
|
|
715
|
+
if (!song)
|
|
716
|
+
return null;
|
|
717
|
+
return match[2] === "mp3" ? { path: song.file, type: "audio/mpeg" } : { path: song.lyricsFile, type: "text/plain; charset=utf-8" };
|
|
718
|
+
}
|
|
719
|
+
/** The label song at this path, for the now-playing card's Download buttons. */
|
|
720
|
+
songAt(file) {
|
|
721
|
+
for (const artist of this.artists.values()) {
|
|
722
|
+
for (const album of artist.albums) {
|
|
723
|
+
const song = album.songs.find((one) => one.file === file);
|
|
724
|
+
if (song)
|
|
725
|
+
return { artist: artist.key, album: album.key, n: song.n, title: song.title, artistName: artist.name, albumTitle: album.title };
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
return null;
|
|
729
|
+
}
|
|
730
|
+
/** A released song's MP3, when this grant's scope covers it. */
|
|
731
|
+
songFile(artistKey, albumKey, n) {
|
|
732
|
+
return this.artists.get(artistKey)?.albums.find((one) => one.key === albumKey)?.songs.find((one) => one.n === n && one.file)?.file ?? "";
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* What a purchase's zip holds: every released song in scope with its words,
|
|
736
|
+
* and each album's cover and album.json, under <artist>/<year>-<album>/.
|
|
737
|
+
* Null when there is nothing (or no such artist).
|
|
738
|
+
*/
|
|
739
|
+
zipFor(scope) {
|
|
740
|
+
const artist = this.artists.get(scope.artist);
|
|
741
|
+
if (!artist)
|
|
742
|
+
return null;
|
|
743
|
+
const albums = artist.albums.filter((one) => scope.kind === "artist" || one.key === scope.album);
|
|
744
|
+
const entries = [];
|
|
745
|
+
const top = fileName(artist.name);
|
|
746
|
+
for (const album of albums) {
|
|
747
|
+
const songs = album.songs.filter((one) => one.file && (scope.kind !== "song" || one.n === scope.n));
|
|
748
|
+
if (songs.length === 0)
|
|
749
|
+
continue;
|
|
750
|
+
const folder = `${top}/${album.year}-${fileName(album.title)}`;
|
|
751
|
+
for (const song of songs) {
|
|
752
|
+
entries.push({ name: `${folder}/${songBase(song)}.mp3`, path: song.file });
|
|
753
|
+
entries.push({ name: `${folder}/${songBase(song)}.lyrics.txt`, bytes: Buffer.from(`${song.lyrics}\n`) });
|
|
754
|
+
}
|
|
755
|
+
if (album.cover)
|
|
756
|
+
entries.push({ name: `${folder}/cover.jpg`, path: album.cover });
|
|
757
|
+
entries.push({ name: `${folder}/album.json`, bytes: Buffer.from(`${JSON.stringify({
|
|
758
|
+
artist: artist.name, genre: artist.genre, album: album.title, year: album.year, concept: album.concept,
|
|
759
|
+
label: this.settings.holder, copyright: `℗ ${album.year} ${this.settings.holder}`, madeWithAi: true,
|
|
760
|
+
songs: songs.map((one) => ({ n: one.n, title: one.title, seconds: one.seconds, file: `${songBase(one)}.mp3`, lyrics: `${songBase(one)}.lyrics.txt` })),
|
|
761
|
+
}, null, 2)}\n`) });
|
|
762
|
+
}
|
|
763
|
+
if (entries.length === 0)
|
|
764
|
+
return null;
|
|
765
|
+
const what = scope.kind === "artist" ? "Discography" : scope.kind === "album" ? albums[0].title : this.songFile(scope.artist, scope.album, scope.n) ? albums[0].songs.find((one) => one.n === scope.n).title : "Song";
|
|
766
|
+
return { name: `${fileName(artist.name)} - ${fileName(what)}.zip`, entries };
|
|
767
|
+
}
|
|
768
|
+
require(key) {
|
|
769
|
+
const artist = this.artists.get(key);
|
|
770
|
+
if (!artist)
|
|
771
|
+
throw new StationError("not_found", "no such artist on the label", 404);
|
|
772
|
+
return artist;
|
|
773
|
+
}
|
|
774
|
+
}
|