nixamp 0.55.2 → 0.57.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 +38 -3
- package/dist/ai-label.js +96 -5
- package/dist/channels.d.ts +20 -0
- package/dist/channels.js +24 -0
- package/dist/label-profiles.d.ts +60 -0
- package/dist/label-profiles.js +190 -0
- package/dist/label-routes.js +29 -2
- package/dist/library.d.ts +6 -0
- package/dist/library.js +11 -0
- package/dist/playlist-input.d.ts +32 -7
- package/dist/playlist-input.js +20 -2
- package/dist/server.js +9 -1
- package/dist/station-routes.d.ts +1 -0
- package/dist/station-routes.js +23 -5
- package/dist/station-runner.d.ts +48 -0
- package/dist/station-runner.js +208 -4
- package/dist/stations.d.ts +14 -0
- package/dist/stations.js +11 -0
- package/package.json +1 -1
- package/src/ai-label.ts +95 -8
- package/src/channels.ts +30 -0
- package/src/label-profiles.ts +212 -0
- package/src/label-routes.ts +30 -2
- package/src/library.ts +14 -0
- package/src/playlist-input.ts +46 -5
- package/src/server.ts +10 -1
- package/src/station-routes.ts +23 -6
- package/src/station-runner.ts +205 -4
- package/src/stations.ts +15 -0
- package/web/dist/assets/{ar-DDphLo2L.js → ar-d05d_7Df.js} +1 -1
- package/web/dist/assets/{cs-CMKLJTnw.js → cs-CclrNwa7.js} +1 -1
- package/web/dist/assets/{da-CibDEkHw.js → da-C30KTGl1.js} +1 -1
- package/web/dist/assets/{de-D1BL2QsJ.js → de-D2A2RQwi.js} +1 -1
- package/web/dist/assets/{es-Cj9qr9jo.js → es-BXFx0WZ5.js} +1 -1
- package/web/dist/assets/{fi-DqCO5i_7.js → fi-DPA3S8og.js} +1 -1
- package/web/dist/assets/{fr-DUk14AWf.js → fr-D_0wnr4h.js} +1 -1
- package/web/dist/assets/{hi-U_jzSO9-.js → hi-EfiCQnb9.js} +1 -1
- package/web/dist/assets/{hu-B28enhWa.js → hu-CK5rnxKO.js} +1 -1
- package/web/dist/assets/{id-DlGfGd_6.js → id-BgTV_4UK.js} +1 -1
- package/web/dist/assets/{it-D3HMQpf5.js → it-DGefapXn.js} +1 -1
- package/web/dist/assets/{main-D0eqtyhi.js → main-C-tWhTWo.js} +2 -2
- package/web/dist/assets/{nl-i1Y9R3xr.js → nl-Cyst29Bp.js} +1 -1
- package/web/dist/assets/{ru-DjRg1y2P.js → ru-D8G6Wynm.js} +1 -1
- package/web/dist/assets/{station-DxBlD6Yl.js → station-D6sd4DqP.js} +1 -1
- package/web/dist/assets/{sv-Dsj-93a2.js → sv-aYnDAFwz.js} +1 -1
- package/web/dist/assets/{uk-COWBdTZb.js → uk-BDtew7z3.js} +1 -1
- package/web/dist/assets/{vi-CgEMtDCA.js → vi-CRlDn2Xh.js} +1 -1
- package/web/dist/assets/{zh-CBa5mJ__.js → zh-BCAIjeQq.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/station.html +1 -1
- package/web/dist/sw.js +20 -20
package/dist/ai-label.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type Composer } from "./ai-radio.ts";
|
|
2
2
|
import { askJson } from "./ai-text.ts";
|
|
3
3
|
import { type SyncedLine } from "./lyrics.ts";
|
|
4
|
+
import { type Member } from "./label-profiles.ts";
|
|
4
5
|
import type { LibraryIndex, Probed } from "./library-index.ts";
|
|
5
6
|
import type { StationRunner } from "./station-runner.ts";
|
|
6
7
|
import { type StationStore } from "./stations.ts";
|
|
@@ -58,6 +59,8 @@ export interface LabelArtist {
|
|
|
58
59
|
themes: string;
|
|
59
60
|
enabled: boolean;
|
|
60
61
|
albums: LabelAlbum[];
|
|
62
|
+
/** Its people, by slug: each has members/<slug>.md and .jpg in the artist's folder (label-profiles.ts). */
|
|
63
|
+
members?: string[];
|
|
61
64
|
createdAt: number;
|
|
62
65
|
lastAttemptAt: number;
|
|
63
66
|
lastError: string;
|
|
@@ -109,7 +112,9 @@ export declare function genreSlug(genre: string): string;
|
|
|
109
112
|
export declare function songBase(song: Pick<LabelSong, "n" | "title">): string;
|
|
110
113
|
/** Words as a singer gets them: section lines kept, the model's own notes and fences dropped. */
|
|
111
114
|
export declare function cleanLyrics(text: string): string;
|
|
112
|
-
export declare function planAlbum(artist: Pick<LabelArtist, "name" | "genre" | "sound" | "voice" | "bio" | "themes" | "albums"
|
|
115
|
+
export declare function planAlbum(artist: Pick<LabelArtist, "name" | "genre" | "sound" | "voice" | "bio" | "themes" | "albums"> & {
|
|
116
|
+
cast?: string;
|
|
117
|
+
}, songs: number, ask?: typeof askJson): Promise<{
|
|
113
118
|
title: string;
|
|
114
119
|
concept: string;
|
|
115
120
|
songs: Array<{
|
|
@@ -120,7 +125,9 @@ export declare function planAlbum(artist: Pick<LabelArtist, "name" | "genre" | "
|
|
|
120
125
|
}>;
|
|
121
126
|
/** A new artist for this genre, unlike the ones already signed. */
|
|
122
127
|
export declare function inventArtist(genre: string, taken: string[], ask?: typeof askJson): Promise<ArtistPreset>;
|
|
123
|
-
export declare function writeLyrics(artist: Pick<LabelArtist, "name" | "genre" | "voice" | "themes"
|
|
128
|
+
export declare function writeLyrics(artist: Pick<LabelArtist, "name" | "genre" | "voice" | "themes"> & {
|
|
129
|
+
cast?: string;
|
|
130
|
+
}, album: Pick<LabelAlbum, "title" | "concept">, song: Pick<LabelSong, "title" | "style" | "seconds">, ask?: typeof askJson): Promise<{
|
|
124
131
|
lyrics: string;
|
|
125
132
|
model: string;
|
|
126
133
|
}>;
|
|
@@ -146,7 +153,9 @@ export declare function sungLines(lyrics: string): string[];
|
|
|
146
153
|
*/
|
|
147
154
|
export declare function syncLines(lyrics: string, timed: TimedWord[]): SyncedLine[];
|
|
148
155
|
/** Image prompts for a song's video, one a scene, from its words and mood. */
|
|
149
|
-
export declare function writeStoryboard(artist: Pick<LabelArtist, "name" | "genre" | "sound"
|
|
156
|
+
export declare function writeStoryboard(artist: Pick<LabelArtist, "name" | "genre" | "sound"> & {
|
|
157
|
+
cast?: string;
|
|
158
|
+
}, album: Pick<LabelAlbum, "title" | "concept">, song: Pick<LabelSong, "title" | "style" | "lyrics">, scenes: number, ask?: typeof askJson): Promise<string[]>;
|
|
150
159
|
/**
|
|
151
160
|
* A song's music video from its storyboard: each scene's picture moves (a
|
|
152
161
|
* slow zoom in, a pan, a zoom out, a pan back, in turn), scenes cross-fade,
|
|
@@ -212,6 +221,7 @@ export declare class AiLabel {
|
|
|
212
221
|
private settings;
|
|
213
222
|
private usage;
|
|
214
223
|
private running;
|
|
224
|
+
private profiling;
|
|
215
225
|
/** Songs being made now, by `<album>:<n>`. */
|
|
216
226
|
private claims;
|
|
217
227
|
/** Artists whose next album is being planned now. */
|
|
@@ -332,6 +342,24 @@ export declare class AiLabel {
|
|
|
332
342
|
artistName: string;
|
|
333
343
|
albumTitle: string;
|
|
334
344
|
} | null;
|
|
345
|
+
/** The artist's folder: <root>/<genre>/<artist>/, where its albums and profile pages live. */
|
|
346
|
+
folderOf(artist: Pick<LabelArtist, "genre" | "name">): string;
|
|
347
|
+
/** The artist's people as their pages say now (hand edits win). */
|
|
348
|
+
cast(artist: LabelArtist): Member[];
|
|
349
|
+
/** The people in one line, for a prompt; "" before they are cast. */
|
|
350
|
+
castOf(artist: LabelArtist): string;
|
|
351
|
+
/**
|
|
352
|
+
* The artist's profile: its members invented once (and their portraits
|
|
353
|
+
* drawn), and artist.md written again with what it has released.
|
|
354
|
+
*/
|
|
355
|
+
ensureProfile(artist: LabelArtist): Promise<void>;
|
|
356
|
+
/** Every artist's profile, made or brought up to date; one at a time. */
|
|
357
|
+
profiles(): Promise<void>;
|
|
358
|
+
/** A profile file anybody may read: artist.md, members/<slug>.md or members/<slug>.jpg. */
|
|
359
|
+
profileFile(artistKey: string, what: string): {
|
|
360
|
+
path: string;
|
|
361
|
+
type: string;
|
|
362
|
+
} | null;
|
|
335
363
|
/** The song's MP3 when `file` is one of the label's music videos (it holds the tags, cover and words); else `file`. */
|
|
336
364
|
audioFor(file: string): string;
|
|
337
365
|
/** A released song's MP3, when this grant's scope covers it. */
|
|
@@ -361,6 +389,13 @@ export interface PublicArtist {
|
|
|
361
389
|
name: string;
|
|
362
390
|
genre: string;
|
|
363
391
|
bio: string;
|
|
392
|
+
/** The people (profile pages at /api/v1/label/<artist>/members/<slug>.md, portraits .jpg). */
|
|
393
|
+
members: Array<{
|
|
394
|
+
slug: string;
|
|
395
|
+
name: string;
|
|
396
|
+
role: string;
|
|
397
|
+
bio: string;
|
|
398
|
+
}>;
|
|
364
399
|
albums: Array<{
|
|
365
400
|
key: string;
|
|
366
401
|
title: string;
|
package/dist/ai-label.js
CHANGED
|
@@ -34,6 +34,7 @@ import { aiKey } from "./ai-keys.js";
|
|
|
34
34
|
import { composerFromEnv, OutOfCredit } from "./ai-radio.js";
|
|
35
35
|
import { askJson, hasModel } from "./ai-text.js";
|
|
36
36
|
import { formatLrc } from "./lyrics.js";
|
|
37
|
+
import { bandMarkdown, castLine, inventMembers, readMembers, writeMembers } from "./label-profiles.js";
|
|
37
38
|
import { StationError } from "./stations.js";
|
|
38
39
|
/**
|
|
39
40
|
* The label's first roster: invented names, one genre each, sung in English.
|
|
@@ -327,6 +328,7 @@ export async function planAlbum(artist, songs, ask = askJson) {
|
|
|
327
328
|
`Voice: ${artist.voice}`,
|
|
328
329
|
artist.bio ? `Who they are: ${artist.bio}` : "",
|
|
329
330
|
artist.themes ? `What they sing about: ${artist.themes}` : "",
|
|
331
|
+
artist.cast ? `The band, as they are (write for these people): ${artist.cast}` : "",
|
|
330
332
|
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.",
|
|
331
333
|
`The album has exactly ${songs} songs.`,
|
|
332
334
|
].filter(Boolean).join("\n"),
|
|
@@ -373,6 +375,7 @@ export async function writeLyrics(artist, album, song, ask = askJson) {
|
|
|
373
375
|
prompt: [
|
|
374
376
|
`Artist: ${artist.name} (${artist.genre}); voice: ${artist.voice}`,
|
|
375
377
|
artist.themes ? `What they sing about: ${artist.themes}` : "",
|
|
378
|
+
artist.cast ? `Who sings and plays it (their voice and personality shape the words): ${artist.cast}` : "",
|
|
376
379
|
`Album: "${album.title}"${album.concept ? `: ${album.concept}` : ""}`,
|
|
377
380
|
`Song: "${song.title}": ${song.style}`,
|
|
378
381
|
`The song lasts about ${song.seconds} seconds: keep the words under ${Math.max(600, Math.round(song.seconds * 10))} characters.`,
|
|
@@ -468,6 +471,7 @@ export async function writeStoryboard(artist, album, song, scenes, ask = askJson
|
|
|
468
471
|
system: STORYBOARD_SYSTEM,
|
|
469
472
|
prompt: [
|
|
470
473
|
`Song: "${song.title}" by ${artist.name} (${artist.genre}; ${artist.sound}), from "${album.title}"${album.concept ? `: ${album.concept}` : ""}`,
|
|
474
|
+
artist.cast ? `The band (these are the only performers; whenever one appears, name them and repeat their full look in that prompt, the image model remembers nothing): ${artist.cast}` : "",
|
|
471
475
|
`Mood and arrangement: ${song.style}`,
|
|
472
476
|
song.lyrics ? `Lyrics:\n${song.lyrics.slice(0, 1800)}` : "",
|
|
473
477
|
`Exactly ${scenes} scenes.`,
|
|
@@ -595,6 +599,7 @@ export class AiLabel {
|
|
|
595
599
|
settings = { ...DEFAULTS, pausedReason: "" };
|
|
596
600
|
usage = {};
|
|
597
601
|
running = false;
|
|
602
|
+
profiling = false;
|
|
598
603
|
/** Songs being made now, by `<album>:<n>`. */
|
|
599
604
|
claims = new Map();
|
|
600
605
|
/** Artists whose next album is being planned now. */
|
|
@@ -838,7 +843,8 @@ export class AiLabel {
|
|
|
838
843
|
const pending = artist.albums.find((one) => !one.doneAt);
|
|
839
844
|
if (pending)
|
|
840
845
|
throw new StationError("album_in_progress", `"${pending.title}" is still being made`, 409);
|
|
841
|
-
|
|
846
|
+
await this.ensureProfile(artist).catch(() => undefined);
|
|
847
|
+
const plan = await planAlbum({ ...artist, cast: this.castOf(artist) }, this.settings.songsPerAlbum, this.deps.ask ?? askJson);
|
|
842
848
|
const year = new Date(this.now()).getUTCFullYear();
|
|
843
849
|
let title = plan.title;
|
|
844
850
|
if (artist.albums.some((one) => one.title.toLowerCase() === title.toLowerCase()))
|
|
@@ -868,7 +874,8 @@ export class AiLabel {
|
|
|
868
874
|
this.timer = setInterval(() => { void this.tick(); void this.videos(); }, everyMs);
|
|
869
875
|
this.timer.unref?.();
|
|
870
876
|
void this.tick();
|
|
871
|
-
|
|
877
|
+
// Profiles first (quick), so videos made after cast the right people.
|
|
878
|
+
void this.profiles().finally(() => void this.videos());
|
|
872
879
|
}
|
|
873
880
|
/**
|
|
874
881
|
* Music videos for every released song without one, one at a time (a
|
|
@@ -1006,7 +1013,7 @@ export class AiLabel {
|
|
|
1006
1013
|
if (this.canAsk()) {
|
|
1007
1014
|
try {
|
|
1008
1015
|
const count = Math.max(4, Math.min(16, Math.round(seconds / 9)));
|
|
1009
|
-
const prompts = await writeStoryboard(artist, album, song, count, this.deps.ask ?? askJson);
|
|
1016
|
+
const prompts = await writeStoryboard({ ...artist, cast: this.castOf(artist) }, album, song, count, this.deps.ask ?? askJson);
|
|
1010
1017
|
mkdirSync(scenes, { recursive: true });
|
|
1011
1018
|
const draw = this.deps.image ?? ((prompt) => workersAiCover(prompt));
|
|
1012
1019
|
// A few at a time: the image model is quick, the machine is shared.
|
|
@@ -1226,7 +1233,7 @@ export class AiLabel {
|
|
|
1226
1233
|
try {
|
|
1227
1234
|
let writer = song.made || album.writer;
|
|
1228
1235
|
if (!song.lyrics) {
|
|
1229
|
-
const written = await writeLyrics(artist, album, song, this.deps.ask ?? askJson);
|
|
1236
|
+
const written = await writeLyrics({ ...artist, cast: this.castOf(artist) }, album, song, this.deps.ask ?? askJson);
|
|
1230
1237
|
song.lyrics = written.lyrics;
|
|
1231
1238
|
writer = written.model;
|
|
1232
1239
|
song.made = writer;
|
|
@@ -1266,6 +1273,7 @@ export class AiLabel {
|
|
|
1266
1273
|
if (album.songs.every((one) => one.file)) {
|
|
1267
1274
|
album.doneAt = this.now();
|
|
1268
1275
|
this.log(`${artist.name} released "${album.title}"`);
|
|
1276
|
+
void this.ensureProfile(artist).catch(() => undefined);
|
|
1269
1277
|
}
|
|
1270
1278
|
this.save();
|
|
1271
1279
|
this.writeAlbumJson(artist, album);
|
|
@@ -1292,7 +1300,8 @@ export class AiLabel {
|
|
|
1292
1300
|
return drawing;
|
|
1293
1301
|
}
|
|
1294
1302
|
async drawCover(artist, album) {
|
|
1295
|
-
const
|
|
1303
|
+
const cast = this.castOf(artist);
|
|
1304
|
+
const prompt = `Album cover art for "${album.title}", a ${artist.genre} album by ${artist.name}. ${album.concept} Style: ${artist.sound}.${cast ? ` If the band appears, they look exactly like this: ${cast}.` : ""} Striking, original, square composition. No text, no letters, no logos, no watermark.`;
|
|
1296
1305
|
try {
|
|
1297
1306
|
const picture = await (this.deps.cover ?? ((text) => workersAiCover(text)))(prompt);
|
|
1298
1307
|
if (picture) {
|
|
@@ -1378,6 +1387,7 @@ export class AiLabel {
|
|
|
1378
1387
|
.filter((one) => one.genre === genre)
|
|
1379
1388
|
.map((one) => ({
|
|
1380
1389
|
key: one.key, name: one.name, genre: one.genre, bio: one.bio,
|
|
1390
|
+
members: this.cast(one).map((member) => ({ slug: member.slug, name: member.name, role: member.role, bio: member.bio })),
|
|
1381
1391
|
albums: one.albums
|
|
1382
1392
|
.filter((album) => album.songs.some((song) => song.file))
|
|
1383
1393
|
.map((album) => ({
|
|
@@ -1414,6 +1424,68 @@ export class AiLabel {
|
|
|
1414
1424
|
}
|
|
1415
1425
|
return null;
|
|
1416
1426
|
}
|
|
1427
|
+
/** The artist's folder: <root>/<genre>/<artist>/, where its albums and profile pages live. */
|
|
1428
|
+
folderOf(artist) {
|
|
1429
|
+
return join(this.root, genreSlug(artist.genre), fileName(artist.name));
|
|
1430
|
+
}
|
|
1431
|
+
/** The artist's people as their pages say now (hand edits win). */
|
|
1432
|
+
cast(artist) {
|
|
1433
|
+
return readMembers(this.folderOf(artist), artist.members ?? []);
|
|
1434
|
+
}
|
|
1435
|
+
/** The people in one line, for a prompt; "" before they are cast. */
|
|
1436
|
+
castOf(artist) {
|
|
1437
|
+
return castLine(this.cast(artist));
|
|
1438
|
+
}
|
|
1439
|
+
/**
|
|
1440
|
+
* The artist's profile: its members invented once (and their portraits
|
|
1441
|
+
* drawn), and artist.md written again with what it has released.
|
|
1442
|
+
*/
|
|
1443
|
+
async ensureProfile(artist) {
|
|
1444
|
+
const folder = this.folderOf(artist);
|
|
1445
|
+
mkdirSync(folder, { recursive: true });
|
|
1446
|
+
const band = { name: artist.name, genre: artist.genre, holder: this.settings.holder };
|
|
1447
|
+
const draw = this.deps.image === undefined ? (prompt) => workersAiCover(prompt) : this.deps.image;
|
|
1448
|
+
if (!artist.members?.length && this.canAsk()) {
|
|
1449
|
+
const invented = await inventMembers(artist, this.deps.ask ?? askJson);
|
|
1450
|
+
const written = await writeMembers(folder, invented, band, draw);
|
|
1451
|
+
artist.members = written.map((one) => one.slug);
|
|
1452
|
+
this.save();
|
|
1453
|
+
this.log(`${artist.name}: ${written.map((one) => `${one.name} (${one.role})`).join(", ")}`);
|
|
1454
|
+
}
|
|
1455
|
+
else if (artist.members?.length) {
|
|
1456
|
+
// Portraits that are missing (a page added by hand, a failed drawing) are drawn now.
|
|
1457
|
+
await writeMembers(folder, this.cast(artist), band, draw);
|
|
1458
|
+
}
|
|
1459
|
+
writeFileSync(join(folder, "artist.md"), bandMarkdown({
|
|
1460
|
+
...artist, holder: this.settings.holder, members: this.cast(artist),
|
|
1461
|
+
albums: artist.albums.filter((one) => one.doneAt || one.songs.some((song) => song.file)).map((one) => ({ year: one.year, title: one.title })),
|
|
1462
|
+
}));
|
|
1463
|
+
}
|
|
1464
|
+
/** Every artist's profile, made or brought up to date; one at a time. */
|
|
1465
|
+
async profiles() {
|
|
1466
|
+
if (this.profiling)
|
|
1467
|
+
return;
|
|
1468
|
+
this.profiling = true;
|
|
1469
|
+
try {
|
|
1470
|
+
for (const artist of this.list())
|
|
1471
|
+
await this.ensureProfile(artist).catch((error) => this.log(`no profile for ${artist.name}: ${error.message}`));
|
|
1472
|
+
}
|
|
1473
|
+
finally {
|
|
1474
|
+
this.profiling = false;
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
/** A profile file anybody may read: artist.md, members/<slug>.md or members/<slug>.jpg. */
|
|
1478
|
+
profileFile(artistKey, what) {
|
|
1479
|
+
const artist = this.artists.get(artistKey);
|
|
1480
|
+
if (!artist)
|
|
1481
|
+
return null;
|
|
1482
|
+
if (what === "artist.md")
|
|
1483
|
+
return { path: join(this.folderOf(artist), "artist.md"), type: "text/markdown; charset=utf-8" };
|
|
1484
|
+
const match = /^members\/([a-z0-9-]{1,40})\.(md|jpg)$/.exec(what);
|
|
1485
|
+
if (!match || !(artist.members ?? []).includes(match[1]))
|
|
1486
|
+
return null;
|
|
1487
|
+
return { path: join(this.folderOf(artist), "members", `${match[1]}.${match[2]}`), type: match[2] === "md" ? "text/markdown; charset=utf-8" : "image/jpeg" };
|
|
1488
|
+
}
|
|
1417
1489
|
/** The song's MP3 when `file` is one of the label's music videos (it holds the tags, cover and words); else `file`. */
|
|
1418
1490
|
audioFor(file) {
|
|
1419
1491
|
if (!file.endsWith(".mp4"))
|
|
@@ -1466,6 +1538,25 @@ export class AiLabel {
|
|
|
1466
1538
|
}
|
|
1467
1539
|
if (entries.length === 0)
|
|
1468
1540
|
return null;
|
|
1541
|
+
// Everything by an artist comes with who they are: the band's page, each member's page and portrait.
|
|
1542
|
+
if (scope.kind === "artist") {
|
|
1543
|
+
const folder = this.folderOf(artist);
|
|
1544
|
+
const exists = (path) => { try {
|
|
1545
|
+
return statSync(path).isFile();
|
|
1546
|
+
}
|
|
1547
|
+
catch {
|
|
1548
|
+
return false;
|
|
1549
|
+
} };
|
|
1550
|
+
if (exists(join(folder, "artist.md")))
|
|
1551
|
+
entries.push({ name: `${top}/artist.md`, path: join(folder, "artist.md") });
|
|
1552
|
+
for (const slug of artist.members ?? []) {
|
|
1553
|
+
for (const ext of ["md", "jpg"]) {
|
|
1554
|
+
const path = join(folder, "members", `${slug}.${ext}`);
|
|
1555
|
+
if (exists(path))
|
|
1556
|
+
entries.push({ name: `${top}/members/${slug}.${ext}`, path });
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1469
1560
|
const what = scope.kind === "artist" ? "Discography" : scope.kind === "album" ? albums[0].title : this.songFile(scope.artist, scope.album, scope.n) ? albums[0].songs.find((one) => one.n === scope.n).title : "Song";
|
|
1470
1561
|
return { name: `${fileName(artist.name)} - ${fileName(what)}.zip`, entries };
|
|
1471
1562
|
}
|
package/dist/channels.d.ts
CHANGED
|
@@ -247,6 +247,16 @@ export declare class Channel {
|
|
|
247
247
|
* failure, so it does not count as a redial.
|
|
248
248
|
*/
|
|
249
249
|
skip(): boolean;
|
|
250
|
+
/** How far listeners have got into this dial of a list, by the encoder's progress. */
|
|
251
|
+
private heard;
|
|
252
|
+
/** Where a list would pick up after what listeners hear now (PlaylistInputStream.resumePoint). */
|
|
253
|
+
resumePoint(): {
|
|
254
|
+
file: string;
|
|
255
|
+
upcoming: string[];
|
|
256
|
+
sinceBreak: number;
|
|
257
|
+
} | null;
|
|
258
|
+
/** Whether listeners would get sound (and, for a picture, its opening) if they joined now. */
|
|
259
|
+
get flowing(): boolean;
|
|
250
260
|
/** An entry's tags, as read when it was put on. */
|
|
251
261
|
tagsOf(file: string): {
|
|
252
262
|
title: string;
|
|
@@ -539,6 +549,16 @@ export declare class Channels {
|
|
|
539
549
|
artist: string;
|
|
540
550
|
album: string;
|
|
541
551
|
} | undefined;
|
|
552
|
+
/** Where a channel playing a list would pick up after what is on now; null when it is not one. */
|
|
553
|
+
resumePoint(id: string): {
|
|
554
|
+
file: string;
|
|
555
|
+
upcoming: string[];
|
|
556
|
+
sinceBreak: number;
|
|
557
|
+
} | null;
|
|
558
|
+
/** Whether a channel is up and has started sending. */
|
|
559
|
+
flowing(id: string): boolean;
|
|
560
|
+
/** Who is attached to one channel: listeners, relays, packagers. */
|
|
561
|
+
listenersOf(id: string): number;
|
|
542
562
|
/** The next entry, now, on a channel playing a list of files. */
|
|
543
563
|
skip(id: string): boolean;
|
|
544
564
|
stop(id: string): boolean;
|
package/dist/channels.js
CHANGED
|
@@ -169,6 +169,16 @@ export class Channel {
|
|
|
169
169
|
this.info.redials = redials;
|
|
170
170
|
return done;
|
|
171
171
|
}
|
|
172
|
+
/** How far listeners have got into this dial of a list, by the encoder's progress. */
|
|
173
|
+
heard = 0;
|
|
174
|
+
/** Where a list would pick up after what listeners hear now (PlaylistInputStream.resumePoint). */
|
|
175
|
+
resumePoint() {
|
|
176
|
+
return this.playlistInput?.resumePoint(this.heard) ?? null;
|
|
177
|
+
}
|
|
178
|
+
/** Whether listeners would get sound (and, for a picture, its opening) if they joined now. */
|
|
179
|
+
get flowing() {
|
|
180
|
+
return this.info.bytes > 0 && (!this.fragments || this.fragments.ready);
|
|
181
|
+
}
|
|
172
182
|
/** An entry's tags, as read when it was put on. */
|
|
173
183
|
tagsOf(file) {
|
|
174
184
|
return this.playlistInput?.tags.get(file);
|
|
@@ -334,6 +344,7 @@ export class Channel {
|
|
|
334
344
|
...resume.rotation,
|
|
335
345
|
}) : null;
|
|
336
346
|
this.playlistInput = playlistInput;
|
|
347
|
+
this.heard = 0;
|
|
337
348
|
const through = this.outroOn ? null : playlistInput
|
|
338
349
|
? { format: "mpegts", open: async (signal) => playlistInput.open(signal) }
|
|
339
350
|
: this.options.through?.(this.info, from, input, audio) ?? null;
|
|
@@ -413,6 +424,7 @@ export class Channel {
|
|
|
413
424
|
if (match) {
|
|
414
425
|
const seconds = Number(match[1]) / 1e6;
|
|
415
426
|
if (playlistInput) {
|
|
427
|
+
this.heard = seconds;
|
|
416
428
|
const place = playlistInput.place(seconds);
|
|
417
429
|
// A break (-1) belongs to no list; a track's index belongs to its own.
|
|
418
430
|
if (place.at >= 0 && this.info.playlist !== place.list)
|
|
@@ -1283,6 +1295,18 @@ export class Channels {
|
|
|
1283
1295
|
tagsOf(id, file) {
|
|
1284
1296
|
return this.open.get(id)?.tagsOf(file);
|
|
1285
1297
|
}
|
|
1298
|
+
/** Where a channel playing a list would pick up after what is on now; null when it is not one. */
|
|
1299
|
+
resumePoint(id) {
|
|
1300
|
+
return this.open.get(id)?.resumePoint() ?? null;
|
|
1301
|
+
}
|
|
1302
|
+
/** Whether a channel is up and has started sending. */
|
|
1303
|
+
flowing(id) {
|
|
1304
|
+
return this.open.get(id)?.flowing ?? false;
|
|
1305
|
+
}
|
|
1306
|
+
/** Who is attached to one channel: listeners, relays, packagers. */
|
|
1307
|
+
listenersOf(id) {
|
|
1308
|
+
return this.open.get(id)?.listeners.size ?? 0;
|
|
1309
|
+
}
|
|
1286
1310
|
/** The next entry, now, on a channel playing a list of files. */
|
|
1287
1311
|
skip(id) {
|
|
1288
1312
|
return this.open.get(id)?.skip() ?? false;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { askJson } from "./ai-text.ts";
|
|
2
|
+
export interface Member {
|
|
3
|
+
slug: string;
|
|
4
|
+
name: string;
|
|
5
|
+
/** What they do in the band: "lead vocals", "lead guitar", "drums"... */
|
|
6
|
+
role: string;
|
|
7
|
+
/** How they look, in enough detail to draw the same person again: age, face, hair, build, signature clothes. */
|
|
8
|
+
look: string;
|
|
9
|
+
/** How they sound, for a singer; how they play, for the rest. */
|
|
10
|
+
voice: string;
|
|
11
|
+
personality: string;
|
|
12
|
+
influences: string;
|
|
13
|
+
bio: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function memberSlug(name: string): string;
|
|
16
|
+
/** A member's OpenProfile page. */
|
|
17
|
+
export declare function memberMarkdown(member: Member, band: {
|
|
18
|
+
name: string;
|
|
19
|
+
holder: string;
|
|
20
|
+
}): string;
|
|
21
|
+
/** A member's page read back, so hand edits win; null when it is not one of ours. */
|
|
22
|
+
export declare function readMember(text: string, slug: string): Member | null;
|
|
23
|
+
/** The band's OpenProfile page. */
|
|
24
|
+
export declare function bandMarkdown(band: {
|
|
25
|
+
name: string;
|
|
26
|
+
genre: string;
|
|
27
|
+
sound: string;
|
|
28
|
+
voice: string;
|
|
29
|
+
bio: string;
|
|
30
|
+
themes: string;
|
|
31
|
+
holder: string;
|
|
32
|
+
members: Member[];
|
|
33
|
+
albums: Array<{
|
|
34
|
+
year: number;
|
|
35
|
+
title: string;
|
|
36
|
+
}>;
|
|
37
|
+
}): string;
|
|
38
|
+
/** The band's people, invented once: one for a solo act, three to five for a band. */
|
|
39
|
+
export declare function inventMembers(band: {
|
|
40
|
+
name: string;
|
|
41
|
+
genre: string;
|
|
42
|
+
sound: string;
|
|
43
|
+
voice: string;
|
|
44
|
+
bio: string;
|
|
45
|
+
}, ask: typeof askJson): Promise<Member[]>;
|
|
46
|
+
/** How the band looks on stage, for a cover or a scene: every member by name, role and look. */
|
|
47
|
+
export declare function castLine(members: Member[]): string;
|
|
48
|
+
/** The portrait prompt for a member: the same person every time it is used as a reference. */
|
|
49
|
+
export declare function portraitPrompt(member: Member, band: {
|
|
50
|
+
name: string;
|
|
51
|
+
genre: string;
|
|
52
|
+
}): string;
|
|
53
|
+
/** Writes the member pages and portraits that are missing; returns the members as the pages now say. */
|
|
54
|
+
export declare function writeMembers(folder: string, members: Member[], band: {
|
|
55
|
+
name: string;
|
|
56
|
+
genre: string;
|
|
57
|
+
holder: string;
|
|
58
|
+
}, draw: ((prompt: string) => Promise<Buffer | null>) | null): Promise<Member[]>;
|
|
59
|
+
/** The members as their pages say now (hand edits included), in the order given. */
|
|
60
|
+
export declare function readMembers(folder: string, slugs: string[]): Member[];
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who is in each AI label band: an OpenProfile page per member and one for
|
|
3
|
+
* the band, so the same people sing, appear on covers and in videos every
|
|
4
|
+
* time instead of whoever an image model draws next.
|
|
5
|
+
*
|
|
6
|
+
* <artist folder>/artist.md the band: genre, sound, members, discography
|
|
7
|
+
* <artist folder>/members/<slug>.md a member: role, look, voice, personality...
|
|
8
|
+
* <artist folder>/members/<slug>.jpg their reference portrait
|
|
9
|
+
*
|
|
10
|
+
* The pages are the source of truth once written: a person may edit a
|
|
11
|
+
* member's look or voice by hand, and what is generated next reads the page
|
|
12
|
+
* back (readMember). Members are invented once, by a language model, as
|
|
13
|
+
* fictional people; the portrait is drawn from the look and kept.
|
|
14
|
+
*/
|
|
15
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
export function memberSlug(name) {
|
|
18
|
+
return name.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "member";
|
|
19
|
+
}
|
|
20
|
+
function line(text) {
|
|
21
|
+
return text.replace(/[\r\n]+/g, " ").trim();
|
|
22
|
+
}
|
|
23
|
+
/** A member's OpenProfile page. */
|
|
24
|
+
export function memberMarkdown(member, band) {
|
|
25
|
+
return [
|
|
26
|
+
`# ${member.name}`,
|
|
27
|
+
"",
|
|
28
|
+
"- **Kind**: fictional musician, made with AI",
|
|
29
|
+
`- **Band**: [${band.name}](../artist.md)`,
|
|
30
|
+
`- **Role**: ${line(member.role)}`,
|
|
31
|
+
`- **Avatar**: [portrait](${member.slug}.jpg)`,
|
|
32
|
+
`- **Parent**: ${band.holder}`,
|
|
33
|
+
"",
|
|
34
|
+
line(member.bio),
|
|
35
|
+
"",
|
|
36
|
+
"## Look",
|
|
37
|
+
"",
|
|
38
|
+
line(member.look),
|
|
39
|
+
"",
|
|
40
|
+
"## Voice",
|
|
41
|
+
"",
|
|
42
|
+
line(member.voice),
|
|
43
|
+
"",
|
|
44
|
+
"## Personality",
|
|
45
|
+
"",
|
|
46
|
+
line(member.personality),
|
|
47
|
+
"",
|
|
48
|
+
"## Influences",
|
|
49
|
+
"",
|
|
50
|
+
line(member.influences),
|
|
51
|
+
"",
|
|
52
|
+
].join("\n");
|
|
53
|
+
}
|
|
54
|
+
/** A member's page read back, so hand edits win; null when it is not one of ours. */
|
|
55
|
+
export function readMember(text, slug) {
|
|
56
|
+
const name = /^#\s+(.+)$/m.exec(text)?.[1]?.trim() ?? "";
|
|
57
|
+
if (!name)
|
|
58
|
+
return null;
|
|
59
|
+
const field = (label) => new RegExp(`^- \\*\\*${label}\\*\\*:\\s*(.+)$`, "m").exec(text)?.[1]?.trim() ?? "";
|
|
60
|
+
const section = (heading) => {
|
|
61
|
+
const match = new RegExp(`^##\\s+${heading}\\s*$([\\s\\S]*?)(?=^##\\s|$(?![\\s\\S]))`, "m").exec(text);
|
|
62
|
+
return (match?.[1] ?? "").trim().replace(/\s+/g, " ");
|
|
63
|
+
};
|
|
64
|
+
// The bio is the prose between the fields and the first section.
|
|
65
|
+
const bio = (/^- \*\*Parent\*\*:.*$\n+([\s\S]*?)(?=^##\s)/m.exec(text)?.[1] ?? "").trim().replace(/\s+/g, " ");
|
|
66
|
+
return { slug, name, role: field("Role"), look: section("Look"), voice: section("Voice"), personality: section("Personality"), influences: section("Influences"), bio };
|
|
67
|
+
}
|
|
68
|
+
/** The band's OpenProfile page. */
|
|
69
|
+
export function bandMarkdown(band) {
|
|
70
|
+
return [
|
|
71
|
+
`# ${band.name}`,
|
|
72
|
+
"",
|
|
73
|
+
"- **Kind**: band, fictional, made with AI",
|
|
74
|
+
`- **Genre**: ${band.genre}`,
|
|
75
|
+
`- **Label**: ${band.holder}`,
|
|
76
|
+
`- **Members**: ${band.members.map((one) => `[${one.name}](members/${one.slug}.md) (${one.role})`).join(", ")}`,
|
|
77
|
+
"",
|
|
78
|
+
line(band.bio),
|
|
79
|
+
"",
|
|
80
|
+
"## Sound",
|
|
81
|
+
"",
|
|
82
|
+
line(band.sound),
|
|
83
|
+
"",
|
|
84
|
+
"## Lead voice",
|
|
85
|
+
"",
|
|
86
|
+
line(band.voice),
|
|
87
|
+
"",
|
|
88
|
+
"## Themes",
|
|
89
|
+
"",
|
|
90
|
+
line(band.themes),
|
|
91
|
+
"",
|
|
92
|
+
"## Discography",
|
|
93
|
+
"",
|
|
94
|
+
...(band.albums.length ? band.albums.map((one) => `- ${one.year} · ${one.title}`) : ["- (first album on its way)"]),
|
|
95
|
+
"",
|
|
96
|
+
].join("\n");
|
|
97
|
+
}
|
|
98
|
+
const MEMBERS_SYSTEM = [
|
|
99
|
+
"You cast the members of a fictional band (or the one person behind a solo act) for an AI record label.",
|
|
100
|
+
"Everyone is invented: never a real person, never a lookalike of one. Give each a full name, their role, and a look described",
|
|
101
|
+
"precisely enough that an illustrator could draw the same person every time: age range, face, skin tone, hair, build, signature outfit and accessories.",
|
|
102
|
+
"Answer as JSON: {\"members\": [{\"name\": \"\", \"role\": \"\", \"look\": \"at most 300 characters\", \"voice\": \"how they sing or play, at most 150 characters\",",
|
|
103
|
+
"\"personality\": \"at most 150 characters\", \"influences\": \"styles and eras, never named artists, at most 120 characters\", \"bio\": \"one or two sentences\"}]}.",
|
|
104
|
+
].join(" ");
|
|
105
|
+
/** The band's people, invented once: one for a solo act, three to five for a band. */
|
|
106
|
+
export async function inventMembers(band, ask) {
|
|
107
|
+
const answer = await ask({
|
|
108
|
+
system: MEMBERS_SYSTEM,
|
|
109
|
+
prompt: [
|
|
110
|
+
`Act: ${band.name} (${band.genre})`,
|
|
111
|
+
`Sound: ${band.sound}`,
|
|
112
|
+
`Lead voice: ${band.voice}`,
|
|
113
|
+
band.bio ? `Who they are: ${band.bio}` : "",
|
|
114
|
+
"If the name reads as one person's, cast that one person as the solo artist (role: vocals and the instrument that fits); otherwise cast the band, the lead singer first, matching the lead voice.",
|
|
115
|
+
].filter(Boolean).join("\n"),
|
|
116
|
+
maxTokens: 2000,
|
|
117
|
+
timeoutMs: 90_000,
|
|
118
|
+
});
|
|
119
|
+
const list = Array.isArray(answer.value["members"]) ? answer.value["members"] : [];
|
|
120
|
+
const seen = new Set();
|
|
121
|
+
const members = list
|
|
122
|
+
.map((one) => {
|
|
123
|
+
const name = line(String(one?.["name"] ?? "")).slice(0, 60);
|
|
124
|
+
return {
|
|
125
|
+
slug: memberSlug(name), name,
|
|
126
|
+
role: line(String(one?.["role"] ?? "")).slice(0, 80),
|
|
127
|
+
look: line(String(one?.["look"] ?? "")).slice(0, 500),
|
|
128
|
+
voice: line(String(one?.["voice"] ?? "")).slice(0, 300),
|
|
129
|
+
personality: line(String(one?.["personality"] ?? "")).slice(0, 300),
|
|
130
|
+
influences: line(String(one?.["influences"] ?? "")).slice(0, 200),
|
|
131
|
+
bio: line(String(one?.["bio"] ?? "")).slice(0, 500),
|
|
132
|
+
};
|
|
133
|
+
})
|
|
134
|
+
.filter((one) => one.name && one.look.length > 20 && !seen.has(one.slug) && seen.add(one.slug))
|
|
135
|
+
.slice(0, 6);
|
|
136
|
+
if (members.length === 0)
|
|
137
|
+
throw new Error("the model cast nobody");
|
|
138
|
+
return members;
|
|
139
|
+
}
|
|
140
|
+
/** How the band looks on stage, for a cover or a scene: every member by name, role and look. */
|
|
141
|
+
export function castLine(members) {
|
|
142
|
+
return members.map((one) => `${one.name} (${one.role}): ${one.look}`).join("; ");
|
|
143
|
+
}
|
|
144
|
+
/** The portrait prompt for a member: the same person every time it is used as a reference. */
|
|
145
|
+
export function portraitPrompt(member, band) {
|
|
146
|
+
return `Photographic portrait of ${member.look}. ${member.role} in the ${band.genre} band ${band.name}. Head and shoulders, three-quarter view, natural expression, soft studio light, plain dark background, sharp focus, 85mm lens. No text, no logos.`;
|
|
147
|
+
}
|
|
148
|
+
/** Writes the member pages and portraits that are missing; returns the members as the pages now say. */
|
|
149
|
+
export async function writeMembers(folder, members, band, draw) {
|
|
150
|
+
const dir = join(folder, "members");
|
|
151
|
+
mkdirSync(dir, { recursive: true });
|
|
152
|
+
const out = [];
|
|
153
|
+
for (const member of members) {
|
|
154
|
+
const page = join(dir, `${member.slug}.md`);
|
|
155
|
+
let current = member;
|
|
156
|
+
try {
|
|
157
|
+
current = readMember(readFileSync(page, "utf8"), member.slug) ?? member;
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
writeFileSync(page, memberMarkdown(member, band));
|
|
161
|
+
}
|
|
162
|
+
const portrait = join(dir, `${member.slug}.jpg`);
|
|
163
|
+
let drawn = true;
|
|
164
|
+
try {
|
|
165
|
+
readFileSync(portrait);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
drawn = false;
|
|
169
|
+
}
|
|
170
|
+
if (!drawn && draw) {
|
|
171
|
+
const picture = await draw(portraitPrompt(current, band)).catch(() => null);
|
|
172
|
+
if (picture)
|
|
173
|
+
writeFileSync(portrait, picture);
|
|
174
|
+
}
|
|
175
|
+
out.push(current);
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
/** The members as their pages say now (hand edits included), in the order given. */
|
|
180
|
+
export function readMembers(folder, slugs) {
|
|
181
|
+
return slugs.flatMap((slug) => {
|
|
182
|
+
try {
|
|
183
|
+
const member = readMember(readFileSync(join(folder, "members", `${slug}.md`), "utf8"), slug);
|
|
184
|
+
return member ? [member] : [];
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return [];
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
}
|