nixamp 0.72.1 → 0.74.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/ai-keys.d.ts +1 -1
  2. package/dist/ai-keys.js +2 -0
  3. package/dist/ai-label.d.ts +22 -0
  4. package/dist/ai-label.js +156 -12
  5. package/dist/ai-text.d.ts +2 -0
  6. package/dist/ai-text.js +1 -1
  7. package/dist/guide-vods.d.ts +2 -0
  8. package/dist/guide-vods.js +5 -2
  9. package/dist/library-usage.d.ts +1 -1
  10. package/dist/library-usage.js +2 -2
  11. package/package.json +1 -1
  12. package/src/ai-keys.ts +2 -0
  13. package/src/ai-label.ts +178 -12
  14. package/src/ai-text.ts +3 -1
  15. package/src/guide-vods.ts +5 -2
  16. package/src/library-usage.ts +2 -2
  17. package/web/dist/assets/{ar-eyxh1zc7.js → ar-Bsgrtf2h.js} +1 -1
  18. package/web/dist/assets/{cs-B90TGfUS.js → cs-ZsF7vMVh.js} +1 -1
  19. package/web/dist/assets/{da-DeALlwS0.js → da-381kzPn7.js} +1 -1
  20. package/web/dist/assets/{de-Bh0WOnki.js → de-DizoHpaG.js} +1 -1
  21. package/web/dist/assets/{es-DBpX7st3.js → es-YAdydD7d.js} +1 -1
  22. package/web/dist/assets/{fi-D6UNXw4X.js → fi-BUHThgUk.js} +1 -1
  23. package/web/dist/assets/{fr-BMJjqZmD.js → fr-CKa1Nkl9.js} +1 -1
  24. package/web/dist/assets/{hi-DpKm0Tn3.js → hi-Cbv0ZU2J.js} +1 -1
  25. package/web/dist/assets/{hu-CrQnpajp.js → hu-25AxyySz.js} +1 -1
  26. package/web/dist/assets/{id-CsoO6zNU.js → id-rYanwc8o.js} +1 -1
  27. package/web/dist/assets/{it-CDtrb8CW.js → it-eebB-mQw.js} +1 -1
  28. package/web/dist/assets/{main-7tQ30Sxs.js → main-CFt8ILSw.js} +2 -2
  29. package/web/dist/assets/{nl-B1dym6rM.js → nl-D1EYp0XC.js} +1 -1
  30. package/web/dist/assets/{ru-BWEBAfPH.js → ru-DpxLY1H_.js} +1 -1
  31. package/web/dist/assets/{sv-Briwmc7a.js → sv--koGVKUA.js} +1 -1
  32. package/web/dist/assets/{uk-Cr4aHUYR.js → uk-oI3mSYqs.js} +1 -1
  33. package/web/dist/assets/{vi-D99lOoJC.js → vi-bt6nXnTa.js} +1 -1
  34. package/web/dist/assets/{zh-Di4VXEll.js → zh-Cs3h-m_j.js} +1 -1
  35. package/web/dist/index.html +2 -2
  36. package/web/dist/sw.js +19 -19
