nixamp 0.54.0 → 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 (36) hide show
  1. package/dist/ai-label.d.ts +26 -0
  2. package/dist/ai-label.js +90 -1
  3. package/dist/lyrics.d.ts +25 -15
  4. package/dist/lyrics.js +72 -1
  5. package/dist/station-routes.js +1 -1
  6. package/package.json +1 -1
  7. package/src/ai-label.ts +92 -1
  8. package/src/lyrics.ts +76 -1
  9. package/src/station-routes.ts +1 -1
  10. package/web/dist/assets/{ar-CHJqwLbG.js → ar-CCVdTxXd.js} +1 -1
  11. package/web/dist/assets/{cs-CmwqmJKg.js → cs-DazinpO5.js} +1 -1
  12. package/web/dist/assets/{da-Xdzrm0AY.js → da-dANXD44J.js} +1 -1
  13. package/web/dist/assets/{de-h2ntHz8Y.js → de-BH5N_2Zt.js} +1 -1
  14. package/web/dist/assets/{es-DgbBXAxw.js → es-lGWlpKlv.js} +1 -1
  15. package/web/dist/assets/{fi-Bso9G00g.js → fi-CIIF4ItN.js} +1 -1
  16. package/web/dist/assets/{fr-6Q3UZKIG.js → fr-s0WjlDkZ.js} +1 -1
  17. package/web/dist/assets/{hi-T7_WxQ11.js → hi-Dwrx_fdX.js} +1 -1
  18. package/web/dist/assets/{hu-BjOppQVq.js → hu-B7yw75er.js} +1 -1
  19. package/web/dist/assets/{id-BIkZDUv5.js → id-DNdkH2kw.js} +1 -1
  20. package/web/dist/assets/{it-CHOmOHju.js → it-C9858eN5.js} +1 -1
  21. package/web/dist/assets/{main-BpsUYSCl.js → main-Coq6MRdf.js} +2 -2
  22. package/web/dist/assets/{nl-CHQR6VFb.js → nl-BeH2Fgue.js} +1 -1
  23. package/web/dist/assets/{ru-Du7NQrxe.js → ru-DZ2nMkFG.js} +1 -1
  24. package/web/dist/assets/{station-Bqk5iV8L.css → station-CSnjPeod.css} +1 -1
  25. package/web/dist/assets/{station-xTLbhzsu.js → station-DGgba3hs.js} +2 -2
  26. package/web/dist/assets/{sv-B6AFovY0.js → sv-CuTZodhi.js} +1 -1
  27. package/web/dist/assets/{uk-LXJz37Kp.js → uk-Bt534Jo1.js} +1 -1
  28. package/web/dist/assets/{vi-C4taTvjH.js → vi-Ci0kYdXP.js} +1 -1
  29. package/web/dist/assets/{zh-dBiC2V5a.js → zh-C8tiLpKW.js} +1 -1
  30. package/web/dist/index.html +1 -1
  31. package/web/dist/station.html +3 -2
  32. package/web/dist/sw.js +24 -24
  33. package/web/dist/vod.html +2 -2
  34. package/web/dist/watch.html +2 -2
  35. /package/web/dist/assets/{vod-BSo-pl8M.js → vod-Br9Ooz6-.js} +0 -0
  36. /package/web/dist/assets/{watch-DBSszFQm.js → watch-Dxsz5gHd.js} +0 -0
@@ -1,5 +1,6 @@
1
1
  import { type Composer } from "./ai-radio.ts";
2
2
  import { askJson } from "./ai-text.ts";
3
+ import { type SyncedLine } from "./lyrics.ts";
3
4
  import type { LibraryIndex, Probed } from "./library-index.ts";
4
5
  import type { StationRunner } from "./station-runner.ts";
5
6
  import { type StationStore } from "./stations.ts";
