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.
Files changed (51) hide show
  1. package/dist/ai-label.d.ts +38 -3
  2. package/dist/ai-label.js +96 -5
  3. package/dist/channels.d.ts +20 -0
  4. package/dist/channels.js +24 -0
  5. package/dist/label-profiles.d.ts +60 -0
  6. package/dist/label-profiles.js +190 -0
  7. package/dist/label-routes.js +29 -2
  8. package/dist/library.d.ts +6 -0
  9. package/dist/library.js +11 -0
  10. package/dist/playlist-input.d.ts +32 -7
  11. package/dist/playlist-input.js +20 -2
  12. package/dist/server.js +9 -1
  13. package/dist/station-routes.d.ts +1 -0
  14. package/dist/station-routes.js +23 -5
  15. package/dist/station-runner.d.ts +48 -0
  16. package/dist/station-runner.js +208 -4
  17. package/dist/stations.d.ts +14 -0
  18. package/dist/stations.js +11 -0
  19. package/package.json +1 -1
  20. package/src/ai-label.ts +95 -8
  21. package/src/channels.ts +30 -0
  22. package/src/label-profiles.ts +212 -0
  23. package/src/label-routes.ts +30 -2
  24. package/src/library.ts +14 -0
  25. package/src/playlist-input.ts +46 -5
  26. package/src/server.ts +10 -1
  27. package/src/station-routes.ts +23 -6
  28. package/src/station-runner.ts +205 -4
  29. package/src/stations.ts +15 -0
  30. package/web/dist/assets/{ar-DDphLo2L.js → ar-d05d_7Df.js} +1 -1
  31. package/web/dist/assets/{cs-CMKLJTnw.js → cs-CclrNwa7.js} +1 -1
  32. package/web/dist/assets/{da-CibDEkHw.js → da-C30KTGl1.js} +1 -1
  33. package/web/dist/assets/{de-D1BL2QsJ.js → de-D2A2RQwi.js} +1 -1
  34. package/web/dist/assets/{es-Cj9qr9jo.js → es-BXFx0WZ5.js} +1 -1
  35. package/web/dist/assets/{fi-DqCO5i_7.js → fi-DPA3S8og.js} +1 -1
  36. package/web/dist/assets/{fr-DUk14AWf.js → fr-D_0wnr4h.js} +1 -1
  37. package/web/dist/assets/{hi-U_jzSO9-.js → hi-EfiCQnb9.js} +1 -1
  38. package/web/dist/assets/{hu-B28enhWa.js → hu-CK5rnxKO.js} +1 -1
  39. package/web/dist/assets/{id-DlGfGd_6.js → id-BgTV_4UK.js} +1 -1
  40. package/web/dist/assets/{it-D3HMQpf5.js → it-DGefapXn.js} +1 -1
  41. package/web/dist/assets/{main-D0eqtyhi.js → main-C-tWhTWo.js} +2 -2
  42. package/web/dist/assets/{nl-i1Y9R3xr.js → nl-Cyst29Bp.js} +1 -1
  43. package/web/dist/assets/{ru-DjRg1y2P.js → ru-D8G6Wynm.js} +1 -1
  44. package/web/dist/assets/{station-DxBlD6Yl.js → station-D6sd4DqP.js} +1 -1
  45. package/web/dist/assets/{sv-Dsj-93a2.js → sv-aYnDAFwz.js} +1 -1
  46. package/web/dist/assets/{uk-COWBdTZb.js → uk-BDtew7z3.js} +1 -1
  47. package/web/dist/assets/{vi-CgEMtDCA.js → vi-CRlDn2Xh.js} +1 -1
  48. package/web/dist/assets/{zh-CBa5mJ__.js → zh-BCAIjeQq.js} +1 -1
  49. package/web/dist/index.html +1 -1
  50. package/web/dist/station.html +1 -1
  51. package/web/dist/sw.js +20 -20
@@ -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">, songs: number, ask?: typeof askJson): Promise<{
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">, album: Pick<LabelAlbum, "title" | "concept">, song: Pick<LabelSong, "title" | "style" | "seconds">, ask?: typeof askJson): Promise<{
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">, album: Pick<LabelAlbum, "title" | "concept">, song: Pick<LabelSong, "title" | "style" | "lyrics">, scenes: number, ask?: typeof askJson): Promise<string[]>;
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
- const plan = await planAlbum(artist, this.settings.songsPerAlbum, this.deps.ask ?? askJson);
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
- void this.videos();
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 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.`;
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
  }
@@ -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
+ }