nixamp 0.51.0 → 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 (46) 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 +380 -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-DMx2aeAO.js} +1 -1
  26. package/web/dist/assets/{cs-B2ab-8qO.js → cs-Ddu0q3kG.js} +1 -1
  27. package/web/dist/assets/{da-Ek2g1uTg.js → da-CZ9gg_pb.js} +1 -1
  28. package/web/dist/assets/{de-CM7-9Fbk.js → de-CgJX4xg2.js} +1 -1
  29. package/web/dist/assets/{es-BFSOrr3I.js → es-Cp5ryDrJ.js} +1 -1
  30. package/web/dist/assets/{fi-D9Tf69sv.js → fi-D912EKux.js} +1 -1
  31. package/web/dist/assets/{fr-Gh9yYJGo.js → fr-FE_K0MPb.js} +1 -1
  32. package/web/dist/assets/{hi-B3ow3HJC.js → hi-BdYtQs1S.js} +1 -1
  33. package/web/dist/assets/{hu-paju-6Kr.js → hu-oh5tEg0p.js} +1 -1
  34. package/web/dist/assets/{id-mubOQFX0.js → id-NbCUWeB1.js} +1 -1
  35. package/web/dist/assets/{it-0EM-qnDT.js → it-CiHbKZ6Y.js} +1 -1
  36. package/web/dist/assets/{main-CECXge6G.js → main-8wV87D-g.js} +2 -2
  37. package/web/dist/assets/{nl-c6HalphH.js → nl-nLHK_AQ-.js} +1 -1
  38. package/web/dist/assets/{radio-CDUEoM0y.js → radio-BmHvBkVy.js} +1 -1
  39. package/web/dist/assets/{ru-CUKYVlgf.js → ru-BDdAS0Vv.js} +1 -1
  40. package/web/dist/assets/{sv-BUxBsZR5.js → sv-COweXM56.js} +1 -1
  41. package/web/dist/assets/{uk-708lBssa.js → uk-BJFzR1LS.js} +1 -1
  42. package/web/dist/assets/{vi-B1ropUvk.js → vi-qg2DJ6cR.js} +1 -1
  43. package/web/dist/assets/{zh-C_ndTsNh.js → zh-DUWOKDkb.js} +1 -1
  44. package/web/dist/index.html +2 -1
  45. package/web/dist/radio.html +1 -1
  46. package/web/dist/sw.js +20 -20
package/dist/ai-label.js CHANGED
@@ -75,12 +75,103 @@ export const ROSTER = {
75
75
  themes: "ruins, resilience, storms, rebuilding from ashes, defiance",
76
76
  },