@@ -15,6 +16,8 @@ export interface LabelSong {
15
16
  /** The MP3, "" until composed. */
16
17
  file: string;
17
18
  lyricsFile: string;
19
+ /** Its words timed to the recording (001. <title>.lrc, enhanced LRC), for karaoke on the station page; absent until aligned. */
20
+ lrc?: string;
18
21
  /** Its music video (001. <title>.mp4), "" until made; absent on songs from before videos. */
19
22
  video?: string;
20
23
  /** Whether the video carries its song's tags (videos from 0.52.1 and before do not, until retagged). */
@@ -127,6 +130,21 @@ export declare function writeLyrics(artist: Pick<LabelArtist, "name" | "genre" |
127
130
  * OPENAI_API_KEY its token); null when there is no image model to ask.
128
131
  */
129
132
  export declare function workersAiCover(prompt: string, env?: Record<string, string | undefined>, fetcher?: typeof fetch): Promise<Buffer | null>;
133
+ /** A timed word from an aligner: seconds from the start of the song. */
134
+ export interface TimedWord {
135
+ text: string;
136
+ start: number;
137
+ end: number;
138
+ }
139
+ /** ElevenLabs forced alignment: the song and its words in, each word's time out. */
140
+ export declare function elevenLabsAligner(key: string, fetcher?: typeof fetch): (audio: string, text: string) => Promise<TimedWord[]>;
141
+ /** The sung lines of a song's words: section tags ([Chorus]) and blank lines are not sung. */
142
+ export declare function sungLines(lyrics: string): string[];
143
+ /**
144
+ * Timed words laid back onto the lines they were written in, in order, so
145
+ * each line and each word knows when it is sung.
146
+ */
147
+ export declare function syncLines(lyrics: string, timed: TimedWord[]): SyncedLine[];
130
148
  /** Image prompts for a song's video, one a scene, from its words and mood. */
131
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[]>;
132
150
  /**
@@ -169,6 +187,8 @@ export interface AiLabelDeps {
169
187
  tag?: (from: string, to: string, tags: Record<string, string>, cover: string) => Promise<void>;
170
188
  /** Renders a song's music video to `out`; ffmpeg by default (renderVideo). */
171
189
  video?: (audio: string, cover: string, out: string) => Promise<void>;
190
+ /** Times each sung word in a recording; ElevenLabs forced alignment by default, null without a key. */
191
+ align?: ((audio: string, text: string) => Promise<TimedWord[]>) | null;
172
192
  /** Renders a storyboard music video; ffmpeg by default (renderStoryVideo). */
173
193
  storyVideo?: (input: {
174
194
  audio: string;
@@ -200,6 +220,8 @@ export declare class AiLabel {
200
220
  private filming;
201
221
  /** Songs whose video failed since this server started. */
202
222
  private videoFailed;
223
+ /** Songs whose words were already sent to the aligner since this server started. */
224
+ private alignTried;
203
225
  /** Old videos already tried as storyboards since this server started. */
204
226
  private upgradeTried;
205
227
  /** The day's album is tried at most every 30 minutes, so a failing model is not hammered. */
@@ -246,6 +268,10 @@ export declare class AiLabel {
246
268
  * song's tags (seconds each), then old cover-and-waveform videos made again
247
269
  * as storyboards.
248
270
  */
271
+ /** The aligner this server can use: the dependency, else ElevenLabs with its key, else none. */
272
+ private aligner;
273
+ /** 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). */
274
+ private sync;
249
275
  private filmPass;
250
276
  /**
251
277
  * One song's video: a storyboard of scenes when a model can write it and an
package/dist/ai-label.js CHANGED
@@ -33,6 +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
37
  import { StationError } from "./stations.js";
37
38
  /**
38
39
  * The label's first roster: invented names, one genre each, sung in English.
@@ -408,6 +409,53 @@ export async function workersAiCover(prompt, env = process.env, fetcher = fetch)
408
409
  throw new Error("the cover model answered with no picture");
409
410
  return image;
410
411
  }
412
+ /** ElevenLabs forced alignment: the song and its words in, each word's time out. */
413
+ export function elevenLabsAligner(key, fetcher = fetch) {
414
+ return async (audio, text) => {
415
+ const form = new FormData();
416
+ form.append("file", new Blob([readFileSync(audio)], { type: "audio/mpeg" }), "song.mp3");
417
+ form.append("text", text);
418
+ const response = await fetcher("https://api.elevenlabs.io/v1/forced-alignment", {
419
+ method: "POST",
420
+ headers: { "xi-api-key": key },
421
+ body: form,
422
+ signal: AbortSignal.timeout(5 * 60_000),
423
+ });
424
+ if (response.status === 401 || response.status === 402)
425
+ throw new OutOfCredit(`elevenlabs answered HTTP ${response.status} to the alignment`);
426
+ if (!response.ok)
427
+ throw new Error(`the alignment answered HTTP ${response.status}`);
428
+ const body = await response.json();
429
+ return (body.words ?? [])
430
+ .map((one) => ({ text: String(one.text ?? "").trim(), start: Number(one.start), end: Number(one.end) }))
431
+ .filter((one) => one.text && Number.isFinite(one.start) && Number.isFinite(one.end));
432
+ };
433
+ }
434
+ /** The sung lines of a song's words: section tags ([Chorus]) and blank lines are not sung. */
435
+ export function sungLines(lyrics) {
436
+ return lyrics.split("\n").map((line) => line.trim()).filter((line) => line && !/^\[[^\]]*\]$/.test(line));
437
+ }
438
+ /**
439
+ * Timed words laid back onto the lines they were written in, in order, so
440
+ * each line and each word knows when it is sung.
441
+ */
442
+ export function syncLines(lyrics, timed) {
443
+ const out = [];
444
+ let at = 0;
445
+ for (const line of sungLines(lyrics)) {
446
+ const words = line.split(/\s+/).filter(Boolean);
447
+ const placed = words.map((word) => {
448
+ const hit = timed[at];
449
+ if (hit)
450
+ at += 1;
451
+ return hit ? { text: word, start: hit.start, end: hit.end } : null;
452
+ }).filter((one) => one !== null);
453
+ if (placed.length === 0)
454
+ break;
455
+ out.push({ start: placed[0].start, end: placed[placed.length - 1].end, text: line, words: placed });
456
+ }
457
+ return out;
458
+ }
411
459
  const STORYBOARD_SYSTEM = [
412
460
  "You storyboard music videos for original songs. Write one image prompt per scene, in order, following the song's story and mood.",
413
461
  "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).",
@@ -555,6 +603,8 @@ export class AiLabel {
555
603
  filming = null;
556
604
  /** Songs whose video failed since this server started. */
557
605
  videoFailed = new Set();
606
+ /** Songs whose words were already sent to the aligner since this server started. */
607
+ alignTried = new Set();
558
608
  /** Old videos already tried as storyboards since this server started. */
559
609
  upgradeTried = new Set();
560
610
  /** The day's album is tried at most every 30 minutes, so a failing model is not hammered. */
@@ -844,7 +894,43 @@ export class AiLabel {
844
894
  * song's tags (seconds each), then old cover-and-waveform videos made again
845
895
  * as storyboards.
846
896
  */
897
+ /** The aligner this server can use: the dependency, else ElevenLabs with its key, else none. */
898
+ aligner() {
899
+ if (this.deps.align !== undefined)
900
+ return this.deps.align;
901
+ const key = aiKey("ELEVENLABS_API_KEY");
902
+ return key ? elevenLabsAligner(key) : null;
903
+ }
904
+ /** 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). */
905
+ async sync(artist, album, song) {
906
+ const align = this.aligner();
907
+ if (!align || !song.file || !song.lyrics || song.lrc || this.alignTried.has(song.file))
908
+ return false;
909
+ this.alignTried.add(song.file);
910
+ try {
911
+ const timed = await align(song.file, sungLines(song.lyrics).join("\n"));
912
+ const lines = syncLines(song.lyrics, timed);
913
+ if (lines.length === 0)
914
+ throw new Error("the aligner placed no words");
915
+ const file = join(album.folder, `${songBase(song)}.lrc`);
916
+ writeFileSync(file, formatLrc(lines, { title: song.title, artist: artist.name, album: album.title }));
917
+ song.lrc = file;
918
+ this.save();
919
+ return true;
920
+ }
921
+ catch (error) {
922
+ this.log(`could not time the words of "${song.title}": ${error.message}`);
923
+ return false;
924
+ }
925
+ }
847
926
  async filmPass() {
927
+ // Words timed first: quick, and what the station page's karaoke needs.
928
+ for (const artist of this.list()) {
929
+ for (const album of artist.albums) {
930
+ for (const song of album.songs)
931
+ await this.sync(artist, album, song);
932
+ }
933
+ }
848
934
  const songs = this.list().flatMap((artist) => artist.albums
849
935
  .filter((album) => album.cover || album.coverTried)
850
936
  .flatMap((album) => album.songs.filter((song) => song.file && !this.videoFailed.has(song.file)).map((song) => ({ artist, album, song }))));
@@ -1184,7 +1270,8 @@ export class AiLabel {
1184
1270
  this.save();
1185
1271
  this.writeAlbumJson(artist, album);
1186
1272
  await this.place(artist.genre);
1187
- // Its video is made beside it, without holding up the next song.
1273
+ // Its words timed to the recording, then its video, without holding up the next song.
1274
+ await this.sync(artist, album, song).catch(() => undefined);
1188
1275
  void this.videos();
1189
1276
  }
1190
1277
  catch (error) {
@@ -1364,6 +1451,8 @@ export class AiLabel {
1364
1451
  for (const song of songs) {
1365
1452
  entries.push({ name: `${folder}/${songBase(song)}.mp3`, path: song.file });
1366
1453
  entries.push({ name: `${folder}/${songBase(song)}.lyrics.txt`, bytes: Buffer.from(`${song.lyrics}\n`) });
1454
+ if (song.lrc)
1455
+ entries.push({ name: `${folder}/${songBase(song)}.lrc`, path: song.lrc });
1367
1456
  if (song.video)
1368
1457
  entries.push({ name: `${folder}/${songBase(song)}.mp4`, path: song.video });
1369
1458
  }
package/dist/lyrics.d.ts CHANGED
@@ -1,18 +1,3 @@
1
- /**
2
- * The words of the song on the air, for a station's page.
3
- *
4
- * Two places they come from, in this order:
5
- * 1. the file itself: an ID3 USLT frame (ffprobe calls it `lyrics-eng`, or
6
- * `lyrics-<description>-<lang>`), a Vorbis/APE `LYRICS` or
7
- * `UNSYNCEDLYRICS` tag. Suno writes the words it sang into USLT, so our
8
- * own catalog carries them already;
9
- * 2. nichedb.dev's lyrics, asked by artist and title, which is where a file
10
- * without the tag gets them (and where ours are published back to:
11
- * lyrics-publish.ts).
12
- * Each answer is kept: the tags with the file (by size and time, TagReader),
13
- * nichedb's for ten minutes, misses included, so a station of tracks nobody
14
- * has words for asks once per track per ten minutes and never waits long.
15
- */
16
1
  export interface Lyrics {
17
2
  title: string;
18
3
  artist: string;
@@ -21,7 +6,32 @@ export interface Lyrics {
21
6
  source: string;
22
7
  /** ISO 639-1 where we know the mapping, else what the tag said; "" when unsaid. */
23
8
  language: string;
9
+ /** When a .lrc beside the file says when each line (and word) is sung: seconds from the file's start. */
10
+ synced?: SyncedLine[];
11
+ }
12
+ export interface SyncedLine {
13
+ start: number;
14
+ end: number;
15
+ text: string;
16
+ words: Array<{
17
+ start: number;
18
+ end: number;
19
+ text: string;
20
+ }>;
24
21
  }
22
+ /**
23
+ * Enhanced LRC: `[mm:ss.xx]` before each sung line, and `<mm:ss.xx>` before
24
+ * each word when the words are timed. Tags like `[ar:...]` and lines without a
25
+ * time are skipped. Ends are the next start (the last line runs 4 s).
26
+ */
27
+ export declare function parseLrc(text: string): SyncedLine[];
28
+ export declare function formatLrc(lines: SyncedLine[], head?: {
29
+ title?: string;
30
+ artist?: string;
31
+ album?: string;
32
+ }): string;
33
+ /** The .lrc that sits beside a file (same name, .lrc), or "". */
34
+ export declare function lrcBeside(file: string): string;
25
35
  export declare const NICHEDB_LYRICS = "https://nichedb.dev";
26
36
  /** Text as it may be shown: newlines kept, control characters gone, trimmed, capped. */
27
37
  export declare function cleanLyrics(text: string): string;
package/dist/lyrics.js CHANGED
@@ -13,6 +13,67 @@
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";
17
+ const STAMP = /^(\d{1,3}):(\d{2})(?:[.:](\d{1,3}))?$/;
18
+ function seconds(stamp) {
19
+ const match = STAMP.exec(stamp);
20
+ if (!match)
21
+ return Number.NaN;
22
+ const fraction = match[3] ? Number(`0.${match[3]}`) : 0;
23
+ return Number(match[1]) * 60 + Number(match[2]) + fraction;
24
+ }
25
+ function stamp(at) {
26
+ const whole = Math.max(0, at);
27
+ const minutes = Math.floor(whole / 60);
28
+ const rest = whole - minutes * 60;
29
+ return `${String(minutes).padStart(2, "0")}:${rest.toFixed(2).padStart(5, "0")}`;
30
+ }
31
+ /**
32
+ * Enhanced LRC: `[mm:ss.xx]` before each sung line, and `<mm:ss.xx>` before
33
+ * each word when the words are timed. Tags like `[ar:...]` and lines without a
34
+ * time are skipped. Ends are the next start (the last line runs 4 s).
35
+ */
36
+ export function parseLrc(text) {
37
+ const lines = [];
38
+ for (const raw of text.replace(/\r\n?/g, "\n").split("\n")) {
39
+ const head = /^\[([^\]]+)\](.*)$/.exec(raw.trim());
40
+ if (!head)
41
+ continue;
42
+ const start = seconds(head[1]);
43
+ if (!Number.isFinite(start))
44
+ continue;
45
+ const body = head[2];
46
+ const words = [];
47
+ const parts = body.split(/<([^>]+)>/);
48
+ if (parts.length > 1) {
49
+ for (let i = 1; i < parts.length; i += 2) {
50
+ const at = seconds(parts[i]);
51
+ const word = (parts[i + 1] ?? "").trim();
52
+ if (Number.isFinite(at) && word)
53
+ words.push({ start: at, end: at, text: word });
54
+ }
55
+ }
56
+ const plain = (parts.length > 1 ? words.map((one) => one.text).join(" ") : body).trim();
57
+ if (!plain)
58
+ continue;
59
+ lines.push({ start, end: start, text: plain, words });
60
+ }
61
+ lines.sort((a, b) => a.start - b.start);
62
+ lines.forEach((line, i) => {
63
+ line.end = lines[i + 1]?.start ?? line.start + 4;
64
+ line.words.forEach((word, j) => { word.end = line.words[j + 1]?.start ?? line.end; });
65
+ });
66
+ return lines;
67
+ }
68
+ export function formatLrc(lines, head = {}) {
69
+ const tags = [head.artist ? `[ar:${head.artist}]` : "", head.title ? `[ti:${head.title}]` : "", head.album ? `[al:${head.album}]` : ""].filter(Boolean);
70
+ const body = lines.map((line) => `[${stamp(line.start)}]${line.words.length ? line.words.map((word) => ` <${stamp(word.start)}>${word.text}`).join("") : line.text}`);
71
+ return `${[...tags, ...body].join("\n")}\n`;
72
+ }
73
+ /** The .lrc that sits beside a file (same name, .lrc), or "". */
74
+ export function lrcBeside(file) {
75
+ return file.replace(/\.[^./]+$/, "") + ".lrc";
76
+ }
16
77
  export const NICHEDB_LYRICS = "https://nichedb.dev";
17
78
  const MAX_TEXT = 20_000;
18
79
  /** ID3 / ISO 639-2 codes for the languages we are likely to meet, as 639-1. */
@@ -94,8 +155,18 @@ export class LyricsFinder {
94
155
  const title = tags["title"] || known?.title || "";
95
156
  const artist = tags["artist"] || tags["album_artist"] || known?.artist || "";
96
157
  const own = lyricsFromTags(tags);
158
+ // Timed words beside the file (karaoke): the page follows along with them.
159
+ let synced = [];
160
+ try {
161
+ synced = parseLrc(readFileSync(lrcBeside(file), "utf8"));
162
+ }
163
+ catch {
164
+ synced = [];
165
+ }
97
166
  if (own)
98
- return { title, artist, text: own.text, source: "embedded", language: own.language };
167
+ return { title, artist, text: own.text, source: "embedded", language: own.language, ...(synced.length ? { synced } : {}) };
168
+ if (synced.length)
169
+ return { title, artist, text: synced.map((line) => line.text).join("\n"), source: "embedded", language: "", synced };
99
170
  if (!title || !artist || !this.base)
100
171
  return null;
101
172
  return this.lookup(artist, title);
@@ -240,7 +240,7 @@ export async function handleStations(request, response, path, url, ctx) {
240
240
  ctx.json(response, 404, { error: "no lyrics for what is playing", code: "not_found" });
241
241
  return true;
242
242
  }
243
- ctx.json(response, 200, { lyrics: { title: lyrics.title, artist: lyrics.artist, text: lyrics.text, source: lyrics.source, language: lyrics.language } });
243
+ ctx.json(response, 200, { lyrics: { title: lyrics.title, artist: lyrics.artist, text: lyrics.text, source: lyrics.source, language: lyrics.language, ...(lyrics.synced ? { synced: lyrics.synced } : {}) } });
244
244
  return true;
245
245
  }
246
246
  if (what === "now") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.54.0",
3
+ "version": "0.55.0",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/ai-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 });
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
  }
@@ -324,7 +324,7 @@ export async function handleStations(
324
324
  ctx.json(response, 404, { error: "no lyrics for what is playing", code: "not_found" });
325
325
  return true;
326
326
  }
327
- ctx.json(response, 200, { lyrics: { title: lyrics.title, artist: lyrics.artist, text: lyrics.text, source: lyrics.source, language: lyrics.language } });
327
+ ctx.json(response, 200, { lyrics: { title: lyrics.title, artist: lyrics.artist, text: lyrics.text, source: lyrics.source, language: lyrics.language, ...(lyrics.synced ? { synced: lyrics.synced } : {}) } });
328
328
  return true;
329
329
  }
330
330
  if (what === "now") {
@@ -1 +1 @@
1
- import{t as e}from"./main-BpsUYSCl.js";var t={...e,"No parties live right now.":`لا توجد جلسات مباشرة الآن.`,"Could not refresh parties.":`تعذر تحديث الجلسات.`,"Pick a server in the directory, or paste its link here.":`اختر خادمًا من الدليل أو الصق رابطه هنا.`,"Interface language":`لغة الواجهة`,Language:`اللغة`,"Translation language":`لغة الترجمة`,"Original (auto-detect)":`الأصل (اكتشاف تلقائي)`,Play:`تشغيل`,Pause:`إيقاف مؤقت`,Stop:`إيقاف`,Next:`التالي`,Previous:`السابق`,Select:`اختيار`,"Next/Prev":`التالي/السابق`,Quit:`خروج`,"Now Playing":`قيد التشغيل`,"Spectrum Analyser":`محلل الطيف`,Playlist:`قائمة التشغيل`,Files:`الملفات`,"Open files":`فتح ملفات`,"Open folder":`فتح مجلد`,"Add to library":`إضافة إلى المكتبة`,"Replace playlist":`استبدال قائمة التشغيل`,Filter:`تصفية`,Search:`بحث`,"Nothing loaded.":`لم يُحمّل أي محتوى.`,"Empty.":`فارغ.`,"Loading…":`جارٍ التحميل…`,"Connecting…":`جارٍ الاتصال…`,Connected:`متصل`,"Not connected":`غير متصل`,"No source":`لا يوجد مصدر`,Playing:`يعمل`,Stopped:`متوقف`,Volume:`مستوى الصوت`,Seek:`موضع التشغيل`,"Full screen":`ملء الشاشة`,Download:`تنزيل`,Share:`مشاركة`,Copy:`نسخ`,"Copy link":`نسخ الرابط`,Copied:`تم النسخ`,Save:`حفظ`,Cancel:`إلغاء`,Close:`إغلاق`,Remove:`إزالة`,Delete:`حذف`,Edit:`تعديل`,Add:`إضافة`,Send:`إرسال`,Retry:`إعادة المحاولة`,Back:`رجوع`,"Next page":`الصفحة التالية`,"Previous page":`الصفحة السابقة`,"Show more":`عرض المزيد`,Panels:`اللوحات`,"Reset layout":`إعادة ضبط التخطيط`,Show:`إظهار`,Hide:`إخفاء`,Collapse:`طي`,Expand:`توسيع`,"Move up":`نقل لأعلى`,"Move down":`نقل لأسفل`,Install:`تثبيت`,"TV mode":`وضع التلفاز`,On:`مفعّل`,Off:`معطّل`,Account:`الحساب`,"Sign in":`تسجيل الدخول`,"Sign out":`تسجيل الخروج`,"Create account":`إنشاء حساب`,Email:`البريد الإلكتروني`,Password:`كلمة المرور`,"Forgot password?":`نسيت كلمة المرور؟`,"Welcome back":`مرحبًا بعودتك`,Profile:`الملف الشخصي`,Handle:`اسم المستخدم`,"Your servers":`خوادمك`,Server:`الخادم`,Connect:`اتصال`,Disconnect:`قطع الاتصال`,Directory:`الدليل`,Favourites:`المفضلة`,Follow:`متابعة`,Unfollow:`إلغاء المتابعة`,Notifications:`الإشعارات`,"Phone number":`رقم الهاتف`,"Save number":`حفظ الرقم`,Transcript:`النص المفرغ`,Captions:`الترجمة النصية`,"Translate audio":`ترجمة الصوت`,"Audio language":`لغة الصوت`,"Audio options":`خيارات الصوت`,"Keep background sound":`الاحتفاظ بأصوات الخلفية`,"Background level":`مستوى صوت الخلفية`,"Speaker voices":`أصوات المتحدثين`,"Any voice":`أي صوت`,"Male voice":`صوت رجالي`,"Female voice":`صوت نسائي`,"Original audio.":`الصوت الأصلي.`,"Buy translated audio":`شراء الصوت المترجم`,"Audio credit":`رصيد الصوت`,"Buy pass":`شراء تصريح`,Pass:`تصريح`,"Pay with":`الدفع باستخدام`,"Check payment":`التحقق من الدفع`,"Open checkout":`الانتقال للدفع`,"Free sessions":`جلسات مجانية`,"Usage rates and expiry":`الأسعار ومدة الصلاحية`,Catalogs:`الكتالوجات`,"Add catalog":`إضافة كتالوج`,"Catalog name":`اسم الكتالوج`,Link:`الرابط`,"Play link":`تشغيل الرابط`,"Go live":`بدء بث مباشر`,Publish:`نشر`,Listen:`استماع`,"Live now":`مباشر الآن`,Upcoming:`قريبًا`,Parties:`حفلات المشاهدة`,"Join party":`الانضمام للحفلة`,"Invite code":`رمز الدعوة`,Invite:`دعوة`,Chat:`الدردشة`,"Chat message":`رسالة دردشة`,People:`الأشخاص`,Speaker:`متحدث`,Speakers:`المتحدثون`,Listener:`مستمع`,Moderator:`مشرف`,"Raise hand":`رفع اليد`,"Hand raises":`الأيدي المرفوعة`,Questions:`الأسئلة`,Resources:`الموارد`,Replay:`إعادة العرض`,Schedule:`الجدول`,Event:`الفعالية`,Host:`المضيف`,About:`حول`,Stage:`المنصة`,Title:`العنوان`,Description:`الوصف`,Topic:`الموضوع`,Public:`عام`,"Anyone with the link":`أي شخص لديه الرابط`,"Invited people":`الأشخاص المدعوون`,"Who can join":`من يمكنه الانضمام`,"Date and time":`التاريخ والوقت`,"Expected length":`المدة المتوقعة`,Repeat:`تكرار`,"Does not repeat":`لا يتكرر`,"Every day":`كل يوم`,"Every week":`كل أسبوع`,Homepage:`الموقع الإلكتروني`,"Host name":`اسم المضيف`,"Profile photo URL":`رابط صورة الملف الشخصي`,"Broadcast link":`رابط البث`,"Edit event":`تعديل الفعالية`,"Save changes":`حفظ التغييرات`,"Create live":`إنشاء بث مباشر`,"Schedule class":`جدولة درس`,"Start class":`بدء الدرس`,"End event":`إنهاء الفعالية`,"End session":`إنهاء الجلسة`,"Session ended":`انتهت الجلسة`,"Event ended":`انتهت الفعالية`,Cancelled:`ملغاة`,"Replay available":`إعادة العرض متاحة`,Join:`انضمام`,"Create invite":`إنشاء دعوة`,"Invite as":`الدعوة بصفة`,"Watch now":`شاهد الآن`,"Start a room":`إنشاء غرفة`,"Recent replays":`إعادات العرض الأخيرة`,"Your account":`حسابك`,"Write with AI":`الكتابة بالذكاء الاصطناعي`,"Suggested title":`العنوان المقترح`,"Suggested description":`الوصف المقترح`,"Suggested topic":`الموضوع المقترح`,"Use these details":`استخدام هذه التفاصيل`,"Learn something live.":`تعلّم شيئًا مباشرة.`,"Screen share":`مشاركة الشاشة`,"Upload and broadcast":`رفع وبث`,Help:`مساعدة`,"Reload Player":`إعادة تحميل المشغل`,"Copy Bundled CLI Path":`نسخ مسار أداة سطر الأوامر المضمنة`,"Developer tools":`أدوات المطور`,Undo:`تراجع`,Redo:`إعادة`,Cut:`قص`,Paste:`لصق`,"Select all":`تحديد الكل`,Status:`الحالة`,"Activity log":`سجل النشاط`,Admin:`الإدارة`,"Playback controls":`عناصر التحكم بالتشغيل`,"Tracks and folders":`المقاطع والمجلدات`,"Original-language captions.":`ترجمة نصية باللغة الأصلية.`,"Skip to playback controls":`الانتقال إلى عناصر التحكم بالتشغيل`,"Skip to playlist":`الانتقال إلى قائمة التشغيل`,"Skip to transcript and translated audio":`الانتقال إلى النص المفرغ والصوت المترجم`};export{t as default};
1
+ import{t as e}from"./main-Coq6MRdf.js";var t={...e,"No parties live right now.":`لا توجد جلسات مباشرة الآن.`,"Could not refresh parties.":`تعذر تحديث الجلسات.`,"Pick a server in the directory, or paste its link here.":`اختر خادمًا من الدليل أو الصق رابطه هنا.`,"Interface language":`لغة الواجهة`,Language:`اللغة`,"Translation language":`لغة الترجمة`,"Original (auto-detect)":`الأصل (اكتشاف تلقائي)`,Play:`تشغيل`,Pause:`إيقاف مؤقت`,Stop:`إيقاف`,Next:`التالي`,Previous:`السابق`,Select:`اختيار`,"Next/Prev":`التالي/السابق`,Quit:`خروج`,"Now Playing":`قيد التشغيل`,"Spectrum Analyser":`محلل الطيف`,Playlist:`قائمة التشغيل`,Files:`الملفات`,"Open files":`فتح ملفات`,"Open folder":`فتح مجلد`,"Add to library":`إضافة إلى المكتبة`,"Replace playlist":`استبدال قائمة التشغيل`,Filter:`تصفية`,Search:`بحث`,"Nothing loaded.":`لم يُحمّل أي محتوى.`,"Empty.":`فارغ.`,"Loading…":`جارٍ التحميل…`,"Connecting…":`جارٍ الاتصال…`,Connected:`متصل`,"Not connected":`غير متصل`,"No source":`لا يوجد مصدر`,Playing:`يعمل`,Stopped:`متوقف`,Volume:`مستوى الصوت`,Seek:`موضع التشغيل`,"Full screen":`ملء الشاشة`,Download:`تنزيل`,Share:`مشاركة`,Copy:`نسخ`,"Copy link":`نسخ الرابط`,Copied:`تم النسخ`,Save:`حفظ`,Cancel:`إلغاء`,Close:`إغلاق`,Remove:`إزالة`,Delete:`حذف`,Edit:`تعديل`,Add:`إضافة`,Send:`إرسال`,Retry:`إعادة المحاولة`,Back:`رجوع`,"Next page":`الصفحة التالية`,"Previous page":`الصفحة السابقة`,"Show more":`عرض المزيد`,Panels:`اللوحات`,"Reset layout":`إعادة ضبط التخطيط`,Show:`إظهار`,Hide:`إخفاء`,Collapse:`طي`,Expand:`توسيع`,"Move up":`نقل لأعلى`,"Move down":`نقل لأسفل`,Install:`تثبيت`,"TV mode":`وضع التلفاز`,On:`مفعّل`,Off:`معطّل`,Account:`الحساب`,"Sign in":`تسجيل الدخول`,"Sign out":`تسجيل الخروج`,"Create account":`إنشاء حساب`,Email:`البريد الإلكتروني`,Password:`كلمة المرور`,"Forgot password?":`نسيت كلمة المرور؟`,"Welcome back":`مرحبًا بعودتك`,Profile:`الملف الشخصي`,Handle:`اسم المستخدم`,"Your servers":`خوادمك`,Server:`الخادم`,Connect:`اتصال`,Disconnect:`قطع الاتصال`,Directory:`الدليل`,Favourites:`المفضلة`,Follow:`متابعة`,Unfollow:`إلغاء المتابعة`,Notifications:`الإشعارات`,"Phone number":`رقم الهاتف`,"Save number":`حفظ الرقم`,Transcript:`النص المفرغ`,Captions:`الترجمة النصية`,"Translate audio":`ترجمة الصوت`,"Audio language":`لغة الصوت`,"Audio options":`خيارات الصوت`,"Keep background sound":`الاحتفاظ بأصوات الخلفية`,"Background level":`مستوى صوت الخلفية`,"Speaker voices":`أصوات المتحدثين`,"Any voice":`أي صوت`,"Male voice":`صوت رجالي`,"Female voice":`صوت نسائي`,"Original audio.":`الصوت الأصلي.`,"Buy translated audio":`شراء الصوت المترجم`,"Audio credit":`رصيد الصوت`,"Buy pass":`شراء تصريح`,Pass:`تصريح`,"Pay with":`الدفع باستخدام`,"Check payment":`التحقق من الدفع`,"Open checkout":`الانتقال للدفع`,"Free sessions":`جلسات مجانية`,"Usage rates and expiry":`الأسعار ومدة الصلاحية`,Catalogs:`الكتالوجات`,"Add catalog":`إضافة كتالوج`,"Catalog name":`اسم الكتالوج`,Link:`الرابط`,"Play link":`تشغيل الرابط`,"Go live":`بدء بث مباشر`,Publish:`نشر`,Listen:`استماع`,"Live now":`مباشر الآن`,Upcoming:`قريبًا`,Parties:`حفلات المشاهدة`,"Join party":`الانضمام للحفلة`,"Invite code":`رمز الدعوة`,Invite:`دعوة`,Chat:`الدردشة`,"Chat message":`رسالة دردشة`,People:`الأشخاص`,Speaker:`متحدث`,Speakers:`المتحدثون`,Listener:`مستمع`,Moderator:`مشرف`,"Raise hand":`رفع اليد`,"Hand raises":`الأيدي المرفوعة`,Questions:`الأسئلة`,Resources:`الموارد`,Replay:`إعادة العرض`,Schedule:`الجدول`,Event:`الفعالية`,Host:`المضيف`,About:`حول`,Stage:`المنصة`,Title:`العنوان`,Description:`الوصف`,Topic:`الموضوع`,Public:`عام`,"Anyone with the link":`أي شخص لديه الرابط`,"Invited people":`الأشخاص المدعوون`,"Who can join":`من يمكنه الانضمام`,"Date and time":`التاريخ والوقت`,"Expected length":`المدة المتوقعة`,Repeat:`تكرار`,"Does not repeat":`لا يتكرر`,"Every day":`كل يوم`,"Every week":`كل أسبوع`,Homepage:`الموقع الإلكتروني`,"Host name":`اسم المضيف`,"Profile photo URL":`رابط صورة الملف الشخصي`,"Broadcast link":`رابط البث`,"Edit event":`تعديل الفعالية`,"Save changes":`حفظ التغييرات`,"Create live":`إنشاء بث مباشر`,"Schedule class":`جدولة درس`,"Start class":`بدء الدرس`,"End event":`إنهاء الفعالية`,"End session":`إنهاء الجلسة`,"Session ended":`انتهت الجلسة`,"Event ended":`انتهت الفعالية`,Cancelled:`ملغاة`,"Replay available":`إعادة العرض متاحة`,Join:`انضمام`,"Create invite":`إنشاء دعوة`,"Invite as":`الدعوة بصفة`,"Watch now":`شاهد الآن`,"Start a room":`إنشاء غرفة`,"Recent replays":`إعادات العرض الأخيرة`,"Your account":`حسابك`,"Write with AI":`الكتابة بالذكاء الاصطناعي`,"Suggested title":`العنوان المقترح`,"Suggested description":`الوصف المقترح`,"Suggested topic":`الموضوع المقترح`,"Use these details":`استخدام هذه التفاصيل`,"Learn something live.":`تعلّم شيئًا مباشرة.`,"Screen share":`مشاركة الشاشة`,"Upload and broadcast":`رفع وبث`,Help:`مساعدة`,"Reload Player":`إعادة تحميل المشغل`,"Copy Bundled CLI Path":`نسخ مسار أداة سطر الأوامر المضمنة`,"Developer tools":`أدوات المطور`,Undo:`تراجع`,Redo:`إعادة`,Cut:`قص`,Paste:`لصق`,"Select all":`تحديد الكل`,Status:`الحالة`,"Activity log":`سجل النشاط`,Admin:`الإدارة`,"Playback controls":`عناصر التحكم بالتشغيل`,"Tracks and folders":`المقاطع والمجلدات`,"Original-language captions.":`ترجمة نصية باللغة الأصلية.`,"Skip to playback controls":`الانتقال إلى عناصر التحكم بالتشغيل`,"Skip to playlist":`الانتقال إلى قائمة التشغيل`,"Skip to transcript and translated audio":`الانتقال إلى النص المفرغ والصوت المترجم`};export{t as default};