package/dist/ai-keys.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const AI_KEY_NAMES: readonly ["ELEVENLABS_API_KEY", "GEMINI_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "NIXAMP_OPENAI_URL", "NIXAMP_OPENAI_MODEL", "NIXAMP_MUSIC_MODEL", "NIXAMP_LYRICS_KEY"];
1
+ export declare const AI_KEY_NAMES: readonly ["ELEVENLABS_API_KEY", "GEMINI_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "NIXAMP_OPENAI_URL", "NIXAMP_OPENAI_MODEL", "NIXAMP_LABEL_MODEL", "NIXAMP_MUSIC_MODEL", "NIXAMP_LYRICS_KEY"];
2
2
  export type AiKeyName = (typeof AI_KEY_NAMES)[number];
3
3
  export declare function aiKeysFile(): string;
4
4
  /** The key, from the environment or ai.json; "" when neither has it. */
package/dist/ai-keys.js CHANGED
@@ -13,6 +13,8 @@ export const AI_KEY_NAMES = [
13
13
  "ELEVENLABS_API_KEY", "GEMINI_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
14
14
  // Any OpenAI-compatible endpoint in OpenAI's place (Cloudflare Workers AI, Ollama, ...) and its model.
15
15
  "NIXAMP_OPENAI_URL", "NIXAMP_OPENAI_MODEL",
16
+ // The label's songwriter on that endpoint (album plans, lyrics, new artists), e.g. @cf/openai/gpt-oss-120b; NIXAMP_OPENAI_MODEL when unset.
17
+ "NIXAMP_LABEL_MODEL",
16
18
  // Which ElevenLabs music model composes: music_v1, music_v2 or music_v2_5 (the default).
17
19
  "NIXAMP_MUSIC_MODEL",
18
20
  // nichedb.dev's key for publishing our own songs' words (lyrics-publish.ts).
@@ -128,6 +128,28 @@ export type ArtistPreset = Pick<LabelArtist, "name" | "genre" | "sound" | "voice
128
128
  * `nixamp station label artist add --preset KEY`, or all of them with `--roster`.
129
129
  */
130
130
  export declare const ROSTER: Record<string, ArtistPreset>;
131
+ /**
132
+ * How a genre is written and performed: the album plan, the words and the
133
+ * music model's brief all read it, so a rapper raps and a punk band does not
134
+ * write a ballad. A genre without one gets the general rules only.
135
+ */
136
+ export interface GenreCraft {
137
+ /** Rapped verses (hooks may be chanted or sung) or sung throughout. */
138
+ delivery: "rapped" | "sung";
139
+ /** How the words are built: form, rhyme, point of view, what makes it the genre. */
140
+ lyrics: string;
141
+ /** What the record sounds like beyond the artist's own sound. */
142
+ production: string;
143
+ /** What the music model is told to leave out. */
144
+ avoid: string;
145
+ }
146
+ /** The genre's craft notes, or none for a genre the label has no notes on. */
147
+ export declare function craftFor(genre: string): GenreCraft | undefined;
148
+ /**
149
+ * The music model's brief for one song: the sound, the song's own style, the
150
+ * voice, how it is delivered and what to leave out.
151
+ */
152
+ export declare function composePrompt(artist: Pick<LabelArtist, "genre" | "sound" | "voice">, song: Pick<LabelSong, "style">): string;
131
153
  export declare const DEFAULTS: Omit<LabelSettings, "pausedReason">;
132
154
  /** A name as a folder or file name: what a filesystem or a zip refuses is dropped. */
133
155
  export declare function fileName(text: string): string;
package/dist/ai-label.js CHANGED
@@ -258,7 +258,147 @@ export const ROSTER = {
258
258
  bio: "Glitter on the outside, journal entries on the inside.",
259
259
  themes: "first nights out, confidence, crushes, friendship, dancing it off",
260
260
  },
261
+ "lowrider-gospel": {
262
+ name: "Lowrider Gospel", genre: "Gangsta Rap",
263
+ sound: "West Coast G-funk, whining portamento synth lead, rubbery Moog bassline, talkbox hook, laid-back swung drums with fat claps, sun-bleached Rhodes, 90-96 BPM",
264
+ voice: "unhurried male baritone with a slow West Coast drawl, rapping a hair behind the beat, cool even when the story turns violent",
265
+ bio: "Raps like he is giving you directions to a block you should not visit after dark.",
266
+ themes: "cruising on Sundays, set loyalty, cousins doing time, the cop car in the mirror, money that came too fast, mothers who pray",
267
+ },
268
+ "cutlass-deacon": {
269
+ name: "Cutlass Deacon", genre: "Gangsta Rap",
270
+ sound: "dark Southern street rap, Memphis horror-movie piano loop, cowbell, rattling TR-808 hats in triplets, distorted sub bass, chanted hooks, 68-75 BPM half-time",
271
+ voice: "menacing low male voice, hushed and close to the mic, rapid triplet flow that snaps into chanted call-and-response hooks",
272
+ bio: "Grew up between a church pew and a trap house and still quotes both.",
273
+ themes: "paranoia, debts collected, a city that eats its young, faith and guilt, the price of loyalty, never sleeping",
274
+ },
275
+ "duchess-ransom": {
276
+ name: "Duchess Ransom", genre: "Gangsta Rap",
277
+ sound: "East Coast mafioso rap, cinematic orchestral sample with strings and brass stabs, hard dusty boom-bap drums, crackling vinyl, sparse menacing piano, 86-90 BPM",
278
+ voice: "cold, precise female MC in a low register, dense multisyllabic rhymes delivered conversationally, never raises her voice",
279
+ bio: "Runs her verses like a ledger: every name, every debt, every date.",
280
+ themes: "the come-up as a business, informants, crooked lawyers, fur and funerals, trust as currency, the cost of the crown",
281
+ },
261
282
  };
283
+ const RAP_FORM = [
284
+ "Rap structure: [Intro] of 1-2 ad-lib lines, [Verse 1] of 12-16 bars (one bar per line), [Hook] of 4 lines, [Verse 2] of 12-16 bars, [Hook] again, optional [Verse 3] or [Outro].",
285
+ "No pre-chorus, no bridge. Every verse line is a full bar of 9-15 syllables with internal and multisyllabic rhymes (slant rhymes welcome) chained across lines, not just end rhymes on simple words.",
286
+ "Verses tell a story in first person with specific detail: a street, a car model year, a sum of money, a time of night, what someone said. Punchlines and wordplay land at the end of every 4 bars.",
287
+ "The hook is short, chantable and rhythmic, repeated phrases, not a sung pop melody.",
288
+ ].join(" ");
289
+ const CRAFT = {
290
+ "gangsta-rap": {
291
+ delivery: "rapped",
292
+ lyrics: `${RAP_FORM} Gangsta rap is street reportage: hard, cinematic first-person crime stories with consequences (arrests, funerals, betrayal, paranoia), bravado earned by detail, slang that fits the region, dark humour. Mild profanity is fine; no slurs, no real names of people, gangs or places' crews. Never preachy, never a happy ending tied in a bow.`,
293
+ production: "Hard-hitting mix, loud punchy drums and heavy bass up front, the rap vocal dry and on top of the beat, ad-libs and hype doubles at line ends.",
294
+ avoid: "singing in the verses, autotune pop melodies, acoustic guitar, EDM drops, uplifting choirs, cheerful major-key pop",
295
+ },
296
+ "hip-hop": {
297
+ delivery: "rapped",
298
+ lyrics: `${RAP_FORM} Hip-hop with real bars: confident, witty, specific, a clear point of view, no motivational-poster lines.`,
299
+ production: "Punchy drums and bass up front, the rap vocal dry and clear on top, ad-libs at line ends.",
300
+ avoid: "singing in the verses, cheesy pop melodies, acoustic strumming, EDM drops",
301
+ },
302
+ punk: {
303
+ delivery: "sung",
304
+ lyrics: "Punk: short, fast, angry and funny. Two-line or four-line verses that spit a specific grievance (a landlord, a boss, a bus that never comes), a shout-along chorus of 2-4 words repeated, no bridge or a 2-line breakdown. Blunt plain words, no poetry-class metaphors.",
305
+ production: "Raw live-room energy, loud distorted guitars, shouted gang vocals on the chorus.",
306
+ avoid: "polished pop vocals, synths, slow ballad sections, autotune",
307
+ },
308
+ blues: {
309
+ delivery: "sung",
310
+ lyrics: "Blues: AAB verses (a line, the same line again with a small twist, then a line that answers it), 3-5 verses, call-and-response with the guitar, earthy concrete detail (rent, rain, the 5:15 train, a woman or man who left), wry humour inside the hurt.",
311
+ production: "Live band in a small room, expressive guitar fills between the vocal lines.",
312
+ avoid: "pop chorus hooks, synths, autotune, polished modern production",
313
+ },
314
+ country: {
315
+ delivery: "sung",
316
+ lyrics: "Country: a story song with a twist in the last chorus, names, places and objects (a pawned guitar, a county road number, a diner waitress), a turn of phrase in the hook that means two things. Plain-spoken, conversational lines.",
317
+ production: "Warm organic band, upfront vocal with natural twang.",
318
+ avoid: "EDM drums, trap hi-hats, autotune, generic stadium pop",
319
+ },
320
+ jazz: {
321
+ delivery: "sung",
322
+ lyrics: "Jazz standard form: AABA, 32 bars, sophisticated adult wit, internal rhymes, a clever title phrase turned three ways, late-night city detail.",
323
+ production: "Acoustic small group, natural room sound, solos between choruses.",
324
+ avoid: "pop drums, synths, autotune, rap",
325
+ },
326
+ metal: {
327
+ delivery: "sung",
328
+ lyrics: "Metal: dark, mythic or apocalyptic imagery made concrete, short punchy lines that fit riffs, a chanted chorus, a breakdown section of 2-4 lines. No self-help language.",
329
+ production: "Crushing tight guitars, powerful drums, huge low end.",
330
+ avoid: "pop vocals, acoustic softness, synth-pop, rap",
331
+ },
332
+ "classic-rock": {
333
+ delivery: "sung",
334
+ lyrics: "Classic rock: swaggering, sensual or road-worn stories, a riff-shaped chorus with a memorable title phrase, characters and places, a little danger.",
335
+ production: "Analog 1970s band sound, warm tube amps, live drums.",
336
+ avoid: "modern pop gloss, trap drums, autotune, synths",
337
+ },
338
+ rock: {
339
+ delivery: "sung",
340
+ lyrics: "Rock: concrete images and a story, a chorus built on a strong title phrase, verses that move the story on.",
341
+ production: "Big live band, guitars and drums up front.",
342
+ avoid: "autotune, trap drums, EDM drops",
343
+ },
344
+ pop: {
345
+ delivery: "sung",
346
+ lyrics: "Pop: a single sharp title idea, conversational verses with specific moments (a text message, a dress, a parking lot at 2 a.m.), a chorus that is instantly singable and repeats the title.",
347
+ production: "Polished modern mix, vocals bright and forward.",
348
+ avoid: "muddy lo-fi, out-of-tune vocals, rap verses",
349
+ },
350
+ rnb: {
351
+ delivery: "sung",
352
+ lyrics: "R&B: intimate, sensual, adult, specific (a hotel key, a voicemail, a late shift), melismatic hook phrases, a pre-chorus that builds tension.",
353
+ production: "Smooth warm low end, lush stacked harmonies, ad-libs.",
354
+ avoid: "rock guitars, EDM drops, stiff robotic vocals",
355
+ },
356
+ synthwave: {
357
+ delivery: "sung",
358
+ lyrics: "Synthwave: cinematic, nocturnal, a movie told in images, short evocative lines.",
359
+ production: "Retro analog synths, gated reverb drums, wide stereo.",
360
+ avoid: "acoustic instruments, trap hi-hats, modern pop vocal effects",
361
+ },
362
+ "yacht-rock": {
363
+ delivery: "sung",
364
+ lyrics: "Yacht rock: smooth, wry adult romance, marinas and late lunches, sophisticated chords mean sophisticated words, a falsetto-ready chorus.",
365
+ production: "Glossy late-1970s West Coast studio sound, Rhodes, clean guitar, tight session drums.",
366
+ avoid: "distortion, rap, EDM, trap drums",
367
+ },
368
+ };
369
+ /** The genre's craft notes, or none for a genre the label has no notes on. */
370
+ export function craftFor(genre) {
371
+ return CRAFT[genreSlug(genre)];
372
+ }
373
+ /**
374
+ * How the label's songwriter is asked: NIXAMP_LABEL_MODEL when set (a reasoning
375
+ * model like gpt-oss-120b writes far better words than the quick default, but
376
+ * thinks first, so it gets room and time).
377
+ */
378
+ function writer() {
379
+ const model = aiKey("NIXAMP_LABEL_MODEL");
380
+ return { ...(model ? { model } : {}), maxTokens: 9000, timeoutMs: 240_000 };
381
+ }
382
+ /** What every lyric avoids: the stock lines models reach for first. */
383
+ const CLICHES = "neon, fire inside, rise above, shine so bright, heart of gold, chains, echoes, shadows, the night is young, we'll never back down, stand tall, break free, feel alive, against all odds, journey, destiny, soul on fire, light the way, never give up";
384
+ /**
385
+ * The music model's brief for one song: the sound, the song's own style, the
386
+ * voice, how it is delivered and what to leave out.
387
+ */
388
+ export function composePrompt(artist, song) {
389
+ const craft = craftFor(artist.genre);
390
+ const delivery = craft?.delivery === "rapped"
391
+ ? "Lead vocal raps the verses in English: rhythmic, percussive flow locked to the beat, not sung; the hook is chanted or half-sung."
392
+ : "Sung in English.";
393
+ return [
394
+ `${artist.sound}.`,
395
+ `${song.style}.`,
396
+ `Lead vocal: ${artist.voice}.`,
397
+ delivery,
398
+ craft?.production ?? "",
399
+ craft?.avoid ? `Avoid: ${craft.avoid}.` : "",
400
+ ].filter(Boolean).join(" ").slice(0, 1500);
401
+ }
262
402
  export const DEFAULTS = {
263
403
  holder: "Profullstack, Inc.",
264
404
  enabled: true,
@@ -317,12 +457,16 @@ const PLAN_SYSTEM = [
317
457
  "Plan one album. Answer as JSON: {\"title\": \"1-6 words\", \"concept\": \"what the album is about, at most 300 characters\",",
318
458
  "\"songs\": [{\"title\": \"1-6 words\", \"style\": \"the production brief for this song, at most 300 characters: tempo in BPM, mood, key or mode, featured instruments, arrangement\"}]}.",
319
459
  "Vary tempo, mood and arrangement across the running order, within the artist's sound; open strong, end memorable.",
460
+ "Titles are specific and surprising (an object, a place, a phrase someone says), never motivational slogans or abstract words like Rise, Empowered, Shine, Journey.",
461
+ "Each song's style names its own story in a few words (who, where, what happens) so no two songs are about the same thing.",
320
462
  ].join(" ");
321
463
  const LYRICS_SYSTEM = [
322
- "You write original English song lyrics for an AI singer. Never quote or imitate an existing song and never name a real artist.",
323
- "Put each section tag on its own line: [Intro], [Verse 1], [Pre-Chorus], [Chorus], [Verse 2], [Bridge], [Outro].",
324
- "Use two verses of four lines, a four-line chorus sung two or three times, a pre-chorus of two lines and a bridge of two to four lines; vivid, specific, singable lines that rhyme where it sounds natural. Never just one line under a section.",
325
- "Only the words that are sung, no stage directions or notes. Answer as JSON: {\"lyrics\": \"the lyrics, lines separated by \\n\"}.",
464
+ "You are a professional songwriter writing original English lyrics for an AI performer. Never quote or imitate an existing song and never name a real artist.",
465
+ "Write like a working pro, not a greeting card: concrete nouns, sensory detail, a point of view, a story that moves, lines a real artist would be proud of. Show, never tell how anyone feels.",
466
+ `Never use these stock words or phrases: ${CLICHES}. No rhyming a word with itself, no filler like "oh yeah" to finish a line.`,
467
+ "Put each section tag on its own line, e.g. [Intro], [Verse 1], [Pre-Chorus], [Chorus], [Hook], [Verse 2], [Bridge], [Outro]. Never just one line under a section.",
468
+ "Follow the genre's craft notes when given; without them: two verses of four to eight lines, a pre-chorus of two lines, a four-line chorus sung two or three times, a bridge of two to four lines.",
469
+ "Only the words that are performed, no stage directions or notes. Answer as JSON: {\"lyrics\": \"the lyrics, lines separated by \\n\"}.",
326
470
  ].join(" ");
327
471
  export async function planAlbum(artist, songs, ask = askJson) {
328
472
  const before = artist.albums.slice(-4).map((one) => `"${one.title}" (${one.songs.map((song) => song.title).join("; ")})`);
@@ -336,11 +480,11 @@ export async function planAlbum(artist, songs, ask = askJson) {
336
480
  artist.bio ? `Who they are: ${artist.bio}` : "",
337
481
  artist.themes ? `What they sing about: ${artist.themes}` : "",
338
482
  artist.cast ? `The band, as they are (write for these people): ${artist.cast}` : "",
483
+ craftFor(artist.genre) ? `Genre craft: ${craftFor(artist.genre).lyrics}` : "",
339
484
  before.length ? `Albums already out (do not reuse their titles or song titles; take the story somewhere new): ${before.join(" | ")}` : "This is the artist's debut album.",
340
485
  `The album has exactly ${songs} songs.`,
341
486
  ].filter(Boolean).join("\n"),
342
- maxTokens: 2048,
343
- timeoutMs: 90_000,
487
+ ...writer(),
344
488
  });
345
489
  const title = clean(answer.value["title"], 80).replace(/"/g, "");
346
490
  const concept = clean(answer.value["concept"], 400);
@@ -365,8 +509,7 @@ export async function inventArtist(genre, taken, ask = askJson) {
365
509
  const answer = await ask({
366
510
  system: ARTIST_SYSTEM,
367
511
  prompt: `Genre: ${genre}\nAlready on the label (do not reuse or echo these names or sounds): ${taken.slice(-40).join(", ") || "nobody yet"}`,
368
- maxTokens: 600,
369
- timeoutMs: 60_000,
512
+ ...writer(),
370
513
  });
371
514
  const name = clean(answer.value["name"], 60).replace(/"/g, "");
372
515
  const sound = clean(answer.value["sound"], 400);
@@ -377,6 +520,7 @@ export async function inventArtist(genre, taken, ask = askJson) {
377
520
  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) };
378
521
  }
379
522
  export async function writeLyrics(artist, album, song, ask = askJson) {
523
+ const craft = craftFor(artist.genre);
380
524
  const answer = await ask({
381
525
  system: LYRICS_SYSTEM,
382
526
  prompt: [
@@ -385,10 +529,10 @@ export async function writeLyrics(artist, album, song, ask = askJson) {
385
529
  artist.cast ? `Who sings and plays it (their voice and personality shape the words): ${artist.cast}` : "",
386
530
  `Album: "${album.title}"${album.concept ? `: ${album.concept}` : ""}`,
387
531
  `Song: "${song.title}": ${song.style}`,
388
- `The song lasts about ${song.seconds} seconds: keep the words under ${Math.max(600, Math.round(song.seconds * 10))} characters.`,
532
+ craft ? `Genre craft (follow it): ${craft.lyrics}` : "",
533
+ `The song lasts about ${song.seconds} seconds: keep the words under ${Math.max(600, Math.round(song.seconds * (craft?.delivery === "rapped" ? 16 : 10)))} characters.`,
389
534
  ].filter(Boolean).join("\n"),
390
- maxTokens: 1500,
391
- timeoutMs: 90_000,
535
+ ...writer(),
392
536
  });
393
537
  const lyrics = cleanLyrics(String(answer.value["lyrics"] ?? ""));
394
538
  if (lyrics.replace(/\[[^\]]*\]/g, "").trim().length < 40)
@@ -1776,7 +1920,7 @@ export class AiLabel {
1776
1920
  }
1777
1921
  await this.ensureCover(artist, album);
1778
1922
  this.log(`composing "${song.title}" (${artist.name}, ${album.title}, ${song.seconds} s, ${composer.name})`);
1779
- const prompt = `${artist.sound}. ${song.style}. Lead vocal: ${artist.voice}. Sung in English.`.slice(0, 1500);
1923
+ const prompt = composePrompt(artist, song);
1780
1924
  await composer.compose({ prompt, seconds: song.seconds, vocals: true, out: raw, lyrics: song.lyrics });
1781
1925
  const month = this.period();
1782
1926
  this.usage[month] = (this.usage[month] ?? 0) + song.seconds;
package/dist/ai-text.d.ts CHANGED
@@ -7,6 +7,8 @@ export interface AskOptions {
7
7
  timeoutMs?: number;
8
8
  /** The longest answer, in tokens (default 1024): a song's words need more than a title does. */
9
9
  maxTokens?: number;
10
+ /** The OpenAI-compatible model to ask instead of NIXAMP_OPENAI_MODEL (a slower, better writer for song words). */
11
+ model?: string;
10
12
  fetcher?: typeof fetch;
11
13
  env?: Record<string, string | undefined>;
12
14
  }
package/dist/ai-text.js CHANGED
@@ -117,7 +117,7 @@ export async function askJson(options) {
117
117
  // Workers AI's /ai/v1, Ollama's /v1): NIXAMP_OPENAI_URL is that base.
118
118
  const baseUrl = (aiKey("NIXAMP_OPENAI_URL", env) || "https://api.openai.com/v1").replace(/\/+$/, "");
119
119
  const official = new URL(baseUrl).host === "api.openai.com";
120
- const model = aiKey("NIXAMP_OPENAI_MODEL", env) || "gpt-4.1-mini";
120
+ const model = options.model || aiKey("NIXAMP_OPENAI_MODEL", env) || "gpt-4.1-mini";
121
121
  const response = await send(`${baseUrl}/chat/completions`, {
122
122
  method: "POST",
123
123
  signal: AbortSignal.timeout(options.timeoutMs ?? 30_000),
@@ -19,6 +19,8 @@ export interface GuideVod {
19
19
  }
20
20
  /** The bases a price may be put on: what the owner may sell, not just share. */
21
21
  export declare const SELLABLE_BASES: readonly RoomBasis[];
22
+ /** What a new video of the owner's costs to download unless it says otherwise (Anthony: "$1 per video"). */
23
+ export declare const VOD_DEFAULT_PRICE_CENTS = 100;
22
24
  /** $100: a price above that is a typo. */
23
25
  export declare const VOD_PRICE_MAX_CENTS = 10000;
24
26
  /** Files one member may have on the guide at once, and files on it in all. */
@@ -24,6 +24,8 @@ import { randomBytes } from "node:crypto";
24
24
  import { ROOM_BASES } from "./guide-rooms.js";
25
25
  /** The bases a price may be put on: what the owner may sell, not just share. */
26
26
  export const SELLABLE_BASES = ["owned", "public-domain"];
27
+ /** What a new video of the owner's costs to download unless it says otherwise (Anthony: "$1 per video"). */
28
+ export const VOD_DEFAULT_PRICE_CENTS = 100;
27
29
  /** $100: a price above that is a typo. */
28
30
  export const VOD_PRICE_MAX_CENTS = 10_000;
29
31
  /** Files one member may have on the guide at once, and files on it in all. */
@@ -273,8 +275,9 @@ export async function handleVods(request, response, path, ctx) {
273
275
  const title = typeof body?.title === "string" ? body.title.trim().slice(0, 160) : "";
274
276
  const note = typeof body?.note === "string" ? body.note.trim().slice(0, 300) : "";
275
277
  const owner = taken?.by ?? by;
276
- // A price, in dollars: only the owner, only on its own, only what it may sell. Left out, the old one stays.
277
- let priceCents = taken?.priceCents ?? 0;
278
+ // A price, in dollars: only the owner, only on its own, only what it may sell. Left out, the old one stays,
279
+ // and a new one of the owner's that it may sell costs the default.
280
+ let priceCents = taken ? (taken.priceCents ?? 0) : owner === "" && ctx.canControl && SELLABLE_BASES.includes(basis) ? VOD_DEFAULT_PRICE_CENTS : 0;
278
281
  if (body?.price !== undefined && body.price !== null) {
279
282
  const cents = Math.round(Number(body.price) * 100);
280
283
  if (!ctx.canControl || owner !== "") {
@@ -1,3 +1,3 @@
1
1
  /** `nixamp library` subcommands and usage, apart so help does not load the client. */
2
2
  export declare const LIBRARY_COMMANDS: Set<string>;
3
- export declare const LIBRARY_USAGE = "nixamp library \u2014 what this server has, indexed\n\n nixamp library PATH the folder the daemon serves (set once)\n nixamp library status items by state, sources, last scans\n nixamp library sources\n nixamp library add PATH index another folder (inside your home, not hidden)\n nixamp library scan [SOURCE] [--force] [--wait]\n nixamp library search WORDS [--artist A] [--album A] [--state failed] [--limit 20]\n nixamp library show ITEM\n nixamp library fix ITEM --title T [--artist A] [--album A] [--genre G] [--track N] [--year N]\n nixamp library remove SOURCE forget a folder; its files are not touched\n nixamp library playlists\n nixamp library playlist new NAME [ITEM...]\n nixamp library playlist show|delete PLAYLIST\n nixamp library playlist add PLAYLIST ITEM...\n nixamp library publish ITEM... | FOLDER | WORDS --basis B [--title T] [--note N] [--dry-run]\n on demand on live.streaming: a link that plays each from the start,\n from any of your servers (needs nixamp login); --here for this server's own guide\n (B: owned, permission, licensed, creative-commons, public-domain)\n --price USD: sell it as a download on nixamp.com (guide owner, owned or\n public-domain only; publish again with --price 0 to stop)\n nixamp library vods what is published on demand, with each link\n nixamp library unpublish VOD take one off\n\n --json machine-readable output\n --url, --key another server, as with `nixamp admin`";
3
+ export declare const LIBRARY_USAGE = "nixamp library \u2014 what this server has, indexed\n\n nixamp library PATH the folder the daemon serves (set once)\n nixamp library status items by state, sources, last scans\n nixamp library sources\n nixamp library add PATH index another folder (inside your home, not hidden)\n nixamp library scan [SOURCE] [--force] [--wait]\n nixamp library search WORDS [--artist A] [--album A] [--state failed] [--limit 20]\n nixamp library show ITEM\n nixamp library fix ITEM --title T [--artist A] [--album A] [--genre G] [--track N] [--year N]\n nixamp library remove SOURCE forget a folder; its files are not touched\n nixamp library playlists\n nixamp library playlist new NAME [ITEM...]\n nixamp library playlist show|delete PLAYLIST\n nixamp library playlist add PLAYLIST ITEM...\n nixamp library publish ITEM... | FOLDER | WORDS --basis B [--title T] [--note N] [--dry-run]\n on demand on live.streaming: a link that plays each from the start,\n from any of your servers (needs nixamp login); --here for this server's own guide\n (B: owned, permission, licensed, creative-commons, public-domain)\n --price USD: its download price on nixamp.com (guide owner, owned or\n public-domain only; new ones default to $1, --price 0 for free)\n nixamp library vods what is published on demand, with each link\n nixamp library unpublish VOD take one off\n\n --json machine-readable output\n --url, --key another server, as with `nixamp admin`";
@@ -19,8 +19,8 @@ export const LIBRARY_USAGE = `nixamp library — what this server has, indexed
19
19
  on demand on live.streaming: a link that plays each from the start,
20
20
  from any of your servers (needs nixamp login); --here for this server's own guide
21
21
  (B: owned, permission, licensed, creative-commons, public-domain)
22
- --price USD: sell it as a download on nixamp.com (guide owner, owned or
23
- public-domain only; publish again with --price 0 to stop)
22
+ --price USD: its download price on nixamp.com (guide owner, owned or
23
+ public-domain only; new ones default to $1, --price 0 for free)
24
24
  nixamp library vods what is published on demand, with each link
25
25
  nixamp library unpublish VOD take one off
26
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.72.1",
3
+ "version": "0.74.0",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/ai-keys.ts CHANGED
@@ -14,6 +14,8 @@ export const AI_KEY_NAMES = [
14
14
  "ELEVENLABS_API_KEY", "GEMINI_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
15
15
  // Any OpenAI-compatible endpoint in OpenAI's place (Cloudflare Workers AI, Ollama, ...) and its model.
16
16
  "NIXAMP_OPENAI_URL", "NIXAMP_OPENAI_MODEL",
17
+ // The label's songwriter on that endpoint (album plans, lyrics, new artists), e.g. @cf/openai/gpt-oss-120b; NIXAMP_OPENAI_MODEL when unset.
18
+ "NIXAMP_LABEL_MODEL",
17
19
  // Which ElevenLabs music model composes: music_v1, music_v2 or music_v2_5 (the default).
18
20
  "NIXAMP_MUSIC_MODEL",
19
21
  // nichedb.dev's key for publishing our own songs' words (lyrics-publish.ts).
package/src/ai-label.ts CHANGED
@@ -384,8 +384,170 @@ export const ROSTER: Record<string, ArtistPreset> = {
384
384
  bio: "Glitter on the outside, journal entries on the inside.",
385
385
  themes: "first nights out, confidence, crushes, friendship, dancing it off",
386
386
  },
387
+ "lowrider-gospel": {
388
+ name: "Lowrider Gospel", genre: "Gangsta Rap",
389
+ sound: "West Coast G-funk, whining portamento synth lead, rubbery Moog bassline, talkbox hook, laid-back swung drums with fat claps, sun-bleached Rhodes, 90-96 BPM",
390
+ voice: "unhurried male baritone with a slow West Coast drawl, rapping a hair behind the beat, cool even when the story turns violent",
391
+ bio: "Raps like he is giving you directions to a block you should not visit after dark.",
392
+ themes: "cruising on Sundays, set loyalty, cousins doing time, the cop car in the mirror, money that came too fast, mothers who pray",
393
+ },
394
+ "cutlass-deacon": {
395
+ name: "Cutlass Deacon", genre: "Gangsta Rap",
396
+ sound: "dark Southern street rap, Memphis horror-movie piano loop, cowbell, rattling TR-808 hats in triplets, distorted sub bass, chanted hooks, 68-75 BPM half-time",
397
+ voice: "menacing low male voice, hushed and close to the mic, rapid triplet flow that snaps into chanted call-and-response hooks",
398
+ bio: "Grew up between a church pew and a trap house and still quotes both.",
399
+ themes: "paranoia, debts collected, a city that eats its young, faith and guilt, the price of loyalty, never sleeping",
400
+ },
401
+ "duchess-ransom": {
402
+ name: "Duchess Ransom", genre: "Gangsta Rap",
403
+ sound: "East Coast mafioso rap, cinematic orchestral sample with strings and brass stabs, hard dusty boom-bap drums, crackling vinyl, sparse menacing piano, 86-90 BPM",
404
+ voice: "cold, precise female MC in a low register, dense multisyllabic rhymes delivered conversationally, never raises her voice",
405
+ bio: "Runs her verses like a ledger: every name, every debt, every date.",
406
+ themes: "the come-up as a business, informants, crooked lawyers, fur and funerals, trust as currency, the cost of the crown",
407
+ },
408
+ };
409
+
410
+ /**
411
+ * How a genre is written and performed: the album plan, the words and the
412
+ * music model's brief all read it, so a rapper raps and a punk band does not
413
+ * write a ballad. A genre without one gets the general rules only.
414
+ */
415
+ export interface GenreCraft {
416
+ /** Rapped verses (hooks may be chanted or sung) or sung throughout. */
417
+ delivery: "rapped" | "sung";
418
+ /** How the words are built: form, rhyme, point of view, what makes it the genre. */
419
+ lyrics: string;
420
+ /** What the record sounds like beyond the artist's own sound. */
421
+ production: string;
422
+ /** What the music model is told to leave out. */
423
+ avoid: string;
424
+ }
425
+
426
+ const RAP_FORM = [
427
+ "Rap structure: [Intro] of 1-2 ad-lib lines, [Verse 1] of 12-16 bars (one bar per line), [Hook] of 4 lines, [Verse 2] of 12-16 bars, [Hook] again, optional [Verse 3] or [Outro].",
428
+ "No pre-chorus, no bridge. Every verse line is a full bar of 9-15 syllables with internal and multisyllabic rhymes (slant rhymes welcome) chained across lines, not just end rhymes on simple words.",
429
+ "Verses tell a story in first person with specific detail: a street, a car model year, a sum of money, a time of night, what someone said. Punchlines and wordplay land at the end of every 4 bars.",
430
+ "The hook is short, chantable and rhythmic, repeated phrases, not a sung pop melody.",
431
+ ].join(" ");
432
+
433
+ const CRAFT: Record<string, GenreCraft> = {
434
+ "gangsta-rap": {
435
+ delivery: "rapped",
436
+ lyrics: `${RAP_FORM} Gangsta rap is street reportage: hard, cinematic first-person crime stories with consequences (arrests, funerals, betrayal, paranoia), bravado earned by detail, slang that fits the region, dark humour. Mild profanity is fine; no slurs, no real names of people, gangs or places' crews. Never preachy, never a happy ending tied in a bow.`,
437
+ production: "Hard-hitting mix, loud punchy drums and heavy bass up front, the rap vocal dry and on top of the beat, ad-libs and hype doubles at line ends.",
438
+ avoid: "singing in the verses, autotune pop melodies, acoustic guitar, EDM drops, uplifting choirs, cheerful major-key pop",
439
+ },
440
+ "hip-hop": {
441
+ delivery: "rapped",
442
+ lyrics: `${RAP_FORM} Hip-hop with real bars: confident, witty, specific, a clear point of view, no motivational-poster lines.`,
443
+ production: "Punchy drums and bass up front, the rap vocal dry and clear on top, ad-libs at line ends.",
444
+ avoid: "singing in the verses, cheesy pop melodies, acoustic strumming, EDM drops",
445
+ },
446
+ punk: {
447
+ delivery: "sung",
448
+ lyrics: "Punk: short, fast, angry and funny. Two-line or four-line verses that spit a specific grievance (a landlord, a boss, a bus that never comes), a shout-along chorus of 2-4 words repeated, no bridge or a 2-line breakdown. Blunt plain words, no poetry-class metaphors.",
449
+ production: "Raw live-room energy, loud distorted guitars, shouted gang vocals on the chorus.",
450
+ avoid: "polished pop vocals, synths, slow ballad sections, autotune",
451
+ },
452
+ blues: {
453
+ delivery: "sung",
454
+ lyrics: "Blues: AAB verses (a line, the same line again with a small twist, then a line that answers it), 3-5 verses, call-and-response with the guitar, earthy concrete detail (rent, rain, the 5:15 train, a woman or man who left), wry humour inside the hurt.",
455
+ production: "Live band in a small room, expressive guitar fills between the vocal lines.",
456
+ avoid: "pop chorus hooks, synths, autotune, polished modern production",
457
+ },
458
+ country: {
459
+ delivery: "sung",
460
+ lyrics: "Country: a story song with a twist in the last chorus, names, places and objects (a pawned guitar, a county road number, a diner waitress), a turn of phrase in the hook that means two things. Plain-spoken, conversational lines.",
461
+ production: "Warm organic band, upfront vocal with natural twang.",
462
+ avoid: "EDM drums, trap hi-hats, autotune, generic stadium pop",
463
+ },
464
+ jazz: {
465
+ delivery: "sung",
466
+ lyrics: "Jazz standard form: AABA, 32 bars, sophisticated adult wit, internal rhymes, a clever title phrase turned three ways, late-night city detail.",
467
+ production: "Acoustic small group, natural room sound, solos between choruses.",
468
+ avoid: "pop drums, synths, autotune, rap",
469
+ },
470
+ metal: {
471
+ delivery: "sung",
472
+ lyrics: "Metal: dark, mythic or apocalyptic imagery made concrete, short punchy lines that fit riffs, a chanted chorus, a breakdown section of 2-4 lines. No self-help language.",
473
+ production: "Crushing tight guitars, powerful drums, huge low end.",
474
+ avoid: "pop vocals, acoustic softness, synth-pop, rap",
475
+ },
476
+ "classic-rock": {
477
+ delivery: "sung",
478
+ lyrics: "Classic rock: swaggering, sensual or road-worn stories, a riff-shaped chorus with a memorable title phrase, characters and places, a little danger.",
479
+ production: "Analog 1970s band sound, warm tube amps, live drums.",
480
+ avoid: "modern pop gloss, trap drums, autotune, synths",
481
+ },
482
+ rock: {
483
+ delivery: "sung",
484
+ lyrics: "Rock: concrete images and a story, a chorus built on a strong title phrase, verses that move the story on.",
485
+ production: "Big live band, guitars and drums up front.",
486
+ avoid: "autotune, trap drums, EDM drops",
487
+ },
488
+ pop: {
489
+ delivery: "sung",
490
+ lyrics: "Pop: a single sharp title idea, conversational verses with specific moments (a text message, a dress, a parking lot at 2 a.m.), a chorus that is instantly singable and repeats the title.",
491
+ production: "Polished modern mix, vocals bright and forward.",
492
+ avoid: "muddy lo-fi, out-of-tune vocals, rap verses",
493
+ },
494
+ rnb: {
495
+ delivery: "sung",
496
+ lyrics: "R&B: intimate, sensual, adult, specific (a hotel key, a voicemail, a late shift), melismatic hook phrases, a pre-chorus that builds tension.",
497
+ production: "Smooth warm low end, lush stacked harmonies, ad-libs.",
498
+ avoid: "rock guitars, EDM drops, stiff robotic vocals",
499
+ },
500
+ synthwave: {
501
+ delivery: "sung",
502
+ lyrics: "Synthwave: cinematic, nocturnal, a movie told in images, short evocative lines.",
503
+ production: "Retro analog synths, gated reverb drums, wide stereo.",
504
+ avoid: "acoustic instruments, trap hi-hats, modern pop vocal effects",
505
+ },
506
+ "yacht-rock": {
507
+ delivery: "sung",
508
+ lyrics: "Yacht rock: smooth, wry adult romance, marinas and late lunches, sophisticated chords mean sophisticated words, a falsetto-ready chorus.",
509
+ production: "Glossy late-1970s West Coast studio sound, Rhodes, clean guitar, tight session drums.",
510
+ avoid: "distortion, rap, EDM, trap drums",
511
+ },
387
512
  };
388
513
 
514
+ /** The genre's craft notes, or none for a genre the label has no notes on. */
515
+ export function craftFor(genre: string): GenreCraft | undefined {
516
+ return CRAFT[genreSlug(genre)];
517
+ }
518
+
519
+ /**
520
+ * How the label's songwriter is asked: NIXAMP_LABEL_MODEL when set (a reasoning
521
+ * model like gpt-oss-120b writes far better words than the quick default, but
522
+ * thinks first, so it gets room and time).
523
+ */
524
+ function writer(): { model?: string; maxTokens: number; timeoutMs: number } {
525
+ const model = aiKey("NIXAMP_LABEL_MODEL");
526
+ return { ...(model ? { model } : {}), maxTokens: 9000, timeoutMs: 240_000 };
527
+ }
528
+
529
+ /** What every lyric avoids: the stock lines models reach for first. */
530
+ const CLICHES = "neon, fire inside, rise above, shine so bright, heart of gold, chains, echoes, shadows, the night is young, we'll never back down, stand tall, break free, feel alive, against all odds, journey, destiny, soul on fire, light the way, never give up";
531
+
532
+ /**
533
+ * The music model's brief for one song: the sound, the song's own style, the
534
+ * voice, how it is delivered and what to leave out.
535
+ */
536
+ export function composePrompt(artist: Pick<LabelArtist, "genre" | "sound" | "voice">, song: Pick<LabelSong, "style">): string {
537
+ const craft = craftFor(artist.genre);
538
+ const delivery = craft?.delivery === "rapped"
539
+ ? "Lead vocal raps the verses in English: rhythmic, percussive flow locked to the beat, not sung; the hook is chanted or half-sung."
540
+ : "Sung in English.";
541
+ return [
542
+ `${artist.sound}.`,
543
+ `${song.style}.`,
544
+ `Lead vocal: ${artist.voice}.`,
545
+ delivery,
546
+ craft?.production ?? "",
547
+ craft?.avoid ? `Avoid: ${craft.avoid}.` : "",
548
+ ].filter(Boolean).join(" ").slice(0, 1500);
549
+ }
550
+
389
551
  export const DEFAULTS: Omit<LabelSettings, "pausedReason"> = {
390
552
  holder: "Profullstack, Inc.",
391
553
  enabled: true,
@@ -451,13 +613,17 @@ const PLAN_SYSTEM = [
451
613
  "Plan one album. Answer as JSON: {\"title\": \"1-6 words\", \"concept\": \"what the album is about, at most 300 characters\",",
452
614
  "\"songs\": [{\"title\": \"1-6 words\", \"style\": \"the production brief for this song, at most 300 characters: tempo in BPM, mood, key or mode, featured instruments, arrangement\"}]}.",
453
615
  "Vary tempo, mood and arrangement across the running order, within the artist's sound; open strong, end memorable.",
616
+ "Titles are specific and surprising (an object, a place, a phrase someone says), never motivational slogans or abstract words like Rise, Empowered, Shine, Journey.",
617
+ "Each song's style names its own story in a few words (who, where, what happens) so no two songs are about the same thing.",
454
618
  ].join(" ");
455
619
 
456
620
  const LYRICS_SYSTEM = [
457
- "You write original English song lyrics for an AI singer. Never quote or imitate an existing song and never name a real artist.",
458
- "Put each section tag on its own line: [Intro], [Verse 1], [Pre-Chorus], [Chorus], [Verse 2], [Bridge], [Outro].",
459
- "Use two verses of four lines, a four-line chorus sung two or three times, a pre-chorus of two lines and a bridge of two to four lines; vivid, specific, singable lines that rhyme where it sounds natural. Never just one line under a section.",
460
- "Only the words that are sung, no stage directions or notes. Answer as JSON: {\"lyrics\": \"the lyrics, lines separated by \\n\"}.",
621
+ "You are a professional songwriter writing original English lyrics for an AI performer. Never quote or imitate an existing song and never name a real artist.",
622
+ "Write like a working pro, not a greeting card: concrete nouns, sensory detail, a point of view, a story that moves, lines a real artist would be proud of. Show, never tell how anyone feels.",
623
+ `Never use these stock words or phrases: ${CLICHES}. No rhyming a word with itself, no filler like "oh yeah" to finish a line.`,
624
+ "Put each section tag on its own line, e.g. [Intro], [Verse 1], [Pre-Chorus], [Chorus], [Hook], [Verse 2], [Bridge], [Outro]. Never just one line under a section.",
625
+ "Follow the genre's craft notes when given; without them: two verses of four to eight lines, a pre-chorus of two lines, a four-line chorus sung two or three times, a bridge of two to four lines.",
626
+ "Only the words that are performed, no stage directions or notes. Answer as JSON: {\"lyrics\": \"the lyrics, lines separated by \\n\"}.",
461
627
  ].join(" ");
462
628
 
463
629
  export async function planAlbum(
@@ -476,11 +642,11 @@ export async function planAlbum(
476
642
  artist.bio ? `Who they are: ${artist.bio}` : "",
477
643
  artist.themes ? `What they sing about: ${artist.themes}` : "",
478
644
  artist.cast ? `The band, as they are (write for these people): ${artist.cast}` : "",
645
+ craftFor(artist.genre) ? `Genre craft: ${craftFor(artist.genre)!.lyrics}` : "",
479
646
  before.length ? `Albums already out (do not reuse their titles or song titles; take the story somewhere new): ${before.join(" | ")}` : "This is the artist's debut album.",
480
647
  `The album has exactly ${songs} songs.`,
481
648
  ].filter(Boolean).join("\n"),
482
- maxTokens: 2048,
483
- timeoutMs: 90_000,
649
+ ...writer(),
484
650
  });
485
651
  const title = clean(answer.value["title"], 80).replace(/"/g, "");
486
652
  const concept = clean(answer.value["concept"], 400);
@@ -506,8 +672,7 @@ export async function inventArtist(genre: string, taken: string[], ask: typeof a
506
672
  const answer = await ask({
507
673
  system: ARTIST_SYSTEM,
508
674
  prompt: `Genre: ${genre}\nAlready on the label (do not reuse or echo these names or sounds): ${taken.slice(-40).join(", ") || "nobody yet"}`,
509
- maxTokens: 600,
510
- timeoutMs: 60_000,
675
+ ...writer(),
511
676
  });
512
677
  const name = clean(answer.value["name"], 60).replace(/"/g, "");
513
678
  const sound = clean(answer.value["sound"], 400);
@@ -522,6 +687,7 @@ export async function writeLyrics(
522
687
  song: Pick<LabelSong, "title" | "style" | "seconds">,
523
688
  ask: typeof askJson = askJson,
524
689
  ): Promise<{ lyrics: string; model: string }> {
690
+ const craft = craftFor(artist.genre);
525
691
  const answer = await ask({
526
692
  system: LYRICS_SYSTEM,
527
693
  prompt: [
@@ -530,10 +696,10 @@ export async function writeLyrics(
530
696
  artist.cast ? `Who sings and plays it (their voice and personality shape the words): ${artist.cast}` : "",
531
697
  `Album: "${album.title}"${album.concept ? `: ${album.concept}` : ""}`,
532
698
  `Song: "${song.title}": ${song.style}`,
533
- `The song lasts about ${song.seconds} seconds: keep the words under ${Math.max(600, Math.round(song.seconds * 10))} characters.`,
699
+ craft ? `Genre craft (follow it): ${craft.lyrics}` : "",
700
+ `The song lasts about ${song.seconds} seconds: keep the words under ${Math.max(600, Math.round(song.seconds * (craft?.delivery === "rapped" ? 16 : 10)))} characters.`,
534
701
  ].filter(Boolean).join("\n"),
535
- maxTokens: 1500,
536
- timeoutMs: 90_000,
702
+ ...writer(),
537
703
  });
538
704
  const lyrics = cleanLyrics(String(answer.value["lyrics"] ?? ""));
539
705
  if (lyrics.replace(/\[[^\]]*\]/g, "").trim().length < 40) throw new Error("the model wrote no lyrics");
@@ -1889,7 +2055,7 @@ export class AiLabel {
1889
2055
  }
1890
2056
  await this.ensureCover(artist, album);
1891
2057
  this.log(`composing "${song.title}" (${artist.name}, ${album.title}, ${song.seconds} s, ${composer.name})`);
1892
- const prompt = `${artist.sound}. ${song.style}. Lead vocal: ${artist.voice}. Sung in English.`.slice(0, 1500);
2058
+ const prompt = composePrompt(artist, song);
1893
2059
  await composer.compose({ prompt, seconds: song.seconds, vocals: true, out: raw, lyrics: song.lyrics });
1894
2060
  const month = this.period();
1895
2061
  this.usage[month] = (this.usage[month] ?? 0) + song.seconds;