nixamp 0.53.5 → 0.55.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 (55) hide show
  1. package/dist/ai-label.d.ts +26 -0
  2. package/dist/ai-label.js +90 -1
  3. package/dist/follow-page.d.ts +23 -0
  4. package/dist/follow-page.js +111 -0
  5. package/dist/lyrics.d.ts +25 -15
  6. package/dist/lyrics.js +72 -1
  7. package/dist/radio-pages.d.ts +211 -0
  8. package/dist/radio-pages.js +415 -0
  9. package/dist/radio-slug.d.ts +18 -0
  10. package/dist/radio-slug.js +43 -0
  11. package/dist/server.js +242 -3
  12. package/dist/station-guide.d.ts +2 -0
  13. package/dist/station-guide.js +5 -3
  14. package/dist/station-page.d.ts +1 -1
  15. package/dist/station-page.js +4 -2
  16. package/dist/station-routes.d.ts +1 -0
  17. package/dist/station-routes.js +29 -4
  18. package/package.json +1 -1
  19. package/src/ai-label.ts +92 -1
  20. package/src/follow-page.ts +127 -0
  21. package/src/lyrics.ts +76 -1
  22. package/src/radio-pages.ts +513 -0
  23. package/src/radio-slug.ts +45 -0
  24. package/src/server.ts +232 -3
  25. package/src/station-guide.ts +7 -3
  26. package/src/station-page.ts +4 -2
  27. package/src/station-routes.ts +29 -5
  28. package/web/dist/assets/{ar-DqeYPqVe.js → ar-CCVdTxXd.js} +1 -1
  29. package/web/dist/assets/{cs-D4zQN1oV.js → cs-DazinpO5.js} +1 -1
  30. package/web/dist/assets/{da-DKIaXNAT.js → da-dANXD44J.js} +1 -1
  31. package/web/dist/assets/{de-D1lMnV7x.js → de-BH5N_2Zt.js} +1 -1
  32. package/web/dist/assets/{es-CjjnXT7v.js → es-lGWlpKlv.js} +1 -1
  33. package/web/dist/assets/{fi-Dp56MD45.js → fi-CIIF4ItN.js} +1 -1
  34. package/web/dist/assets/{fr-CeifYZP1.js → fr-s0WjlDkZ.js} +1 -1
  35. package/web/dist/assets/{hi-DsBa_glA.js → hi-Dwrx_fdX.js} +1 -1
  36. package/web/dist/assets/{hu-SYFZGXA2.js → hu-B7yw75er.js} +1 -1
  37. package/web/dist/assets/{id-91576EGD.js → id-DNdkH2kw.js} +1 -1
  38. package/web/dist/assets/{it-Cjmfw8XJ.js → it-C9858eN5.js} +1 -1
  39. package/web/dist/assets/{main-CBsLIRa4.js → main-Coq6MRdf.js} +2 -2
  40. package/web/dist/assets/{nl-XqBXZghJ.js → nl-BeH2Fgue.js} +1 -1
  41. package/web/dist/assets/{ru-CYT8xDxB.js → ru-DZ2nMkFG.js} +1 -1
  42. package/web/dist/assets/{station-Bqk5iV8L.css → station-CSnjPeod.css} +1 -1
  43. package/web/dist/assets/station-DGgba3hs.js +3 -0
  44. package/web/dist/assets/{sv-BlpGbShs.js → sv-CuTZodhi.js} +1 -1
  45. package/web/dist/assets/{uk-g-QbP-e_.js → uk-Bt534Jo1.js} +1 -1
  46. package/web/dist/assets/{vi-CF206cCy.js → vi-Ci0kYdXP.js} +1 -1
  47. package/web/dist/assets/{zh-D4QH6alE.js → zh-C8tiLpKW.js} +1 -1
  48. package/web/dist/index.html +1 -1
  49. package/web/dist/station.html +3 -2
  50. package/web/dist/sw.js +24 -24
  51. package/web/dist/vod.html +2 -2
  52. package/web/dist/watch.html +2 -2
  53. package/web/dist/assets/station-DUt85zQk.js +0 -3
  54. /package/web/dist/assets/{vod-BSo-pl8M.js → vod-Br9Ooz6-.js} +0 -0
  55. /package/web/dist/assets/{watch-DBSszFQm.js → watch-Dxsz5gHd.js} +0 -0