77
77
  "iron-lantern-society": {
78
- name: "Iron Lantern Society", genre: "Rock",
78
+ name: "Iron Lantern Society", genre: "Classic Rock",
79
79
  sound: "bluesy classic hard rock, swaggering riffs, Hammond organ, cowbell grooves, big guitar solos, 100-128 BPM",
80
80
  voice: "gravelly male baritone with a wide rasp and a rock-and-roll howl",
81
81
  bio: "Five road dogs who think every song should end in a bar fight or a sunrise.",
82
82
  themes: "the open road, hard luck, loyalty among misfits, last calls and new starts",
83
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
+ },
84
175
  "paper-satellites": {
85
176
  name: "Paper Satellites", genre: "Rock",
86
177
  sound: "jangly indie rock, chorus-drenched guitars, driving eighth-note bass, crisp drums, shout-along choruses, 130-160 BPM",
@@ -171,10 +262,15 @@ export const DEFAULTS = {
171
262
  enabled: true,
172
263
  monthlySeconds: 3600,
173
264
  everyDays: 30,
265
+ albumsPerDay: 1,
266
+ newArtistChance: 0.25,
174
267
  seedAlbums: 1,
175
268
  songsPerAlbum: 8,
176
269
  songSeconds: 150,
177
270
  publish: true,
271
+ videos: true,
272
+ parallel: 1,
273
+ genreOrder: ["Punk", "Blues", "Country", "Jazz", "Classic Rock", "Metal", "Hip-Hop", "Pop", "R&B"],
178
274
  };
179
275
  const DAY = 86_400_000;
180
276
  /** Between tries after a failure: 5 min, doubling, at most 6 h. */
@@ -248,6 +344,28 @@ export async function planAlbum(artist, songs, ask = askJson) {
248
344
  throw new Error("the model planned no album (a title and at least three songs)");
249
345
  return { title, concept, songs: planned, model: answer.model };
250
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
+ }
251
369
  export async function writeLyrics(artist, album, song, ask = askJson) {
252
370
  const answer = await ask({
253
371
  system: LYRICS_SYSTEM,
@@ -291,6 +409,26 @@ export async function workersAiCover(prompt, env = process.env, fetcher = fetch)
291
409
  return image;
292
410
  }
293
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
+ }
294
432
  function tagFile(ffmpeg, from, to, tags, cover) {
295
433
  const args = [...ffmpeg.slice(1), "-hide_banner", "-loglevel", "error", "-y", "-i", from];
296
434
  if (cover)
@@ -314,7 +452,19 @@ export class AiLabel {
314
452
  artists = new Map();
315
453
  settings = { ...DEFAULTS, pausedReason: "" };
316
454
  usage = {};
317
- 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();
318
468
  timer = null;
319
469
  constructor(file, deps) {
320
470
  this.file = file;
@@ -382,6 +532,9 @@ export class AiLabel {
382
532
  return -1;
383
533
  if (artist.albums.length < this.settings.seedAlbums)
384
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;
385
538
  const last = artist.albums[artist.albums.length - 1];
386
539
  const stagger = (parseInt(artist.key.slice(0, 6), 16) % Math.max(1, Math.floor(this.settings.everyDays / 4))) * DAY;
387
540
  return last.plannedAt + this.settings.everyDays * DAY + stagger;
@@ -401,7 +554,8 @@ export class AiLabel {
401
554
  root: this.root,
402
555
  composer: composer?.name ?? null,
403
556
  writer: this.canAsk() ? "language model" : null,
404
- 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),
405
559
  month,
406
560
  secondsThisMonth: this.usage[month] ?? 0,
407
561
  settings: this.settings,
@@ -448,6 +602,24 @@ export class AiLabel {
448
602
  this.settings.enabled = input["enabled"] !== false;
449
603
  if (input["publish"] !== undefined)
450
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
+ }
451
623
  if (input["resume"] === true)
452
624
  this.settings.pausedReason = "";
453
625
  this.save();
@@ -547,66 +719,174 @@ export class AiLabel {
547
719
  startSchedule(everyMs = 5 * 60_000) {
548
720
  if (this.timer)
549
721
  return;
550
- this.timer = setInterval(() => void this.tick(), everyMs);
722
+ this.timer = setInterval(() => { void this.tick(); void this.videos(); }, everyMs);
551
723
  this.timer.unref?.();
552
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;
553
809
  }
554
810
  stopSchedule() {
555
811
  if (this.timer)
556
812
  clearInterval(this.timer);
557
813
  this.timer = null;
558
814
  }
559
- /** 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
+ */
560
820
  async tick(limit = 200) {
561
- if (this.busy || !this.settings.enabled || this.settings.pausedReason)
821
+ if (this.running || !this.settings.enabled || this.settings.pausedReason)
562
822
  return;
563
- this.busy = "starting";
564
- try {
565
- for (let steps = 0; steps < limit; steps++) {
566
- if (this.settings.pausedReason)
567
- break;
823
+ this.running = true;
824
+ let steps = 0;
825
+ const worker = async () => {
826
+ while (steps < limit && !this.settings.pausedReason) {
827
+ steps += 1;
568
828
  if ((await this.step().catch(() => "idle")) === "idle")
569
- break;
829
+ return;
570
830
  }
831
+ };
832
+ try {
833
+ await Promise.all(Array.from({ length: Math.max(1, this.settings.parallel) }, worker));
571
834
  }
572
835
  finally {
573
- this.busy = "";
836
+ this.running = false;
574
837
  }
575
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
+ }
576
844
  async step() {
577
845
  const now = this.now();
578
846
  const composer = this.composer();
579
847
  if (!composer)
580
848
  return "idle";
581
- // 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.
582
851
  const making = this.list()
583
852
  .filter((one) => one.enabled && !this.backingOff(one, now))
584
853
  .flatMap((artist) => artist.albums.filter((album) => !album.doneAt).map((album) => ({ artist, album })))
585
854
  .sort((a, b) => a.album.plannedAt - b.album.plannedAt);
586
855
  for (const { artist, album } of making) {
587
- const song = album.songs.find((one) => !one.file);
588
- if (!song) {
856
+ if (album.songs.every((one) => one.file)) {
589
857
  album.doneAt = now;
590
858
  this.save();
591
859
  continue;
592
860
  }
593
- 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())
594
865
  return "idle";
595
- 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
+ }
596
874
  return "composed";
597
875
  }
598
- // Nothing half-made anywhere: the artist most overdue gets its next album.
599
- 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))
600
879
  return "idle";
601
- if (!this.canAsk() || this.settings.songSeconds > this.budgetLeft())
880
+ if (!this.canAsk() || this.settings.songSeconds + claimedSeconds > this.budgetLeft())
602
881
  return "idle";
603
882
  const due = this.list()
604
- .filter((one) => !this.backingOff(one, now))
883
+ .filter((one) => !this.backingOff(one, now) && !this.planning.has(one.key) && !one.albums.some((album) => !album.doneAt))
605
884
  .map((one) => ({ one, at: this.dueAt(one) }))
606
885
  .filter(({ at }) => at !== -1 && at <= now)
607
- .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];
608
887
  if (!due)
609
- return "idle";
888
+ return this.daily(now);
889
+ this.planning.add(due.one.key);
610
890
  try {
611
891
  await this.plan(due.one.key);
612
892
  due.one.failures = 0;
@@ -618,6 +898,59 @@ export class AiLabel {
618
898
  this.failed(due.one, error);
619
899
  throw error;
620
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
+ }
621
954
  }
622
955
  failed(artist, error) {
623
956
  artist.lastAttemptAt = this.now();
@@ -629,7 +962,6 @@ export class AiLabel {
629
962
  this.log(`${artist.name}: ${artist.lastError}`);
630
963
  }
631
964
  async compose(artist, album, song, composer) {
632
- this.busy = `${artist.name}: ${album.title}: ${song.title}`;
633
965
  artist.lastAttemptAt = this.now();
634
966
  mkdirSync(album.folder, { recursive: true });
635
967
  const raw = join(album.folder, `.${randomBytes(4).toString("hex")}.raw.mp3`);
@@ -680,6 +1012,8 @@ export class AiLabel {
680
1012
  this.save();
681
1013
  this.writeAlbumJson(artist, album);
682
1014
  await this.place(artist.genre);
1015
+ // Its video is made beside it, without holding up the next song.
1016
+ void this.videos();
683
1017
  }
684
1018
  catch (error) {
685
1019
  this.failed(artist, error);
@@ -687,13 +1021,18 @@ export class AiLabel {
687
1021
  }
688
1022
  finally {
689
1023
  rmSync(raw, { force: true });
690
- this.busy = "starting";
691
1024
  }
692
1025
  }
693
- 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) {
694
1028
  if (album.cover || album.coverTried)
695
- return;
1029
+ return this.covering.get(album.key) ?? Promise.resolve();
696
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) {
697
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.`;
698
1037
  try {
699
1038
  const picture = await (this.deps.cover ?? ((text) => workersAiCover(text)))(prompt);
@@ -729,7 +1068,12 @@ export class AiLabel {
729
1068
  return this.deps.store.byAuto(`ai:label:${genreSlug(genre)}`);
730
1069
  }
731
1070
  /** The genre's station gets every song of that genre: made with the first, then kept in step. */
732
- 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) {
733
1077
  await this.deps.index.scan(this.source(), this.deps.probe).done;
734
1078
  const artists = this.list().filter((one) => genreSlug(one.genre) === genreSlug(genre));
735
1079
  const files = artists
@@ -835,6 +1179,8 @@ export class AiLabel {
835
1179
  for (const song of songs) {
836
1180
  entries.push({ name: `${folder}/${songBase(song)}.mp3`, path: song.file });
837
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 });
838
1184
  }
839
1185
  if (album.cover)
840
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");
package/dist/main.js CHANGED
@@ -9,6 +9,7 @@ import { t as uiMessage, i18n, terminalUiLanguage, uiLanguage } from "./i18n.js"
9
9
  */
10
10
  import { USAGE as STATION_USAGE } from "./station-usage.js";
11
11
  import { LIBRARY_COMMANDS, LIBRARY_USAGE } from "./library-usage.js";
12
+ import { MUSIC_USAGE } from "./music-cli.js";
12
13
  import { createApp, themes } from "@profullstack/hqtui";
13
14
  import { fileURLToPath } from "node:url";
14
15
  import { resolve } from "node:path";
@@ -209,6 +210,7 @@ keeps serving its browser remote. One per user.
209
210
  nixamp attach put the player back in front of it
210
211
  nixamp admin who is connected, and re-stream to them
211
212
  nixamp station make a folder into a station others can join (see: nixamp station help)
213
+ nixamp music the AI label's songs as files: catalog, buy (10¢, $1, $2), what you bought (nixamp music help)
212
214
 
213
215
  From inside the player, d hands the music to a daemon without stopping it.
214
216
  `,
@@ -374,6 +376,8 @@ export function helpFor(topic) {
374
376
  return STATION_USAGE;
375
377
  if (topic === "library")
376
378
  return LIBRARY_USAGE;
379
+ if (topic === "music")
380
+ return MUSIC_USAGE;
377
381
  return (topic ? TOPICS[topic] : undefined) ?? HELP;
378
382
  }
379
383
  /**
@@ -531,6 +535,11 @@ export async function main() {
531
535
  process.exitCode = await runDaemon(rest);
532
536
  return;
533
537
  }
538
+ if (first === "music") {
539
+ const { music } = await import("./music-cli.js");
540
+ process.exitCode = await music(rest);
541
+ return;
542
+ }
534
543
  if (first === "station" || first === "stations") {
535
544
  const { station } = await import("./station-cli.js");
536
545
  process.exitCode = await station(rest);
package/dist/mcp.js CHANGED
@@ -218,6 +218,29 @@ export const TOOLS = [
218
218
  required: ["server"],
219
219
  },
220
220
  },
221
+ {
222
+ name: "music_catalog",
223
+ description: "The AI label's catalogue: genres, artists, albums and songs (with lyrics), and each genre's free live station. Read from the server that holds the music, live.streaming by default.",
224
+ inputSchema: { type: "object", properties: { server: { ...STRING, description: "The music server's https address (default https://live.streaming.pit.world)." } } },
225
+ },
226
+ {
227
+ name: "music_buy",
228
+ description: "Buy AI label music as files (a song 10¢, an album $1, everything by an artist $2) with crypto via CoinPay. Answers the page to pay on and the receipt link where the zip (MP3s with cover art and lyrics) is downloaded once paid. Pass a nixamp.com/buy link, or server + kind + artist (+ album, + n).",
229
+ inputSchema: {
230
+ type: "object",
231
+ properties: {
232
+ link: { ...STRING, description: "A nixamp.com/buy?... link." },
233
+ server: STRING, kind: { type: "string", enum: ["song", "album", "artist"] }, artist: STRING, album: STRING, n: { type: "integer" },
234
+ email: { ...STRING, description: "Where the receipt goes; the signed-in account's email by default." },
235
+ coin: { ...STRING, description: "USDC_POL (default), USDC_SOL, SOL, POL, ETH, BTC..." },
236
+ },
237
+ },
238
+ },
239
+ {
240
+ name: "music_purchases",
241
+ description: "The AI label music this account bought, with a fresh download link for each zip. Needs `nixamp login` with the email used to buy.",
242
+ inputSchema: { type: "object", properties: {} },
243
+ },
221
244
  ...STATION_TOOLS,
222
245
  ...PUBLIC_TOOLS,
223
246
  ...LIBRARY_TOOLS,
@@ -260,6 +283,25 @@ export async function callTool(name, args, options = {}) {
260
283
  ...(options.session?.site ? { site: options.session.site } : {}),
261
284
  });
262
285
  }
286
+ // The AI label's music: the catalogue and buying need no sign-in; what was bought does.
287
+ if (name === "music_catalog" || name === "music_buy" || name === "music_purchases") {
288
+ const shop = await import("./music-client.js");
289
+ try {
290
+ if (name === "music_catalog")
291
+ return text(JSON.stringify(await shop.catalogue(typeof args["server"] === "string" && args["server"] ? args["server"] : undefined, options.fetcher), null, 2));
292
+ if (name === "music_purchases")
293
+ return text(JSON.stringify(await shop.purchases(options.fetcher), null, 2));
294
+ const link = typeof args["link"] === "string" ? shop.fromBuyLink(args["link"]) : null;
295
+ if (typeof args["link"] === "string" && !link)
296
+ return failed("that is not a nixamp.com/buy link");
297
+ const pick = (keys) => Object.fromEntries(keys.filter((key) => args[key] !== undefined).map((key) => [key, args[key]]));
298
+ const made = await shop.buy({ ...(link ?? pick(["server", "kind", "artist", "album", "n"])), ...(link?.["n"] ? { n: Number(link["n"]) } : {}), coin: typeof args["coin"] === "string" ? args["coin"] : "USDC_POL", ...pick(["email"]) }, options.fetcher);
299
+ return text(`Pay here: ${made.url}\nReceipt and download, once paid: ${made.receipt}`);
300
+ }
301
+ catch (error) {
302
+ return failed(error.message);
303
+ }
304
+ }
263
305
  // Stations first: on this machine they need the daemon, not a sign-in.
264
306
  if (isStationTool(name) || isLibraryTool(name)) {
265
307
  const local = { ...(options.fetcher ? { fetcher: options.fetcher } : {}), ...(options.session !== undefined ? { session: options.session } : {}), ...(options.stations ?? {}) };