nixamp 0.63.3 → 0.65.1

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 (44) hide show
  1. package/dist/ai-label.d.ts +38 -4
  2. package/dist/ai-label.js +132 -20
  3. package/dist/discovery.d.ts +48 -0
  4. package/dist/discovery.js +181 -0
  5. package/dist/server.js +64 -11
  6. package/dist/station-cli.js +1 -0
  7. package/dist/station-mcp.d.ts +15 -0
  8. package/dist/station-mcp.js +3 -2
  9. package/dist/veo-studio.d.ts +16 -0
  10. package/dist/veo-studio.js +59 -0
  11. package/package.json +1 -1
  12. package/src/ai-label.ts +124 -23
  13. package/src/discovery.ts +210 -0
  14. package/src/server.ts +56 -11
  15. package/src/station-cli.ts +1 -0
  16. package/src/station-mcp.ts +3 -2
  17. package/src/veo-studio.ts +57 -0
  18. package/web/dist/assets/{ar-BdJErAsT.js → ar-DsLCtn6q.js} +1 -1
  19. package/web/dist/assets/{cs-BCAcbLw4.js → cs-CDebRTvk.js} +1 -1
  20. package/web/dist/assets/{da-B8ZLImmi.js → da-CtIZY0F_.js} +1 -1
  21. package/web/dist/assets/{de-BEfJLxqy.js → de-Ce4XnPH4.js} +1 -1
  22. package/web/dist/assets/{es-CvwtfSaX.js → es-DuTsv2ty.js} +1 -1
  23. package/web/dist/assets/{fi-D8zNiMjO.js → fi-BxzgeUzQ.js} +1 -1
  24. package/web/dist/assets/{fr-C2YZ2L4l.js → fr-1e-Cafh4.js} +1 -1
  25. package/web/dist/assets/{hi-K_VSycz2.js → hi-yKzlszV2.js} +1 -1
  26. package/web/dist/assets/{hu-Dl_GeLxY.js → hu-DEEC9-i5.js} +1 -1
  27. package/web/dist/assets/{id-Ba1NrTEb.js → id-Da47jrbA.js} +1 -1
  28. package/web/dist/assets/{it-ClsnZdJN.js → it-DyEoFcC_.js} +1 -1
  29. package/web/dist/assets/{main-DD5A9Exa.js → main-C_3KEwHZ.js} +2 -2
  30. package/web/dist/assets/{music-LviR4dJP.js → music-Bby75I2_.js} +1 -1
  31. package/web/dist/assets/{nl-BLjok-1D.js → nl-uTGCd9L3.js} +1 -1
  32. package/web/dist/assets/{ru-BvhOKYAB.js → ru-BJ7gUalz.js} +1 -1
  33. package/web/dist/assets/{sv-BItd8vZI.js → sv-C9ZiAm2Q.js} +1 -1
  34. package/web/dist/assets/{uk-BcUnWToF.js → uk-DIcSDuMA.js} +1 -1
  35. package/web/dist/assets/{vi-LFZ3qSPF.js → vi-4MI2s3Z9.js} +1 -1
  36. package/web/dist/assets/{zh-DfCRV3mt.js → zh-DI1PPrRY.js} +1 -1
  37. package/web/dist/icons/music/apple-touch-icon.png +0 -0
  38. package/web/dist/icons/music/icon-192.png +0 -0
  39. package/web/dist/icons/music/icon-384.png +0 -0
  40. package/web/dist/icons/music/icon-512.png +0 -0
  41. package/web/dist/index.html +1 -1
  42. package/web/dist/music.html +3 -1
  43. package/web/dist/music.webmanifest +57 -0
  44. package/web/dist/sw.js +34 -24