package/src/ai-label.ts CHANGED
@@ -33,6 +33,7 @@ import { dirname, join, resolve, sep } from "node:path";
33
33
  import { aiKey } from "./ai-keys.ts";
34
34
  import { composerFromEnv, OutOfCredit, type Composer } from "./ai-radio.ts";
35
35
  import { askJson, hasModel } from "./ai-text.ts";
36
+ import { formatLrc, type SyncedLine } from "./lyrics.ts";
36
37
  import type { LibraryIndex, Probed } from "./library-index.ts";
37
38
  import type { StationRunner } from "./station-runner.ts";
38
39
  import { StationError, type StationStore } from "./stations.ts";
@@ -49,6 +50,8 @@ export interface LabelSong {
49
50
  /** The MP3, "" until composed. */
50
51
  file: string;
51
52
  lyricsFile: string;
53
+ /** Its words timed to the recording (001. <title>.lrc, enhanced LRC), for karaoke on the station page; absent until aligned. */
54
+ lrc?: string;
52
55
  /** Its music video (001. <title>.mp4), "" until made; absent on songs from before videos. */
53
56
  video?: string;
54
57
  /** Whether the video carries its song's tags (videos from 0.52.1 and before do not, until retagged). */
@@ -522,6 +525,55 @@ export async function workersAiCover(prompt: string, env: Record<string, string
522
525
  return image;
523
526
  }
524
527
 
528
+ /** A timed word from an aligner: seconds from the start of the song. */
529
+ export interface TimedWord { text: string; start: number; end: number }
530
+
531
+ /** ElevenLabs forced alignment: the song and its words in, each word's time out. */
532
+ export function elevenLabsAligner(key: string, fetcher: typeof fetch = fetch): (audio: string, text: string) => Promise<TimedWord[]> {
533
+ return async (audio, text) => {
534
+ const form = new FormData();
535
+ form.append("file", new Blob([readFileSync(audio)], { type: "audio/mpeg" }), "song.mp3");
536
+ form.append("text", text);
537
+ const response = await fetcher("https://api.elevenlabs.io/v1/forced-alignment", {
538
+ method: "POST",
539
+ headers: { "xi-api-key": key },
540
+ body: form,
541
+ signal: AbortSignal.timeout(5 * 60_000),
542
+ });
543
+ if (response.status === 401 || response.status === 402) throw new OutOfCredit(`elevenlabs answered HTTP ${response.status} to the alignment`);
544
+ if (!response.ok) throw new Error(`the alignment answered HTTP ${response.status}`);
545
+ const body = await response.json() as { words?: Array<{ text?: unknown; start?: unknown; end?: unknown }> };
546
+ return (body.words ?? [])
547
+ .map((one) => ({ text: String(one.text ?? "").trim(), start: Number(one.start), end: Number(one.end) }))
548
+ .filter((one) => one.text && Number.isFinite(one.start) && Number.isFinite(one.end));
549
+ };
550
+ }
551
+
552
+ /** The sung lines of a song's words: section tags ([Chorus]) and blank lines are not sung. */
553
+ export function sungLines(lyrics: string): string[] {
554
+ return lyrics.split("\n").map((line) => line.trim()).filter((line) => line && !/^\[[^\]]*\]$/.test(line));
555
+ }
556
+
557
+ /**
558
+ * Timed words laid back onto the lines they were written in, in order, so
559
+ * each line and each word knows when it is sung.
560
+ */
561
+ export function syncLines(lyrics: string, timed: TimedWord[]): SyncedLine[] {
562
+ const out: SyncedLine[] = [];
563
+ let at = 0;
564
+ for (const line of sungLines(lyrics)) {
565
+ const words = line.split(/\s+/).filter(Boolean);
566
+ const placed = words.map((word) => {
567
+ const hit = timed[at];
568
+ if (hit) at += 1;
569
+ return hit ? { text: word, start: hit.start, end: hit.end } : null;
570
+ }).filter((one): one is { text: string; start: number; end: number } => one !== null);
571
+ if (placed.length === 0) break;
572
+ out.push({ start: placed[0]!.start, end: placed[placed.length - 1]!.end, text: line, words: placed });
573
+ }
574
+ return out;
575
+ }
576
+
525
577
  const STORYBOARD_SYSTEM = [
526
578
  "You storyboard music videos for original songs. Write one image prompt per scene, in order, following the song's story and mood.",
527
579
  "Keep the same main character(s), setting and color palette across scenes so it reads as one video; vary the shots (wide, close-up, action, detail).",
@@ -674,6 +726,8 @@ export interface AiLabelDeps {
674
726
  tag?: (from: string, to: string, tags: Record<string, string>, cover: string) => Promise<void>;
675
727
  /** Renders a song's music video to `out`; ffmpeg by default (renderVideo). */
676
728
  video?: (audio: string, cover: string, out: string) => Promise<void>;
729
+ /** Times each sung word in a recording; ElevenLabs forced alignment by default, null without a key. */
730
+ align?: ((audio: string, text: string) => Promise<TimedWord[]>) | null;
677
731
  /** Renders a storyboard music video; ffmpeg by default (renderStoryVideo). */
678
732
  storyVideo?: (input: { audio: string; images: string[]; seconds: number; artist: string; title: string; out: string }) => Promise<void>;
679
733
  /** A scene's picture for this prompt, or null when nothing can draw one; Workers AI by default. */
@@ -700,6 +754,8 @@ export class AiLabel {
700
754
  private filming: Promise<number> | null = null;
701
755
  /** Songs whose video failed since this server started. */
702
756
  private videoFailed = new Set<string>();
757
+ /** Songs whose words were already sent to the aligner since this server started. */
758
+ private alignTried = new Set<string>();
703
759
  /** Old videos already tried as storyboards since this server started. */
704
760
  private upgradeTried = new Set<string>();
705
761
  /** The day's album is tried at most every 30 minutes, so a failing model is not hammered. */
@@ -976,7 +1032,40 @@ export class AiLabel {
976
1032
  * song's tags (seconds each), then old cover-and-waveform videos made again
977
1033
  * as storyboards.
978
1034
  */
1035
+ /** The aligner this server can use: the dependency, else ElevenLabs with its key, else none. */
1036
+ private aligner(): ((audio: string, text: string) => Promise<TimedWord[]>) | null {
1037
+ if (this.deps.align !== undefined) return this.deps.align;
1038
+ const key = aiKey("ELEVENLABS_API_KEY");
1039
+ return key ? elevenLabsAligner(key) : null;
1040
+ }
1041
+
1042
+ /** A song's words timed to its recording, written as 001. <title>.lrc beside it. Once per song (a failure is not retried this run). */
1043
+ private async sync(artist: LabelArtist, album: LabelAlbum, song: LabelSong): Promise<boolean> {
1044
+ const align = this.aligner();
1045
+ if (!align || !song.file || !song.lyrics || song.lrc || this.alignTried.has(song.file)) return false;
1046
+ this.alignTried.add(song.file);
1047
+ try {
1048
+ const timed = await align(song.file, sungLines(song.lyrics).join("\n"));
1049
+ const lines = syncLines(song.lyrics, timed);
1050
+ if (lines.length === 0) throw new Error("the aligner placed no words");
1051
+ const file = join(album.folder, `${songBase(song)}.lrc`);
1052
+ writeFileSync(file, formatLrc(lines, { title: song.title, artist: artist.name, album: album.title }));
1053
+ song.lrc = file;
1054
+ this.save();
1055
+ return true;
1056
+ } catch (error) {
1057
+ this.log(`could not time the words of "${song.title}": ${(error as Error).message}`);
1058
+ return false;
1059
+ }
1060
+ }
1061
+
979
1062
  private async filmPass(): Promise<number> {
1063
+ // Words timed first: quick, and what the station page's karaoke needs.
1064
+ for (const artist of this.list()) {
1065
+ for (const album of artist.albums) {
1066
+ for (const song of album.songs) await this.sync(artist, album, song);
1067
+ }
1068
+ }
980
1069
  const songs = this.list().flatMap((artist) => artist.albums
981
1070
  .filter((album) => album.cover || album.coverTried)
982
1071
  .flatMap((album) => album.songs.filter((song) => song.file && !this.videoFailed.has(song.file)).map((song) => ({ artist, album, song }))));
@@ -1289,7 +1378,8 @@ export class AiLabel {
1289
1378
  this.save();
1290
1379
  this.writeAlbumJson(artist, album);
1291
1380
  await this.place(artist.genre);
1292
- // Its video is made beside it, without holding up the next song.
1381
+ // Its words timed to the recording, then its video, without holding up the next song.
1382
+ await this.sync(artist, album, song).catch(() => undefined);
1293
1383
  void this.videos();
1294
1384
  } catch (error) {
1295
1385
  this.failed(artist, error);
@@ -1465,6 +1555,7 @@ export class AiLabel {
1465
1555
  for (const song of songs) {
1466
1556
  entries.push({ name: `${folder}/${songBase(song)}.mp3`, path: song.file });
1467
1557
  entries.push({ name: `${folder}/${songBase(song)}.lyrics.txt`, bytes: Buffer.from(`${song.lyrics}\n`) });
1558
+ if (song.lrc) entries.push({ name: `${folder}/${songBase(song)}.lrc`, path: song.lrc });
1468
1559
  if (song.video) entries.push({ name: `${folder}/${songBase(song)}.mp4`, path: song.video });
1469
1560
  }
1470
1561
  if (album.cover) entries.push({ name: `${folder}/cover.jpg`, path: album.cover });
@@ -0,0 +1,127 @@
1
+ /**
2
+ * /follow on nixamp.com: follow an artist from a /radio page (radio-pages.ts).
3
+ *
4
+ * The /radio pages are on another site (live.streaming.pit.world), where a
5
+ * nixamp.com session cookie is a third-party cookie and no browser sends it.
6
+ * So Follow there is a link here, where the session is first-party: this page
7
+ * says who you are following, and a same-site form (SameSite=Lax keeps a
8
+ * cross-site POST from carrying the session) follows or unfollows. The follow
9
+ * is the same follows row as following a streamer, keyed "artist:<name>".
10
+ *
11
+ * GET /follow?id=artist:<name>&name=<shown>&back=<https page>
12
+ * POST /follow id, name, back, act=follow|unfollow -> 303 back to the GET
13
+ */
14
+ import { createHash } from "node:crypto";
15
+ import type { IncomingMessage, ServerResponse } from "node:http";
16
+
17
+ export interface FollowPageContext {
18
+ /** The signed-in account, or null. */
19
+ account: () => Promise<{ id: string } | null>;
20
+ follows: {
21
+ follow: (follower: string, streamer: string) => Promise<boolean>;
22
+ unfollow: (follower: string, streamer: string) => Promise<void>;
23
+ isFollowing: (follower: string, streamer: string) => Promise<boolean>;
24
+ followerCount: (streamer: string) => Promise<number>;
25
+ };
26
+ readBody: (request: IncomingMessage) => Promise<string>;
27
+ }
28
+
29
+ const ID = /^artist:[a-z0-9-]{1,90}$/;
30
+
31
+ function escape(text: string): string {
32
+ return String(text).replace(/[&<>"']/g, (one) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[one]!);
33
+ }
34
+
35
+ const STYLE = `
36
+ :root{--bg:#080c09;--fg:#e8efe9;--muted:#9aa79d;--card:#0e1410;--line:#1f2a22;--accent:#3ddc84}
37
+ *{box-sizing:border-box}html,body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}
38
+ main{max-width:520px;margin:0 auto;padding:40px 16px}
39
+ .card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:24px}
40
+ h1{margin:0 0 4px;font-size:1.5rem;overflow-wrap:anywhere}.by{color:var(--muted);margin:0 0 16px}
41
+ button,a.go{display:block;text-align:center;text-decoration:none;margin-top:18px;width:100%;padding:12px;border:0;border-radius:999px;background:var(--accent);color:#06120a;font:inherit;font-weight:800;cursor:pointer}
42
+ button.quiet{background:transparent;color:var(--fg);border:1px solid var(--line)}
43
+ a{color:var(--accent)}a:focus-visible,button:focus-visible{outline:3px solid var(--accent);outline-offset:2px}
44
+ small{display:block;margin-top:18px;color:var(--muted)}
45
+ `;
46
+
47
+ const hash = (text: string): string => `'sha256-${createHash("sha256").update(text).digest("base64")}'`;
48
+
49
+ export const FOLLOW_CSP = ["default-src 'none'", `style-src ${hash(STYLE)}`, "img-src 'self'", "form-action 'self'", "base-uri 'none'", "frame-ancestors 'none'"].join("; ");
50
+
51
+ function page(title: string, body: string): string {
52
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
53
+ <title>${escape(title)} · nixamp</title><meta name="robots" content="noindex"><link rel="icon" href="/favicon.png" type="image/png"><style>${STYLE}</style></head>
54
+ <body><main><div class="card">${body}</div></main></body></html>`;
55
+ }
56
+
57
+ function send(response: ServerResponse, status: number, html: string, method: string): void {
58
+ response.writeHead(status, { "content-type": "text/html; charset=utf-8", "content-security-policy": FOLLOW_CSP, "cache-control": "no-store", "referrer-policy": "no-referrer", "x-content-type-options": "nosniff" });
59
+ response.end(method === "HEAD" ? undefined : html);
60
+ }
61
+
62
+ /** What the link says, cleaned: an artist id, a name to show, and an https page to go back to. */
63
+ export function followTarget(input: URLSearchParams): { id: string; name: string; back: string } | null {
64
+ const id = (input.get("id") ?? "").trim();
65
+ if (!ID.test(id)) return null;
66
+ const name = (input.get("name") ?? "").replace(/[\u0000-\u001f]/g, " ").trim().slice(0, 120) || id.slice("artist:".length);
67
+ let back = "";
68
+ try {
69
+ const url = new URL(input.get("back") ?? "");
70
+ if (url.protocol === "https:" && !url.username && !url.password) back = url.href;
71
+ } catch {
72
+ back = "";
73
+ }
74
+ return { id, name, back };
75
+ }
76
+
77
+ /** Answers /follow; false for any other path. */
78
+ export async function handleFollowPage(request: IncomingMessage, response: ServerResponse, path: string, url: URL, ctx: FollowPageContext): Promise<boolean> {
79
+ if (path !== "/follow") return false;
80
+ const method = request.method ?? "GET";
81
+ if (method === "POST") {
82
+ // Same site only: a form on another site gets no session (SameSite=Lax), and is told so here too.
83
+ const origin = String(request.headers.origin ?? "");
84
+ const host = String(request.headers["x-forwarded-host"] ?? request.headers.host ?? "").split(",")[0]!.trim();
85
+ if (origin && origin !== "null" && new URL(origin).host !== host) {
86
+ send(response, 403, page("Not from here", `<h1>Not from here</h1><p class="by">Follow from nixamp.com's own page.</p>`), method);
87
+ return true;
88
+ }
89
+ const form = new URLSearchParams(await ctx.readBody(request));
90
+ const target = followTarget(form);
91
+ const me = await ctx.account().catch(() => null);
92
+ if (target && me) {
93
+ if (form.get("act") === "unfollow") await ctx.follows.unfollow(me.id, target.id);
94
+ else await ctx.follows.follow(me.id, target.id);
95
+ }
96
+ const again = target ? `/follow?${new URLSearchParams({ id: target.id, name: target.name, ...(target.back ? { back: target.back } : {}) }).toString()}` : "/follow";
97
+ response.writeHead(303, { location: again, "cache-control": "no-store" });
98
+ response.end();
99
+ return true;
100
+ }
101
+ if (method !== "GET" && method !== "HEAD") {
102
+ response.writeHead(405, { allow: "GET, POST" }).end();
103
+ return true;
104
+ }
105
+ const target = followTarget(url.searchParams);
106
+ if (!target) {
107
+ send(response, 404, page("Nobody to follow", `<h1>Nobody to follow</h1><p class="by">That link does not name an artist.</p>`), method);
108
+ return true;
109
+ }
110
+ const back = target.back ? `<small><a href="${escape(target.back)}">← Back to ${escape(target.name)}</a></small>` : "";
111
+ const me = await ctx.account().catch(() => null);
112
+ if (!me) {
113
+ send(response, 200, page(`Follow ${target.name}`, `<h1>Follow ${escape(target.name)}</h1>
114
+ <p class="by">Sign in to nixamp.com to follow the artists you hear on the radio.</p>
115
+ <a class="go" href="/radio#music">Sign in to nixamp.com</a>
116
+ <small>Signed in, open this page again (or press Follow on the artist's page) to follow.</small>${back}`), method);
117
+ return true;
118
+ }
119
+ const [following, count] = await Promise.all([ctx.follows.isFollowing(me.id, target.id), ctx.follows.followerCount(target.id).catch(() => 0)]);
120
+ const hidden = `<input type="hidden" name="id" value="${escape(target.id)}"><input type="hidden" name="name" value="${escape(target.name)}">${target.back ? `<input type="hidden" name="back" value="${escape(target.back)}">` : ""}`;
121
+ send(response, 200, page(`Follow ${target.name}`, `<h1>${escape(target.name)}</h1>
122
+ <p class="by">${following ? "You follow them." : "Follow them to keep them with your follows on nixamp.com."} ${count} follower${count === 1 ? "" : "s"}.</p>
123
+ <form method="post" action="/follow">${hidden}<input type="hidden" name="act" value="${following ? "unfollow" : "follow"}">
124
+ <button type="submit"${following ? ' class="quiet"' : ""}>${following ? "Unfollow" : `Follow ${escape(target.name)}`}</button></form>
125
+ ${back}`), method);
126
+ return true;
127
+ }
package/src/lyrics.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  * nichedb's for ten minutes, misses included, so a station of tracks nobody
14
14
  * has words for asks once per track per ten minutes and never waits long.
15
15
  */
16
+ import { readFileSync } from "node:fs";
16
17
 
17
18
  export interface Lyrics {
18
19
  title: string;
@@ -22,6 +23,76 @@ export interface Lyrics {
22
23
  source: string;
23
24
  /** ISO 639-1 where we know the mapping, else what the tag said; "" when unsaid. */
24
25
  language: string;
26
+ /** When a .lrc beside the file says when each line (and word) is sung: seconds from the file's start. */
27
+ synced?: SyncedLine[];
28
+ }
29
+
30
+ export interface SyncedLine {
31
+ start: number;
32
+ end: number;
33
+ text: string;
34
+ words: Array<{ start: number; end: number; text: string }>;
35
+ }
36
+
37
+ const STAMP = /^(\d{1,3}):(\d{2})(?:[.:](\d{1,3}))?$/;
38
+
39
+ function seconds(stamp: string): number {
40
+ const match = STAMP.exec(stamp);
41
+ if (!match) return Number.NaN;
42
+ const fraction = match[3] ? Number(`0.${match[3]}`) : 0;
43
+ return Number(match[1]) * 60 + Number(match[2]) + fraction;
44
+ }
45
+
46
+ function stamp(at: number): string {
47
+ const whole = Math.max(0, at);
48
+ const minutes = Math.floor(whole / 60);
49
+ const rest = whole - minutes * 60;
50
+ return `${String(minutes).padStart(2, "0")}:${rest.toFixed(2).padStart(5, "0")}`;
51
+ }
52
+
53
+ /**
54
+ * Enhanced LRC: `[mm:ss.xx]` before each sung line, and `<mm:ss.xx>` before
55
+ * each word when the words are timed. Tags like `[ar:...]` and lines without a
56
+ * time are skipped. Ends are the next start (the last line runs 4 s).
57
+ */
58
+ export function parseLrc(text: string): SyncedLine[] {
59
+ const lines: SyncedLine[] = [];
60
+ for (const raw of text.replace(/\r\n?/g, "\n").split("\n")) {
61
+ const head = /^\[([^\]]+)\](.*)$/.exec(raw.trim());
62
+ if (!head) continue;
63
+ const start = seconds(head[1]!);
64
+ if (!Number.isFinite(start)) continue;
65
+ const body = head[2]!;
66
+ const words: SyncedLine["words"] = [];
67
+ const parts = body.split(/<([^>]+)>/);
68
+ if (parts.length > 1) {
69
+ for (let i = 1; i < parts.length; i += 2) {
70
+ const at = seconds(parts[i]!);
71
+ const word = (parts[i + 1] ?? "").trim();
72
+ if (Number.isFinite(at) && word) words.push({ start: at, end: at, text: word });
73
+ }
74
+ }
75
+ const plain = (parts.length > 1 ? words.map((one) => one.text).join(" ") : body).trim();
76
+ if (!plain) continue;
77
+ lines.push({ start, end: start, text: plain, words });
78
+ }
79
+ lines.sort((a, b) => a.start - b.start);
80
+ lines.forEach((line, i) => {
81
+ line.end = lines[i + 1]?.start ?? line.start + 4;
82
+ line.words.forEach((word, j) => { word.end = line.words[j + 1]?.start ?? line.end; });
83
+ });
84
+ return lines;
85
+ }
86
+
87
+ export function formatLrc(lines: SyncedLine[], head: { title?: string; artist?: string; album?: string } = {}): string {
88
+ const tags = [head.artist ? `[ar:${head.artist}]` : "", head.title ? `[ti:${head.title}]` : "", head.album ? `[al:${head.album}]` : ""].filter(Boolean);
89
+ const body = lines.map((line) => `[${stamp(line.start)}]${line.words.length ? line.words.map((word) => ` <${stamp(word.start)}>${word.text}`).join("") : line.text}`);
90
+ return `${[...tags, ...body].join("\n")}\n`;
91
+ }
92
+
93
+ /** The .lrc that sits beside a file (same name, .lrc), or "". */
94
+ export function lrcBeside(file: string): string {
95
+ return file.replace(/\.[^./]+$/, "") + ".lrc";
25
96
  }
26
97
 
27
98
  export const NICHEDB_LYRICS = "https://nichedb.dev";
@@ -116,7 +187,11 @@ export class LyricsFinder {
116
187
  const title = tags["title"] || known?.title || "";
117
188
  const artist = tags["artist"] || tags["album_artist"] || known?.artist || "";
118
189
  const own = lyricsFromTags(tags);
119
- if (own) return { title, artist, text: own.text, source: "embedded", language: own.language };
190
+ // Timed words beside the file (karaoke): the page follows along with them.
191
+ let synced: SyncedLine[] = [];
192
+ try { synced = parseLrc(readFileSync(lrcBeside(file), "utf8")); } catch { synced = []; }
193
+ if (own) return { title, artist, text: own.text, source: "embedded", language: own.language, ...(synced.length ? { synced } : {}) };
194
+ if (synced.length) return { title, artist, text: synced.map((line) => line.text).join("\n"), source: "embedded", language: "", synced };
120
195
  if (!title || !artist || !this.base) return null;
121
196
  return this.lookup(artist, title);
122
197
  }