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.
- package/dist/ai-label.d.ts +70 -3
- package/dist/ai-label.js +375 -29
- package/dist/label-routes.js +2 -1
- package/dist/main.js +9 -0
- package/dist/mcp.js +42 -0
- package/dist/music-cli.d.ts +2 -0
- package/dist/music-cli.js +109 -0
- package/dist/music-client.d.ts +62 -0
- package/dist/music-client.js +73 -0
- package/dist/station-cli.js +8 -1
- package/dist/station-mcp.d.ts +62 -0
- package/dist/station-mcp.js +5 -2
- package/dist/station-usage.d.ts +1 -1
- package/dist/station-usage.js +3 -0
- package/package.json +1 -1
- package/src/ai-label.ts +380 -29
- package/src/label-routes.ts +2 -1
- package/src/main.ts +8 -0
- package/src/mcp.ts +38 -0
- package/src/music-cli.ts +86 -0
- package/src/music-client.ts +86 -0
- package/src/station-cli.ts +8 -1
- package/src/station-mcp.ts +5 -2
- package/src/station-usage.ts +3 -0
- package/web/dist/assets/{ar--GRGOL7F.js → ar-DMx2aeAO.js} +1 -1
- package/web/dist/assets/{cs-B2ab-8qO.js → cs-Ddu0q3kG.js} +1 -1
- package/web/dist/assets/{da-Ek2g1uTg.js → da-CZ9gg_pb.js} +1 -1
- package/web/dist/assets/{de-CM7-9Fbk.js → de-CgJX4xg2.js} +1 -1
- package/web/dist/assets/{es-BFSOrr3I.js → es-Cp5ryDrJ.js} +1 -1
- package/web/dist/assets/{fi-D9Tf69sv.js → fi-D912EKux.js} +1 -1
- package/web/dist/assets/{fr-Gh9yYJGo.js → fr-FE_K0MPb.js} +1 -1
- package/web/dist/assets/{hi-B3ow3HJC.js → hi-BdYtQs1S.js} +1 -1
- package/web/dist/assets/{hu-paju-6Kr.js → hu-oh5tEg0p.js} +1 -1
- package/web/dist/assets/{id-mubOQFX0.js → id-NbCUWeB1.js} +1 -1
- package/web/dist/assets/{it-0EM-qnDT.js → it-CiHbKZ6Y.js} +1 -1
- package/web/dist/assets/{main-CECXge6G.js → main-8wV87D-g.js} +2 -2
- package/web/dist/assets/{nl-c6HalphH.js → nl-nLHK_AQ-.js} +1 -1
- package/web/dist/assets/{radio-CDUEoM0y.js → radio-BmHvBkVy.js} +1 -1
- package/web/dist/assets/{ru-CUKYVlgf.js → ru-BDdAS0Vv.js} +1 -1
- package/web/dist/assets/{sv-BUxBsZR5.js → sv-COweXM56.js} +1 -1
- package/web/dist/assets/{uk-708lBssa.js → uk-BJFzR1LS.js} +1 -1
- package/web/dist/assets/{vi-B1ropUvk.js → vi-qg2DJ6cR.js} +1 -1
- package/web/dist/assets/{zh-C_ndTsNh.js → zh-DUWOKDkb.js} +1 -1
- package/web/dist/index.html +2 -1
- package/web/dist/radio.html +1 -1
- package/web/dist/sw.js +20 -20
package/dist/ai-label.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
/**
|
|
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.
|
|
821
|
+
if (this.running || !this.settings.enabled || this.settings.pausedReason)
|
|
562
822
|
return;
|
|
563
|
-
this.
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
599
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 });
|
package/dist/label-routes.js
CHANGED
|
@@ -94,7 +94,8 @@ export async function handleLabelRoutes(request, response, path, ctx) {
|
|
|
94
94
|
return true;
|
|
95
95
|
}
|
|
96
96
|
if (path === "/api/v1/label") {
|
|
97
|
-
|
|
97
|
+
// Where this catalogue is sold from, for Buy links built elsewhere (nixamp music, MCP).
|
|
98
|
+
ctx.json(response, 200, { ...label.publicView(), ...(/^https:\/\//.test(ctx.publicBase ?? "") ? { server: ctx.publicBase } : {}) });
|
|
98
99
|
return true;
|
|
99
100
|
}
|
|
100
101
|
const url = new URL(request.url ?? "/", "http://x");
|
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 ?? {}) };
|