nixamp 0.51.0 → 0.52.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/ai-label.d.ts +70 -3
  2. package/dist/ai-label.js +375 -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 +3 -0
  15. package/package.json +1 -1
  16. package/src/ai-label.ts +381 -29
  17. package/src/label-routes.ts +2 -1
  18. package/src/main.ts +8 -0
  19. package/src/mcp.ts +38 -0
  20. package/src/music-cli.ts +86 -0
  21. package/src/music-client.ts +86 -0
  22. package/src/station-cli.ts +8 -1
  23. package/src/station-mcp.ts +5 -2
  24. package/src/station-usage.ts +3 -0
  25. package/web/dist/assets/{ar--GRGOL7F.js → ar-Cmon75WL.js} +1 -1
  26. package/web/dist/assets/chunk-Q2XQMA4B-DdHrVlSJ.css +1 -0
  27. package/web/dist/assets/{chunk-Q2XQMA4B-CDsJ8fBU.js → chunk-Q2XQMA4B-PTuZPMen.js} +1 -1
  28. package/web/dist/assets/{cs-B2ab-8qO.js → cs-BRaPWZ-l.js} +1 -1
  29. package/web/dist/assets/{da-Ek2g1uTg.js → da-DzwX-95l.js} +1 -1
  30. package/web/dist/assets/{de-CM7-9Fbk.js → de-Dp5yZesS.js} +1 -1
  31. package/web/dist/assets/{es-BFSOrr3I.js → es-DI_neNna.js} +1 -1
  32. package/web/dist/assets/{fi-D9Tf69sv.js → fi-RizsR0Qs.js} +1 -1
  33. package/web/dist/assets/{fr-Gh9yYJGo.js → fr-Du-Xj-FG.js} +1 -1
  34. package/web/dist/assets/{hi-B3ow3HJC.js → hi-K3VZUZDS.js} +1 -1
  35. package/web/dist/assets/{hls-OR5FY6YP-bdUZBB11.js → hls-OR5FY6YP-CxI-FwpW.js} +1 -1
  36. package/web/dist/assets/{hu-paju-6Kr.js → hu-s6bvR7Qx.js} +1 -1
  37. package/web/dist/assets/{id-mubOQFX0.js → id-BtqFTjdZ.js} +1 -1
  38. package/web/dist/assets/{it-0EM-qnDT.js → it-DlTM2OMR.js} +1 -1
  39. package/web/dist/assets/{main-CECXge6G.js → main-DaqdNlZC.js} +2 -2
  40. package/web/dist/assets/{mpegts-7CN75AI2-iUctLWip.js → mpegts-7CN75AI2-D-6xFZI-.js} +1 -1
  41. package/web/dist/assets/{mpegts-DeEBZUXP.js → mpegts-D2ZKeEby.js} +1 -1
  42. package/web/dist/assets/{nl-c6HalphH.js → nl-KW4bKgIq.js} +1 -1
  43. package/web/dist/assets/{radio-CDUEoM0y.js → radio-B8n_n-WD.js} +1 -1
  44. package/web/dist/assets/{ru-CUKYVlgf.js → ru-CZhm9rUN.js} +1 -1
  45. package/web/dist/assets/{station-D769XVG0.js → station-CrAZxHcX.js} +1 -1
  46. package/web/dist/assets/{sv-BUxBsZR5.js → sv-Bs6WYhqU.js} +1 -1
  47. package/web/dist/assets/{uk-708lBssa.js → uk-OcuLYdVU.js} +1 -1
  48. package/web/dist/assets/{vi-B1ropUvk.js → vi-BX_K6R8j.js} +1 -1
  49. package/web/dist/assets/{vod-bM1DvPEx.js → vod-CrvTO3FN.js} +1 -1
  50. package/web/dist/assets/{watch-BbhPO0HS.js → watch-itlvgIf8.js} +1 -1
  51. package/web/dist/assets/{zh-C_ndTsNh.js → zh-CEzbVQ57.js} +1 -1
  52. package/web/dist/index.html +11 -8
  53. package/web/dist/radio.html +3 -3
  54. package/web/dist/station.html +3 -3
  55. package/web/dist/sw.js +28 -28
  56. package/web/dist/vod.html +3 -3
  57. package/web/dist/watch.html +3 -3
  58. package/web/dist/assets/chunk-Q2XQMA4B-BBzoLLVb.css +0 -1
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
  }
