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.
Files changed (48) hide show
  1. package/dist/ai-label.d.ts +70 -3
  2. package/dist/ai-label.js +459 -29
  3. package/dist/label-routes.js +2 -1
  4. package/dist/main.js +9 -0
  5. package/dist/mcp.js +42 -0
  6. package/dist/music-cli.d.ts +2 -0
  7. package/dist/music-cli.js +109 -0
  8. package/dist/music-client.d.ts +62 -0
  9. package/dist/music-client.js +73 -0
  10. package/dist/station-cli.js +8 -1
  11. package/dist/station-mcp.d.ts +62 -0
  12. package/dist/station-mcp.js +5 -2
  13. package/dist/station-usage.d.ts +1 -1
  14. package/dist/station-usage.js +6 -3
  15. package/dist/track-facts.js +1 -1
  16. package/package.json +1 -1
  17. package/src/ai-label.ts +464 -29
  18. package/src/label-routes.ts +2 -1
  19. package/src/main.ts +8 -0
  20. package/src/mcp.ts +38 -0
  21. package/src/music-cli.ts +86 -0
  22. package/src/music-client.ts +86 -0
  23. package/src/station-cli.ts +8 -1
  24. package/src/station-mcp.ts +5 -2
  25. package/src/station-usage.ts +6 -3
  26. package/src/track-facts.ts +1 -1
  27. package/web/dist/assets/{ar-CC_uYRlC.js → ar-DMx2aeAO.js} +1 -1
  28. package/web/dist/assets/{cs-k5C-jvfX.js → cs-Ddu0q3kG.js} +1 -1
  29. package/web/dist/assets/{da-CdBzU_fF.js → da-CZ9gg_pb.js} +1 -1
  30. package/web/dist/assets/{de-B-UWa0W7.js → de-CgJX4xg2.js} +1 -1
  31. package/web/dist/assets/{es-Dod8aCau.js → es-Cp5ryDrJ.js} +1 -1
  32. package/web/dist/assets/{fi-C36bBPFW.js → fi-D912EKux.js} +1 -1
  33. package/web/dist/assets/{fr-DWUxs2qv.js → fr-FE_K0MPb.js} +1 -1
  34. package/web/dist/assets/{hi-DjLqhfs8.js → hi-BdYtQs1S.js} +1 -1
  35. package/web/dist/assets/{hu-CgsOlcq8.js → hu-oh5tEg0p.js} +1 -1
  36. package/web/dist/assets/{id-ZA8yTWX9.js → id-NbCUWeB1.js} +1 -1
  37. package/web/dist/assets/{it-DfFhS34g.js → it-CiHbKZ6Y.js} +1 -1
  38. package/web/dist/assets/{main-CjgQp9um.js → main-8wV87D-g.js} +2 -2
  39. package/web/dist/assets/{nl-C257i24U.js → nl-nLHK_AQ-.js} +1 -1
  40. package/web/dist/assets/{radio-CDUEoM0y.js → radio-BmHvBkVy.js} +1 -1
  41. package/web/dist/assets/{ru--30I7BNC.js → ru-BDdAS0Vv.js} +1 -1
  42. package/web/dist/assets/{sv-CxzBeXfk.js → sv-COweXM56.js} +1 -1
  43. package/web/dist/assets/{uk-DXHpMk4i.js → uk-BJFzR1LS.js} +1 -1
  44. package/web/dist/assets/{vi-Dg2LT4x3.js → vi-qg2DJ6cR.js} +1 -1
  45. package/web/dist/assets/{zh-D3pvejo6.js → zh-DUWOKDkb.js} +1 -1
  46. package/web/dist/index.html +2 -1
  47. package/web/dist/radio.html +1 -1
  48. package/web/dist/sw.js +20 -20
