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/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
|
|
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.
|
|
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,88 @@ 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
|
+
} catch (error) {
|
|
885
|
+
this.videoFailed.add(song.file);
|
|
886
|
+
this.log(`no video for "${song.title}": ${(error as Error).message}`);
|
|
887
|
+
} finally {
|
|
888
|
+
rmSync(partial, { force: true });
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
if (made > 0) await this.placeVideos();
|
|
894
|
+
}
|
|
895
|
+
return made;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/** The label's music videos as one TV station: free to watch, with its breaks. */
|
|
899
|
+
private placeVideos(): Promise<void> {
|
|
900
|
+
const next = this.placing.then(async () => {
|
|
901
|
+
await this.deps.index.scan(this.source(), this.deps.probe).done;
|
|
902
|
+
const files = this.list()
|
|
903
|
+
.flatMap((artist) => artist.albums.flatMap((album) => album.songs.map((song) => song.video ?? "")))
|
|
904
|
+
.filter((file) => file && this.deps.index.itemAt(file)?.state === "ready");
|
|
905
|
+
if (files.length === 0) return;
|
|
906
|
+
const existing = this.deps.store.byAuto("ai:label:videos");
|
|
907
|
+
if (existing) {
|
|
908
|
+
this.deps.store.update(existing.id, {}, files);
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
const label = this.settings.holder.replace(/,?\s+(inc|llc|ltd|gmbh|co)\.?$/i, "");
|
|
912
|
+
const made = this.deps.store.create({ name: `${label} AI · Music Videos`, description: `Music videos by ${label}'s AI artists, every genre.`, shuffle: true, repeat: true, video: true }, files, this.deps.owner);
|
|
913
|
+
this.deps.store.markAuto(made.id, "ai:label:videos");
|
|
914
|
+
if (this.settings.publish) {
|
|
915
|
+
this.deps.store.setAccess(made.id, {
|
|
916
|
+
visibility: "public",
|
|
917
|
+
listed: true,
|
|
918
|
+
rights: { basis: "owned", note: `Music videos made for ${this.settings.holder} from its AI artists' songs and covers; no third-party material. ℗ ${this.settings.holder}.` },
|
|
919
|
+
}, this.deps.owner || "ai-label");
|
|
920
|
+
}
|
|
921
|
+
if (this.deps.mayStart()) await this.deps.runner.start(made.id).catch(() => undefined);
|
|
922
|
+
});
|
|
923
|
+
this.placing = next.catch(() => undefined);
|
|
924
|
+
return next;
|
|
658
925
|
}
|
|
659
926
|
|
|
660
927
|
stopSchedule(): void {
|
|
@@ -662,49 +929,73 @@ export class AiLabel {
|
|
|
662
929
|
this.timer = null;
|
|
663
930
|
}
|
|
664
931
|
|
|
665
|
-
/**
|
|
932
|
+
/**
|
|
933
|
+
* Everything that is due, `parallel` songs at a time, until nothing is or
|
|
934
|
+
* something stops it. Each worker claims a song (or an artist to plan for),
|
|
935
|
+
* so two never make the same one.
|
|
936
|
+
*/
|
|
666
937
|
async tick(limit = 200): Promise<void> {
|
|
667
|
-
if (this.
|
|
668
|
-
this.
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
938
|
+
if (this.running || !this.settings.enabled || this.settings.pausedReason) return;
|
|
939
|
+
this.running = true;
|
|
940
|
+
let steps = 0;
|
|
941
|
+
const worker = async (): Promise<void> => {
|
|
942
|
+
while (steps < limit && !this.settings.pausedReason) {
|
|
943
|
+
steps += 1;
|
|
944
|
+
if ((await this.step().catch(() => "idle" as Step)) === "idle") return;
|
|
673
945
|
}
|
|
946
|
+
};
|
|
947
|
+
try {
|
|
948
|
+
await Promise.all(Array.from({ length: Math.max(1, this.settings.parallel) }, worker));
|
|
674
949
|
} finally {
|
|
675
|
-
this.
|
|
950
|
+
this.running = false;
|
|
676
951
|
}
|
|
677
952
|
}
|
|
678
953
|
|
|
954
|
+
/** Where the artist's genre comes in the owner's order (genreOrder); unlisted genres after. */
|
|
955
|
+
private rank(genre: string): number {
|
|
956
|
+
const at = this.settings.genreOrder.findIndex((one) => genreSlug(one) === genreSlug(genre));
|
|
957
|
+
return at === -1 ? this.settings.genreOrder.length : at;
|
|
958
|
+
}
|
|
959
|
+
|
|
679
960
|
private async step(): Promise<Step> {
|
|
680
961
|
const now = this.now();
|
|
681
962
|
const composer = this.composer();
|
|
682
963
|
if (!composer) return "idle";
|
|
683
|
-
|
|
964
|
+
const claimedSeconds = [...this.claims.values()].reduce((sum, one) => sum + one.seconds, 0);
|
|
965
|
+
// A song of an album already planned that nobody is making, oldest album first.
|
|
684
966
|
const making = this.list()
|
|
685
967
|
.filter((one) => one.enabled && !this.backingOff(one, now))
|
|
686
968
|
.flatMap((artist) => artist.albums.filter((album) => !album.doneAt).map((album) => ({ artist, album })))
|
|
687
969
|
.sort((a, b) => a.album.plannedAt - b.album.plannedAt);
|
|
688
970
|
for (const { artist, album } of making) {
|
|
689
|
-
|
|
690
|
-
if (!song) {
|
|
971
|
+
if (album.songs.every((one) => one.file)) {
|
|
691
972
|
album.doneAt = now;
|
|
692
973
|
this.save();
|
|
693
974
|
continue;
|
|
694
975
|
}
|
|
695
|
-
|
|
696
|
-
|
|
976
|
+
const song = album.songs.find((one) => !one.file && !this.claims.has(`${album.key}:${one.n}`));
|
|
977
|
+
if (!song) continue;
|
|
978
|
+
if (song.seconds + claimedSeconds > this.budgetLeft()) return "idle";
|
|
979
|
+
const claim = `${album.key}:${song.n}`;
|
|
980
|
+
this.claims.set(claim, { label: `${artist.name}: ${album.title}: ${song.title}`, seconds: song.seconds });
|
|
981
|
+
try {
|
|
982
|
+
await this.compose(artist, album, song, composer);
|
|
983
|
+
} finally {
|
|
984
|
+
this.claims.delete(claim);
|
|
985
|
+
}
|
|
697
986
|
return "composed";
|
|
698
987
|
}
|
|
699
|
-
//
|
|
700
|
-
|
|
701
|
-
if (
|
|
988
|
+
// Room for another album: the artist most overdue (in the owner's genre order) gets its next one.
|
|
989
|
+
const inProgress = this.list().filter((one) => one.albums.some((album) => !album.doneAt)).length + this.planning.size;
|
|
990
|
+
if (inProgress >= Math.max(1, this.settings.parallel)) return "idle";
|
|
991
|
+
if (!this.canAsk() || this.settings.songSeconds + claimedSeconds > this.budgetLeft()) return "idle";
|
|
702
992
|
const due = this.list()
|
|
703
|
-
.filter((one) => !this.backingOff(one, now))
|
|
993
|
+
.filter((one) => !this.backingOff(one, now) && !this.planning.has(one.key) && !one.albums.some((album) => !album.doneAt))
|
|
704
994
|
.map((one) => ({ one, at: this.dueAt(one) }))
|
|
705
995
|
.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
|
|
996
|
+
.sort((a, b) => a.one.albums.length - b.one.albums.length || this.rank(a.one.genre) - this.rank(b.one.genre) || a.at - b.at)[0];
|
|
997
|
+
if (!due) return this.daily(now);
|
|
998
|
+
this.planning.add(due.one.key);
|
|
708
999
|
try {
|
|
709
1000
|
await this.plan(due.one.key);
|
|
710
1001
|
due.one.failures = 0;
|
|
@@ -714,6 +1005,52 @@ export class AiLabel {
|
|
|
714
1005
|
} catch (error) {
|
|
715
1006
|
this.failed(due.one, error);
|
|
716
1007
|
throw error;
|
|
1008
|
+
} finally {
|
|
1009
|
+
this.planning.delete(due.one.key);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* After the first sweep: the day's album. When the last one was planned a
|
|
1015
|
+
* day ago (or 1/albumsPerDay of one), a random artist gets it, or now and
|
|
1016
|
+
* then an artist invented for one of the owner's genres. Nothing until every
|
|
1017
|
+
* enabled artist has its first albums.
|
|
1018
|
+
*/
|
|
1019
|
+
private async daily(now: number): Promise<Step> {
|
|
1020
|
+
const perDay = this.settings.albumsPerDay;
|
|
1021
|
+
if (perDay <= 0) return "idle";
|
|
1022
|
+
const enabled = this.list().filter((one) => one.enabled);
|
|
1023
|
+
if (enabled.some((one) => one.albums.length < this.settings.seedAlbums)) return "idle";
|
|
1024
|
+
const last = Math.max(0, ...this.list().flatMap((one) => one.albums.map((album) => album.plannedAt)));
|
|
1025
|
+
if (now < last + Math.floor(DAY / perDay) || now < this.dailyTriedAt + 30 * 60_000) return "idle";
|
|
1026
|
+
this.dailyTriedAt = now;
|
|
1027
|
+
const random = this.deps.random ?? Math.random;
|
|
1028
|
+
const genres = this.settings.genreOrder.length ? this.settings.genreOrder : [...new Set(enabled.map((one) => one.genre))];
|
|
1029
|
+
let artist: LabelArtist | undefined;
|
|
1030
|
+
if ((enabled.length === 0 || random() < this.settings.newArtistChance) && genres.length > 0) {
|
|
1031
|
+
const genre = genres[Math.floor(random() * genres.length)]!;
|
|
1032
|
+
try {
|
|
1033
|
+
const invented = await inventArtist(genre, this.list().map((one) => one.name), this.deps.ask ?? askJson);
|
|
1034
|
+
artist = this.sign({ ...invented });
|
|
1035
|
+
this.log(`signed a new artist: ${artist.name} (${genre})`);
|
|
1036
|
+
} catch (error) {
|
|
1037
|
+
this.log(`could not invent a ${genre} artist (${(error as Error).message}); an artist already signed gets the album`);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
if (!artist) {
|
|
1041
|
+
const free = enabled.filter((one) => !this.backingOff(one, now) && !this.planning.has(one.key) && !one.albums.some((album) => !album.doneAt));
|
|
1042
|
+
artist = free[Math.floor(random() * free.length)];
|
|
1043
|
+
}
|
|
1044
|
+
if (!artist) return "idle";
|
|
1045
|
+
this.planning.add(artist.key);
|
|
1046
|
+
try {
|
|
1047
|
+
await this.plan(artist.key);
|
|
1048
|
+
return "planned";
|
|
1049
|
+
} catch (error) {
|
|
1050
|
+
this.failed(artist, error);
|
|
1051
|
+
throw error;
|
|
1052
|
+
} finally {
|
|
1053
|
+
this.planning.delete(artist.key);
|
|
717
1054
|
}
|
|
718
1055
|
}
|
|
719
1056
|
|
|
@@ -727,7 +1064,6 @@ export class AiLabel {
|
|
|
727
1064
|
}
|
|
728
1065
|
|
|
729
1066
|
private async compose(artist: LabelArtist, album: LabelAlbum, song: LabelSong, composer: Composer): Promise<void> {
|
|
730
|
-
this.busy = `${artist.name}: ${album.title}: ${song.title}`;
|
|
731
1067
|
artist.lastAttemptAt = this.now();
|
|
732
1068
|
mkdirSync(album.folder, { recursive: true });
|
|
733
1069
|
const raw = join(album.folder, `.${randomBytes(4).toString("hex")}.raw.mp3`);
|
|
@@ -778,18 +1114,26 @@ export class AiLabel {
|
|
|
778
1114
|
this.save();
|
|
779
1115
|
this.writeAlbumJson(artist, album);
|
|
780
1116
|
await this.place(artist.genre);
|
|
1117
|
+
// Its video is made beside it, without holding up the next song.
|
|
1118
|
+
void this.videos();
|
|
781
1119
|
} catch (error) {
|
|
782
1120
|
this.failed(artist, error);
|
|
783
1121
|
throw error;
|
|
784
1122
|
} finally {
|
|
785
1123
|
rmSync(raw, { force: true });
|
|
786
|
-
this.busy = "starting";
|
|
787
1124
|
}
|
|
788
1125
|
}
|
|
789
1126
|
|
|
790
|
-
|
|
791
|
-
|
|
1127
|
+
/** The album's cover, drawn once; songs made at the same time wait for the same drawing. */
|
|
1128
|
+
private ensureCover(artist: LabelArtist, album: LabelAlbum): Promise<void> {
|
|
1129
|
+
if (album.cover || album.coverTried) return this.covering.get(album.key) ?? Promise.resolve();
|
|
792
1130
|
album.coverTried = true;
|
|
1131
|
+
const drawing = this.drawCover(artist, album).finally(() => this.covering.delete(album.key));
|
|
1132
|
+
this.covering.set(album.key, drawing);
|
|
1133
|
+
return drawing;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
private async drawCover(artist: LabelArtist, album: LabelAlbum): Promise<void> {
|
|
793
1137
|
const prompt = `Album cover art for "${album.title}", a ${artist.genre} album by ${artist.name}. ${album.concept} Style: ${artist.sound}. Striking, original, square composition. No text, no letters, no logos, no watermark.`;
|
|
794
1138
|
try {
|
|
795
1139
|
const picture = await (this.deps.cover ?? ((text) => workersAiCover(text)))(prompt);
|
|
@@ -827,7 +1171,13 @@ export class AiLabel {
|
|
|
827
1171
|
}
|
|
828
1172
|
|
|
829
1173
|
/** The genre's station gets every song of that genre: made with the first, then kept in step. */
|
|
830
|
-
private
|
|
1174
|
+
private place(genre: string): Promise<void> {
|
|
1175
|
+
const next = this.placing.then(() => this.placeNow(genre));
|
|
1176
|
+
this.placing = next.catch(() => undefined);
|
|
1177
|
+
return next;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
private async placeNow(genre: string): Promise<void> {
|
|
831
1181
|
await this.deps.index.scan(this.source(), this.deps.probe).done;
|
|
832
1182
|
const artists = this.list().filter((one) => genreSlug(one.genre) === genreSlug(genre));
|
|
833
1183
|
const files = artists
|
|
@@ -928,6 +1278,7 @@ export class AiLabel {
|
|
|
928
1278
|
for (const song of songs) {
|
|
929
1279
|
entries.push({ name: `${folder}/${songBase(song)}.mp3`, path: song.file });
|
|
930
1280
|
entries.push({ name: `${folder}/${songBase(song)}.lyrics.txt`, bytes: Buffer.from(`${song.lyrics}\n`) });
|
|
1281
|
+
if (song.video) entries.push({ name: `${folder}/${songBase(song)}.mp4`, path: song.video });
|
|
931
1282
|
}
|
|
932
1283
|
if (album.cover) entries.push({ name: `${folder}/cover.jpg`, path: album.cover });
|
|
933
1284
|
entries.push({ name: `${folder}/album.json`, bytes: Buffer.from(`${JSON.stringify({
|
package/src/label-routes.ts
CHANGED
|
@@ -112,7 +112,8 @@ export async function handleLabelRoutes(
|
|
|
112
112
|
return true;
|
|
113
113
|
}
|
|
114
114
|
if (path === "/api/v1/label") {
|
|
115
|
-
|
|
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);
|