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.
- package/dist/ai-label.d.ts +26 -0
- package/dist/ai-label.js +90 -1
- package/dist/lyrics.d.ts +25 -15
- package/dist/lyrics.js +72 -1
- package/dist/station-routes.js +1 -1
- package/package.json +1 -1
- package/src/ai-label.ts +92 -1
- package/src/lyrics.ts +76 -1
- package/src/station-routes.ts +1 -1
- package/web/dist/assets/{ar-CHJqwLbG.js → ar-CCVdTxXd.js} +1 -1
- package/web/dist/assets/{cs-CmwqmJKg.js → cs-DazinpO5.js} +1 -1
- package/web/dist/assets/{da-Xdzrm0AY.js → da-dANXD44J.js} +1 -1
- package/web/dist/assets/{de-h2ntHz8Y.js → de-BH5N_2Zt.js} +1 -1
- package/web/dist/assets/{es-DgbBXAxw.js → es-lGWlpKlv.js} +1 -1
- package/web/dist/assets/{fi-Bso9G00g.js → fi-CIIF4ItN.js} +1 -1
- package/web/dist/assets/{fr-6Q3UZKIG.js → fr-s0WjlDkZ.js} +1 -1
- package/web/dist/assets/{hi-T7_WxQ11.js → hi-Dwrx_fdX.js} +1 -1
- package/web/dist/assets/{hu-BjOppQVq.js → hu-B7yw75er.js} +1 -1
- package/web/dist/assets/{id-BIkZDUv5.js → id-DNdkH2kw.js} +1 -1
- package/web/dist/assets/{it-CHOmOHju.js → it-C9858eN5.js} +1 -1
- package/web/dist/assets/{main-BpsUYSCl.js → main-Coq6MRdf.js} +2 -2
- package/web/dist/assets/{nl-CHQR6VFb.js → nl-BeH2Fgue.js} +1 -1
- package/web/dist/assets/{ru-Du7NQrxe.js → ru-DZ2nMkFG.js} +1 -1
- package/web/dist/assets/{station-Bqk5iV8L.css → station-CSnjPeod.css} +1 -1
- package/web/dist/assets/{station-xTLbhzsu.js → station-DGgba3hs.js} +2 -2
- package/web/dist/assets/{sv-B6AFovY0.js → sv-CuTZodhi.js} +1 -1
- package/web/dist/assets/{uk-LXJz37Kp.js → uk-Bt534Jo1.js} +1 -1
- package/web/dist/assets/{vi-C4taTvjH.js → vi-Ci0kYdXP.js} +1 -1
- package/web/dist/assets/{zh-dBiC2V5a.js → zh-C8tiLpKW.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/station.html +3 -2
- package/web/dist/sw.js +24 -24
- package/web/dist/vod.html +2 -2
- package/web/dist/watch.html +2 -2
- /package/web/dist/assets/{vod-BSo-pl8M.js → vod-Br9Ooz6-.js} +0 -0
- /package/web/dist/assets/{watch-DBSszFQm.js → watch-Dxsz5gHd.js} +0 -0
package/dist/ai-label.d.ts
CHANGED
|
@@ -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
|
|
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);
|
package/dist/station-routes.js
CHANGED
|
@@ -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
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
|
|
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
|
-
|
|
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
|
}
|
package/src/station-routes.ts
CHANGED
|
@@ -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-
|
|
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};
|