@@ -152,12 +168,103 @@ export const ROSTER: Record<string, ArtistPreset> = {
152
168
  themes: "ruins, resilience, storms, rebuilding from ashes, defiance",
153
169
  },
154
170
  "iron-lantern-society": {
155
- name: "Iron Lantern Society", genre: "Rock",
171
+ name: "Iron Lantern Society", genre: "Classic Rock",
156
172
  sound: "bluesy classic hard rock, swaggering riffs, Hammond organ, cowbell grooves, big guitar solos, 100-128 BPM",
157
173
  voice: "gravelly male baritone with a wide rasp and a rock-and-roll howl",
158
174
  bio: "Five road dogs who think every song should end in a bar fight or a sunrise.",
159
175
  themes: "the open road, hard luck, loyalty among misfits, last calls and new starts",
160
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
+ },
161
268
  "paper-satellites": {
162
269
  name: "Paper Satellites", genre: "Rock",
163
270
  sound: "jangly indie rock, chorus-drenched guitars, driving eighth-note bass, crisp drums, shout-along choruses, 130-160 BPM",
@@ -249,10 +356,15 @@ export const DEFAULTS: Omit<LabelSettings, "pausedReason"> = {
249
356
  enabled: true,
250
357
  monthlySeconds: 3600,
251
358
  everyDays: 30,
359
+ albumsPerDay: 1,
360
+ newArtistChance: 0.25,
252
361
  seedAlbums: 1,
253
362
  songsPerAlbum: 8,
254
363
  songSeconds: 150,
255
364
  publish: true,
365
+ videos: true,
366
+ parallel: 1,
367
+ genreOrder: ["Punk", "Blues", "Country", "Jazz", "Classic Rock", "Metal", "Hip-Hop", "Pop", "R&B"],
256
368
  };
257
369
 
258
370
  const DAY = 86_400_000;
@@ -339,6 +451,28 @@ export async function planAlbum(
339
451
  return { title, concept, songs: planned, model: answer.model };
340
452
  }
341
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
+
342
476
  export async function writeLyrics(
343
477
  artist: Pick<LabelArtist, "name" | "genre" | "voice" | "themes">,
344
478
  album: Pick<LabelAlbum, "title" | "concept">,
@@ -385,6 +519,27 @@ export async function workersAiCover(prompt: string, env: Record<string, string
385
519
  }
386
520
 
387
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
+
388
543
  function tagFile(ffmpeg: string[], from: string, to: string, tags: Record<string, string>, cover: string): Promise<void> {
389
544
  const args = [...ffmpeg.slice(1), "-hide_banner", "-loglevel", "error", "-y", "-i", from];
390
545
  if (cover) args.push("-i", cover, "-map", "0:a", "-map", "1:v", "-c", "copy", "-disposition:v", "attached_pic");
@@ -417,6 +572,10 @@ export interface AiLabelDeps {
417
572
  /** A cover picture for this prompt, or null when nothing can draw one. */
418
573
  cover?: (prompt: string) => Promise<Buffer | null>;
419
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;
420
579
  now?: () => number;
421
580
  log?: (line: string) => void;
422
581
  }
@@ -428,7 +587,19 @@ export class AiLabel {
428
587
  private artists = new Map<string, LabelArtist>();
429
588
  private settings: LabelSettings = { ...DEFAULTS, pausedReason: "" };
430
589
  private usage: Record<string, number> = {};
431
- 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();
432
603
  private timer: ReturnType<typeof setInterval> | null = null;
433
604
 
434
605
  constructor(private readonly file: string, private readonly deps: AiLabelDeps) {
@@ -502,6 +673,8 @@ export class AiLabel {
502
673
  dueAt(artist: LabelArtist): number {
503
674
  if (!artist.enabled) return -1;
504
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;
505
678
  const last = artist.albums[artist.albums.length - 1]!;
506
679
  const stagger = (parseInt(artist.key.slice(0, 6), 16) % Math.max(1, Math.floor(this.settings.everyDays / 4))) * DAY;
507
680
  return last.plannedAt + this.settings.everyDays * DAY + stagger;
@@ -523,7 +696,8 @@ export class AiLabel {
523
696
  root: this.root,
524
697
  composer: composer?.name ?? null,
525
698
  writer: this.canAsk() ? "language model" : null,
526
- 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),
527
701
  month,
528
702
  secondsThisMonth: this.usage[month] ?? 0,
529
703
  settings: this.settings,
@@ -561,6 +735,20 @@ export class AiLabel {
561
735
  if (typeof input["holder"] === "string" && input["holder"].trim()) this.settings.holder = clean(input["holder"], 120);
562
736
  if (input["enabled"] !== undefined) this.settings.enabled = input["enabled"] !== false;
563
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
+ }
564
752
  if (input["resume"] === true) this.settings.pausedReason = "";
565
753
  this.save();
566
754
  if (this.settings.enabled) void this.tick();
@@ -652,9 +840,89 @@ export class AiLabel {
652
840
 
653
841
  startSchedule(everyMs = 5 * 60_000): void {
654
842
  if (this.timer) return;
655
- this.timer = setInterval(() => void this.tick(), everyMs);
843
+ this.timer = setInterval(() => { void this.tick(); void this.videos(); }, everyMs);
656
844
  this.timer.unref?.();
657
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
+ // On the air as soon as it is made, not after the whole pass.
885
+ await this.placeVideos().catch(() => undefined);
886
+ } catch (error) {
887
+ this.videoFailed.add(song.file);
888
+ this.log(`no video for "${song.title}": ${(error as Error).message}`);
889
+ } finally {
890
+ rmSync(partial, { force: true });
891
+ }
892
+ }
893
+ }
894
+ }
895
+ }
896
+ return made;
897
+ }
898
+
899
+ /** The label's music videos as one TV station: free to watch, with its breaks. */
900
+ private placeVideos(): Promise<void> {
901
+ const next = this.placing.then(async () => {
902
+ await this.deps.index.scan(this.source(), this.deps.probe).done;
903
+ const files = this.list()
904
+ .flatMap((artist) => artist.albums.flatMap((album) => album.songs.map((song) => song.video ?? "")))
905
+ .filter((file) => file && this.deps.index.itemAt(file)?.state === "ready");
906
+ if (files.length === 0) return;
907
+ const existing = this.deps.store.byAuto("ai:label:videos");
908
+ if (existing) {
909
+ this.deps.store.update(existing.id, {}, files);
910
+ return;
911
+ }
912
+ const label = this.settings.holder.replace(/,?\s+(inc|llc|ltd|gmbh|co)\.?$/i, "");
913
+ 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);
914
+ this.deps.store.markAuto(made.id, "ai:label:videos");
915
+ if (this.settings.publish) {
916
+ this.deps.store.setAccess(made.id, {
917
+ visibility: "public",
918
+ listed: true,
919
+ 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}.` },
920
+ }, this.deps.owner || "ai-label");
921
+ }
922
+ if (this.deps.mayStart()) await this.deps.runner.start(made.id).catch(() => undefined);
923
+ });
924
+ this.placing = next.catch(() => undefined);
925
+ return next;
658
926
  }
659
927
 
660
928
  stopSchedule(): void {
@@ -662,49 +930,73 @@ export class AiLabel {
662
930
  this.timer = null;
663
931
  }
664
932
 
665
- /** Everything that is due, one song at a time, until nothing is or something stops it. */
933
+ /**
934
+ * Everything that is due, `parallel` songs at a time, until nothing is or
935
+ * something stops it. Each worker claims a song (or an artist to plan for),
936
+ * so two never make the same one.
937
+ */
666
938
  async tick(limit = 200): Promise<void> {
667
- if (this.busy || !this.settings.enabled || this.settings.pausedReason) return;
668
- this.busy = "starting";
669
- try {
670
- for (let steps = 0; steps < limit; steps++) {
671
- if (this.settings.pausedReason) break;
672
- if ((await this.step().catch(() => "idle" as Step)) === "idle") break;
939
+ if (this.running || !this.settings.enabled || this.settings.pausedReason) return;
940
+ this.running = true;
941
+ let steps = 0;
942
+ const worker = async (): Promise<void> => {
943
+ while (steps < limit && !this.settings.pausedReason) {
944
+ steps += 1;
945
+ if ((await this.step().catch(() => "idle" as Step)) === "idle") return;
673
946
  }
947
+ };
948
+ try {
949
+ await Promise.all(Array.from({ length: Math.max(1, this.settings.parallel) }, worker));
674
950
  } finally {
675
- this.busy = "";
951
+ this.running = false;
676
952
  }
677
953
  }
678
954
 
955
+ /** Where the artist's genre comes in the owner's order (genreOrder); unlisted genres after. */
956
+ private rank(genre: string): number {
957
+ const at = this.settings.genreOrder.findIndex((one) => genreSlug(one) === genreSlug(genre));
958
+ return at === -1 ? this.settings.genreOrder.length : at;
959
+ }
960
+
679
961
  private async step(): Promise<Step> {
680
962
  const now = this.now();
681
963
  const composer = this.composer();
682
964
  if (!composer) return "idle";
683
- // A song of an album already planned, oldest album first.
965
+ const claimedSeconds = [...this.claims.values()].reduce((sum, one) => sum + one.seconds, 0);
966
+ // A song of an album already planned that nobody is making, oldest album first.
684
967
  const making = this.list()
685
968
  .filter((one) => one.enabled && !this.backingOff(one, now))
686
969
  .flatMap((artist) => artist.albums.filter((album) => !album.doneAt).map((album) => ({ artist, album })))
687
970
  .sort((a, b) => a.album.plannedAt - b.album.plannedAt);
688
971
  for (const { artist, album } of making) {
689
- const song = album.songs.find((one) => !one.file);
690
- if (!song) {
972
+ if (album.songs.every((one) => one.file)) {
691
973
  album.doneAt = now;
692
974
  this.save();
693
975
  continue;
694
976
  }
695
- if (song.seconds > this.budgetLeft()) return "idle";
696
- await this.compose(artist, album, song, composer);
977
+ const song = album.songs.find((one) => !one.file && !this.claims.has(`${album.key}:${one.n}`));
978
+ if (!song) continue;
979
+ if (song.seconds + claimedSeconds > this.budgetLeft()) return "idle";
980
+ const claim = `${album.key}:${song.n}`;
981
+ this.claims.set(claim, { label: `${artist.name}: ${album.title}: ${song.title}`, seconds: song.seconds });
982
+ try {
983
+ await this.compose(artist, album, song, composer);
984
+ } finally {
985
+ this.claims.delete(claim);
986
+ }
697
987
  return "composed";
698
988
  }
699
- // Nothing half-made anywhere: the artist most overdue gets its next album.
700
- if (this.list().some((one) => one.albums.some((album) => !album.doneAt))) return "idle";
701
- if (!this.canAsk() || this.settings.songSeconds > this.budgetLeft()) return "idle";
989
+ // Room for another album: the artist most overdue (in the owner's genre order) gets its next one.
990
+ const inProgress = this.list().filter((one) => one.albums.some((album) => !album.doneAt)).length + this.planning.size;
991
+ if (inProgress >= Math.max(1, this.settings.parallel)) return "idle";
992
+ if (!this.canAsk() || this.settings.songSeconds + claimedSeconds > this.budgetLeft()) return "idle";
702
993
  const due = this.list()
703
- .filter((one) => !this.backingOff(one, now))
994
+ .filter((one) => !this.backingOff(one, now) && !this.planning.has(one.key) && !one.albums.some((album) => !album.doneAt))
704
995
  .map((one) => ({ one, at: this.dueAt(one) }))
705
996
  .filter(({ at }) => at !== -1 && at <= now)
706
- .sort((a, b) => a.one.albums.length - b.one.albums.length || a.at - b.at)[0];
707
- if (!due) return "idle";
997
+ .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];
998
+ if (!due) return this.daily(now);
999
+ this.planning.add(due.one.key);
708
1000
  try {
709
1001
  await this.plan(due.one.key);
710
1002
  due.one.failures = 0;
@@ -714,6 +1006,52 @@ export class AiLabel {
714
1006
  } catch (error) {
715
1007
  this.failed(due.one, error);
716
1008
  throw error;
1009
+ } finally {
1010
+ this.planning.delete(due.one.key);
1011
+ }
1012
+ }
1013
+
1014
+ /**
1015
+ * After the first sweep: the day's album. When the last one was planned a
1016
+ * day ago (or 1/albumsPerDay of one), a random artist gets it, or now and
1017
+ * then an artist invented for one of the owner's genres. Nothing until every
1018
+ * enabled artist has its first albums.
1019
+ */
1020
+ private async daily(now: number): Promise<Step> {
1021
+ const perDay = this.settings.albumsPerDay;
1022
+ if (perDay <= 0) return "idle";
1023
+ const enabled = this.list().filter((one) => one.enabled);
1024
+ if (enabled.some((one) => one.albums.length < this.settings.seedAlbums)) return "idle";
1025
+ const last = Math.max(0, ...this.list().flatMap((one) => one.albums.map((album) => album.plannedAt)));
1026
+ if (now < last + Math.floor(DAY / perDay) || now < this.dailyTriedAt + 30 * 60_000) return "idle";
1027
+ this.dailyTriedAt = now;
1028
+ const random = this.deps.random ?? Math.random;
1029
+ const genres = this.settings.genreOrder.length ? this.settings.genreOrder : [...new Set(enabled.map((one) => one.genre))];
1030
+ let artist: LabelArtist | undefined;
1031
+ if ((enabled.length === 0 || random() < this.settings.newArtistChance) && genres.length > 0) {
1032
+ const genre = genres[Math.floor(random() * genres.length)]!;
1033
+ try {
1034
+ const invented = await inventArtist(genre, this.list().map((one) => one.name), this.deps.ask ?? askJson);
1035
+ artist = this.sign({ ...invented });
1036
+ this.log(`signed a new artist: ${artist.name} (${genre})`);
1037
+ } catch (error) {
1038
+ this.log(`could not invent a ${genre} artist (${(error as Error).message}); an artist already signed gets the album`);
1039
+ }
1040
+ }
1041
+ if (!artist) {
1042
+ const free = enabled.filter((one) => !this.backingOff(one, now) && !this.planning.has(one.key) && !one.albums.some((album) => !album.doneAt));
1043
+ artist = free[Math.floor(random() * free.length)];
1044
+ }
1045
+ if (!artist) return "idle";
1046
+ this.planning.add(artist.key);
1047
+ try {
1048
+ await this.plan(artist.key);
1049
+ return "planned";
1050
+ } catch (error) {
1051
+ this.failed(artist, error);
1052
+ throw error;
1053
+ } finally {
1054
+ this.planning.delete(artist.key);
717
1055
  }
718
1056
  }
719
1057
 
@@ -727,7 +1065,6 @@ export class AiLabel {
727
1065
  }
728
1066
 
729
1067
  private async compose(artist: LabelArtist, album: LabelAlbum, song: LabelSong, composer: Composer): Promise<void> {
730
- this.busy = `${artist.name}: ${album.title}: ${song.title}`;
731
1068
  artist.lastAttemptAt = this.now();
732
1069
  mkdirSync(album.folder, { recursive: true });
733
1070
  const raw = join(album.folder, `.${randomBytes(4).toString("hex")}.raw.mp3`);
@@ -778,18 +1115,26 @@ export class AiLabel {
778
1115
  this.save();
779
1116
  this.writeAlbumJson(artist, album);
780
1117
  await this.place(artist.genre);
1118
+ // Its video is made beside it, without holding up the next song.
1119
+ void this.videos();
781
1120
  } catch (error) {
782
1121
  this.failed(artist, error);
783
1122
  throw error;
784
1123
  } finally {
785
1124
  rmSync(raw, { force: true });
786
- this.busy = "starting";
787
1125
  }
788
1126
  }
789
1127
 
790
- private async ensureCover(artist: LabelArtist, album: LabelAlbum): Promise<void> {
791
- if (album.cover || album.coverTried) return;
1128
+ /** The album's cover, drawn once; songs made at the same time wait for the same drawing. */
1129
+ private ensureCover(artist: LabelArtist, album: LabelAlbum): Promise<void> {
1130
+ if (album.cover || album.coverTried) return this.covering.get(album.key) ?? Promise.resolve();
792
1131
  album.coverTried = true;
1132
+ const drawing = this.drawCover(artist, album).finally(() => this.covering.delete(album.key));
1133
+ this.covering.set(album.key, drawing);
1134
+ return drawing;
1135
+ }
1136
+
1137
+ private async drawCover(artist: LabelArtist, album: LabelAlbum): Promise<void> {
793
1138
  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.`;
794
1139
  try {
795
1140
  const picture = await (this.deps.cover ?? ((text) => workersAiCover(text)))(prompt);
@@ -827,7 +1172,13 @@ export class AiLabel {
827
1172
  }
828
1173
 
829
1174
  /** The genre's station gets every song of that genre: made with the first, then kept in step. */
830
- private async place(genre: string): Promise<void> {
1175
+ private place(genre: string): Promise<void> {
1176
+ const next = this.placing.then(() => this.placeNow(genre));
1177
+ this.placing = next.catch(() => undefined);
1178
+ return next;
1179
+ }
1180
+
1181
+ private async placeNow(genre: string): Promise<void> {
831
1182
  await this.deps.index.scan(this.source(), this.deps.probe).done;
832
1183
  const artists = this.list().filter((one) => genreSlug(one.genre) === genreSlug(genre));
833
1184
  const files = artists
@@ -928,6 +1279,7 @@ export class AiLabel {
928
1279
  for (const song of songs) {
929
1280
  entries.push({ name: `${folder}/${songBase(song)}.mp3`, path: song.file });
930
1281
  entries.push({ name: `${folder}/${songBase(song)}.lyrics.txt`, bytes: Buffer.from(`${song.lyrics}\n`) });
1282
+ if (song.video) entries.push({ name: `${folder}/${songBase(song)}.mp4`, path: song.video });
931
1283
  }
932
1284
  if (album.cover) entries.push({ name: `${folder}/cover.jpg`, path: album.cover });
933
1285
  entries.push({ name: `${folder}/album.json`, bytes: Buffer.from(`${JSON.stringify({
@@ -112,7 +112,8 @@ export async function handleLabelRoutes(
112
112
  return true;
113
113
  }
114
114
  if (path === "/api/v1/label") {
115
- ctx.json(response, 200, label.publicView());
115
+ // Where this catalogue is sold from, for Buy links built elsewhere (nixamp music, MCP).
116
+ ctx.json(response, 200, { ...label.publicView(), ...(/^https:\/\//.test(ctx.publicBase ?? "") ? { server: ctx.publicBase } : {}) });
116
117
  return true;
117
118
  }
118
119
  const url = new URL(request.url ?? "/", "http://x");
package/src/main.ts CHANGED
@@ -9,6 +9,7 @@ import { t as uiMessage, i18n, terminalUiLanguage, uiLanguage } from "./i18n.ts"
9
9
  */
10
10
  import { USAGE as STATION_USAGE } from "./station-usage.ts";
11
11
  import { LIBRARY_COMMANDS, LIBRARY_USAGE } from "./library-usage.ts";
12
+ import { MUSIC_USAGE } from "./music-cli.ts";
12
13
  import { createApp, themes, type BrailleCanvas, type Container, type KeyEvent, type Theme } from "@profullstack/hqtui";
13
14
  import { fileURLToPath } from "node:url";
14
15
  import { resolve } from "node:path";
@@ -235,6 +236,7 @@ keeps serving its browser remote. One per user.
235
236
  nixamp attach put the player back in front of it
236
237
  nixamp admin who is connected, and re-stream to them
237
238
  nixamp station make a folder into a station others can join (see: nixamp station help)
239
+ nixamp music the AI label's songs as files: catalog, buy (10¢, $1, $2), what you bought (nixamp music help)
238
240
 
239
241
  From inside the player, d hands the music to a daemon without stopping it.
240
242
  `,
@@ -399,6 +401,7 @@ q or d leaves; neither stops anything. \`nixamp daemon stop\` is what stops it.
399
401
  export function helpFor(topic: string | undefined): string {
400
402
  if (topic === "station" || topic === "stations") return STATION_USAGE;
401
403
  if (topic === "library") return LIBRARY_USAGE;
404
+ if (topic === "music") return MUSIC_USAGE;
402
405
  return (topic ? TOPICS[topic] : undefined) ?? HELP;
403
406
  }
404
407
 
@@ -558,6 +561,11 @@ export async function main(): Promise<void> {
558
561
  process.exitCode = await runDaemon(rest);
559
562
  return;
560
563
  }
564
+ if (first === "music") {
565
+ const { music } = await import("./music-cli.ts");
566
+ process.exitCode = await music(rest);
567
+ return;
568
+ }
561
569
  if (first === "station" || first === "stations") {
562
570
  const { station } = await import("./station-cli.ts");
563
571
  process.exitCode = await station(rest);