nixamp 0.50.1 → 0.52.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-label.d.ts +70 -3
- package/dist/ai-label.js +459 -29
- package/dist/label-routes.js +2 -1
- package/dist/main.js +9 -0
- package/dist/mcp.js +42 -0
- package/dist/music-cli.d.ts +2 -0
- package/dist/music-cli.js +109 -0
- package/dist/music-client.d.ts +62 -0
- package/dist/music-client.js +73 -0
- package/dist/station-cli.js +8 -1
- package/dist/station-mcp.d.ts +62 -0
- package/dist/station-mcp.js +5 -2
- package/dist/station-usage.d.ts +1 -1
- package/dist/station-usage.js +6 -3
- package/dist/track-facts.js +1 -1
- package/package.json +1 -1
- package/src/ai-label.ts +464 -29
- package/src/label-routes.ts +2 -1
- package/src/main.ts +8 -0
- package/src/mcp.ts +38 -0
- package/src/music-cli.ts +86 -0
- package/src/music-client.ts +86 -0
- package/src/station-cli.ts +8 -1
- package/src/station-mcp.ts +5 -2
- package/src/station-usage.ts +6 -3
- package/src/track-facts.ts +1 -1
- package/web/dist/assets/{ar-CC_uYRlC.js → ar-DMx2aeAO.js} +1 -1
- package/web/dist/assets/{cs-k5C-jvfX.js → cs-Ddu0q3kG.js} +1 -1
- package/web/dist/assets/{da-CdBzU_fF.js → da-CZ9gg_pb.js} +1 -1
- package/web/dist/assets/{de-B-UWa0W7.js → de-CgJX4xg2.js} +1 -1
- package/web/dist/assets/{es-Dod8aCau.js → es-Cp5ryDrJ.js} +1 -1
- package/web/dist/assets/{fi-C36bBPFW.js → fi-D912EKux.js} +1 -1
- package/web/dist/assets/{fr-DWUxs2qv.js → fr-FE_K0MPb.js} +1 -1
- package/web/dist/assets/{hi-DjLqhfs8.js → hi-BdYtQs1S.js} +1 -1
- package/web/dist/assets/{hu-CgsOlcq8.js → hu-oh5tEg0p.js} +1 -1
- package/web/dist/assets/{id-ZA8yTWX9.js → id-NbCUWeB1.js} +1 -1
- package/web/dist/assets/{it-DfFhS34g.js → it-CiHbKZ6Y.js} +1 -1
- package/web/dist/assets/{main-CjgQp9um.js → main-8wV87D-g.js} +2 -2
- package/web/dist/assets/{nl-C257i24U.js → nl-nLHK_AQ-.js} +1 -1
- package/web/dist/assets/{radio-CDUEoM0y.js → radio-BmHvBkVy.js} +1 -1
- package/web/dist/assets/{ru--30I7BNC.js → ru-BDdAS0Vv.js} +1 -1
- package/web/dist/assets/{sv-CxzBeXfk.js → sv-COweXM56.js} +1 -1
- package/web/dist/assets/{uk-DXHpMk4i.js → uk-BJFzR1LS.js} +1 -1
- package/web/dist/assets/{vi-Dg2LT4x3.js → vi-qg2DJ6cR.js} +1 -1
- package/web/dist/assets/{zh-D3pvejo6.js → zh-DUWOKDkb.js} +1 -1
- package/web/dist/index.html +2 -1
- package/web/dist/radio.html +1 -1
- package/web/dist/sw.js +20 -20
package/dist/ai-label.js
CHANGED
|
@@ -74,6 +74,181 @@ export const ROSTER = {
|
|
|
74
74
|
bio: "Maps of places that burned down, drawn at full volume.",
|
|
75
75
|
themes: "ruins, resilience, storms, rebuilding from ashes, defiance",
|
|
76
76
|
},
|
|
77
|
+
"iron-lantern-society": {
|
|
78
|
+
name: "Iron Lantern Society", genre: "Classic Rock",
|
|
79
|
+
sound: "bluesy classic hard rock, swaggering riffs, Hammond organ, cowbell grooves, big guitar solos, 100-128 BPM",
|
|
80
|
+
voice: "gravelly male baritone with a wide rasp and a rock-and-roll howl",
|
|
81
|
+
bio: "Five road dogs who think every song should end in a bar fight or a sunrise.",
|
|
82
|
+
themes: "the open road, hard luck, loyalty among misfits, last calls and new starts",
|
|
83
|
+
},
|
|
84
|
+
"highway-sermon": {
|
|
85
|
+
name: "Highway Sermon", genre: "Classic Rock",
|
|
86
|
+
sound: "1970s classic rock, twin harmony lead guitars, warm analog bass, piano, big open drums, arena choruses, 96-124 BPM",
|
|
87
|
+
voice: "soaring, slightly raspy male tenor with a wide vibrato",
|
|
88
|
+
bio: "Four friends who found a crate of their fathers' records and never gave them back.",
|
|
89
|
+
themes: "freedom, the open highway, small-town legends, love and leaving",
|
|
90
|
+
},
|
|
91
|
+
"copper-jubilee": {
|
|
92
|
+
name: "Copper Jubilee", genre: "Classic Rock",
|
|
93
|
+
sound: "southern classic rock, slide guitar, honky-tonk piano, Hammond organ, swampy groove, gospel backing vocals, 88-116 BPM",
|
|
94
|
+
voice: "powerful bluesy female belter with grit and gospel runs",
|
|
95
|
+
bio: "A riverboat revue that plugged in and turned it up.",
|
|
96
|
+
themes: "rivers, revival nights, rebellion, home cooking and heartbreak",
|
|
97
|
+
},
|
|
98
|
+
"the-bent-spokes": {
|
|
99
|
+
name: "The Bent Spokes", genre: "Punk",
|
|
100
|
+
sound: "fast melodic skate punk, palm-muted power chords, driving eighth-note bass, snappy double-time drums, gang vocal choruses, 180-210 BPM",
|
|
101
|
+
voice: "snotty, energetic male tenor with shouted group backing vocals",
|
|
102
|
+
bio: "Three teenagers who never stopped being teenagers, and a drummer who might be a skateboard.",
|
|
103
|
+
themes: "boredom, suburbia, friendship, skipping school, refusing to grow up",
|
|
104
|
+
},
|
|
105
|
+
"gutter-velvet": {
|
|
106
|
+
name: "Gutter Velvet", genre: "Punk",
|
|
107
|
+
sound: "raw street punk, distorted buzzsaw guitars, thumping bass, stomping drums, chant-along choruses, 160-190 BPM",
|
|
108
|
+
voice: "furious, raspy female shouter with a sneer",
|
|
109
|
+
bio: "Born in a squat, raised on a photocopier.",
|
|
110
|
+
themes: "rent, landlords, solidarity, the city at 3 a.m., not backing down",
|
|
111
|
+
},
|
|
112
|
+
"no-refunds": {
|
|
113
|
+
name: "No Refunds", genre: "Punk",
|
|
114
|
+
sound: "pop punk, bright crunchy guitars, bouncy bass, big singalong hooks, half-time bridges, 160-185 BPM",
|
|
115
|
+
voice: "catchy, slightly nasal male lead with harmonized choruses",
|
|
116
|
+
bio: "Mall-parking-lot punk with a heart of gold and a dented van.",
|
|
117
|
+
themes: "crushes, bad jobs, summer, best friends, second chances",
|
|
118
|
+
},
|
|
119
|
+
"muddy-ledger": {
|
|
120
|
+
name: "Muddy Ledger", genre: "Blues",
|
|
121
|
+
sound: "electric Chicago blues, stinging lead guitar, harmonica, walking bass, shuffle drums, slow 12-bar and mid-tempo shuffles, 60-110 BPM",
|
|
122
|
+
voice: "deep, gravelly male baritone with call-and-response guitar",
|
|
123
|
+
bio: "Plays like he owes the guitar money.",
|
|
124
|
+
themes: "hard times, trains, lost love, payday, getting even",
|
|
125
|
+
},
|
|
126
|
+
"delta-ruby": {
|
|
127
|
+
name: "Delta Ruby", genre: "Blues",
|
|
128
|
+
sound: "soulful blues, Hammond organ, horn section, slow-burning guitar, brushed and backbeat drums, 60-100 BPM",
|
|
129
|
+
voice: "rich, powerful female contralto with soaring gospel-blues phrasing",
|
|
130
|
+
bio: "A juke-joint queen with a voice that rattles the windows.",
|
|
131
|
+
themes: "heartbreak, strength, the river, Saturday night, survival",
|
|
132
|
+
},
|
|
133
|
+
"rattlesnake-porch": {
|
|
134
|
+
name: "Rattlesnake Porch", genre: "Blues",
|
|
135
|
+
sound: "acoustic delta blues, slide resonator guitar, foot stomps, harmonica, hand claps, 70-110 BPM",
|
|
136
|
+
voice: "weathered male voice, half-sung, half-spoken storytelling",
|
|
137
|
+
bio: "A porch, a slide and a hundred stories, all of them true enough.",
|
|
138
|
+
themes: "crossroads, floods, the devil's bargains, home, honest work",
|
|
139
|
+
},
|
|
140
|
+
"blue-hour-quartet": {
|
|
141
|
+
name: "The Blue Hour Quartet", genre: "Jazz",
|
|
142
|
+
sound: "smoky vocal jazz, upright bass, brushed drums, piano trio comping, muted trumpet solos, swing and ballads, 70-140 BPM",
|
|
143
|
+
voice: "velvety female jazz alto with relaxed phrasing and occasional scat",
|
|
144
|
+
bio: "A jazz club that only opens after the last train leaves.",
|
|
145
|
+
themes: "late nights, rain on windows, old flames, city lights, small joys",
|
|
146
|
+
},
|
|
147
|
+
"sterling-hayes": {
|
|
148
|
+
name: "Sterling Hayes", genre: "Jazz",
|
|
149
|
+
sound: "big band swing and crooner jazz, brass section, walking bass, swing drums, lush strings on ballads, 100-180 BPM",
|
|
150
|
+
voice: "smooth, warm male crooner baritone",
|
|
151
|
+
bio: "A crooner who never got the memo that it isn't 1958.",
|
|
152
|
+
themes: "romance, dancing, champagne nights, wistful goodbyes",
|
|
153
|
+
},
|
|
154
|
+
"nightcap-collective": {
|
|
155
|
+
name: "Nightcap Collective", genre: "Jazz",
|
|
156
|
+
sound: "modern soul-jazz, Rhodes piano, tenor sax, laid-back hip hop influenced drums, warm bass, 80-100 BPM",
|
|
157
|
+
voice: "silky male and female duet vocals trading verses",
|
|
158
|
+
bio: "A rotating cast of night owls who meet at the same bar every Thursday.",
|
|
159
|
+
themes: "slow evenings, conversation, love that takes its time, the city after dark",
|
|
160
|
+
},
|
|
161
|
+
"satin-avenue": {
|
|
162
|
+
name: "Satin Avenue", genre: "R&B",
|
|
163
|
+
sound: "smooth modern R&B, lush chords, deep 808s, crisp snaps, warm synth pads, falsetto ad-libs, 65-90 BPM",
|
|
164
|
+
voice: "silky female R&B soprano with agile runs",
|
|
165
|
+
bio: "Late-night R&B for the drive home after a long week.",
|
|
166
|
+
themes: "love, trust, self-worth, late-night phone calls",
|
|
167
|
+
},
|
|
168
|
+
"jalen-reign": {
|
|
169
|
+
name: "Jalen Reign", genre: "R&B",
|
|
170
|
+
sound: "neo-soul and contemporary R&B, live bass, Rhodes, tight pocket drums, gospel-tinged harmonies, 70-95 BPM",
|
|
171
|
+
voice: "warm, soulful male tenor with a smooth falsetto",
|
|
172
|
+
bio: "Church-raised, club-tested, heart on his sleeve.",
|
|
173
|
+
themes: "devotion, apologies, growing together, Sunday mornings",
|
|
174
|
+
},
|
|
175
|
+
"paper-satellites": {
|
|
176
|
+
name: "Paper Satellites", genre: "Rock",
|
|
177
|
+
sound: "jangly indie rock, chorus-drenched guitars, driving eighth-note bass, crisp drums, shout-along choruses, 130-160 BPM",
|
|
178
|
+
voice: "earnest male tenor, slightly nasal, with gang vocals in the choruses",
|
|
179
|
+
bio: "Bedroom recordings that grew up and got a van.",
|
|
180
|
+
themes: "growing up, small apartments, friendship, leaving and coming back",
|
|
181
|
+
},
|
|
182
|
+
"mira-solace": {
|
|
183
|
+
name: "Mira Solace", genre: "Hip-Hop",
|
|
184
|
+
sound: "melodic modern trap, airy pads, rolling hi-hats, sliding 808s, sparse piano, 130-145 BPM half-time",
|
|
185
|
+
voice: "confident female rapper with a smoky tone who switches to sung melodic hooks",
|
|
186
|
+
bio: "Writes verses on her phone between shifts and performs them like a closing argument.",
|
|
187
|
+
themes: "self-belief, heartbreak turned into fuel, sisterhood, getting paid",
|
|
188
|
+
},
|
|
189
|
+
"cold-brew-cipher": {
|
|
190
|
+
name: "Cold Brew Cipher", genre: "Hip-Hop",
|
|
191
|
+
sound: "jazzy laid-back hip hop, upright bass, muted trumpet, dusty drum breaks, vinyl warmth, 84-92 BPM",
|
|
192
|
+
voice: "relaxed male rapper with a warm baritone, wordplay-heavy, with a soulful sung chorus",
|
|
193
|
+
bio: "A coffee-shop cipher that never closed.",
|
|
194
|
+
themes: "slow mornings, neighbourhood characters, gratitude, wit and wordplay",
|
|
195
|
+
},
|
|
196
|
+
"vapor-meridian": {
|
|
197
|
+
name: "Vapor Meridian", genre: "Synthwave",
|
|
198
|
+
sound: "dark synthwave, pulsing bass sequences, cold arpeggios, gated snares, cinematic swells, 90-110 BPM",
|
|
199
|
+
voice: "cool, breathy female alto, close-miked and haunting",
|
|
200
|
+
bio: "The soundtrack to a neon noir that only exists after midnight.",
|
|
201
|
+
themes: "rain-soaked cities, secrets, rogue machines, love in the static",
|
|
202
|
+
},
|
|
203
|
+
"static-riviera": {
|
|
204
|
+
name: "Static Riviera", genre: "Synthwave",
|
|
205
|
+
sound: "sunny outrun synthwave, bright saw leads, funky slap bass, sax hooks, sunset pads, 110-124 BPM",
|
|
206
|
+
voice: "smooth male tenor with a touch of vocoder on the hooks",
|
|
207
|
+
bio: "A beach-town cassette left in the sun since 1986.",
|
|
208
|
+
themes: "summer nights, coastal highways, arcades, retro romance",
|
|
209
|
+
},
|
|
210
|
+
"boone-calloway": {
|
|
211
|
+
name: "Boone Calloway", genre: "Country",
|
|
212
|
+
sound: "outlaw country, twangy Telecaster, train-beat drums, honky-tonk piano, dobro, 90-120 BPM",
|
|
213
|
+
voice: "deep, weathered male baritone with a slow drawl",
|
|
214
|
+
bio: "Sings like he has been thrown out of every bar in three counties and misses all of them.",
|
|
215
|
+
themes: "freedom, regret, dusty highways, family, whiskey and redemption",
|
|
216
|
+
},
|
|
217
|
+
"gravel-hollow": {
|
|
218
|
+
name: "The Gravel Hollow Band", genre: "Country",
|
|
219
|
+
sound: "bluegrass and Americana, banjo rolls, mandolin chops, fiddle, upright bass, stacked harmonies, 100-140 BPM",
|
|
220
|
+
voice: "a lead male tenor with tight three-part mountain harmonies",
|
|
221
|
+
bio: "A porch band that never learned to play quietly.",
|
|
222
|
+
themes: "mountains, rivers, hard work, faith, homecoming",
|
|
223
|
+
},
|
|
224
|
+
"hollow-sovereign": {
|
|
225
|
+
name: "Hollow Sovereign", genre: "Metal",
|
|
226
|
+
sound: "symphonic doom metal, crushing slow riffs, choirs, strings, thundering double-kick climaxes, 60-90 BPM",
|
|
227
|
+
voice: "soaring operatic female soprano over deep male growled counterpoints",
|
|
228
|
+
bio: "A cathedral of riffs built in the ruins of a kingdom that never was.",
|
|
229
|
+
themes: "fallen empires, myth, grief, endurance, crowns and chains",
|
|
230
|
+
},
|
|
231
|
+
"rust-cathedral": {
|
|
232
|
+
name: "Rust Cathedral", genre: "Metal",
|
|
233
|
+
sound: "fast thrash metal, palm-muted gallop riffs, shredding solos, pounding double kick, 180-220 BPM",
|
|
234
|
+
voice: "aggressive male shout-singing with high screams",
|
|
235
|
+
bio: "Factory-floor thrash forged from overtime and broken machines.",
|
|
236
|
+
themes: "industry, rebellion, machines, the working class fighting back",
|
|
237
|
+
},
|
|
238
|
+
"theo-marlowe": {
|
|
239
|
+
name: "Theo Marlowe", genre: "Pop",
|
|
240
|
+
sound: "groovy funk-pop, slap bass, tight disco drums, bright brass stabs, falsetto hooks, 112-122 BPM",
|
|
241
|
+
voice: "smooth male pop tenor with a playful falsetto",
|
|
242
|
+
bio: "A charmer with a dance floor in his back pocket.",
|
|
243
|
+
themes: "flirting, confidence, summer romance, good nights out",
|
|
244
|
+
},
|
|
245
|
+
"glass-hearts-club": {
|
|
246
|
+
name: "Glass Hearts Club", genre: "Pop",
|
|
247
|
+
sound: "shimmering synth-pop, gated drums, glassy synths, big layered chorus vocals, 118-128 BPM",
|
|
248
|
+
voice: "a trio of bright female voices trading lines and stacking harmonies",
|
|
249
|
+
bio: "Three best friends turning group chats into choruses.",
|
|
250
|
+
themes: "friendship, crushes, breakups survived together, dancing until the lights come on",
|
|
251
|
+
},
|
|
77
252
|
"lumi-kestrel": {
|
|
78
253
|
name: "Lumi Kestrel", genre: "Pop",
|
|
79
254
|
sound: "bright modern dance pop, punchy four-on-the-floor drums, glossy synths, plucky guitar, big pre-chorus lifts, 110-124 BPM",
|
|
@@ -87,10 +262,15 @@ export const DEFAULTS = {
|
|
|
87
262
|
enabled: true,
|
|
88
263
|
monthlySeconds: 3600,
|
|
89
264
|
everyDays: 30,
|
|
90
|
-
|
|
265
|
+
albumsPerDay: 1,
|
|
266
|
+
newArtistChance: 0.25,
|
|
267
|
+
seedAlbums: 1,
|
|
91
268
|
songsPerAlbum: 8,
|
|
92
269
|
songSeconds: 150,
|
|
93
270
|
publish: true,
|
|
271
|
+
videos: true,
|
|
272
|
+
parallel: 1,
|
|
273
|
+
genreOrder: ["Punk", "Blues", "Country", "Jazz", "Classic Rock", "Metal", "Hip-Hop", "Pop", "R&B"],
|
|
94
274
|
};
|
|
95
275
|
const DAY = 86_400_000;
|
|
96
276
|
/** Between tries after a failure: 5 min, doubling, at most 6 h. */
|
|
@@ -164,6 +344,28 @@ export async function planAlbum(artist, songs, ask = askJson) {
|
|
|
164
344
|
throw new Error("the model planned no album (a title and at least three songs)");
|
|
165
345
|
return { title, concept, songs: planned, model: answer.model };
|
|
166
346
|
}
|
|
347
|
+
const ARTIST_SYSTEM = [
|
|
348
|
+
"You invent original recording artists for an independent record label whose artists are AI and sing in English.",
|
|
349
|
+
"The name must be new: never a real artist, band or label, and never imitating one.",
|
|
350
|
+
"Answer as JSON: {\"name\": \"1-4 words\", \"sound\": \"the music in words, at most 300 characters: subgenre, instruments, production, tempo in BPM\",",
|
|
351
|
+
"\"voice\": \"the lead voice, at most 150 characters\", \"bio\": \"one striking sentence\", \"themes\": \"what they sing about, at most 150 characters\"}.",
|
|
352
|
+
].join(" ");
|
|
353
|
+
/** A new artist for this genre, unlike the ones already signed. */
|
|
354
|
+
export async function inventArtist(genre, taken, ask = askJson) {
|
|
355
|
+
const answer = await ask({
|
|
356
|
+
system: ARTIST_SYSTEM,
|
|
357
|
+
prompt: `Genre: ${genre}\nAlready on the label (do not reuse or echo these names or sounds): ${taken.slice(-40).join(", ") || "nobody yet"}`,
|
|
358
|
+
maxTokens: 600,
|
|
359
|
+
timeoutMs: 60_000,
|
|
360
|
+
});
|
|
361
|
+
const name = clean(answer.value["name"], 60).replace(/"/g, "");
|
|
362
|
+
const sound = clean(answer.value["sound"], 400);
|
|
363
|
+
if (!name || sound.length < 10)
|
|
364
|
+
throw new Error("the model invented no artist");
|
|
365
|
+
if (taken.some((one) => one.toLowerCase() === name.toLowerCase()))
|
|
366
|
+
throw new Error(`the model reused the name ${name}`);
|
|
367
|
+
return { name, genre, sound, voice: clean(answer.value["voice"], 200) || "expressive lead vocal", bio: clean(answer.value["bio"], 300), themes: clean(answer.value["themes"], 200) };
|
|
368
|
+
}
|
|
167
369
|
export async function writeLyrics(artist, album, song, ask = askJson) {
|
|
168
370
|
const answer = await ask({
|
|
169
371
|
system: LYRICS_SYSTEM,
|
|
@@ -207,6 +409,26 @@ export async function workersAiCover(prompt, env = process.env, fetcher = fetch)
|
|
|
207
409
|
return image;
|
|
208
410
|
}
|
|
209
411
|
/** Writes the tags (and the cover, as the attached picture) into a copy of the file. */
|
|
412
|
+
/**
|
|
413
|
+
* A song's music video: its album cover over a blurred copy of itself, the
|
|
414
|
+
* song's waveform along the bottom, 720p H.264 with the MP3's audio copied in.
|
|
415
|
+
* Without a cover, a dark plate.
|
|
416
|
+
*/
|
|
417
|
+
export function renderVideo(ffmpeg, audio, cover, out) {
|
|
418
|
+
const picture = cover ? ["-loop", "1", "-framerate", "25", "-i", cover] : ["-f", "lavfi", "-i", "color=c=0x0d1410:s=1280x720:r=25"];
|
|
419
|
+
const graph = [
|
|
420
|
+
"[0:v]scale=1280:720:force_original_aspect_ratio=increase,crop=1280:720,boxblur=24:2,eq=brightness=-0.18[bg]",
|
|
421
|
+
"[0:v]scale=500:500[fg]",
|
|
422
|
+
"[1:a]showwaves=s=1280x150:mode=cline:rate=25:colors=white@0.85,format=rgba,colorkey=0x000000:0.1:0[viz]",
|
|
423
|
+
"[bg][fg]overlay=(W-w)/2:40[a]",
|
|
424
|
+
"[a][viz]overlay=0:H-160,format=yuv420p[v]",
|
|
425
|
+
].join(";");
|
|
426
|
+
const args = [...ffmpeg.slice(1), "-hide_banner", "-loglevel", "error", "-y", ...picture, "-i", audio, "-filter_complex", graph,
|
|
427
|
+
"-map", "[v]", "-map", "1:a", "-c:v", "libx264", "-preset", "veryfast", "-crf", "26", "-r", "25", "-c:a", "copy", "-shortest", "-movflags", "+faststart", out];
|
|
428
|
+
return new Promise((done, fail) => {
|
|
429
|
+
execFile(ffmpeg[0], args, { timeout: 20 * 60_000 }, (error) => (error ? fail(new Error(`the video failed: ${error.message}`)) : done()));
|
|
430
|
+
});
|
|
431
|
+
}
|
|
210
432
|
function tagFile(ffmpeg, from, to, tags, cover) {
|
|
211
433
|
const args = [...ffmpeg.slice(1), "-hide_banner", "-loglevel", "error", "-y", "-i", from];
|
|
212
434
|
if (cover)
|
|
@@ -230,7 +452,19 @@ export class AiLabel {
|
|
|
230
452
|
artists = new Map();
|
|
231
453
|
settings = { ...DEFAULTS, pausedReason: "" };
|
|
232
454
|
usage = {};
|
|
233
|
-
|
|
455
|
+
running = false;
|
|
456
|
+
/** Songs being made now, by `<album>:<n>`. */
|
|
457
|
+
claims = new Map();
|
|
458
|
+
/** Artists whose next album is being planned now. */
|
|
459
|
+
planning = new Set();
|
|
460
|
+
covering = new Map();
|
|
461
|
+
filming = null;
|
|
462
|
+
/** Songs whose video failed since this server started. */
|
|
463
|
+
videoFailed = new Set();
|
|
464
|
+
/** The day's album is tried at most every 30 minutes, so a failing model is not hammered. */
|
|
465
|
+
dailyTriedAt = 0;
|
|
466
|
+
/** Genre stations are kept in step one change at a time. */
|
|
467
|
+
placing = Promise.resolve();
|
|
234
468
|
timer = null;
|
|
235
469
|
constructor(file, deps) {
|
|
236
470
|
this.file = file;
|
|
@@ -298,6 +532,9 @@ export class AiLabel {
|
|
|
298
532
|
return -1;
|
|
299
533
|
if (artist.albums.length < this.settings.seedAlbums)
|
|
300
534
|
return 0;
|
|
535
|
+
// On the daily schedule an artist's next album is whenever the day's pick lands on it.
|
|
536
|
+
if (this.settings.albumsPerDay > 0)
|
|
537
|
+
return -1;
|
|
301
538
|
const last = artist.albums[artist.albums.length - 1];
|
|
302
539
|
const stagger = (parseInt(artist.key.slice(0, 6), 16) % Math.max(1, Math.floor(this.settings.everyDays / 4))) * DAY;
|
|
303
540
|
return last.plannedAt + this.settings.everyDays * DAY + stagger;
|
|
@@ -317,7 +554,8 @@ export class AiLabel {
|
|
|
317
554
|
root: this.root,
|
|
318
555
|
composer: composer?.name ?? null,
|
|
319
556
|
writer: this.canAsk() ? "language model" : null,
|
|
320
|
-
busy: this.
|
|
557
|
+
busy: this.running ? ([...this.claims.values()].map((one) => one.label).join("; ") || "planning") : "",
|
|
558
|
+
recording: [...this.claims.values()].map((one) => one.label),
|
|
321
559
|
month,
|
|
322
560
|
secondsThisMonth: this.usage[month] ?? 0,
|
|
323
561
|
settings: this.settings,
|
|
@@ -364,6 +602,24 @@ export class AiLabel {
|
|
|
364
602
|
this.settings.enabled = input["enabled"] !== false;
|
|
365
603
|
if (input["publish"] !== undefined)
|
|
366
604
|
this.settings.publish = input["publish"] !== false;
|
|
605
|
+
if (input["videos"] !== undefined)
|
|
606
|
+
this.settings.videos = input["videos"] !== false;
|
|
607
|
+
const perDay = whole("albumsPerDay", 0, 24);
|
|
608
|
+
if (perDay !== undefined)
|
|
609
|
+
this.settings.albumsPerDay = perDay;
|
|
610
|
+
if (input["newArtistChance"] !== undefined) {
|
|
611
|
+
const chance = Number(input["newArtistChance"]);
|
|
612
|
+
if (!Number.isFinite(chance) || chance < 0 || chance > 1)
|
|
613
|
+
throw new StationError("invalid_newArtistChance", "newArtistChance is from 0 to 1");
|
|
614
|
+
this.settings.newArtistChance = chance;
|
|
615
|
+
}
|
|
616
|
+
const parallel = whole("parallel", 1, 8);
|
|
617
|
+
if (parallel !== undefined)
|
|
618
|
+
this.settings.parallel = parallel;
|
|
619
|
+
if (input["genreOrder"] !== undefined) {
|
|
620
|
+
const list = Array.isArray(input["genreOrder"]) ? input["genreOrder"] : String(input["genreOrder"]).split(",");
|
|
621
|
+
this.settings.genreOrder = list.map((one) => clean(one, 40)).filter(Boolean).slice(0, 30);
|
|
622
|
+
}
|
|
367
623
|
if (input["resume"] === true)
|
|
368
624
|
this.settings.pausedReason = "";
|
|
369
625
|
this.save();
|
|
@@ -463,66 +719,174 @@ export class AiLabel {
|
|
|
463
719
|
startSchedule(everyMs = 5 * 60_000) {
|
|
464
720
|
if (this.timer)
|
|
465
721
|
return;
|
|
466
|
-
this.timer = setInterval(() => void this.tick(), everyMs);
|
|
722
|
+
this.timer = setInterval(() => { void this.tick(); void this.videos(); }, everyMs);
|
|
467
723
|
this.timer.unref?.();
|
|
468
724
|
void this.tick();
|
|
725
|
+
void this.videos();
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Music videos for every released song without one, one at a time (a
|
|
729
|
+
* video is CPU, not credit), and the TV station of them kept in step. A
|
|
730
|
+
* video that fails is tried again after the next restart, not in a loop.
|
|
731
|
+
*/
|
|
732
|
+
videos() {
|
|
733
|
+
if (!this.settings.videos)
|
|
734
|
+
return Promise.resolve(0);
|
|
735
|
+
// One run at a time; asking during one waits for it.
|
|
736
|
+
this.filming ??= this.film().finally(() => { this.filming = null; });
|
|
737
|
+
return this.filming;
|
|
738
|
+
}
|
|
739
|
+
async film() {
|
|
740
|
+
let total = 0;
|
|
741
|
+
// Songs that land while videos are being made are picked up by the next pass.
|
|
742
|
+
for (let made = await this.filmPass(); made > 0; made = await this.filmPass())
|
|
743
|
+
total += made;
|
|
744
|
+
return total;
|
|
745
|
+
}
|
|
746
|
+
async filmPass() {
|
|
747
|
+
let made = 0;
|
|
748
|
+
{
|
|
749
|
+
for (const artist of this.list()) {
|
|
750
|
+
for (const album of artist.albums) {
|
|
751
|
+
if (!album.cover && !album.coverTried)
|
|
752
|
+
continue;
|
|
753
|
+
for (const song of album.songs) {
|
|
754
|
+
if (!song.file || song.video || this.videoFailed.has(song.file) || !this.settings.videos)
|
|
755
|
+
continue;
|
|
756
|
+
const out = join(album.folder, `${songBase(song)}.mp4`);
|
|
757
|
+
const partial = join(album.folder, `.${randomBytes(4).toString("hex")}.video.mp4`);
|
|
758
|
+
try {
|
|
759
|
+
await (this.deps.video ?? ((audio, cover, to) => renderVideo(this.deps.ffmpeg, audio, cover, to)))(song.file, album.cover, partial);
|
|
760
|
+
renameSync(partial, out);
|
|
761
|
+
song.video = out;
|
|
762
|
+
made += 1;
|
|
763
|
+
this.save();
|
|
764
|
+
}
|
|
765
|
+
catch (error) {
|
|
766
|
+
this.videoFailed.add(song.file);
|
|
767
|
+
this.log(`no video for "${song.title}": ${error.message}`);
|
|
768
|
+
}
|
|
769
|
+
finally {
|
|
770
|
+
rmSync(partial, { force: true });
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
if (made > 0)
|
|
776
|
+
await this.placeVideos();
|
|
777
|
+
}
|
|
778
|
+
return made;
|
|
779
|
+
}
|
|
780
|
+
/** The label's music videos as one TV station: free to watch, with its breaks. */
|
|
781
|
+
placeVideos() {
|
|
782
|
+
const next = this.placing.then(async () => {
|
|
783
|
+
await this.deps.index.scan(this.source(), this.deps.probe).done;
|
|
784
|
+
const files = this.list()
|
|
785
|
+
.flatMap((artist) => artist.albums.flatMap((album) => album.songs.map((song) => song.video ?? "")))
|
|
786
|
+
.filter((file) => file && this.deps.index.itemAt(file)?.state === "ready");
|
|
787
|
+
if (files.length === 0)
|
|
788
|
+
return;
|
|
789
|
+
const existing = this.deps.store.byAuto("ai:label:videos");
|
|
790
|
+
if (existing) {
|
|
791
|
+
this.deps.store.update(existing.id, {}, files);
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
const label = this.settings.holder.replace(/,?\s+(inc|llc|ltd|gmbh|co)\.?$/i, "");
|
|
795
|
+
const made = this.deps.store.create({ name: `${label} AI · Music Videos`, description: `Music videos by ${label}'s AI artists, every genre.`, shuffle: true, repeat: true, video: true }, files, this.deps.owner);
|
|
796
|
+
this.deps.store.markAuto(made.id, "ai:label:videos");
|
|
797
|
+
if (this.settings.publish) {
|
|
798
|
+
this.deps.store.setAccess(made.id, {
|
|
799
|
+
visibility: "public",
|
|
800
|
+
listed: true,
|
|
801
|
+
rights: { basis: "owned", note: `Music videos made for ${this.settings.holder} from its AI artists' songs and covers; no third-party material. ℗ ${this.settings.holder}.` },
|
|
802
|
+
}, this.deps.owner || "ai-label");
|
|
803
|
+
}
|
|
804
|
+
if (this.deps.mayStart())
|
|
805
|
+
await this.deps.runner.start(made.id).catch(() => undefined);
|
|
806
|
+
});
|
|
807
|
+
this.placing = next.catch(() => undefined);
|
|
808
|
+
return next;
|
|
469
809
|
}
|
|
470
810
|
stopSchedule() {
|
|
471
811
|
if (this.timer)
|
|
472
812
|
clearInterval(this.timer);
|
|
473
813
|
this.timer = null;
|
|
474
814
|
}
|
|
475
|
-
/**
|
|
815
|
+
/**
|
|
816
|
+
* Everything that is due, `parallel` songs at a time, until nothing is or
|
|
817
|
+
* something stops it. Each worker claims a song (or an artist to plan for),
|
|
818
|
+
* so two never make the same one.
|
|
819
|
+
*/
|
|
476
820
|
async tick(limit = 200) {
|
|
477
|
-
if (this.
|
|
821
|
+
if (this.running || !this.settings.enabled || this.settings.pausedReason)
|
|
478
822
|
return;
|
|
479
|
-
this.
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
823
|
+
this.running = true;
|
|
824
|
+
let steps = 0;
|
|
825
|
+
const worker = async () => {
|
|
826
|
+
while (steps < limit && !this.settings.pausedReason) {
|
|
827
|
+
steps += 1;
|
|
484
828
|
if ((await this.step().catch(() => "idle")) === "idle")
|
|
485
|
-
|
|
829
|
+
return;
|
|
486
830
|
}
|
|
831
|
+
};
|
|
832
|
+
try {
|
|
833
|
+
await Promise.all(Array.from({ length: Math.max(1, this.settings.parallel) }, worker));
|
|
487
834
|
}
|
|
488
835
|
finally {
|
|
489
|
-
this.
|
|
836
|
+
this.running = false;
|
|
490
837
|
}
|
|
491
838
|
}
|
|
839
|
+
/** Where the artist's genre comes in the owner's order (genreOrder); unlisted genres after. */
|
|
840
|
+
rank(genre) {
|
|
841
|
+
const at = this.settings.genreOrder.findIndex((one) => genreSlug(one) === genreSlug(genre));
|
|
842
|
+
return at === -1 ? this.settings.genreOrder.length : at;
|
|
843
|
+
}
|
|
492
844
|
async step() {
|
|
493
845
|
const now = this.now();
|
|
494
846
|
const composer = this.composer();
|
|
495
847
|
if (!composer)
|
|
496
848
|
return "idle";
|
|
497
|
-
|
|
849
|
+
const claimedSeconds = [...this.claims.values()].reduce((sum, one) => sum + one.seconds, 0);
|
|
850
|
+
// A song of an album already planned that nobody is making, oldest album first.
|
|
498
851
|
const making = this.list()
|
|
499
852
|
.filter((one) => one.enabled && !this.backingOff(one, now))
|
|
500
853
|
.flatMap((artist) => artist.albums.filter((album) => !album.doneAt).map((album) => ({ artist, album })))
|
|
501
854
|
.sort((a, b) => a.album.plannedAt - b.album.plannedAt);
|
|
502
855
|
for (const { artist, album } of making) {
|
|
503
|
-
|
|
504
|
-
if (!song) {
|
|
856
|
+
if (album.songs.every((one) => one.file)) {
|
|
505
857
|
album.doneAt = now;
|
|
506
858
|
this.save();
|
|
507
859
|
continue;
|
|
508
860
|
}
|
|
509
|
-
|
|
861
|
+
const song = album.songs.find((one) => !one.file && !this.claims.has(`${album.key}:${one.n}`));
|
|
862
|
+
if (!song)
|
|
863
|
+
continue;
|
|
864
|
+
if (song.seconds + claimedSeconds > this.budgetLeft())
|
|
510
865
|
return "idle";
|
|
511
|
-
|
|
866
|
+
const claim = `${album.key}:${song.n}`;
|
|
867
|
+
this.claims.set(claim, { label: `${artist.name}: ${album.title}: ${song.title}`, seconds: song.seconds });
|
|
868
|
+
try {
|
|
869
|
+
await this.compose(artist, album, song, composer);
|
|
870
|
+
}
|
|
871
|
+
finally {
|
|
872
|
+
this.claims.delete(claim);
|
|
873
|
+
}
|
|
512
874
|
return "composed";
|
|
513
875
|
}
|
|
514
|
-
//
|
|
515
|
-
|
|
876
|
+
// Room for another album: the artist most overdue (in the owner's genre order) gets its next one.
|
|
877
|
+
const inProgress = this.list().filter((one) => one.albums.some((album) => !album.doneAt)).length + this.planning.size;
|
|
878
|
+
if (inProgress >= Math.max(1, this.settings.parallel))
|
|
516
879
|
return "idle";
|
|
517
|
-
if (!this.canAsk() || this.settings.songSeconds > this.budgetLeft())
|
|
880
|
+
if (!this.canAsk() || this.settings.songSeconds + claimedSeconds > this.budgetLeft())
|
|
518
881
|
return "idle";
|
|
519
882
|
const due = this.list()
|
|
520
|
-
.filter((one) => !this.backingOff(one, now))
|
|
883
|
+
.filter((one) => !this.backingOff(one, now) && !this.planning.has(one.key) && !one.albums.some((album) => !album.doneAt))
|
|
521
884
|
.map((one) => ({ one, at: this.dueAt(one) }))
|
|
522
885
|
.filter(({ at }) => at !== -1 && at <= now)
|
|
523
|
-
.sort((a, b) => a.one.albums.length - b.one.albums.length || a.at - b.at)[0];
|
|
886
|
+
.sort((a, b) => a.one.albums.length - b.one.albums.length || this.rank(a.one.genre) - this.rank(b.one.genre) || a.at - b.at)[0];
|
|
524
887
|
if (!due)
|
|
525
|
-
return
|
|
888
|
+
return this.daily(now);
|
|
889
|
+
this.planning.add(due.one.key);
|
|
526
890
|
try {
|
|
527
891
|
await this.plan(due.one.key);
|
|
528
892
|
due.one.failures = 0;
|
|
@@ -534,6 +898,59 @@ export class AiLabel {
|
|
|
534
898
|
this.failed(due.one, error);
|
|
535
899
|
throw error;
|
|
536
900
|
}
|
|
901
|
+
finally {
|
|
902
|
+
this.planning.delete(due.one.key);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* After the first sweep: the day's album. When the last one was planned a
|
|
907
|
+
* day ago (or 1/albumsPerDay of one), a random artist gets it, or now and
|
|
908
|
+
* then an artist invented for one of the owner's genres. Nothing until every
|
|
909
|
+
* enabled artist has its first albums.
|
|
910
|
+
*/
|
|
911
|
+
async daily(now) {
|
|
912
|
+
const perDay = this.settings.albumsPerDay;
|
|
913
|
+
if (perDay <= 0)
|
|
914
|
+
return "idle";
|
|
915
|
+
const enabled = this.list().filter((one) => one.enabled);
|
|
916
|
+
if (enabled.some((one) => one.albums.length < this.settings.seedAlbums))
|
|
917
|
+
return "idle";
|
|
918
|
+
const last = Math.max(0, ...this.list().flatMap((one) => one.albums.map((album) => album.plannedAt)));
|
|
919
|
+
if (now < last + Math.floor(DAY / perDay) || now < this.dailyTriedAt + 30 * 60_000)
|
|
920
|
+
return "idle";
|
|
921
|
+
this.dailyTriedAt = now;
|
|
922
|
+
const random = this.deps.random ?? Math.random;
|
|
923
|
+
const genres = this.settings.genreOrder.length ? this.settings.genreOrder : [...new Set(enabled.map((one) => one.genre))];
|
|
924
|
+
let artist;
|
|
925
|
+
if ((enabled.length === 0 || random() < this.settings.newArtistChance) && genres.length > 0) {
|
|
926
|
+
const genre = genres[Math.floor(random() * genres.length)];
|
|
927
|
+
try {
|
|
928
|
+
const invented = await inventArtist(genre, this.list().map((one) => one.name), this.deps.ask ?? askJson);
|
|
929
|
+
artist = this.sign({ ...invented });
|
|
930
|
+
this.log(`signed a new artist: ${artist.name} (${genre})`);
|
|
931
|
+
}
|
|
932
|
+
catch (error) {
|
|
933
|
+
this.log(`could not invent a ${genre} artist (${error.message}); an artist already signed gets the album`);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
if (!artist) {
|
|
937
|
+
const free = enabled.filter((one) => !this.backingOff(one, now) && !this.planning.has(one.key) && !one.albums.some((album) => !album.doneAt));
|
|
938
|
+
artist = free[Math.floor(random() * free.length)];
|
|
939
|
+
}
|
|
940
|
+
if (!artist)
|
|
941
|
+
return "idle";
|
|
942
|
+
this.planning.add(artist.key);
|
|
943
|
+
try {
|
|
944
|
+
await this.plan(artist.key);
|
|
945
|
+
return "planned";
|
|
946
|
+
}
|
|
947
|
+
catch (error) {
|
|
948
|
+
this.failed(artist, error);
|
|
949
|
+
throw error;
|
|
950
|
+
}
|
|
951
|
+
finally {
|
|
952
|
+
this.planning.delete(artist.key);
|
|
953
|
+
}
|
|
537
954
|
}
|
|
538
955
|
failed(artist, error) {
|
|
539
956
|
artist.lastAttemptAt = this.now();
|
|
@@ -545,7 +962,6 @@ export class AiLabel {
|
|
|
545
962
|
this.log(`${artist.name}: ${artist.lastError}`);
|
|
546
963
|
}
|
|
547
964
|
async compose(artist, album, song, composer) {
|
|
548
|
-
this.busy = `${artist.name}: ${album.title}: ${song.title}`;
|
|
549
965
|
artist.lastAttemptAt = this.now();
|
|
550
966
|
mkdirSync(album.folder, { recursive: true });
|
|
551
967
|
const raw = join(album.folder, `.${randomBytes(4).toString("hex")}.raw.mp3`);
|
|
@@ -596,6 +1012,8 @@ export class AiLabel {
|
|
|
596
1012
|
this.save();
|
|
597
1013
|
this.writeAlbumJson(artist, album);
|
|
598
1014
|
await this.place(artist.genre);
|
|
1015
|
+
// Its video is made beside it, without holding up the next song.
|
|
1016
|
+
void this.videos();
|
|
599
1017
|
}
|
|
600
1018
|
catch (error) {
|
|
601
1019
|
this.failed(artist, error);
|
|
@@ -603,13 +1021,18 @@ export class AiLabel {
|
|
|
603
1021
|
}
|
|
604
1022
|
finally {
|
|
605
1023
|
rmSync(raw, { force: true });
|
|
606
|
-
this.busy = "starting";
|
|
607
1024
|
}
|
|
608
1025
|
}
|
|
609
|
-
|
|
1026
|
+
/** The album's cover, drawn once; songs made at the same time wait for the same drawing. */
|
|
1027
|
+
ensureCover(artist, album) {
|
|
610
1028
|
if (album.cover || album.coverTried)
|
|
611
|
-
return;
|
|
1029
|
+
return this.covering.get(album.key) ?? Promise.resolve();
|
|
612
1030
|
album.coverTried = true;
|
|
1031
|
+
const drawing = this.drawCover(artist, album).finally(() => this.covering.delete(album.key));
|
|
1032
|
+
this.covering.set(album.key, drawing);
|
|
1033
|
+
return drawing;
|
|
1034
|
+
}
|
|
1035
|
+
async drawCover(artist, album) {
|
|
613
1036
|
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
1037
|
try {
|
|
615
1038
|
const picture = await (this.deps.cover ?? ((text) => workersAiCover(text)))(prompt);
|
|
@@ -645,7 +1068,12 @@ export class AiLabel {
|
|
|
645
1068
|
return this.deps.store.byAuto(`ai:label:${genreSlug(genre)}`);
|
|
646
1069
|
}
|
|
647
1070
|
/** The genre's station gets every song of that genre: made with the first, then kept in step. */
|
|
648
|
-
|
|
1071
|
+
place(genre) {
|
|
1072
|
+
const next = this.placing.then(() => this.placeNow(genre));
|
|
1073
|
+
this.placing = next.catch(() => undefined);
|
|
1074
|
+
return next;
|
|
1075
|
+
}
|
|
1076
|
+
async placeNow(genre) {
|
|
649
1077
|
await this.deps.index.scan(this.source(), this.deps.probe).done;
|
|
650
1078
|
const artists = this.list().filter((one) => genreSlug(one.genre) === genreSlug(genre));
|
|
651
1079
|
const files = artists
|
|
@@ -751,6 +1179,8 @@ export class AiLabel {
|
|
|
751
1179
|
for (const song of songs) {
|
|
752
1180
|
entries.push({ name: `${folder}/${songBase(song)}.mp3`, path: song.file });
|
|
753
1181
|
entries.push({ name: `${folder}/${songBase(song)}.lyrics.txt`, bytes: Buffer.from(`${song.lyrics}\n`) });
|
|
1182
|
+
if (song.video)
|
|
1183
|
+
entries.push({ name: `${folder}/${songBase(song)}.mp4`, path: song.video });
|
|
754
1184
|
}
|
|
755
1185
|
if (album.cover)
|
|
756
1186
|
entries.push({ name: `${folder}/cover.jpg`, path: album.cover });
|
package/dist/label-routes.js
CHANGED
|
@@ -94,7 +94,8 @@ export async function handleLabelRoutes(request, response, path, ctx) {
|
|
|
94
94
|
return true;
|
|
95
95
|
}
|
|
96
96
|
if (path === "/api/v1/label") {
|
|
97
|
-
|
|
97
|
+
// Where this catalogue is sold from, for Buy links built elsewhere (nixamp music, MCP).
|
|
98
|
+
ctx.json(response, 200, { ...label.publicView(), ...(/^https:\/\//.test(ctx.publicBase ?? "") ? { server: ctx.publicBase } : {}) });
|
|
98
99
|
return true;
|
|
99
100
|
}
|
|
100
101
|
const url = new URL(request.url ?? "/", "http://x");
|