package/src/ai-label.ts CHANGED
@@ -49,6 +49,8 @@ export interface LabelSong {
49
49
  /** The MP3, "" until composed. */
50
50
  file: string;
51
51
  lyricsFile: string;
52
+ /** Its music video (001. <title>.mp4), "" until made; absent on songs from before videos. */
53
+ video?: string;
52
54
  /** Who wrote the words and who sang them, e.g. "llama-3.3 + elevenlabs/music_v2_5". */
53
55
  made: string;
54
56
  composedAt: number;
@@ -97,14 +99,28 @@ export interface LabelSettings {
97
99
  enabled: boolean;
98
100
  /** Seconds of music a month across the label; 0 for no cap. */
99
101
  monthlySeconds: number;
100
- /** Days between an artist's albums once it has its first ones. */
102
+ /** Days between an artist's albums once it has its first ones (only when albumsPerDay is 0). */
101
103
  everyDays: number;
104
+ /**
105
+ * Once every artist has its first albums: new albums a day across the label,
106
+ * each by an artist picked at random or, now and then, a new one invented
107
+ * in one of the owner's genres. 0 goes back to one per artist every everyDays.
108
+ */
109
+ albumsPerDay: number;
110
+ /** How often (0-1) the day's album is by a newly invented artist. */
111
+ newArtistChance: number;
102
112
  /** Albums every artist gets as soon as it can. */
103
113
  seedAlbums: number;
104
114
  songsPerAlbum: number;
105
115
  songSeconds: number;
116
+ /** A music video for every song (the cover over itself, blurred, and the song's waveform), and a TV station of them. */
117
+ videos: boolean;
106
118
  /** Public, listed and owned, so the genre stations are on the guide. */
107
119
  publish: boolean;
120
+ /** Songs made at the same time (ElevenLabs takes several requests at once on a paid plan). */
121
+ parallel: number;
122
+ /** Genres whose artists get their albums first, in this order. */
123
+ genreOrder: string[];
108
124
  /** Set by a provider that is out of credit; cleared by `resume`. */
109
125
  pausedReason: string;
110
126
  }
@@ -151,6 +167,181 @@ export const ROSTER: Record<string, ArtistPreset> = {
151
167
  bio: "Maps of places that burned down, drawn at full volume.",
152
168
  themes: "ruins, resilience, storms, rebuilding from ashes, defiance",
153
169
  },
170
+ "iron-lantern-society": {
171
+ name: "Iron Lantern Society", genre: "Classic Rock",
172
+ sound: "bluesy classic hard rock, swaggering riffs, Hammond organ, cowbell grooves, big guitar solos, 100-128 BPM",
173
+ voice: "gravelly male baritone with a wide rasp and a rock-and-roll howl",
174
+ bio: "Five road dogs who think every song should end in a bar fight or a sunrise.",
175
+ themes: "the open road, hard luck, loyalty among misfits, last calls and new starts",
176
+ },
177
+ "highway-sermon": {
178
+ name: "Highway Sermon", genre: "Classic Rock",
179
+ sound: "1970s classic rock, twin harmony lead guitars, warm analog bass, piano, big open drums, arena choruses, 96-124 BPM",
180
+ voice: "soaring, slightly raspy male tenor with a wide vibrato",
181
+ bio: "Four friends who found a crate of their fathers' records and never gave them back.",
182
+ themes: "freedom, the open highway, small-town legends, love and leaving",
183
+ },
184
+ "copper-jubilee": {
185
+ name: "Copper Jubilee", genre: "Classic Rock",
186
+ sound: "southern classic rock, slide guitar, honky-tonk piano, Hammond organ, swampy groove, gospel backing vocals, 88-116 BPM",
187
+ voice: "powerful bluesy female belter with grit and gospel runs",
188
+ bio: "A riverboat revue that plugged in and turned it up.",
189
+ themes: "rivers, revival nights, rebellion, home cooking and heartbreak",
190
+ },
191
+ "the-bent-spokes": {
192
+ name: "The Bent Spokes", genre: "Punk",
193
+ sound: "fast melodic skate punk, palm-muted power chords, driving eighth-note bass, snappy double-time drums, gang vocal choruses, 180-210 BPM",
194
+ voice: "snotty, energetic male tenor with shouted group backing vocals",
195
+ bio: "Three teenagers who never stopped being teenagers, and a drummer who might be a skateboard.",
196
+ themes: "boredom, suburbia, friendship, skipping school, refusing to grow up",
197
+ },
198
+ "gutter-velvet": {
199
+ name: "Gutter Velvet", genre: "Punk",
200
+ sound: "raw street punk, distorted buzzsaw guitars, thumping bass, stomping drums, chant-along choruses, 160-190 BPM",
201
+ voice: "furious, raspy female shouter with a sneer",
202
+ bio: "Born in a squat, raised on a photocopier.",
203
+ themes: "rent, landlords, solidarity, the city at 3 a.m., not backing down",
204
+ },
205
+ "no-refunds": {
206
+ name: "No Refunds", genre: "Punk",
207
+ sound: "pop punk, bright crunchy guitars, bouncy bass, big singalong hooks, half-time bridges, 160-185 BPM",
208
+ voice: "catchy, slightly nasal male lead with harmonized choruses",
209
+ bio: "Mall-parking-lot punk with a heart of gold and a dented van.",
210
+ themes: "crushes, bad jobs, summer, best friends, second chances",
211
+ },
212
+ "muddy-ledger": {
213
+ name: "Muddy Ledger", genre: "Blues",
214
+ sound: "electric Chicago blues, stinging lead guitar, harmonica, walking bass, shuffle drums, slow 12-bar and mid-tempo shuffles, 60-110 BPM",
215
+ voice: "deep, gravelly male baritone with call-and-response guitar",
216
+ bio: "Plays like he owes the guitar money.",
217
+ themes: "hard times, trains, lost love, payday, getting even",
218
+ },
219
+ "delta-ruby": {
220
+ name: "Delta Ruby", genre: "Blues",
221
+ sound: "soulful blues, Hammond organ, horn section, slow-burning guitar, brushed and backbeat drums, 60-100 BPM",
222
+ voice: "rich, powerful female contralto with soaring gospel-blues phrasing",
223
+ bio: "A juke-joint queen with a voice that rattles the windows.",
224
+ themes: "heartbreak, strength, the river, Saturday night, survival",
225
+ },
226
+ "rattlesnake-porch": {
227
+ name: "Rattlesnake Porch", genre: "Blues",
228
+ sound: "acoustic delta blues, slide resonator guitar, foot stomps, harmonica, hand claps, 70-110 BPM",
229
+ voice: "weathered male voice, half-sung, half-spoken storytelling",
230
+ bio: "A porch, a slide and a hundred stories, all of them true enough.",
231
+ themes: "crossroads, floods, the devil's bargains, home, honest work",
232
+ },
233
+ "blue-hour-quartet": {
234
+ name: "The Blue Hour Quartet", genre: "Jazz",
235
+ sound: "smoky vocal jazz, upright bass, brushed drums, piano trio comping, muted trumpet solos, swing and ballads, 70-140 BPM",
236
+ voice: "velvety female jazz alto with relaxed phrasing and occasional scat",
237
+ bio: "A jazz club that only opens after the last train leaves.",
238
+ themes: "late nights, rain on windows, old flames, city lights, small joys",
239
+ },
240
+ "sterling-hayes": {
241
+ name: "Sterling Hayes", genre: "Jazz",
242
+ sound: "big band swing and crooner jazz, brass section, walking bass, swing drums, lush strings on ballads, 100-180 BPM",
243
+ voice: "smooth, warm male crooner baritone",
244
+ bio: "A crooner who never got the memo that it isn't 1958.",
245
+ themes: "romance, dancing, champagne nights, wistful goodbyes",
246
+ },
247
+ "nightcap-collective": {
248
+ name: "Nightcap Collective", genre: "Jazz",
249
+ sound: "modern soul-jazz, Rhodes piano, tenor sax, laid-back hip hop influenced drums, warm bass, 80-100 BPM",
250
+ voice: "silky male and female duet vocals trading verses",
251
+ bio: "A rotating cast of night owls who meet at the same bar every Thursday.",
252
+ themes: "slow evenings, conversation, love that takes its time, the city after dark",
253
+ },
254
+ "satin-avenue": {
255
+ name: "Satin Avenue", genre: "R&B",
256
+ sound: "smooth modern R&B, lush chords, deep 808s, crisp snaps, warm synth pads, falsetto ad-libs, 65-90 BPM",
257
+ voice: "silky female R&B soprano with agile runs",
258
+ bio: "Late-night R&B for the drive home after a long week.",
259
+ themes: "love, trust, self-worth, late-night phone calls",
260
+ },
261
+ "jalen-reign": {
262
+ name: "Jalen Reign", genre: "R&B",
263
+ sound: "neo-soul and contemporary R&B, live bass, Rhodes, tight pocket drums, gospel-tinged harmonies, 70-95 BPM",
264
+ voice: "warm, soulful male tenor with a smooth falsetto",
265
+ bio: "Church-raised, club-tested, heart on his sleeve.",
266
+ themes: "devotion, apologies, growing together, Sunday mornings",
267
+ },
268
+ "paper-satellites": {
269
+ name: "Paper Satellites", genre: "Rock",
270
+ sound: "jangly indie rock, chorus-drenched guitars, driving eighth-note bass, crisp drums, shout-along choruses, 130-160 BPM",
271
+ voice: "earnest male tenor, slightly nasal, with gang vocals in the choruses",
272
+ bio: "Bedroom recordings that grew up and got a van.",
273
+ themes: "growing up, small apartments, friendship, leaving and coming back",
274
+ },
275
+ "mira-solace": {
276
+ name: "Mira Solace", genre: "Hip-Hop",
277
+ sound: "melodic modern trap, airy pads, rolling hi-hats, sliding 808s, sparse piano, 130-145 BPM half-time",
278
+ voice: "confident female rapper with a smoky tone who switches to sung melodic hooks",
279
+ bio: "Writes verses on her phone between shifts and performs them like a closing argument.",
280
+ themes: "self-belief, heartbreak turned into fuel, sisterhood, getting paid",
281
+ },
282
+ "cold-brew-cipher": {
283
+ name: "Cold Brew Cipher", genre: "Hip-Hop",
284
+ sound: "jazzy laid-back hip hop, upright bass, muted trumpet, dusty drum breaks, vinyl warmth, 84-92 BPM",
285
+ voice: "relaxed male rapper with a warm baritone, wordplay-heavy, with a soulful sung chorus",
286
+ bio: "A coffee-shop cipher that never closed.",
287
+ themes: "slow mornings, neighbourhood characters, gratitude, wit and wordplay",
288
+ },
289
+ "vapor-meridian": {
290
+ name: "Vapor Meridian", genre: "Synthwave",
291
+ sound: "dark synthwave, pulsing bass sequences, cold arpeggios, gated snares, cinematic swells, 90-110 BPM",
292
+ voice: "cool, breathy female alto, close-miked and haunting",
293
+ bio: "The soundtrack to a neon noir that only exists after midnight.",
294
+ themes: "rain-soaked cities, secrets, rogue machines, love in the static",
295
+ },
296
+ "static-riviera": {
297
+ name: "Static Riviera", genre: "Synthwave",
298
+ sound: "sunny outrun synthwave, bright saw leads, funky slap bass, sax hooks, sunset pads, 110-124 BPM",
299
+ voice: "smooth male tenor with a touch of vocoder on the hooks",
300
+ bio: "A beach-town cassette left in the sun since 1986.",
301
+ themes: "summer nights, coastal highways, arcades, retro romance",
302
+ },
303
+ "boone-calloway": {
304
+ name: "Boone Calloway", genre: "Country",
305
+ sound: "outlaw country, twangy Telecaster, train-beat drums, honky-tonk piano, dobro, 90-120 BPM",
306
+ voice: "deep, weathered male baritone with a slow drawl",
307
+ bio: "Sings like he has been thrown out of every bar in three counties and misses all of them.",
308
+ themes: "freedom, regret, dusty highways, family, whiskey and redemption",
309
+ },
310
+ "gravel-hollow": {
311
+ name: "The Gravel Hollow Band", genre: "Country",
312
+ sound: "bluegrass and Americana, banjo rolls, mandolin chops, fiddle, upright bass, stacked harmonies, 100-140 BPM",
313
+ voice: "a lead male tenor with tight three-part mountain harmonies",
314
+ bio: "A porch band that never learned to play quietly.",
315
+ themes: "mountains, rivers, hard work, faith, homecoming",
316
+ },
317
+ "hollow-sovereign": {
318
+ name: "Hollow Sovereign", genre: "Metal",
319
+ sound: "symphonic doom metal, crushing slow riffs, choirs, strings, thundering double-kick climaxes, 60-90 BPM",
320
+ voice: "soaring operatic female soprano over deep male growled counterpoints",
321
+ bio: "A cathedral of riffs built in the ruins of a kingdom that never was.",
322
+ themes: "fallen empires, myth, grief, endurance, crowns and chains",
323
+ },
324
+ "rust-cathedral": {
325
+ name: "Rust Cathedral", genre: "Metal",
326
+ sound: "fast thrash metal, palm-muted gallop riffs, shredding solos, pounding double kick, 180-220 BPM",
327
+ voice: "aggressive male shout-singing with high screams",
328
+ bio: "Factory-floor thrash forged from overtime and broken machines.",
329
+ themes: "industry, rebellion, machines, the working class fighting back",
330
+ },
331
+ "theo-marlowe": {
332
+ name: "Theo Marlowe", genre: "Pop",
333
+ sound: "groovy funk-pop, slap bass, tight disco drums, bright brass stabs, falsetto hooks, 112-122 BPM",
334
+ voice: "smooth male pop tenor with a playful falsetto",
335
+ bio: "A charmer with a dance floor in his back pocket.",
336
+ themes: "flirting, confidence, summer romance, good nights out",
337
+ },
338
+ "glass-hearts-club": {
339
+ name: "Glass Hearts Club", genre: "Pop",
340
+ sound: "shimmering synth-pop, gated drums, glassy synths, big layered chorus vocals, 118-128 BPM",
341
+ voice: "a trio of bright female voices trading lines and stacking harmonies",
342
+ bio: "Three best friends turning group chats into choruses.",
343
+ themes: "friendship, crushes, breakups survived together, dancing until the lights come on",
344
+ },
154
345
  "lumi-kestrel": {
155
346
  name: "Lumi Kestrel", genre: "Pop",
156
347
  sound: "bright modern dance pop, punchy four-on-the-floor drums, glossy synths, plucky guitar, big pre-chorus lifts, 110-124 BPM",
@@ -165,10 +356,15 @@ export const DEFAULTS: Omit<LabelSettings, "pausedReason"> = {
165
356
  enabled: true,
166
357
  monthlySeconds: 3600,
167
358
  everyDays: 30,
168
- seedAlbums: 2,
359
+ albumsPerDay: 1,
360
+ newArtistChance: 0.25,
361
+ seedAlbums: 1,
169
362
  songsPerAlbum: 8,
170
363
  songSeconds: 150,
171
364
  publish: true,
365
+ videos: true,
366
+ parallel: 1,
367
+ genreOrder: ["Punk", "Blues", "Country", "Jazz", "Classic Rock", "Metal", "Hip-Hop", "Pop", "R&B"],
172
368
  };
173
369
 
174
370
  const DAY = 86_400_000;
@@ -255,6 +451,28 @@ export async function planAlbum(
255
451
  return { title, concept, songs: planned, model: answer.model };
256
452
  }
257
453
 
454
+ const ARTIST_SYSTEM = [
455
+ "You invent original recording artists for an independent record label whose artists are AI and sing in English.",
456
+ "The name must be new: never a real artist, band or label, and never imitating one.",
457
+ "Answer as JSON: {\"name\": \"1-4 words\", \"sound\": \"the music in words, at most 300 characters: subgenre, instruments, production, tempo in BPM\",",
458
+ "\"voice\": \"the lead voice, at most 150 characters\", \"bio\": \"one striking sentence\", \"themes\": \"what they sing about, at most 150 characters\"}.",
459
+ ].join(" ");
460
+
461
+ /** A new artist for this genre, unlike the ones already signed. */
462
+ export async function inventArtist(genre: string, taken: string[], ask: typeof askJson = askJson): Promise<ArtistPreset> {
463
+ const answer = await ask({
464
+ system: ARTIST_SYSTEM,
465
+ prompt: `Genre: ${genre}\nAlready on the label (do not reuse or echo these names or sounds): ${taken.slice(-40).join(", ") || "nobody yet"}`,
466
+ maxTokens: 600,
467
+ timeoutMs: 60_000,
468
+ });
469
+ const name = clean(answer.value["name"], 60).replace(/"/g, "");
470
+ const sound = clean(answer.value["sound"], 400);
471
+ if (!name || sound.length < 10) throw new Error("the model invented no artist");
472
+ if (taken.some((one) => one.toLowerCase() === name.toLowerCase())) throw new Error(`the model reused the name ${name}`);
473
+ 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) };
474
+ }
475
+
258
476
  export async function writeLyrics(
259
477
  artist: Pick<LabelArtist, "name" | "genre" | "voice" | "themes">,
260
478
  album: Pick<LabelAlbum, "title" | "concept">,
@@ -301,6 +519,27 @@ export async function workersAiCover(prompt: string, env: Record<string, string
301
519
  }
302
520
 
303
521
  /** Writes the tags (and the cover, as the attached picture) into a copy of the file. */
522
+ /**
523
+ * A song's music video: its album cover over a blurred copy of itself, the
524
+ * song's waveform along the bottom, 720p H.264 with the MP3's audio copied in.
525
+ * Without a cover, a dark plate.
526
+ */
527
+ export function renderVideo(ffmpeg: string[], audio: string, cover: string, out: string): Promise<void> {
528
+ const picture = cover ? ["-loop", "1", "-framerate", "25", "-i", cover] : ["-f", "lavfi", "-i", "color=c=0x0d1410:s=1280x720:r=25"];
529
+ const graph = [
530
+ "[0:v]scale=1280:720:force_original_aspect_ratio=increase,crop=1280:720,boxblur=24:2,eq=brightness=-0.18[bg]",
531
+ "[0:v]scale=500:500[fg]",
532
+ "[1:a]showwaves=s=1280x150:mode=cline:rate=25:colors=white@0.85,format=rgba,colorkey=0x000000:0.1:0[viz]",
533
+ "[bg][fg]overlay=(W-w)/2:40[a]",
534
+ "[a][viz]overlay=0:H-160,format=yuv420p[v]",
535
+ ].join(";");
536
+ const args = [...ffmpeg.slice(1), "-hide_banner", "-loglevel", "error", "-y", ...picture, "-i", audio, "-filter_complex", graph,
537
+ "-map", "[v]", "-map", "1:a", "-c:v", "libx264", "-preset", "veryfast", "-crf", "26", "-r", "25", "-c:a", "copy", "-shortest", "-movflags", "+faststart", out];
538
+ return new Promise((done, fail) => {
539
+ execFile(ffmpeg[0]!, args, { timeout: 20 * 60_000 }, (error) => (error ? fail(new Error(`the video failed: ${error.message}`)) : done()));
540
+ });
541
+ }
542
+
304
543
  function tagFile(ffmpeg: string[], from: string, to: string, tags: Record<string, string>, cover: string): Promise<void> {
305
544
  const args = [...ffmpeg.slice(1), "-hide_banner", "-loglevel", "error", "-y", "-i", from];
306
545
  if (cover) args.push("-i", cover, "-map", "0:a", "-map", "1:v", "-c", "copy", "-disposition:v", "attached_pic");
@@ -333,6 +572,10 @@ export interface AiLabelDeps {
333
572
  /** A cover picture for this prompt, or null when nothing can draw one. */
334
573
  cover?: (prompt: string) => Promise<Buffer | null>;
335
574
  tag?: (from: string, to: string, tags: Record<string, string>, cover: string) => Promise<void>;
575
+ /** Renders a song's music video to `out`; ffmpeg by default (renderVideo). */
576
+ video?: (audio: string, cover: string, out: string) => Promise<void>;
577
+ /** The day's pick (artist, genre, new or not); Math.random by default. */
578
+ random?: () => number;
336
579
  now?: () => number;
337
580
  log?: (line: string) => void;
338
581
  }
@@ -344,7 +587,19 @@ export class AiLabel {
344
587
  private artists = new Map<string, LabelArtist>();
345
588
  private settings: LabelSettings = { ...DEFAULTS, pausedReason: "" };
346
589
  private usage: Record<string, number> = {};
347
- private busy = "";
590
+ private running = false;
591
+ /** Songs being made now, by `<album>:<n>`. */
592
+ private claims = new Map<string, { label: string; seconds: number }>();
593
+ /** Artists whose next album is being planned now. */
594
+ private planning = new Set<string>();
595
+ private covering = new Map<string, Promise<void>>();
596
+ private filming: Promise<number> | null = null;
597
+ /** Songs whose video failed since this server started. */
598
+ private videoFailed = new Set<string>();
599
+ /** The day's album is tried at most every 30 minutes, so a failing model is not hammered. */
600
+ private dailyTriedAt = 0;
601
+ /** Genre stations are kept in step one change at a time. */
602
+ private placing: Promise<void> = Promise.resolve();
348
603
  private timer: ReturnType<typeof setInterval> | null = null;
349
604
 
350
605
  constructor(private readonly file: string, private readonly deps: AiLabelDeps) {
@@ -418,6 +673,8 @@ export class AiLabel {
418
673
  dueAt(artist: LabelArtist): number {
419
674
  if (!artist.enabled) return -1;
420
675
  if (artist.albums.length < this.settings.seedAlbums) return 0;
676
+ // On the daily schedule an artist's next album is whenever the day's pick lands on it.
677
+ if (this.settings.albumsPerDay > 0) return -1;
421
678
  const last = artist.albums[artist.albums.length - 1]!;
422
679
  const stagger = (parseInt(artist.key.slice(0, 6), 16) % Math.max(1, Math.floor(this.settings.everyDays / 4))) * DAY;
423
680
  return last.plannedAt + this.settings.everyDays * DAY + stagger;
@@ -439,7 +696,8 @@ export class AiLabel {
439
696
  root: this.root,
440
697
  composer: composer?.name ?? null,
441
698
  writer: this.canAsk() ? "language model" : null,
442
- busy: this.busy,
699
+ busy: this.running ? ([...this.claims.values()].map((one) => one.label).join("; ") || "planning") : "",
700
+ recording: [...this.claims.values()].map((one) => one.label),
443
701
  month,
444
702
  secondsThisMonth: this.usage[month] ?? 0,
445
703
  settings: this.settings,
@@ -477,6 +735,20 @@ export class AiLabel {
477
735
  if (typeof input["holder"] === "string" && input["holder"].trim()) this.settings.holder = clean(input["holder"], 120);
478
736
  if (input["enabled"] !== undefined) this.settings.enabled = input["enabled"] !== false;
479
737
  if (input["publish"] !== undefined) this.settings.publish = input["publish"] !== false;
738
+ if (input["videos"] !== undefined) this.settings.videos = input["videos"] !== false;
739
+ const perDay = whole("albumsPerDay", 0, 24);
740
+ if (perDay !== undefined) this.settings.albumsPerDay = perDay;
741
+ if (input["newArtistChance"] !== undefined) {
742
+ const chance = Number(input["newArtistChance"]);
743
+ if (!Number.isFinite(chance) || chance < 0 || chance > 1) throw new StationError("invalid_newArtistChance", "newArtistChance is from 0 to 1");
744
+ this.settings.newArtistChance = chance;
745
+ }
746
+ const parallel = whole("parallel", 1, 8);
747
+ if (parallel !== undefined) this.settings.parallel = parallel;
748
+ if (input["genreOrder"] !== undefined) {
749
+ const list = Array.isArray(input["genreOrder"]) ? input["genreOrder"] : String(input["genreOrder"]).split(",");
750
+ this.settings.genreOrder = list.map((one) => clean(one, 40)).filter(Boolean).slice(0, 30);
751
+ }
480
752
  if (input["resume"] === true) this.settings.pausedReason = "";
481
753
  this.save();
482
754
  if (this.settings.enabled) void this.tick();
@@ -568,9 +840,88 @@ export class AiLabel {
568
840
 
569
841
  startSchedule(everyMs = 5 * 60_000): void {
570
842
  if (this.timer) return;
571
- this.timer = setInterval(() => void this.tick(), everyMs);
843
+ this.timer = setInterval(() => { void this.tick(); void this.videos(); }, everyMs);
572
844
  this.timer.unref?.();
573
845
  void this.tick();
846
+ void this.videos();
847
+ }
848
+
849
+ /**
850
+ * Music videos for every released song without one, one at a time (a
851
+ * video is CPU, not credit), and the TV station of them kept in step. A
852
+ * video that fails is tried again after the next restart, not in a loop.
853
+ */
854
+ videos(): Promise<number> {
855
+ if (!this.settings.videos) return Promise.resolve(0);
856
+ // One run at a time; asking during one waits for it.
857
+ this.filming ??= this.film().finally(() => { this.filming = null; });
858
+ return this.filming;
859
+ }
860
+
861
+ private async film(): Promise<number> {
862
+ let total = 0;
863
+ // Songs that land while videos are being made are picked up by the next pass.
864
+ for (let made = await this.filmPass(); made > 0; made = await this.filmPass()) total += made;
865
+ return total;
866
+ }
867
+
868
+ private async filmPass(): Promise<number> {
869
+ let made = 0;
870
+ {
871
+ for (const artist of this.list()) {
872
+ for (const album of artist.albums) {
873
+ if (!album.cover && !album.coverTried) continue;
874
+ for (const song of album.songs) {
875
+ if (!song.file || song.video || this.videoFailed.has(song.file) || !this.settings.videos) continue;
876
+ const out = join(album.folder, `${songBase(song)}.mp4`);
877
+ const partial = join(album.folder, `.${randomBytes(4).toString("hex")}.video.mp4`);
878
+ try {
879
+ await (this.deps.video ?? ((audio, cover, to) => renderVideo(this.deps.ffmpeg, audio, cover, to)))(song.file, album.cover, partial);
880
+ renameSync(partial, out);
881
+ song.video = out;
882
+ made += 1;
883
+ this.save();
884
+ } catch (error) {
885
+ this.videoFailed.add(song.file);
886
+ this.log(`no video for "${song.title}": ${(error as Error).message}`);
887
+ } finally {
888
+ rmSync(partial, { force: true });
889
+ }
890
+ }
891
+ }
892
+ }
893
+ if (made > 0) await this.placeVideos();
894
+ }
895
+ return made;
896
+ }
897
+
898
+ /** The label's music videos as one TV station: free to watch, with its breaks. */
899
+ private placeVideos(): Promise<void> {
900
+ const next = this.placing.then(async () => {
901
+ await this.deps.index.scan(this.source(), this.deps.probe).done;
902
+ const files = this.list()
903
+ .flatMap((artist) => artist.albums.flatMap((album) => album.songs.map((song) => song.video ?? "")))
904
+ .filter((file) => file && this.deps.index.itemAt(file)?.state === "ready");
905
+ if (files.length === 0) return;
906
+ const existing = this.deps.store.byAuto("ai:label:videos");
907
+ if (existing) {
908
+ this.deps.store.update(existing.id, {}, files);
909
+ return;
910
+ }
911
+ const label = this.settings.holder.replace(/,?\s+(inc|llc|ltd|gmbh|co)\.?$/i, "");
912
+ 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);
913
+ this.deps.store.markAuto(made.id, "ai:label:videos");
914
+ if (this.settings.publish) {
915
+ this.deps.store.setAccess(made.id, {
916
+ visibility: "public",
917
+ listed: true,
918
+ 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}.` },
919
+ }, this.deps.owner || "ai-label");
920
+ }
921
+ if (this.deps.mayStart()) await this.deps.runner.start(made.id).catch(() => undefined);
922
+ });
923
+ this.placing = next.catch(() => undefined);
924
+ return next;
574
925
  }
575
926
 
576
927
  stopSchedule(): void {
@@ -578,49 +929,73 @@ export class AiLabel {
578
929
  this.timer = null;
579
930
  }
580
931
 
581
- /** Everything that is due, one song at a time, until nothing is or something stops it. */
932
+ /**
933
+ * Everything that is due, `parallel` songs at a time, until nothing is or
934
+ * something stops it. Each worker claims a song (or an artist to plan for),
935
+ * so two never make the same one.
936
+ */
582
937
  async tick(limit = 200): Promise<void> {
583
- if (this.busy || !this.settings.enabled || this.settings.pausedReason) return;
584
- this.busy = "starting";
585
- try {
586
- for (let steps = 0; steps < limit; steps++) {
587
- if (this.settings.pausedReason) break;
588
- if ((await this.step().catch(() => "idle" as Step)) === "idle") break;
938
+ if (this.running || !this.settings.enabled || this.settings.pausedReason) return;
939
+ this.running = true;
940
+ let steps = 0;
941
+ const worker = async (): Promise<void> => {
942
+ while (steps < limit && !this.settings.pausedReason) {
943
+ steps += 1;
944
+ if ((await this.step().catch(() => "idle" as Step)) === "idle") return;
589
945
  }
946
+ };
947
+ try {
948
+ await Promise.all(Array.from({ length: Math.max(1, this.settings.parallel) }, worker));
590
949
  } finally {
591
- this.busy = "";
950
+ this.running = false;
592
951
  }
593
952
  }
594
953
 
954
+ /** Where the artist's genre comes in the owner's order (genreOrder); unlisted genres after. */
955
+ private rank(genre: string): number {
956
+ const at = this.settings.genreOrder.findIndex((one) => genreSlug(one) === genreSlug(genre));
957
+ return at === -1 ? this.settings.genreOrder.length : at;
958
+ }
959
+
595
960
  private async step(): Promise<Step> {
596
961
  const now = this.now();
597
962
  const composer = this.composer();
598
963
  if (!composer) return "idle";
599
- // A song of an album already planned, oldest album first.
964
+ const claimedSeconds = [...this.claims.values()].reduce((sum, one) => sum + one.seconds, 0);
965
+ // A song of an album already planned that nobody is making, oldest album first.
600
966
  const making = this.list()
601
967
  .filter((one) => one.enabled && !this.backingOff(one, now))
602
968
  .flatMap((artist) => artist.albums.filter((album) => !album.doneAt).map((album) => ({ artist, album })))
603
969
  .sort((a, b) => a.album.plannedAt - b.album.plannedAt);
604
970
  for (const { artist, album } of making) {
605
- const song = album.songs.find((one) => !one.file);
606
- if (!song) {
971
+ if (album.songs.every((one) => one.file)) {
607
972
  album.doneAt = now;
608
973
  this.save();
609
974
  continue;
610
975
  }
611
- if (song.seconds > this.budgetLeft()) return "idle";
612
- await this.compose(artist, album, song, composer);
976
+ const song = album.songs.find((one) => !one.file && !this.claims.has(`${album.key}:${one.n}`));
977
+ if (!song) continue;
978
+ if (song.seconds + claimedSeconds > this.budgetLeft()) return "idle";
979
+ const claim = `${album.key}:${song.n}`;
980
+ this.claims.set(claim, { label: `${artist.name}: ${album.title}: ${song.title}`, seconds: song.seconds });
981
+ try {
982
+ await this.compose(artist, album, song, composer);
983
+ } finally {
984
+ this.claims.delete(claim);
985
+ }
613
986
  return "composed";
614
987
  }
615
- // Nothing half-made anywhere: the artist most overdue gets its next album.
616
- if (this.list().some((one) => one.albums.some((album) => !album.doneAt))) return "idle";
617
- if (!this.canAsk() || this.settings.songSeconds > this.budgetLeft()) return "idle";
988
+ // Room for another album: the artist most overdue (in the owner's genre order) gets its next one.
989
+ const inProgress = this.list().filter((one) => one.albums.some((album) => !album.doneAt)).length + this.planning.size;
990
+ if (inProgress >= Math.max(1, this.settings.parallel)) return "idle";
991
+ if (!this.canAsk() || this.settings.songSeconds + claimedSeconds > this.budgetLeft()) return "idle";
618
992
  const due = this.list()
619
- .filter((one) => !this.backingOff(one, now))
993
+ .filter((one) => !this.backingOff(one, now) && !this.planning.has(one.key) && !one.albums.some((album) => !album.doneAt))
620
994
  .map((one) => ({ one, at: this.dueAt(one) }))
621
995
  .filter(({ at }) => at !== -1 && at <= now)
622
- .sort((a, b) => a.one.albums.length - b.one.albums.length || a.at - b.at)[0];
623
- if (!due) return "idle";
996
+ .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];
997
+ if (!due) return this.daily(now);
998
+ this.planning.add(due.one.key);
624
999
  try {
625
1000
  await this.plan(due.one.key);
626
1001
  due.one.failures = 0;
@@ -630,6 +1005,52 @@ export class AiLabel {
630
1005
  } catch (error) {
631
1006
  this.failed(due.one, error);
632
1007
  throw error;
1008
+ } finally {
1009
+ this.planning.delete(due.one.key);
1010
+ }
1011
+ }
1012
+
1013
+ /**
1014
+ * After the first sweep: the day's album. When the last one was planned a
1015
+ * day ago (or 1/albumsPerDay of one), a random artist gets it, or now and
1016
+ * then an artist invented for one of the owner's genres. Nothing until every
1017
+ * enabled artist has its first albums.
1018
+ */
1019
+ private async daily(now: number): Promise<Step> {
1020
+ const perDay = this.settings.albumsPerDay;
1021
+ if (perDay <= 0) return "idle";
1022
+ const enabled = this.list().filter((one) => one.enabled);
1023
+ if (enabled.some((one) => one.albums.length < this.settings.seedAlbums)) return "idle";
1024
+ const last = Math.max(0, ...this.list().flatMap((one) => one.albums.map((album) => album.plannedAt)));
1025
+ if (now < last + Math.floor(DAY / perDay) || now < this.dailyTriedAt + 30 * 60_000) return "idle";
1026
+ this.dailyTriedAt = now;
1027
+ const random = this.deps.random ?? Math.random;
1028
+ const genres = this.settings.genreOrder.length ? this.settings.genreOrder : [...new Set(enabled.map((one) => one.genre))];
1029
+ let artist: LabelArtist | undefined;
1030
+ if ((enabled.length === 0 || random() < this.settings.newArtistChance) && genres.length > 0) {
1031
+ const genre = genres[Math.floor(random() * genres.length)]!;
1032
+ try {
1033
+ const invented = await inventArtist(genre, this.list().map((one) => one.name), this.deps.ask ?? askJson);
1034
+ artist = this.sign({ ...invented });
1035
+ this.log(`signed a new artist: ${artist.name} (${genre})`);
1036
+ } catch (error) {
1037
+ this.log(`could not invent a ${genre} artist (${(error as Error).message}); an artist already signed gets the album`);
1038
+ }
1039
+ }
1040
+ if (!artist) {
1041
+ const free = enabled.filter((one) => !this.backingOff(one, now) && !this.planning.has(one.key) && !one.albums.some((album) => !album.doneAt));
1042
+ artist = free[Math.floor(random() * free.length)];
1043
+ }
1044
+ if (!artist) return "idle";
1045
+ this.planning.add(artist.key);
1046
+ try {
1047
+ await this.plan(artist.key);
1048
+ return "planned";
1049
+ } catch (error) {
1050
+ this.failed(artist, error);
1051
+ throw error;
1052
+ } finally {
1053
+ this.planning.delete(artist.key);
633
1054
  }
634
1055
  }
635
1056
 
@@ -643,7 +1064,6 @@ export class AiLabel {
643
1064
  }
644
1065
 
645
1066
  private async compose(artist: LabelArtist, album: LabelAlbum, song: LabelSong, composer: Composer): Promise<void> {
646
- this.busy = `${artist.name}: ${album.title}: ${song.title}`;
647
1067
  artist.lastAttemptAt = this.now();
648
1068
  mkdirSync(album.folder, { recursive: true });
649
1069
  const raw = join(album.folder, `.${randomBytes(4).toString("hex")}.raw.mp3`);
@@ -694,18 +1114,26 @@ export class AiLabel {
694
1114
  this.save();
695
1115
  this.writeAlbumJson(artist, album);
696
1116
  await this.place(artist.genre);
1117
+ // Its video is made beside it, without holding up the next song.
1118
+ void this.videos();
697
1119
  } catch (error) {
698
1120
  this.failed(artist, error);
699
1121
  throw error;
700
1122
  } finally {
701
1123
  rmSync(raw, { force: true });
702
- this.busy = "starting";
703
1124
  }
704
1125
  }
705
1126
 
706
- private async ensureCover(artist: LabelArtist, album: LabelAlbum): Promise<void> {
707
- if (album.cover || album.coverTried) return;
1127
+ /** The album's cover, drawn once; songs made at the same time wait for the same drawing. */
1128
+ private ensureCover(artist: LabelArtist, album: LabelAlbum): Promise<void> {
1129
+ if (album.cover || album.coverTried) return this.covering.get(album.key) ?? Promise.resolve();
708
1130
  album.coverTried = true;
1131
+ const drawing = this.drawCover(artist, album).finally(() => this.covering.delete(album.key));
1132
+ this.covering.set(album.key, drawing);
1133
+ return drawing;
1134
+ }
1135
+
1136
+ private async drawCover(artist: LabelArtist, album: LabelAlbum): Promise<void> {
709
1137
  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.`;
710
1138
  try {
711
1139
  const picture = await (this.deps.cover ?? ((text) => workersAiCover(text)))(prompt);
@@ -743,7 +1171,13 @@ export class AiLabel {
743
1171
  }
744
1172
 
745
1173
  /** The genre's station gets every song of that genre: made with the first, then kept in step. */
746
- private async place(genre: string): Promise<void> {
1174
+ private place(genre: string): Promise<void> {
1175
+ const next = this.placing.then(() => this.placeNow(genre));
1176
+ this.placing = next.catch(() => undefined);
1177
+ return next;
1178
+ }
1179
+
1180
+ private async placeNow(genre: string): Promise<void> {
747
1181
  await this.deps.index.scan(this.source(), this.deps.probe).done;
748
1182
  const artists = this.list().filter((one) => genreSlug(one.genre) === genreSlug(genre));
749
1183
  const files = artists
@@ -844,6 +1278,7 @@ export class AiLabel {
844
1278
  for (const song of songs) {
845
1279
  entries.push({ name: `${folder}/${songBase(song)}.mp3`, path: song.file });
846
1280
  entries.push({ name: `${folder}/${songBase(song)}.lyrics.txt`, bytes: Buffer.from(`${song.lyrics}\n`) });
1281
+ if (song.video) entries.push({ name: `${folder}/${songBase(song)}.mp4`, path: song.video });
847
1282
  }
848
1283
  if (album.cover) entries.push({ name: `${folder}/cover.jpg`, path: album.cover });
849
1284
  entries.push({ name: `${folder}/album.json`, bytes: Buffer.from(`${JSON.stringify({