@@ -90,8 +90,10 @@ export interface LabelSettings {
90
90
  videos: boolean;
91
91
  /** Each album's single (song 1) is filmed with Veo, while the month's Veo budget lasts. */
92
92
  veo: boolean;
93
- /** How many 8 s Veo shots a single is cut from. */
93
+ /** How many 8 s Veo shots of the band a single is cut from. */
94
94
  veoShots: number;
95
+ /** How many close-ups of the singer a single gets, lip-synced to the song (0: none, the band only). */
96
+ lipsyncShots: number;
95
97
  /** Public, listed and owned, so the genre stations are on the guide. */
96
98
  publish: boolean;
97
99
  /** Songs made at the same time (ElevenLabs takes several requests at once on a paid plan). */
@@ -172,7 +174,7 @@ export declare class VeoUnavailable extends Error {
172
174
  * images, described by role and look only: Veo refuses names with faces
173
175
  * ("we can't create videos with real people's names or likenesses").
174
176
  */
175
- export declare function veoPrompts(artist: Pick<LabelArtist, "genre" | "sound">, song: Pick<LabelSong, "style">, members: Array<Pick<Member, "role" | "look">>, count: number): string[];
177
+ export declare function veoPrompts(artist: Pick<LabelArtist, "genre" | "sound">, song: Pick<LabelSong, "style">, members: Array<Pick<Member, "role" | "look">>, count: number, kind?: "band" | "singer"): string[];
176
178
  /**
177
179
  * A song's music video from its storyboard: each scene's picture moves (a
178
180
  * slow zoom in, a pan, a zoom out, a pan back, in turn), scenes cross-fade,
@@ -192,8 +194,18 @@ export declare function renderStoryVideo(ffmpeg: string[], input: {
192
194
  }>;
193
195
  }): Promise<void>;
194
196
  /**
195
- * The version of a video cut from Veo footage with nobody singing on camera; anything
196
- * older (a slideshow, or 4: footage whose singers were out of sync) is not shown.
197
+ * Where a single's lip-synced close-ups go: the start of a sung line (its .lrc)
198
+ * nearest a quarter and three quarters in (one: a third in), each `length`
199
+ * seconds, after the title card, never overlapping. Without timed words, those
200
+ * points themselves.
201
+ */
202
+ export declare function closeUpTimes(seconds: number, lines: Array<{
203
+ start: number;
204
+ }>, count: number, length?: number): number[];
205
+ /**
206
+ * The version of a video cut from Veo footage: the band with nobody singing, and the
207
+ * singer only in close-ups lip-synced to the song. Anything older (a slideshow, or 4:
208
+ * footage whose singers were out of sync) is not shown.
197
209
  */
198
210
  export declare const FILMED = 5;
199
211
  /**
@@ -212,6 +224,11 @@ export declare function renderFilmedVideo(ffmpeg: string[], input: {
212
224
  artist: string;
213
225
  title: string;
214
226
  out: string;
227
+ pinned?: Array<{
228
+ path: string;
229
+ at: number;
230
+ seconds: number;
231
+ }>;
215
232
  }): Promise<void>;
216
233
  /**
217
234
  * A song's music video without a storyboard: its album cover over a blurred
@@ -268,7 +285,18 @@ export interface AiLabelDeps {
268
285
  artist: string;
269
286
  title: string;
270
287
  out: string;
288
+ pinned?: Array<{
289
+ path: string;
290
+ at: number;
291
+ seconds: number;
292
+ }>;
271
293
  }) => Promise<void>;
294
+ /** A singer's close-up with the mouth redrawn to these seconds of the song (nixamp.com, fal.ai); VeoUnavailable when the month is spent. */
295
+ lipsync?: ((input: {
296
+ video: Buffer;
297
+ audio: Buffer;
298
+ seconds: number;
299
+ }) => Promise<Buffer>) | null;
272
300
  /** Renders a storyboard music video; ffmpeg by default (renderStoryVideo). */
273
301
  storyVideo?: (input: {
274
302
  audio: string;
@@ -376,6 +404,12 @@ export declare class AiLabel {
376
404
  * video at all: footage stretched that thin would be a loop, not a video.
377
405
  */
378
406
  private filmSong;
407
+ /**
408
+ * The singer's close-ups, each placed on a sung line and its mouth redrawn
409
+ * to those seconds of the song. A close-up that cannot be synced is left
410
+ * out (the band fills its place): an unsynced singer is never shown.
411
+ */
412
+ private syncCloseUps;
379
413
  /** Whether this song gets filmed Veo shots: an album's single, Veo on, and the month's budget not spent. */
380
414
  private wantsVeo;
381
415
  /**
package/dist/ai-label.js CHANGED
@@ -33,7 +33,7 @@ import { dirname, join, resolve, sep } from "node:path";
33
33
  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
- import { formatLrc } from "./lyrics.js";
36
+ import { formatLrc, parseLrc } from "./lyrics.js";
37
37
  import { bandMarkdown, castLine, inventMembers, readMembers, writeMembers } from "./label-profiles.js";
38
38
  import { StationError } from "./stations.js";
39
39
  /**
@@ -274,6 +274,7 @@ export const DEFAULTS = {
274
274
  videos: true,
275
275
  veo: false,
276
276
  veoShots: 6,
277
+ lipsyncShots: 2,
277
278
  parallel: 1,
278
279
  genreOrder: ["Punk", "Blues", "Country", "Jazz", "Classic Rock", "Metal", "Hip-Hop", "Pop", "R&B"],
279
280
  };
@@ -504,18 +505,30 @@ const SHOTS = [
504
505
  "Close-up of the bass player's fingers and the amplifier, then a tilt up to the band in silhouette",
505
506
  "The band walking out on stage before the show, backlit silhouettes, nobody speaking or singing",
506
507
  ];
508
+ // The singer's close-ups, which are lip-synced to the song afterwards (fal.ai). The mouth
509
+ // must be clear: whatever covers it (a microphone, a hand) is smeared into the face when
510
+ // the mouth is redrawn.
511
+ const CLOSE_UPS = [
512
+ "Medium close-up of the lead vocalist singing straight to camera, the microphone stand lowered to chest height so the whole mouth and chin are clear, nothing in front of the face",
513
+ "Close-up of the lead vocalist singing with feeling, head and shoulders, a handheld microphone held down at the chest, the whole mouth clearly visible",
514
+ "Medium shot of the lead vocalist singing to the crowd, the microphone held out to the side, face lit and the mouth fully visible",
515
+ "Close-up of the lead vocalist singing in profile-three-quarter view, no microphone in frame, the mouth clearly visible",
516
+ ];
507
517
  /**
508
518
  * The prompts for a single's filmed shots. The people are the reference
509
519
  * images, described by role and look only: Veo refuses names with faces
510
520
  * ("we can't create videos with real people's names or likenesses").
511
521
  */
512
- export function veoPrompts(artist, song, members, count) {
522
+ export function veoPrompts(artist, song, members, count, kind = "band") {
513
523
  const cast = members.slice(0, 3).map((one, i) => `the ${one.role} from reference image ${i + 1} (${one.look.replace(/\b[A-Z][a-z]+ [A-Z][a-z]+\b/g, "")})`).join("; ");
514
- return SHOTS.slice(0, count).map((shot) => [
524
+ const rule = kind === "singer"
525
+ ? "The vocalist sings clearly, the whole mouth always visible, never covered by a microphone or a hand. Cinematic, 35mm film look. No text, no logos."
526
+ : "Nobody sings, speaks or lip-syncs on camera: no mouths in close-up. Cinematic, 35mm film look, dynamic camera. No text, no logos.";
527
+ return (kind === "singer" ? CLOSE_UPS : SHOTS).slice(0, count).map((shot) => [
515
528
  `Music video for a fictional ${artist.genre} band. ${shot}.`,
516
529
  cast ? `The performers are exactly the people in the reference images: ${cast}.` : "",
517
530
  `Mood and sound: ${song.style} ${artist.sound}.`.slice(0, 400),
518
- "Nobody sings, speaks or lip-syncs on camera: no mouths in close-up. Cinematic, 35mm film look, dynamic camera. No text, no logos.",
531
+ rule,
519
532
  ].filter(Boolean).join(" "));
520
533
  }
521
534
  /** The DejaVu bold face most Linux boxes have, for the title card; "" when there is none. */
@@ -609,8 +622,30 @@ export function renderStoryVideo(ffmpeg, input) {
609
622
  return runLow(ffmpeg, [...ffmpeg.slice(1), ...args], 60 * 60_000, "the video failed");
610
623
  }
611
624
  /**
612
- * The version of a video cut from Veo footage with nobody singing on camera; anything
613
- * older (a slideshow, or 4: footage whose singers were out of sync) is not shown.
625
+ * Where a single's lip-synced close-ups go: the start of a sung line (its .lrc)
626
+ * nearest a quarter and three quarters in (one: a third in), each `length`
627
+ * seconds, after the title card, never overlapping. Without timed words, those
628
+ * points themselves.
629
+ */
630
+ export function closeUpTimes(seconds, lines, count, length = 8) {
631
+ if (count < 1)
632
+ return [];
633
+ const targets = count === 1 ? [1 / 3] : Array.from({ length: count }, (_, i) => 0.25 + (0.5 * i) / (count - 1));
634
+ const fits = (t) => t >= 6 && t + length <= seconds - 1;
635
+ const starts = lines.map((line) => Math.round(line.start * 100) / 100).filter(fits);
636
+ const chosen = [];
637
+ for (const share of targets) {
638
+ const want = share * seconds;
639
+ const pool = (starts.length ? starts : [Math.round(want)]).filter((t) => fits(t) && chosen.every((one) => Math.abs(one - t) >= length));
640
+ if (pool.length)
641
+ chosen.push(pool.reduce((best, t) => (Math.abs(t - want) < Math.abs(best - want) ? t : best)));
642
+ }
643
+ return chosen.sort((a, b) => a - b);
644
+ }
645
+ /**
646
+ * The version of a video cut from Veo footage: the band with nobody singing, and the
647
+ * singer only in close-ups lip-synced to the song. Anything older (a slideshow, or 4:
648
+ * footage whose singers were out of sync) is not shown.
614
649
  */
615
650
  export const FILMED = 5;
616
651
  /**
@@ -623,14 +658,31 @@ export function renderFilmedVideo(ffmpeg, input) {
623
658
  if (input.clips.length === 0)
624
659
  return Promise.reject(new Error("no footage to cut"));
625
660
  const cut = 4;
661
+ // Lip-synced close-ups play whole at the second of the song they were synced to; the band fills around them.
662
+ const pinned = [...(input.pinned ?? [])].sort((a, b) => a.at - b.at);
626
663
  const cuts = [];
627
- for (let at = 0, i = 0; at < input.seconds; i++) {
664
+ for (let at = 0, i = 0; at < input.seconds - 0.05;) {
665
+ const pin = pinned.find((one) => Math.abs(one.at - at) < 0.05);
666
+ if (pin) {
667
+ const length = Math.min(pin.seconds, input.seconds - at + 0.5);
668
+ cuts.push({ path: pin.path, start: 0, length });
669
+ at += length;
670
+ continue;
671
+ }
672
+ const next = pinned.find((one) => one.at > at + 0.05);
628
673
  const clip = input.clips[i % input.clips.length];
629
674
  const half = Math.floor(i / input.clips.length) % 2;
630
- const start = Math.min(half * cut, Math.max(0, clip.seconds - cut));
631
- const length = Math.min(cut, clip.seconds - start, input.seconds - at + 0.5);
675
+ let length = Math.min(cut, input.seconds - at + 0.5);
676
+ // Up to a close-up: one cut to meet it exactly, never a sliver.
677
+ if (next)
678
+ length = next.at - at < cut * 1.5 ? next.at - at : Math.min(length, next.at - at);
679
+ const start = length > cut ? 0 : Math.min(half * cut, Math.max(0, clip.seconds - cut));
680
+ length = Math.min(length, clip.seconds - start);
681
+ if (length < 0.04)
682
+ break;
632
683
  cuts.push({ path: clip.path, start, length });
633
684
  at += length;
685
+ i += 1;
634
686
  }
635
687
  const parts = cuts.map((_, i) => `[${i}:v]scale=1920:1080:force_original_aspect_ratio=increase,crop=1920:1080,fps=25,setsar=1,setpts=PTS-STARTPTS[c${i}]`);
636
688
  parts.push(`${cuts.map((_, i) => `[c${i}]`).join("")}concat=n=${cuts.length}:v=1:a=0[cut]`);
@@ -889,6 +941,9 @@ export class AiLabel {
889
941
  const shots = whole("veoShots", 1, 8);
890
942
  if (shots !== undefined)
891
943
  this.settings.veoShots = shots;
944
+ const close = whole("lipsyncShots", 0, 4);
945
+ if (close !== undefined)
946
+ this.settings.lipsyncShots = close;
892
947
  const perDay = whole("albumsPerDay", 0, 24);
893
948
  if (perDay !== undefined)
894
949
  this.settings.albumsPerDay = perDay;
@@ -1151,13 +1206,65 @@ export class AiLabel {
1151
1206
  catch { /* the asked-for length */ }
1152
1207
  // Paid-for shots live beside the album until the video is cut, so nothing filmed is filmed twice.
1153
1208
  const shots = join(_album.folder, `.veo-${song.n}`);
1154
- const clips = await this.filmShots(artist, song, shots, scenes);
1209
+ const { band: clips, singer } = await this.filmShots(artist, song, shots, scenes);
1155
1210
  if (clips.length < Math.max(2, Math.ceil(this.settings.veoShots / 2)))
1156
1211
  throw new Error(`only ${clips.length} of ${this.settings.veoShots} shots filmed so far`);
1157
- await (this.deps.filmedVideo ?? ((input) => renderFilmedVideo(this.deps.ffmpeg, input)))({ audio: song.file, clips, seconds, artist: artist.name, title: song.title, out });
1212
+ const pinned = await this.syncCloseUps(song, singer, seconds, shots);
1213
+ await (this.deps.filmedVideo ?? ((input) => renderFilmedVideo(this.deps.ffmpeg, input)))({ audio: song.file, clips, seconds, artist: artist.name, title: song.title, out, ...(pinned.length ? { pinned } : {}) });
1158
1214
  rmSync(shots, { recursive: true, force: true });
1159
1215
  return FILMED;
1160
1216
  }
1217
+ /**
1218
+ * The singer's close-ups, each placed on a sung line and its mouth redrawn
1219
+ * to those seconds of the song. A close-up that cannot be synced is left
1220
+ * out (the band fills its place): an unsynced singer is never shown.
1221
+ */
1222
+ async syncCloseUps(song, singer, seconds, keep) {
1223
+ const lipsync = this.deps.lipsync;
1224
+ if (!lipsync || singer.length === 0)
1225
+ return [];
1226
+ let lines = [];
1227
+ try {
1228
+ if (song.lrc)
1229
+ lines = parseLrc(readFileSync(song.lrc, "utf8"));
1230
+ }
1231
+ catch { /* the song's thirds instead */ }
1232
+ const times = closeUpTimes(seconds, lines, singer.length);
1233
+ const pinned = [];
1234
+ for (const [i, at] of times.entries()) {
1235
+ const synced = join(keep, `synced-${i}-${Math.round(at * 100)}.mp4`);
1236
+ try {
1237
+ if (statSync(synced).size > 10_000) {
1238
+ pinned.push({ path: synced, at, seconds: 8 });
1239
+ continue;
1240
+ }
1241
+ }
1242
+ catch { /* not synced yet */ }
1243
+ const shot = join(keep, `.send-${i}.mp4`);
1244
+ const audio = join(keep, `.send-${i}.mp3`);
1245
+ try {
1246
+ // The shot made small enough to send (1080p, no sound) and the song's same 8 seconds.
1247
+ await runLow(this.deps.ffmpeg, [...this.deps.ffmpeg.slice(1), "-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-i", singer[i].path, "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "21", "-t", "8", shot], 5 * 60_000, "the close-up did not shrink");
1248
+ await runLow(this.deps.ffmpeg, [...this.deps.ffmpeg.slice(1), "-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-ss", at.toFixed(2), "-t", "8", "-i", song.file, "-c:a", "libmp3lame", "-b:a", "192k", audio], 60_000, "the song's seconds did not cut");
1249
+ const bytes = await lipsync({ video: readFileSync(shot), audio: readFileSync(audio), seconds: 8 });
1250
+ writeFileSync(`${synced}.part`, bytes);
1251
+ renameSync(`${synced}.part`, synced);
1252
+ pinned.push({ path: synced, at, seconds: 8 });
1253
+ }
1254
+ catch (error) {
1255
+ this.log(`lip sync ${i + 1} for "${song.title}": ${error.message}; the band plays there instead`);
1256
+ if (error instanceof VeoUnavailable)
1257
+ break;
1258
+ }
1259
+ finally {
1260
+ rmSync(shot, { force: true });
1261
+ rmSync(audio, { force: true });
1262
+ }
1263
+ }
1264
+ if (pinned.length)
1265
+ this.log(`lip-synced ${pinned.length} close-up(s) of "${song.title}"`);
1266
+ return pinned;
1267
+ }
1161
1268
  /** Whether this song gets filmed Veo shots: an album's single, Veo on, and the month's budget not spent. */
1162
1269
  wantsVeo(song) {
1163
1270
  return this.settings.veo && song.n === 1 && Boolean(this.deps.veo) && this.veoSpentIn !== new Date(this.now()).toISOString().slice(0, 7);
@@ -1170,7 +1277,7 @@ export class AiLabel {
1170
1277
  async filmShots(artist, song, keep, dir) {
1171
1278
  const veo = this.deps.veo;
1172
1279
  if (!veo)
1173
- return [];
1280
+ return { band: [], singer: [] };
1174
1281
  mkdirSync(dir, { recursive: true });
1175
1282
  mkdirSync(keep, { recursive: true });
1176
1283
  const members = this.cast(artist);
@@ -1187,12 +1294,17 @@ export class AiLabel {
1187
1294
  // A member without a portrait is described in words only.
1188
1295
  }
1189
1296
  }
1190
- const clips = [];
1191
- for (const [i, prompt] of veoPrompts(artist, song, members, this.settings.veoShots).entries()) {
1192
- const file = join(keep, `shot-${i}.mp4`);
1297
+ const band = [];
1298
+ const singer = [];
1299
+ // The singer's close-ups only where they can be lip-synced afterwards.
1300
+ const plan = [
1301
+ ...veoPrompts(artist, song, members, this.settings.veoShots).map((prompt, i) => ({ prompt, file: join(keep, `shot-${i}.mp4`), into: band })),
1302
+ ...(this.deps.lipsync ? veoPrompts(artist, song, members, this.settings.lipsyncShots, "singer").map((prompt, i) => ({ prompt, file: join(keep, `singer-${i}.mp4`), into: singer })) : []),
1303
+ ];
1304
+ for (const [i, { prompt, file, into }] of plan.entries()) {
1193
1305
  try {
1194
1306
  if (statSync(file).size > 10_000) {
1195
- clips.push({ path: file, seconds: 8 });
1307
+ into.push({ path: file, seconds: 8 });
1196
1308
  continue;
1197
1309
  }
1198
1310
  }
@@ -1214,7 +1326,7 @@ export class AiLabel {
1214
1326
  const partial = `${file}.part`;
1215
1327
  writeFileSync(partial, bytes);
1216
1328
  renameSync(partial, file);
1217
- clips.push({ path: file, seconds: 8 });
1329
+ into.push({ path: file, seconds: 8 });
1218
1330
  }
1219
1331
  catch (error) {
1220
1332
  if (error instanceof VeoUnavailable) {
@@ -1225,9 +1337,9 @@ export class AiLabel {
1225
1337
  this.log(`Veo shot ${i + 1} for "${song.title}": ${error.message}`);
1226
1338
  }
1227
1339
  }
1228
- if (clips.length)
1229
- this.log(`Veo: ${clips.length} filmed shots for "${song.title}" (${artist.name})`);
1230
- return clips;
1340
+ if (band.length || singer.length)
1341
+ this.log(`Veo: ${band.length + singer.length} filmed shots for "${song.title}" (${artist.name})`);
1342
+ return { band, singer };
1231
1343
  }
1232
1344
  /** The label's music videos as one TV station: free to watch, with its breaks. */
1233
1345
  placeVideos() {
@@ -0,0 +1,48 @@
1
+ /**
2
+ * How an agent, a store or a person finds every way into nixamp from nixamp.com
3
+ * alone: /llms.txt names the MCP servers, the API and the desktop downloads;
4
+ * /openapi.json describes the API; /api/v1/health says it is up; /download is
5
+ * the desktop app, and /download/<target> sends the browser straight to that
6
+ * installer on the latest release.
7
+ *
8
+ * Only nixamp.com answers these (the server where accounts live). A home
9
+ * server is somebody's own machine and has no business advertising itself.
10
+ */
11
+ import type { IncomingMessage, ServerResponse } from "node:http";
12
+ export declare const RELEASES = "https://github.com/profullstack/nixamp/releases";
13
+ /** Each installer release.yml builds, by the address /download/<target> gives it. */
14
+ export declare const DESKTOP_TARGETS: ReadonlyArray<{
15
+ target: string;
16
+ label: string;
17
+ suffix: string;
18
+ }>;
19
+ export declare const DISCOVERY_PATHS: Set<string>;
20
+ export declare const isDiscoveryPath: (path: string) => boolean;
21
+ export interface ReleaseAsset {
22
+ name: string;
23
+ browser_download_url: string;
24
+ }
25
+ export interface Release {
26
+ tag_name: string;
27
+ assets: ReleaseAsset[];
28
+ }
29
+ /** The installer for `target` among a release's files, or null when that build is missing. */
30
+ export declare function desktopAsset(release: Release | null, target: string): string | null;
31
+ /**
32
+ * The latest published release, asked of GitHub at most every ten minutes. A
33
+ * failed ask keeps the last answer; with none, the downloads fall back to the
34
+ * releases page, which is never wrong, only one click longer.
35
+ */
36
+ export declare function latestRelease(fetcher?: typeof fetch, now?: () => number): () => Promise<Release | null>;
37
+ export declare function llmsTxt(site: string): string;
38
+ /** The API as OpenAPI 3.1: what an agent can call on nixamp.com, and which calls want a token. */
39
+ export declare function openapiDocument(site: string, version: string): Record<string, unknown>;
40
+ export declare function downloadPage(site: string, release: Release | null): string;
41
+ export interface DiscoveryContext {
42
+ site: string;
43
+ version: string;
44
+ release: () => Promise<Release | null>;
45
+ json: (response: ServerResponse, code: number, body: unknown) => void;
46
+ }
47
+ /** Answers a discovery path; false when `path` is not one, so the caller carries on. */
48
+ export declare function handleDiscovery(request: IncomingMessage, response: ServerResponse, path: string, ctx: DiscoveryContext): Promise<boolean>;
@@ -0,0 +1,181 @@
1
+ export const RELEASES = "https://github.com/profullstack/nixamp/releases";
2
+ const LATEST_API = "https://api.github.com/repos/profullstack/nixamp/releases/latest";
3
+ /** Each installer release.yml builds, by the address /download/<target> gives it. */
4
+ export const DESKTOP_TARGETS = [
5
+ { target: "mac-arm64", label: "macOS (Apple silicon)", suffix: "-mac-arm64.dmg" },
6
+ { target: "mac-x64", label: "macOS (Intel)", suffix: "-mac-x64.dmg" },
7
+ { target: "win-x64", label: "Windows (x64)", suffix: "-win-x64.exe" },
8
+ { target: "win-arm64", label: "Windows (ARM)", suffix: "-win-arm64.exe" },
9
+ { target: "linux-x64", label: "Linux AppImage (x86_64)", suffix: "-linux-x86_64.AppImage" },
10
+ { target: "linux-arm64", label: "Linux AppImage (arm64)", suffix: "-linux-arm64.AppImage" },
11
+ { target: "deb-amd64", label: "Debian/Ubuntu .deb (amd64)", suffix: "-linux-amd64.deb" },
12
+ { target: "deb-arm64", label: "Debian/Ubuntu .deb (arm64)", suffix: "-linux-arm64.deb" },
13
+ ];
14
+ export const DISCOVERY_PATHS = new Set(["/llms.txt", "/openapi.json", "/api/v1/health", "/download"]);
15
+ export const isDiscoveryPath = (path) => DISCOVERY_PATHS.has(path) || /^\/download\/[a-z0-9-]{1,20}$/.test(path);
16
+ /** The installer for `target` among a release's files, or null when that build is missing. */
17
+ export function desktopAsset(release, target) {
18
+ const spec = DESKTOP_TARGETS.find((one) => one.target === target);
19
+ if (!spec || !release)
20
+ return null;
21
+ return release.assets.find((asset) => asset.name.startsWith("nixamp-") && asset.name.endsWith(spec.suffix))?.browser_download_url ?? null;
22
+ }
23
+ /**
24
+ * The latest published release, asked of GitHub at most every ten minutes. A
25
+ * failed ask keeps the last answer; with none, the downloads fall back to the
26
+ * releases page, which is never wrong, only one click longer.
27
+ */
28
+ export function latestRelease(fetcher = fetch, now = Date.now) {
29
+ let kept = { at: -Infinity, release: null };
30
+ return async () => {
31
+ if (now() - kept.at < 10 * 60_000)
32
+ return kept.release;
33
+ try {
34
+ const response = await fetcher(LATEST_API, { headers: { accept: "application/vnd.github+json", "user-agent": "nixamp.com" }, signal: AbortSignal.timeout(8_000) });
35
+ if (!response.ok)
36
+ throw new Error(`HTTP ${response.status}`);
37
+ const body = await response.json();
38
+ kept = { at: now(), release: { tag_name: String(body.tag_name ?? ""), assets: Array.isArray(body.assets) ? body.assets : [] } };
39
+ }
40
+ catch {
41
+ kept = { at: now(), release: kept.release };
42
+ }
43
+ return kept.release;
44
+ };
45
+ }
46
+ export function llmsTxt(site) {
47
+ const desktop = DESKTOP_TARGETS.map((one) => `- ${one.label}: ${site}/download/${one.target}`).join("\n");
48
+ return `# nixamp
49
+
50
+ > Broadcast live radio, television and film from your own machine. Open source streaming you run yourself: a terminal player, a web app (PWA) and a desktop app on one engine. Listen free, go live in one command, monetize with x402.
51
+
52
+ ## Web app (PWA)
53
+ - ${site}/ : the player and remote, installable
54
+ - ${site}/music : AI music, live and on demand, installable on its own
55
+
56
+ ## CLI
57
+ - Install: curl -fsSL ${site}/install.sh | sh
58
+ - Windows: irm ${site}/install.ps1 | iex
59
+ - npm: npx -y nixamp
60
+
61
+ ## MCP
62
+ - Public, no account (stations, what is on, share a track): ${site}/mcp/public
63
+ - Signed in (watch parties, rooms, transcripts, music, your stations): ${site}/mcp , a nixamp token as the bearer (\`nixamp token create\`) or OAuth
64
+ - Local over stdio: npx -y nixamp mcp
65
+
66
+ ## API
67
+ - OpenAPI: ${site}/openapi.json
68
+ - Base: ${site}/api/v1
69
+ - GET /api/v1/health
70
+ - GET /api/v1/music : the AI label's catalogue, every genre station, artist and album
71
+ - GET /api/v1/stations : public stations
72
+ - GET /api/v1/guide/rooms : live rooms
73
+ - GET /api/v1/guide/vods : films and shows on demand
74
+ - GET /api/directory : every nixamp server that is broadcasting
75
+ - GET /api/v1/auth/me : who the bearer token is
76
+ - POST /api/v1/auth/device/code : sign a CLI or agent in from a browser
77
+
78
+ ## Desktop app
79
+ Downloads: ${site}/download
80
+ ${desktop}
81
+ Every release: ${RELEASES}
82
+ `;
83
+ }
84
+ const ok = { description: "OK", content: { "application/json": { schema: { type: "object" } } } };
85
+ const signedIn = { description: "Not signed in", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } };
86
+ const route = (summary, tags, auth = false) => ({
87
+ summary,
88
+ tags,
89
+ ...(auth ? { security: [{ bearer: [] }], responses: { 200: ok, 401: signedIn } } : { responses: { 200: ok } }),
90
+ });
91
+ /** The API as OpenAPI 3.1: what an agent can call on nixamp.com, and which calls want a token. */
92
+ export function openapiDocument(site, version) {
93
+ return {
94
+ openapi: "3.1.0",
95
+ info: {
96
+ title: "nixamp",
97
+ version,
98
+ description: "Live radio, television and film from your own machine. Public routes answer anybody; the rest want a nixamp token as the bearer (`nixamp token create`).",
99
+ license: { name: "MIT", identifier: "MIT" },
100
+ },
101
+ servers: [{ url: site }],
102
+ externalDocs: { description: "llms.txt: MCP, CLI, desktop and API in one place", url: `${site}/llms.txt` },
103
+ components: {
104
+ securitySchemes: { bearer: { type: "http", scheme: "bearer", description: "A nixamp token (`nixamp token create`) or an OAuth access token." } },
105
+ schemas: { Error: { type: "object", properties: { error: { type: "string" } }, required: ["error"] } },
106
+ },
107
+ paths: {
108
+ "/api/v1/health": { get: route("Is nixamp.com up, and which version", ["meta"]) },
109
+ "/api/directory": { get: route("Every nixamp server that is broadcasting", ["stations"]) },
110
+ "/api/v1/stations": { get: route("Public stations", ["stations"]) },
111
+ "/api/v1/guide/rooms": { get: route("Live rooms on the guide", ["stations"]) },
112
+ "/api/v1/guide/vods": { get: route("Films and shows on demand", ["stations"]) },
113
+ "/api/v1/opendirs": { get: route("Open directories nixamp has indexed", ["stations"]) },
114
+ "/api/v1/music": { get: route("The AI label's catalogue: genre stations, artists, albums", ["music"]) },
115
+ "/api/v1/auth/providers": { get: route("How you can sign in", ["auth"]) },
116
+ "/api/v1/auth/device/code": { post: route("Start a device sign-in (CLI or agent); a person approves it in a browser", ["auth"]) },
117
+ "/api/v1/auth/device/token": { post: route("Collect the token once the device sign-in is approved", ["auth"]) },
118
+ "/api/v1/auth/me": { get: route("Who the bearer is", ["auth"], true) },
119
+ "/api/v1/auth/tokens": { get: route("Your tokens", ["auth"], true), post: route("Make a token", ["auth"], true) },
120
+ "/api/v1/follows": { get: route("Who you follow", ["social"], true) },
121
+ "/api/v1/favorites": { get: route("Your favourite stations", ["social"], true) },
122
+ "/api/v1/servers": { get: route("Your nixamp servers", ["servers"], true) },
123
+ "/mcp/public": { post: route("MCP (streamable HTTP, JSON-RPC): the public station tools", ["mcp"]) },
124
+ "/mcp": { post: route("MCP (streamable HTTP, JSON-RPC): every tool, as the bearer", ["mcp"], true) },
125
+ },
126
+ };
127
+ }
128
+ const escape = (text) => text.replace(/[&<>"']/g, (one) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[one]);
129
+ const STYLE = "body{margin:0;background:#080c09;color:#d8e6da;font:16px/1.5 system-ui,sans-serif}main{max-width:640px;margin:0 auto;padding:32px 16px}h1{font-size:28px;margin:0 0 4px}a{color:#7ee787}ul{padding:0;list-style:none}li{margin:10px 0}li a{display:block;padding:12px 14px;border:1px solid #1f2b22;border-radius:8px;text-decoration:none}li a:hover{border-color:#7ee787}code{background:#111a13;padding:2px 6px;border-radius:4px}.by{color:#8aa08e;margin:0 0 24px}";
130
+ export function downloadPage(site, release) {
131
+ const tag = release?.tag_name ? ` ${escape(release.tag_name)}` : "";
132
+ const items = DESKTOP_TARGETS.map((one) => `<li><a href="/download/${one.target}">${escape(one.label)}</a></li>`).join("");
133
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
134
+ <title>Download the desktop app · nixamp</title><meta name="description" content="nixamp for macOS, Windows and Linux: the player, the radio console and a nixamp server in one app.">
135
+ <link rel="canonical" href="${escape(site)}/download"><link rel="icon" href="/favicon.png" type="image/png"><style>${STYLE}</style></head>
136
+ <body><main><h1>nixamp for your desktop</h1><p class="by">The player, the radio console and a nixamp server in one app${tag}.</p>
137
+ <ul>${items}</ul>
138
+ <p>Or in a terminal: <code>curl -fsSL ${escape(site)}/install.sh | sh</code></p>
139
+ <p><a href="/">Open the web app</a> · <a href="${RELEASES}">Every release</a> · <a href="/llms.txt">For agents</a></p></main></body></html>`;
140
+ }
141
+ function send(response, code, type, body, method, extra = {}) {
142
+ response.writeHead(code, { "content-type": type, "content-length": Buffer.byteLength(body), "cache-control": "public, max-age=300", "access-control-allow-origin": "*", ...extra });
143
+ response.end(method === "HEAD" ? undefined : body);
144
+ }
145
+ /** Answers a discovery path; false when `path` is not one, so the caller carries on. */
146
+ export async function handleDiscovery(request, response, path, ctx) {
147
+ if (!isDiscoveryPath(path))
148
+ return false;
149
+ const method = request.method ?? "GET";
150
+ if (method !== "GET" && method !== "HEAD") {
151
+ response.writeHead(405, { allow: "GET, HEAD" });
152
+ response.end();
153
+ return true;
154
+ }
155
+ const site = ctx.site.replace(/\/+$/, "");
156
+ if (path === "/api/v1/health") {
157
+ ctx.json(response, 200, { ok: true, name: "nixamp", version: ctx.version });
158
+ return true;
159
+ }
160
+ if (path === "/llms.txt") {
161
+ send(response, 200, "text/plain; charset=utf-8", llmsTxt(site), method);
162
+ return true;
163
+ }
164
+ if (path === "/openapi.json") {
165
+ send(response, 200, "application/json; charset=utf-8", JSON.stringify(openapiDocument(site, ctx.version), null, 2), method);
166
+ return true;
167
+ }
168
+ if (path === "/download") {
169
+ send(response, 200, "text/html; charset=utf-8", downloadPage(site, await ctx.release()), method, { "content-security-policy": `default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; base-uri 'none'; frame-ancestors 'none'` });
170
+ return true;
171
+ }
172
+ const target = path.slice("/download/".length);
173
+ if (!DESKTOP_TARGETS.some((one) => one.target === target)) {
174
+ ctx.json(response, 404, { error: `no such download; one of ${DESKTOP_TARGETS.map((one) => one.target).join(", ")}` });
175
+ return true;
176
+ }
177
+ // Short-lived: the next release moves it.
178
+ response.writeHead(302, { location: desktopAsset(await ctx.release(), target) ?? `${RELEASES}/latest`, "cache-control": "public, max-age=300" });
179
+ response.end();
180
+ return true;
181
+ }