nixamp 0.50.1 → 0.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai-label.d.ts +70 -3
- package/dist/ai-label.js +459 -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 +6 -3
- package/dist/track-facts.js +1 -1
- package/package.json +1 -1
- package/src/ai-label.ts +464 -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 +6 -3
- package/src/track-facts.ts +1 -1
- package/web/dist/assets/{ar-CC_uYRlC.js → ar-DMx2aeAO.js} +1 -1
- package/web/dist/assets/{cs-k5C-jvfX.js → cs-Ddu0q3kG.js} +1 -1
- package/web/dist/assets/{da-CdBzU_fF.js → da-CZ9gg_pb.js} +1 -1
- package/web/dist/assets/{de-B-UWa0W7.js → de-CgJX4xg2.js} +1 -1
- package/web/dist/assets/{es-Dod8aCau.js → es-Cp5ryDrJ.js} +1 -1
- package/web/dist/assets/{fi-C36bBPFW.js → fi-D912EKux.js} +1 -1
- package/web/dist/assets/{fr-DWUxs2qv.js → fr-FE_K0MPb.js} +1 -1
- package/web/dist/assets/{hi-DjLqhfs8.js → hi-BdYtQs1S.js} +1 -1
- package/web/dist/assets/{hu-CgsOlcq8.js → hu-oh5tEg0p.js} +1 -1
- package/web/dist/assets/{id-ZA8yTWX9.js → id-NbCUWeB1.js} +1 -1
- package/web/dist/assets/{it-DfFhS34g.js → it-CiHbKZ6Y.js} +1 -1
- package/web/dist/assets/{main-CjgQp9um.js → main-8wV87D-g.js} +2 -2
- package/web/dist/assets/{nl-C257i24U.js → nl-nLHK_AQ-.js} +1 -1
- package/web/dist/assets/{radio-CDUEoM0y.js → radio-BmHvBkVy.js} +1 -1
- package/web/dist/assets/{ru--30I7BNC.js → ru-BDdAS0Vv.js} +1 -1
- package/web/dist/assets/{sv-CxzBeXfk.js → sv-COweXM56.js} +1 -1
- package/web/dist/assets/{uk-DXHpMk4i.js → uk-BJFzR1LS.js} +1 -1
- package/web/dist/assets/{vi-Dg2LT4x3.js → vi-qg2DJ6cR.js} +1 -1
- package/web/dist/assets/{zh-D3pvejo6.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
|
}
|
|
@@ -151,6 +167,181 @@ export const ROSTER: Record<string, ArtistPreset> = {
|
|
|
151
167
|
bio: "Maps of places that burned down, drawn at full volume.",
|
|
152
168
|
themes: "ruins, resilience, storms, rebuilding from ashes, defiance",
|
|
153
169
|
},
|
|
170
|
+
"iron-lantern-society": {
|
|
171
|
+
name: "Iron Lantern Society", genre: "Classic Rock",
|
|
172
|
+
sound: "bluesy classic hard rock, swaggering riffs, Hammond organ, cowbell grooves, big guitar solos, 100-128 BPM",
|
|
173
|
+
voice: "gravelly male baritone with a wide rasp and a rock-and-roll howl",
|
|
174
|
+
bio: "Five road dogs who think every song should end in a bar fight or a sunrise.",
|
|
175
|
+
themes: "the open road, hard luck, loyalty among misfits, last calls and new starts",
|
|
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
|
+
},
|
|
268
|
+
"paper-satellites": {
|
|
269
|
+
name: "Paper Satellites", genre: "Rock",
|
|
270
|
+
sound: "jangly indie rock, chorus-drenched guitars, driving eighth-note bass, crisp drums, shout-along choruses, 130-160 BPM",
|
|
271
|
+
voice: "earnest male tenor, slightly nasal, with gang vocals in the choruses",
|
|
272
|
+
bio: "Bedroom recordings that grew up and got a van.",
|
|
273
|
+
themes: "growing up, small apartments, friendship, leaving and coming back",
|
|
274
|
+
},
|
|
275
|
+
"mira-solace": {
|
|
276
|
+
name: "Mira Solace", genre: "Hip-Hop",
|
|
277
|
+
sound: "melodic modern trap, airy pads, rolling hi-hats, sliding 808s, sparse piano, 130-145 BPM half-time",
|
|
278
|
+
voice: "confident female rapper with a smoky tone who switches to sung melodic hooks",
|
|
279
|
+
bio: "Writes verses on her phone between shifts and performs them like a closing argument.",
|
|
280
|
+
themes: "self-belief, heartbreak turned into fuel, sisterhood, getting paid",
|
|
281
|
+
},
|
|
282
|
+
"cold-brew-cipher": {
|
|
283
|
+
name: "Cold Brew Cipher", genre: "Hip-Hop",
|
|
284
|
+
sound: "jazzy laid-back hip hop, upright bass, muted trumpet, dusty drum breaks, vinyl warmth, 84-92 BPM",
|
|
285
|
+
voice: "relaxed male rapper with a warm baritone, wordplay-heavy, with a soulful sung chorus",
|
|
286
|
+
bio: "A coffee-shop cipher that never closed.",
|
|
287
|
+
themes: "slow mornings, neighbourhood characters, gratitude, wit and wordplay",
|
|
288
|
+
},
|
|
289
|
+
"vapor-meridian": {
|
|
290
|
+
name: "Vapor Meridian", genre: "Synthwave",
|
|
291
|
+
sound: "dark synthwave, pulsing bass sequences, cold arpeggios, gated snares, cinematic swells, 90-110 BPM",
|
|
292
|
+
voice: "cool, breathy female alto, close-miked and haunting",
|
|
293
|
+
bio: "The soundtrack to a neon noir that only exists after midnight.",
|
|
294
|
+
themes: "rain-soaked cities, secrets, rogue machines, love in the static",
|
|
295
|
+
},
|
|
296
|
+
"static-riviera": {
|
|
297
|
+
name: "Static Riviera", genre: "Synthwave",
|
|
298
|
+
sound: "sunny outrun synthwave, bright saw leads, funky slap bass, sax hooks, sunset pads, 110-124 BPM",
|
|
299
|
+
voice: "smooth male tenor with a touch of vocoder on the hooks",
|
|
300
|
+
bio: "A beach-town cassette left in the sun since 1986.",
|
|
301
|
+
themes: "summer nights, coastal highways, arcades, retro romance",
|
|
302
|
+
},
|
|
303
|
+
"boone-calloway": {
|
|
304
|
+
name: "Boone Calloway", genre: "Country",
|
|
305
|
+
sound: "outlaw country, twangy Telecaster, train-beat drums, honky-tonk piano, dobro, 90-120 BPM",
|
|
306
|
+
voice: "deep, weathered male baritone with a slow drawl",
|
|
307
|
+
bio: "Sings like he has been thrown out of every bar in three counties and misses all of them.",
|
|
308
|
+
themes: "freedom, regret, dusty highways, family, whiskey and redemption",
|
|
309
|
+
},
|
|
310
|
+
"gravel-hollow": {
|
|
311
|
+
name: "The Gravel Hollow Band", genre: "Country",
|
|
312
|
+
sound: "bluegrass and Americana, banjo rolls, mandolin chops, fiddle, upright bass, stacked harmonies, 100-140 BPM",
|
|
313
|
+
voice: "a lead male tenor with tight three-part mountain harmonies",
|
|
314
|
+
bio: "A porch band that never learned to play quietly.",
|
|
315
|
+
themes: "mountains, rivers, hard work, faith, homecoming",
|
|
316
|
+
},
|
|
317
|
+
"hollow-sovereign": {
|
|
318
|
+
name: "Hollow Sovereign", genre: "Metal",
|
|
319
|
+
sound: "symphonic doom metal, crushing slow riffs, choirs, strings, thundering double-kick climaxes, 60-90 BPM",
|
|
320
|
+
voice: "soaring operatic female soprano over deep male growled counterpoints",
|
|
321
|
+
bio: "A cathedral of riffs built in the ruins of a kingdom that never was.",
|
|
322
|
+
themes: "fallen empires, myth, grief, endurance, crowns and chains",
|
|
323
|
+
},
|
|
324
|
+
"rust-cathedral": {
|
|
325
|
+
name: "Rust Cathedral", genre: "Metal",
|
|
326
|
+
sound: "fast thrash metal, palm-muted gallop riffs, shredding solos, pounding double kick, 180-220 BPM",
|
|
327
|
+
voice: "aggressive male shout-singing with high screams",
|
|
328
|
+
bio: "Factory-floor thrash forged from overtime and broken machines.",
|
|
329
|
+
themes: "industry, rebellion, machines, the working class fighting back",
|
|
330
|
+
},
|
|
331
|
+
"theo-marlowe": {
|
|
332
|
+
name: "Theo Marlowe", genre: "Pop",
|
|
333
|
+
sound: "groovy funk-pop, slap bass, tight disco drums, bright brass stabs, falsetto hooks, 112-122 BPM",
|
|
334
|
+
voice: "smooth male pop tenor with a playful falsetto",
|
|
335
|
+
bio: "A charmer with a dance floor in his back pocket.",
|
|
336
|
+
themes: "flirting, confidence, summer romance, good nights out",
|
|
337
|
+
},
|
|
338
|
+
"glass-hearts-club": {
|
|
339
|
+
name: "Glass Hearts Club", genre: "Pop",
|
|
340
|
+
sound: "shimmering synth-pop, gated drums, glassy synths, big layered chorus vocals, 118-128 BPM",
|
|
341
|
+
voice: "a trio of bright female voices trading lines and stacking harmonies",
|
|
342
|
+
bio: "Three best friends turning group chats into choruses.",
|
|
343
|
+
themes: "friendship, crushes, breakups survived together, dancing until the lights come on",
|
|
344
|
+
},
|
|
154
345
|
"lumi-kestrel": {
|
|
155
346
|
name: "Lumi Kestrel", genre: "Pop",
|
|
156
347
|
sound: "bright modern dance pop, punchy four-on-the-floor drums, glossy synths, plucky guitar, big pre-chorus lifts, 110-124 BPM",
|
|
@@ -165,10 +356,15 @@ export const DEFAULTS: Omit<LabelSettings, "pausedReason"> = {
|
|
|
165
356
|
enabled: true,
|
|
166
357
|
monthlySeconds: 3600,
|
|
167
358
|
everyDays: 30,
|
|
168
|
-
|
|
359
|
+
albumsPerDay: 1,
|
|
360
|
+
newArtistChance: 0.25,
|
|
361
|
+
seedAlbums: 1,
|
|
169
362
|
songsPerAlbum: 8,
|
|
170
363
|
songSeconds: 150,
|
|
171
364
|
publish: true,
|
|
365
|
+
videos: true,
|
|
366
|
+
parallel: 1,
|
|
367
|
+
genreOrder: ["Punk", "Blues", "Country", "Jazz", "Classic Rock", "Metal", "Hip-Hop", "Pop", "R&B"],
|
|
172
368
|
};
|
|
173
369
|
|
|
174
370
|
const DAY = 86_400_000;
|
|
@@ -255,6 +451,28 @@ export async function planAlbum(
|
|
|
255
451
|
return { title, concept, songs: planned, model: answer.model };
|
|
256
452
|
}
|
|
257
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
|
+
|
|
258
476
|
export async function writeLyrics(
|
|
259
477
|
artist: Pick<LabelArtist, "name" | "genre" | "voice" | "themes">,
|
|
260
478
|
album: Pick<LabelAlbum, "title" | "concept">,
|
|
@@ -301,6 +519,27 @@ export async function workersAiCover(prompt: string, env: Record<string, string
|
|
|
301
519
|
}
|
|
302
520
|
|
|
303
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
|
+
|
|
304
543
|
function tagFile(ffmpeg: string[], from: string, to: string, tags: Record<string, string>, cover: string): Promise<void> {
|
|
305
544
|
const args = [...ffmpeg.slice(1), "-hide_banner", "-loglevel", "error", "-y", "-i", from];
|
|
306
545
|
if (cover) args.push("-i", cover, "-map", "0:a", "-map", "1:v", "-c", "copy", "-disposition:v", "attached_pic");
|
|
@@ -333,6 +572,10 @@ export interface AiLabelDeps {
|
|
|
333
572
|
/** A cover picture for this prompt, or null when nothing can draw one. */
|
|
334
573
|
cover?: (prompt: string) => Promise<Buffer | null>;
|
|
335
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;
|
|
336
579
|
now?: () => number;
|
|
337
580
|
log?: (line: string) => void;
|
|
338
581
|
}
|
|
@@ -344,7 +587,19 @@ export class AiLabel {
|
|
|
344
587
|
private artists = new Map<string, LabelArtist>();
|
|
345
588
|
private settings: LabelSettings = { ...DEFAULTS, pausedReason: "" };
|
|
346
589
|
private usage: Record<string, number> = {};
|
|
347
|
-
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();
|
|
348
603
|
private timer: ReturnType<typeof setInterval> | null = null;
|
|
349
604
|
|
|
350
605
|
constructor(private readonly file: string, private readonly deps: AiLabelDeps) {
|
|
@@ -418,6 +673,8 @@ export class AiLabel {
|
|
|
418
673
|
dueAt(artist: LabelArtist): number {
|
|
419
674
|
if (!artist.enabled) return -1;
|
|
420
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;
|
|
421
678
|
const last = artist.albums[artist.albums.length - 1]!;
|
|
422
679
|
const stagger = (parseInt(artist.key.slice(0, 6), 16) % Math.max(1, Math.floor(this.settings.everyDays / 4))) * DAY;
|
|
423
680
|
return last.plannedAt + this.settings.everyDays * DAY + stagger;
|
|
@@ -439,7 +696,8 @@ export class AiLabel {
|
|
|
439
696
|
root: this.root,
|
|
440
697
|
composer: composer?.name ?? null,
|
|
441
698
|
writer: this.canAsk() ? "language model" : null,
|
|
442
|
-
busy: this.
|
|
699
|
+
busy: this.running ? ([...this.claims.values()].map((one) => one.label).join("; ") || "planning") : "",
|
|
700
|
+
recording: [...this.claims.values()].map((one) => one.label),
|
|
443
701
|
month,
|
|
444
702
|
secondsThisMonth: this.usage[month] ?? 0,
|
|
445
703
|
settings: this.settings,
|
|
@@ -477,6 +735,20 @@ export class AiLabel {
|
|
|
477
735
|
if (typeof input["holder"] === "string" && input["holder"].trim()) this.settings.holder = clean(input["holder"], 120);
|
|
478
736
|
if (input["enabled"] !== undefined) this.settings.enabled = input["enabled"] !== false;
|
|
479
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
|
+
}
|
|
480
752
|
if (input["resume"] === true) this.settings.pausedReason = "";
|
|
481
753
|
this.save();
|
|
482
754
|
if (this.settings.enabled) void this.tick();
|
|
@@ -568,9 +840,88 @@ export class AiLabel {
|
|
|
568
840
|
|
|
569
841
|
startSchedule(everyMs = 5 * 60_000): void {
|
|
570
842
|
if (this.timer) return;
|
|
571
|
-
this.timer = setInterval(() => void this.tick(), everyMs);
|
|
843
|
+
this.timer = setInterval(() => { void this.tick(); void this.videos(); }, everyMs);
|
|
572
844
|
this.timer.unref?.();
|
|
573
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;
|
|
574
925
|
}
|
|
575
926
|
|
|
576
927
|
stopSchedule(): void {
|
|
@@ -578,49 +929,73 @@ export class AiLabel {
|
|
|
578
929
|
this.timer = null;
|
|
579
930
|
}
|
|
580
931
|
|
|
581
|
-
/**
|
|
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
|
+
*/
|
|
582
937
|
async tick(limit = 200): Promise<void> {
|
|
583
|
-
if (this.
|
|
584
|
-
this.
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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;
|
|
589
945
|
}
|
|
946
|
+
};
|
|
947
|
+
try {
|
|
948
|
+
await Promise.all(Array.from({ length: Math.max(1, this.settings.parallel) }, worker));
|
|
590
949
|
} finally {
|
|
591
|
-
this.
|
|
950
|
+
this.running = false;
|
|
592
951
|
}
|
|
593
952
|
}
|
|
594
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
|
+
|
|
595
960
|
private async step(): Promise<Step> {
|
|
596
961
|
const now = this.now();
|
|
597
962
|
const composer = this.composer();
|
|
598
963
|
if (!composer) return "idle";
|
|
599
|
-
|
|
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.
|
|
600
966
|
const making = this.list()
|
|
601
967
|
.filter((one) => one.enabled && !this.backingOff(one, now))
|
|
602
968
|
.flatMap((artist) => artist.albums.filter((album) => !album.doneAt).map((album) => ({ artist, album })))
|
|
603
969
|
.sort((a, b) => a.album.plannedAt - b.album.plannedAt);
|
|
604
970
|
for (const { artist, album } of making) {
|
|
605
|
-
|
|
606
|
-
if (!song) {
|
|
971
|
+
if (album.songs.every((one) => one.file)) {
|
|
607
972
|
album.doneAt = now;
|
|
608
973
|
this.save();
|
|
609
974
|
continue;
|
|
610
975
|
}
|
|
611
|
-
|
|
612
|
-
|
|
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
|
+
}
|
|
613
986
|
return "composed";
|
|
614
987
|
}
|
|
615
|
-
//
|
|
616
|
-
|
|
617
|
-
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";
|
|
618
992
|
const due = this.list()
|
|
619
|
-
.filter((one) => !this.backingOff(one, now))
|
|
993
|
+
.filter((one) => !this.backingOff(one, now) && !this.planning.has(one.key) && !one.albums.some((album) => !album.doneAt))
|
|
620
994
|
.map((one) => ({ one, at: this.dueAt(one) }))
|
|
621
995
|
.filter(({ at }) => at !== -1 && at <= now)
|
|
622
|
-
.sort((a, b) => a.one.albums.length - b.one.albums.length || a.at - b.at)[0];
|
|
623
|
-
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);
|
|
624
999
|
try {
|
|
625
1000
|
await this.plan(due.one.key);
|
|
626
1001
|
due.one.failures = 0;
|
|
@@ -630,6 +1005,52 @@ export class AiLabel {
|
|
|
630
1005
|
} catch (error) {
|
|
631
1006
|
this.failed(due.one, error);
|
|
632
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);
|
|
633
1054
|
}
|
|
634
1055
|
}
|
|
635
1056
|
|
|
@@ -643,7 +1064,6 @@ export class AiLabel {
|
|
|
643
1064
|
}
|
|
644
1065
|
|
|
645
1066
|
private async compose(artist: LabelArtist, album: LabelAlbum, song: LabelSong, composer: Composer): Promise<void> {
|
|
646
|
-
this.busy = `${artist.name}: ${album.title}: ${song.title}`;
|
|
647
1067
|
artist.lastAttemptAt = this.now();
|
|
648
1068
|
mkdirSync(album.folder, { recursive: true });
|
|
649
1069
|
const raw = join(album.folder, `.${randomBytes(4).toString("hex")}.raw.mp3`);
|
|
@@ -694,18 +1114,26 @@ export class AiLabel {
|
|
|
694
1114
|
this.save();
|
|
695
1115
|
this.writeAlbumJson(artist, album);
|
|
696
1116
|
await this.place(artist.genre);
|
|
1117
|
+
// Its video is made beside it, without holding up the next song.
|
|
1118
|
+
void this.videos();
|
|
697
1119
|
} catch (error) {
|
|
698
1120
|
this.failed(artist, error);
|
|
699
1121
|
throw error;
|
|
700
1122
|
} finally {
|
|
701
1123
|
rmSync(raw, { force: true });
|
|
702
|
-
this.busy = "starting";
|
|
703
1124
|
}
|
|
704
1125
|
}
|
|
705
1126
|
|
|
706
|
-
|
|
707
|
-
|
|
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();
|
|
708
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> {
|
|
709
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.`;
|
|
710
1138
|
try {
|
|
711
1139
|
const picture = await (this.deps.cover ?? ((text) => workersAiCover(text)))(prompt);
|
|
@@ -743,7 +1171,13 @@ export class AiLabel {
|
|
|
743
1171
|
}
|
|
744
1172
|
|
|
745
1173
|
/** The genre's station gets every song of that genre: made with the first, then kept in step. */
|
|
746
|
-
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> {
|
|
747
1181
|
await this.deps.index.scan(this.source(), this.deps.probe).done;
|
|
748
1182
|
const artists = this.list().filter((one) => genreSlug(one.genre) === genreSlug(genre));
|
|
749
1183
|
const files = artists
|
|
@@ -844,6 +1278,7 @@ export class AiLabel {
|
|
|
844
1278
|
for (const song of songs) {
|
|
845
1279
|
entries.push({ name: `${folder}/${songBase(song)}.mp3`, path: song.file });
|
|
846
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 });
|
|
847
1282
|
}
|
|
848
1283
|
if (album.cover) entries.push({ name: `${folder}/cover.jpg`, path: album.cover });
|
|
849
1284
|
entries.push({ name: `${folder}/album.json`, bytes: Buffer.from(`${JSON.stringify({
|