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/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
- seedAlbums: 2,
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
- busy = "";
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.busy,
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
- /** Everything that is due, one song at a time, until nothing is or something stops it. */
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.busy || !this.settings.enabled || this.settings.pausedReason)
821
+ if (this.running || !this.settings.enabled || this.settings.pausedReason)
478
822
  return;
479
- this.busy = "starting";
480
- try {
481
- for (let steps = 0; steps < limit; steps++) {
482
- if (this.settings.pausedReason)
483
- break;
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
- break;
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.busy = "";
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
- // A song of an album already planned, oldest album first.
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
- const song = album.songs.find((one) => !one.file);
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
- if (song.seconds > this.budgetLeft())
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
- await this.compose(artist, album, song, composer);
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
- // Nothing half-made anywhere: the artist most overdue gets its next album.
515
- if (this.list().some((one) => one.albums.some((album) => !album.doneAt)))
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 "idle";
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
- async ensureCover(artist, album) {
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
- async place(genre) {
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 });
@@ -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
- ctx.json(response, 200, label.publicView());
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");