mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-20 04:30:54 +02:00
lyrics support + lookup
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,76 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { parseLyricsText, sanitizeLyricsLines } from './parsing.ts';
|
||||
|
||||
test('plain LRC parses timestamps and preserves order', () => {
|
||||
const payload = parseLyricsText('[00:15.50]World\n[00:12.00]Hello', 'lrclib', 'lrc');
|
||||
assert.ok(payload);
|
||||
assert.equal(payload.format, 'lrc');
|
||||
assert.equal(payload.syncedLines.length, 2);
|
||||
// Lines are re-sorted by timestamp.
|
||||
assert.deepEqual(
|
||||
payload.syncedLines.map((line) => [line.timestampMs, line.text]),
|
||||
[
|
||||
[12000, 'Hello'],
|
||||
[15500, 'World'],
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test('plain LRC without timestamps degrades to plain format', () => {
|
||||
const payload = parseLyricsText('just some words\nno timing here', 'lrclib', 'lrc');
|
||||
assert.ok(payload);
|
||||
assert.equal(payload.format, 'plain');
|
||||
assert.equal(payload.syncedLines.length, 0);
|
||||
assert.ok(payload.plainLyrics?.includes('just some words'));
|
||||
});
|
||||
|
||||
test('XLRC furigana attaches a ruby over the kanji only', () => {
|
||||
const payload = parseLyricsText('[00:01.00]私[わたし]は', 'xlrcdb', 'xlrc');
|
||||
assert.ok(payload);
|
||||
assert.equal(payload.format, 'xlrc');
|
||||
const [line] = payload.syncedLines;
|
||||
assert.equal(line.text, '私は');
|
||||
assert.ok(line.furigana && line.furigana.length === 1);
|
||||
assert.deepEqual(line.furigana[0], { start: 0, end: 1, base: '私', reading: 'わたし' });
|
||||
});
|
||||
|
||||
test('XLRC inline translation attaches to the preceding lyric line', () => {
|
||||
const payload = parseLyricsText("[00:01.00]君がいいの\n[>en]It's you I want", 'xlrcdb', 'xlrc');
|
||||
assert.ok(payload);
|
||||
const [line] = payload.syncedLines;
|
||||
assert.ok(line.translations && line.translations.length === 1);
|
||||
assert.equal(line.translations[0].lang, 'en');
|
||||
assert.equal(line.translations[0].text, "It's you I want");
|
||||
});
|
||||
|
||||
test('XLRC word timing yields per-word cues', () => {
|
||||
const payload = parseLyricsText('[00:01.00]<00:01.00>Hello <00:01.50>World', 'xlrcdb', 'xlrc');
|
||||
assert.ok(payload);
|
||||
const [line] = payload.syncedLines;
|
||||
assert.ok(line.words && line.words.length === 2);
|
||||
assert.equal(line.words[0].text.trim(), 'Hello');
|
||||
assert.equal(line.words[1].timestampMs, 1500);
|
||||
});
|
||||
|
||||
test('empty timestamp line becomes a silence cue', () => {
|
||||
const payload = parseLyricsText('[00:00.00]Intro\n[00:20.00]', 'xlrcdb', 'xlrc');
|
||||
assert.ok(payload);
|
||||
const silence = payload.syncedLines.find((line) => line.kind === 'silence');
|
||||
assert.ok(silence);
|
||||
assert.equal(silence.timestampMs, 20000);
|
||||
});
|
||||
|
||||
test('offset header shifts every timestamp', () => {
|
||||
const payload = parseLyricsText('[offset:500]\n[00:10.00]Line', 'lrclib', 'lrc');
|
||||
assert.ok(payload);
|
||||
assert.equal(payload.syncedLines[0].timestampMs, 10500);
|
||||
});
|
||||
|
||||
test('sanitizeLyricsLines drops out-of-range furigana', () => {
|
||||
const lines = sanitizeLyricsLines([
|
||||
{ timestampMs: 0, text: 'ab', furigana: [{ start: 0, end: 5, base: 'ab', reading: 'x' }] },
|
||||
]);
|
||||
assert.equal(lines.length, 1);
|
||||
assert.equal(lines[0].furigana, undefined);
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
// Parsing bridge — ported near-verbatim from desktop
|
||||
// (astra/src/main/services/lyricsParsing.ts). Converts the `@boof2015/xlrc`
|
||||
// parser output (XLRCLine) into the app-internal LyricsLine contract, keeping
|
||||
// rich fields (words/furigana/translations/voice) for XLRC and dropping them
|
||||
// for plain LRC. Pure functions — no RN/Node dependencies.
|
||||
|
||||
import { parseXLRC, type XLRCFile, type XLRCLine } from '@boof2015/xlrc';
|
||||
import type {
|
||||
LyricsFormat,
|
||||
LyricsFurigana,
|
||||
LyricsLine,
|
||||
LyricsPayload,
|
||||
LyricsTranslation,
|
||||
LyricsWord,
|
||||
} from './types';
|
||||
|
||||
export function normalizeLyricsText(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const normalized = value.replace(/\r\n/g, '\n').trim();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeTimestampMs(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
? Math.max(0, Math.floor(value))
|
||||
: null;
|
||||
}
|
||||
|
||||
function sanitizeFurigana(raw: unknown, text: string): LyricsFurigana[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
|
||||
const furigana: LyricsFurigana[] = [];
|
||||
for (const entry of raw) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
|
||||
const record = entry as { start?: unknown; end?: unknown; base?: unknown; reading?: unknown };
|
||||
if (typeof record.base !== 'string' || typeof record.reading !== 'string') continue;
|
||||
if (typeof record.start !== 'number' || typeof record.end !== 'number') continue;
|
||||
if (!Number.isInteger(record.start) || !Number.isInteger(record.end)) continue;
|
||||
const start = record.start;
|
||||
const end = record.end;
|
||||
if (start < 0 || end <= start || end > text.length) continue;
|
||||
const base = record.base.trim();
|
||||
const reading = record.reading.trim();
|
||||
if (!base || !reading) continue;
|
||||
|
||||
furigana.push({ start, end, base, reading });
|
||||
}
|
||||
|
||||
return furigana;
|
||||
}
|
||||
|
||||
function sanitizeWords(raw: unknown): LyricsWord[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
|
||||
const words: LyricsWord[] = [];
|
||||
for (const entry of raw) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
|
||||
const record = entry as { timestampMs?: unknown; timestamp?: unknown; text?: unknown; furigana?: unknown };
|
||||
if (typeof record.text !== 'string') continue;
|
||||
const timestampMs = normalizeTimestampMs(record.timestampMs ?? record.timestamp);
|
||||
if (timestampMs === null) continue;
|
||||
const text = record.text;
|
||||
if (!text.trim()) continue;
|
||||
const furigana = sanitizeFurigana(record.furigana, text);
|
||||
|
||||
words.push({
|
||||
timestampMs,
|
||||
text,
|
||||
...(furigana.length > 0 ? { furigana } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return words;
|
||||
}
|
||||
|
||||
function sanitizeTranslations(raw: unknown): LyricsTranslation[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
|
||||
const translations: LyricsTranslation[] = [];
|
||||
for (const entry of raw) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
|
||||
const record = entry as { lang?: unknown; text?: unknown };
|
||||
if (typeof record.lang !== 'string' || typeof record.text !== 'string') continue;
|
||||
const lang = record.lang.trim();
|
||||
const text = record.text.trim();
|
||||
if (!lang || !text) continue;
|
||||
translations.push({ lang, text });
|
||||
}
|
||||
|
||||
return translations;
|
||||
}
|
||||
|
||||
export function sanitizeLyricsLines(rawValue: unknown): LyricsLine[] {
|
||||
if (!Array.isArray(rawValue)) return [];
|
||||
|
||||
const lines: LyricsLine[] = [];
|
||||
for (const entry of rawValue) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
|
||||
const record = entry as {
|
||||
timestampMs?: unknown;
|
||||
text?: unknown;
|
||||
kind?: unknown;
|
||||
words?: unknown;
|
||||
furigana?: unknown;
|
||||
translations?: unknown;
|
||||
voice?: unknown;
|
||||
};
|
||||
if (typeof record.text !== 'string') continue;
|
||||
|
||||
const timestampMs = normalizeTimestampMs(record.timestampMs);
|
||||
if (timestampMs === null) continue;
|
||||
|
||||
if (record.kind === 'silence') {
|
||||
lines.push({ timestampMs, text: '', kind: 'silence' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = record.text.trim();
|
||||
if (!text) continue;
|
||||
|
||||
const words = sanitizeWords(record.words);
|
||||
const furigana = sanitizeFurigana(record.furigana, text);
|
||||
const translations = sanitizeTranslations(record.translations);
|
||||
const voice = typeof record.voice === 'string' && record.voice.trim() ? record.voice.trim() : null;
|
||||
|
||||
lines.push({
|
||||
timestampMs,
|
||||
text,
|
||||
...(words.length > 0 ? { words } : {}),
|
||||
...(furigana.length > 0 ? { furigana } : {}),
|
||||
...(translations.length > 0 ? { translations } : {}),
|
||||
...(voice ? { voice } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
lines.sort((left, right) => left.timestampMs - right.timestampMs);
|
||||
return lines;
|
||||
}
|
||||
|
||||
function normalizeParsedOffsetMs(file: XLRCFile): number {
|
||||
const offset = file.meta.offset;
|
||||
return typeof offset === 'number' && Number.isFinite(offset) ? Math.trunc(offset) : 0;
|
||||
}
|
||||
|
||||
function applyParsedOffsetMs(timestampMs: number, offsetMs: number): number {
|
||||
return Math.max(0, Math.floor(timestampMs + offsetMs));
|
||||
}
|
||||
|
||||
function mapParsedLine(line: XLRCLine, offsetMs: number, preserveRichFields: boolean): LyricsLine {
|
||||
const timestampMs = applyParsedOffsetMs(line.timestamp, offsetMs);
|
||||
const text = line.text.trim();
|
||||
if (line.isEmpty || !text) {
|
||||
return {
|
||||
timestampMs,
|
||||
text: '',
|
||||
kind: 'silence',
|
||||
};
|
||||
}
|
||||
|
||||
if (!preserveRichFields) {
|
||||
return { timestampMs, text };
|
||||
}
|
||||
|
||||
const words = line.words
|
||||
.map((word): LyricsWord => {
|
||||
const wordText = word.text;
|
||||
const wordFurigana = sanitizeFurigana(word.furigana, wordText);
|
||||
return {
|
||||
timestampMs: applyParsedOffsetMs(word.timestamp, offsetMs),
|
||||
text: wordText,
|
||||
...(wordFurigana.length > 0 ? { furigana: wordFurigana } : {}),
|
||||
};
|
||||
})
|
||||
.filter((word) => word.text.trim().length > 0);
|
||||
const furigana = sanitizeFurigana(line.furigana, text);
|
||||
const translations = sanitizeTranslations(line.translations);
|
||||
const voice = line.voice?.trim() || null;
|
||||
|
||||
return {
|
||||
timestampMs,
|
||||
text,
|
||||
...(words.length > 0 ? { words } : {}),
|
||||
...(furigana.length > 0 ? { furigana } : {}),
|
||||
...(translations.length > 0 ? { translations } : {}),
|
||||
...(voice ? { voice } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePackageSyncedLines(lyricsText: string, preserveRichFields: boolean): LyricsLine[] {
|
||||
const normalizedText = normalizeLyricsText(lyricsText);
|
||||
if (!normalizedText) return [];
|
||||
|
||||
const parsed = parseXLRC(normalizedText);
|
||||
const offsetMs = normalizeParsedOffsetMs(parsed);
|
||||
return sanitizeLyricsLines(
|
||||
parsed.lines.map((line) => mapParsedLine(line, offsetMs, preserveRichFields))
|
||||
);
|
||||
}
|
||||
|
||||
export function parseLrcSyncedLines(lyricsText: string): LyricsLine[] {
|
||||
return parsePackageSyncedLines(lyricsText, false);
|
||||
}
|
||||
|
||||
function parseXlrcSyncedLines(lyricsText: string): LyricsLine[] {
|
||||
return parsePackageSyncedLines(lyricsText, true);
|
||||
}
|
||||
|
||||
export function toPlainLyricsFromLines(lines: LyricsLine[]): string | null {
|
||||
const textLines = lines
|
||||
.filter((line) => line.kind !== 'silence' && line.text.trim().length > 0)
|
||||
.map((line) => line.text);
|
||||
if (textLines.length === 0) return null;
|
||||
return textLines.join('\n');
|
||||
}
|
||||
|
||||
export function createLyricsPayload(
|
||||
source: LyricsPayload['source'],
|
||||
provider: LyricsPayload['provider'],
|
||||
format: LyricsFormat,
|
||||
plainLyrics: string | null,
|
||||
syncedLyrics: string | null,
|
||||
syncedLines: LyricsLine[]
|
||||
): LyricsPayload | null {
|
||||
const normalizedPlain = normalizeLyricsText(plainLyrics);
|
||||
const normalizedSynced = normalizeLyricsText(syncedLyrics);
|
||||
const parsedSyncedLines =
|
||||
normalizedSynced && (format === 'lrc' || format === 'xlrc')
|
||||
? parsePackageSyncedLines(normalizedSynced, format === 'xlrc')
|
||||
: [];
|
||||
const sourceLines = parsedSyncedLines.length > 0 ? parsedSyncedLines : syncedLines;
|
||||
const normalizedLines = sanitizeLyricsLines(sourceLines);
|
||||
|
||||
if (!normalizedPlain && !normalizedSynced && normalizedLines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
source,
|
||||
provider,
|
||||
format,
|
||||
plainLyrics: normalizedPlain ?? toPlainLyricsFromLines(normalizedLines),
|
||||
syncedLyrics: normalizedSynced ?? toPlainLyricsFromLines(normalizedLines),
|
||||
syncedLines: normalizedLines,
|
||||
};
|
||||
}
|
||||
|
||||
function parseXlrcLyricsText(lyricsText: string, source: LyricsPayload['source']): LyricsPayload | null {
|
||||
const normalizedText = normalizeLyricsText(lyricsText);
|
||||
if (!normalizedText) return null;
|
||||
|
||||
const syncedLines = parseXlrcSyncedLines(normalizedText);
|
||||
if (syncedLines.length === 0) return null;
|
||||
|
||||
return createLyricsPayload(
|
||||
source,
|
||||
null,
|
||||
'xlrc',
|
||||
toPlainLyricsFromLines(syncedLines),
|
||||
normalizedText,
|
||||
syncedLines
|
||||
);
|
||||
}
|
||||
|
||||
export function parseLyricsText(
|
||||
lyricsText: string,
|
||||
source: LyricsPayload['source'],
|
||||
format: LyricsFormat = 'lrc'
|
||||
): LyricsPayload | null {
|
||||
const normalizedText = normalizeLyricsText(lyricsText);
|
||||
if (!normalizedText) return null;
|
||||
|
||||
if (format === 'xlrc') {
|
||||
return parseXlrcLyricsText(normalizedText, source);
|
||||
}
|
||||
|
||||
if (format === 'plain') {
|
||||
return createLyricsPayload(source, null, 'plain', normalizedText, null, []);
|
||||
}
|
||||
|
||||
const syncedLines = parseLrcSyncedLines(normalizedText);
|
||||
const hasSyncedLyrics = syncedLines.length > 0;
|
||||
const syncedLyrics = hasSyncedLyrics ? normalizedText : null;
|
||||
const plainLyrics = hasSyncedLyrics
|
||||
? toPlainLyricsFromLines(syncedLines) ?? normalizedText
|
||||
: normalizedText;
|
||||
|
||||
return createLyricsPayload(source, null, hasSyncedLyrics ? 'lrc' : 'plain', plainLyrics, syncedLyrics, syncedLines);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { LyricsLine, LyricsPayload } from './types.ts';
|
||||
import {
|
||||
findActiveSyncedLineIndex,
|
||||
getCompensatedLyricsTime,
|
||||
getLyricsLineSeekTimeSeconds,
|
||||
getLyricsMetaChipText,
|
||||
getPreferredLyricsTranslation,
|
||||
getSyncedLyricsDisplayLines,
|
||||
resolveSyncedLyricsTiming,
|
||||
} from './presentation.ts';
|
||||
|
||||
function lines(): LyricsLine[] {
|
||||
return [
|
||||
{ timestampMs: 0, text: 'A' },
|
||||
{ timestampMs: 1000, text: 'B' },
|
||||
{ timestampMs: 2000, text: 'C' },
|
||||
];
|
||||
}
|
||||
|
||||
test('active line resolves to the cue at or before the playback time', () => {
|
||||
assert.equal(findActiveSyncedLineIndex(lines(), 0.5), 0);
|
||||
assert.equal(findActiveSyncedLineIndex(lines(), 1.2), 1);
|
||||
assert.equal(findActiveSyncedLineIndex(lines(), 2.9), 2);
|
||||
});
|
||||
|
||||
test('before the first cue there is no active line', () => {
|
||||
const timing = resolveSyncedLyricsTiming(
|
||||
[
|
||||
{ timestampMs: 5000, text: 'later' },
|
||||
],
|
||||
1
|
||||
);
|
||||
assert.equal(timing.activeLineIndex, -1);
|
||||
assert.equal(timing.isNeutral, true);
|
||||
});
|
||||
|
||||
test('a long instrumental gap inserts a synthetic gap row and neutralizes', () => {
|
||||
const withGap: LyricsLine[] = [
|
||||
{ timestampMs: 0, text: 'A' },
|
||||
{ timestampMs: 20000, text: 'B' },
|
||||
];
|
||||
const display = getSyncedLyricsDisplayLines(withGap);
|
||||
assert.ok(display.some((row) => row.kind === 'gap'));
|
||||
|
||||
// 6s in (past the 4s post-line hold, within the 10s+ gap) → neutral.
|
||||
const timing = resolveSyncedLyricsTiming(withGap, 6);
|
||||
assert.equal(timing.activeLineIndex, -1);
|
||||
assert.equal(timing.isNeutral, true);
|
||||
});
|
||||
|
||||
test('translation selection honors the language priority list', () => {
|
||||
const line: LyricsLine = {
|
||||
timestampMs: 0,
|
||||
text: 'x',
|
||||
translations: [
|
||||
{ lang: 'ja-Latn', text: 'romaji' },
|
||||
{ lang: 'en', text: 'english' },
|
||||
],
|
||||
};
|
||||
assert.equal(getPreferredLyricsTranslation(line, ['en', 'ja-Latn'])?.text, 'english');
|
||||
assert.equal(getPreferredLyricsTranslation(line, ['ja-Latn'])?.text, 'romaji');
|
||||
assert.equal(getPreferredLyricsTranslation(line, ['fr'])?.text, 'romaji'); // falls back to first
|
||||
});
|
||||
|
||||
test('seek + compensation math clamp to duration', () => {
|
||||
assert.equal(getLyricsLineSeekTimeSeconds(1500, 200, 0), 1.5);
|
||||
assert.equal(getLyricsLineSeekTimeSeconds(1500, 1, 0), 1); // clamped to duration
|
||||
assert.equal(getCompensatedLyricsTime(10, 200, 500), 9.5); // 500ms delay subtracted
|
||||
assert.equal(getCompensatedLyricsTime(0.2, 200, 500), 0); // never negative
|
||||
});
|
||||
|
||||
function hit(source: LyricsPayload['source'], format: LyricsPayload['format'], cached: boolean) {
|
||||
const lyrics: LyricsPayload = {
|
||||
source,
|
||||
provider: source === 'xlrcdb' ? 'xlrcdb' : 'lrclib',
|
||||
format,
|
||||
plainLyrics: 'x',
|
||||
syncedLyrics: 'x',
|
||||
syncedLines: [{ timestampMs: 0, text: 'x' }],
|
||||
};
|
||||
return { status: 'hit' as const, lyrics, cached };
|
||||
}
|
||||
|
||||
test('meta chip reflects source, sync, and cache state', () => {
|
||||
assert.equal(
|
||||
getLyricsMetaChipText({ hasTrack: true, result: hit('lrclib', 'lrc', false), hasSyncedLyrics: true, isLoading: false }),
|
||||
'LRCLIB • Synced'
|
||||
);
|
||||
assert.equal(
|
||||
getLyricsMetaChipText({ hasTrack: true, result: hit('xlrcdb', 'xlrc', true), hasSyncedLyrics: true, isLoading: false }),
|
||||
'XLRCDB • Synced • Cached'
|
||||
);
|
||||
assert.equal(
|
||||
getLyricsMetaChipText({ hasTrack: true, result: null, hasSyncedLyrics: false, isLoading: true }),
|
||||
'Loading'
|
||||
);
|
||||
assert.equal(
|
||||
getLyricsMetaChipText({
|
||||
hasTrack: true,
|
||||
result: { status: 'not_found', reason: 'provider-not-found' },
|
||||
hasSyncedLyrics: false,
|
||||
isLoading: false,
|
||||
}),
|
||||
'Not Found'
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,437 @@
|
||||
// Lyrics timing + presentation — ported from desktop
|
||||
// (astra/src/renderer/utils/lyricsPresentation.ts). Pure functions that map a
|
||||
// playback position onto an active line/word and expand a line list into a
|
||||
// display list with synthetic instrumental-gap rows. No RN dependencies, so it
|
||||
// runs under `node --test`. The desktop-only body-state/display-settings copy is
|
||||
// intentionally omitted; v1 renders furigana + translations unconditionally.
|
||||
|
||||
import type {
|
||||
LyricsFormat,
|
||||
LyricsLine,
|
||||
LyricsPayload,
|
||||
LyricsSource,
|
||||
LyricsTranslation,
|
||||
LyricsWord,
|
||||
} from './types';
|
||||
|
||||
export function getLyricsSourceLabel(source: LyricsSource, format?: LyricsFormat): string {
|
||||
if (source === 'embedded') return 'Embedded';
|
||||
if (source === 'manual') return format === 'xlrc' ? 'Manual XLRC' : 'Manual';
|
||||
if (source === 'xlrc') return 'XLRC File';
|
||||
if (source === 'lrc') return 'LRC File';
|
||||
if (source === 'xlrcdb') return 'XLRCDB';
|
||||
return 'LRCLIB';
|
||||
}
|
||||
|
||||
export function getLyricsPayloadSourceLabel(payload: LyricsPayload): string {
|
||||
return getLyricsSourceLabel(payload.source, payload.format);
|
||||
}
|
||||
|
||||
export const LYRICS_INFERRED_GAP_THRESHOLD_MS = 10_000;
|
||||
export const LYRICS_POST_LINE_HOLD_MS = 4_000;
|
||||
|
||||
export interface RenderableSyncedLine {
|
||||
line: LyricsLine;
|
||||
cueIndex: number;
|
||||
displayIndex: number;
|
||||
}
|
||||
|
||||
export type SyncedLyricsDisplayLine =
|
||||
| {
|
||||
kind: 'lyric';
|
||||
line: LyricsLine;
|
||||
cueIndex: number;
|
||||
afterCueIndex: null;
|
||||
displayIndex: number;
|
||||
key: string;
|
||||
timestampMs: number;
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
kind: 'gap';
|
||||
cueIndex: number | null;
|
||||
afterCueIndex: number | null;
|
||||
displayIndex: number;
|
||||
key: string;
|
||||
timestampMs: number;
|
||||
text: '';
|
||||
progressStartMs: number;
|
||||
progressEndMs: number | null;
|
||||
};
|
||||
|
||||
export interface SyncedLyricsTimingOptions {
|
||||
durationSeconds?: number | null;
|
||||
neutralGapThresholdMs?: number;
|
||||
postLineHoldMs?: number;
|
||||
}
|
||||
|
||||
export interface SyncedLyricsTimingState {
|
||||
activeCueIndex: number;
|
||||
activeLineIndex: number;
|
||||
focusLineIndex: number;
|
||||
isNeutral: boolean;
|
||||
}
|
||||
|
||||
function toPlaybackTimeMs(currentTimeSeconds: number): number {
|
||||
return Number.isFinite(currentTimeSeconds) ? Math.max(0, Math.floor(currentTimeSeconds * 1000)) : 0;
|
||||
}
|
||||
|
||||
function toDurationMs(durationSeconds: number | null | undefined): number | null {
|
||||
if (typeof durationSeconds !== 'number' || !Number.isFinite(durationSeconds) || durationSeconds <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.floor(durationSeconds * 1000);
|
||||
}
|
||||
|
||||
export function getCompensatedLyricsTime(
|
||||
currentTimeSeconds: number,
|
||||
durationSeconds: number | null | undefined,
|
||||
effectiveDelayMs: number
|
||||
): number {
|
||||
const normalizedTime = Number.isFinite(currentTimeSeconds) ? Math.max(0, currentTimeSeconds) : 0;
|
||||
const normalizedDelaySeconds = Number.isFinite(effectiveDelayMs) ? Math.max(0, effectiveDelayMs) / 1000 : 0;
|
||||
const compensatedTime = Math.max(0, normalizedTime - normalizedDelaySeconds);
|
||||
if (typeof durationSeconds !== 'number' || !Number.isFinite(durationSeconds) || durationSeconds <= 0) {
|
||||
return compensatedTime;
|
||||
}
|
||||
return Math.min(durationSeconds, compensatedTime);
|
||||
}
|
||||
|
||||
export function getLyricsLineSeekTimeSeconds(
|
||||
timestampMs: number,
|
||||
durationSeconds: number | null | undefined,
|
||||
effectiveDelayMs: number
|
||||
): number | null {
|
||||
if (!Number.isFinite(timestampMs) || timestampMs < 0) return null;
|
||||
|
||||
const normalizedDelaySeconds = Number.isFinite(effectiveDelayMs) ? Math.max(0, effectiveDelayMs) / 1000 : 0;
|
||||
const seekTimeSeconds = Math.max(0, timestampMs / 1000 + normalizedDelaySeconds);
|
||||
if (typeof durationSeconds !== 'number' || !Number.isFinite(durationSeconds) || durationSeconds <= 0) {
|
||||
return seekTimeSeconds;
|
||||
}
|
||||
return Math.min(durationSeconds, seekTimeSeconds);
|
||||
}
|
||||
|
||||
export function isRenderableSyncedLine(line: LyricsLine): boolean {
|
||||
return line.kind !== 'silence' && line.text.trim().length > 0;
|
||||
}
|
||||
|
||||
export function getRenderableSyncedLines(lines: LyricsLine[]): RenderableSyncedLine[] {
|
||||
const renderableLines: RenderableSyncedLine[] = [];
|
||||
lines.forEach((line, cueIndex) => {
|
||||
if (!isRenderableSyncedLine(line)) return;
|
||||
renderableLines.push({
|
||||
line,
|
||||
cueIndex,
|
||||
displayIndex: renderableLines.length,
|
||||
});
|
||||
});
|
||||
return renderableLines;
|
||||
}
|
||||
|
||||
function findNextRenderableLineTimestamp(lines: LyricsLine[], startIndex: number): number | null {
|
||||
for (let index = startIndex; index < lines.length; index += 1) {
|
||||
if (isRenderableSyncedLine(lines[index])) return lines[index].timestampMs;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getSyncedLyricsDisplayLines(
|
||||
lines: LyricsLine[],
|
||||
options: SyncedLyricsTimingOptions = {}
|
||||
): SyncedLyricsDisplayLine[] {
|
||||
const displayLines: SyncedLyricsDisplayLine[] = [];
|
||||
const postLineHoldMs = options.postLineHoldMs ?? LYRICS_POST_LINE_HOLD_MS;
|
||||
const neutralGapThresholdMs = options.neutralGapThresholdMs ?? LYRICS_INFERRED_GAP_THRESHOLD_MS;
|
||||
const durationMs = toDurationMs(options.durationSeconds);
|
||||
|
||||
lines.forEach((line, cueIndex) => {
|
||||
const displayIndex = displayLines.length;
|
||||
if (isRenderableSyncedLine(line)) {
|
||||
displayLines.push({
|
||||
kind: 'lyric',
|
||||
line,
|
||||
cueIndex,
|
||||
afterCueIndex: null,
|
||||
displayIndex,
|
||||
key: `lyric:${line.timestampMs}:${cueIndex}`,
|
||||
timestampMs: line.timestampMs,
|
||||
text: line.text,
|
||||
});
|
||||
|
||||
const nextCue = lines[cueIndex + 1] ?? null;
|
||||
const nextCueGapMs = nextCue ? nextCue.timestampMs - line.timestampMs : null;
|
||||
const outroGapMs = durationMs === null ? null : durationMs - line.timestampMs;
|
||||
const shouldInsertGap =
|
||||
(nextCueGapMs !== null && nextCueGapMs >= neutralGapThresholdMs) ||
|
||||
(!nextCue && outroGapMs !== null && outroGapMs >= neutralGapThresholdMs);
|
||||
|
||||
if (shouldInsertGap) {
|
||||
const gapTimestampMs = line.timestampMs + postLineHoldMs;
|
||||
const progressEndMs = findNextRenderableLineTimestamp(lines, cueIndex + 1) ?? durationMs;
|
||||
displayLines.push({
|
||||
kind: 'gap',
|
||||
cueIndex: null,
|
||||
afterCueIndex: cueIndex,
|
||||
displayIndex: displayLines.length,
|
||||
key: `gap-after:${line.timestampMs}:${cueIndex}`,
|
||||
timestampMs: gapTimestampMs,
|
||||
text: '',
|
||||
progressStartMs: line.timestampMs,
|
||||
progressEndMs,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.kind !== 'silence') return;
|
||||
const progressEndMs = findNextRenderableLineTimestamp(lines, cueIndex + 1) ?? durationMs;
|
||||
displayLines.push({
|
||||
kind: 'gap',
|
||||
cueIndex,
|
||||
afterCueIndex: null,
|
||||
displayIndex,
|
||||
key: `gap-cue:${line.timestampMs}:${cueIndex}`,
|
||||
timestampMs: line.timestampMs,
|
||||
text: '',
|
||||
progressStartMs: line.timestampMs,
|
||||
progressEndMs,
|
||||
});
|
||||
});
|
||||
|
||||
return displayLines;
|
||||
}
|
||||
|
||||
export function getSyncedLyricsGapProgress(line: SyncedLyricsDisplayLine, currentTimeSeconds: number): number | null {
|
||||
if (line.kind !== 'gap') return null;
|
||||
if (line.progressEndMs === null || line.progressEndMs <= line.progressStartMs) return null;
|
||||
|
||||
const currentTimeMs = toPlaybackTimeMs(currentTimeSeconds);
|
||||
const progress = (currentTimeMs - line.progressStartMs) / (line.progressEndMs - line.progressStartMs);
|
||||
return Math.max(0, Math.min(1, progress));
|
||||
}
|
||||
|
||||
export function getPreferredLyricsTranslation(
|
||||
line: LyricsLine,
|
||||
languagePriority: string[]
|
||||
): LyricsTranslation | null {
|
||||
const translations = line.translations ?? [];
|
||||
if (translations.length === 0) return null;
|
||||
|
||||
const normalizedPriority = languagePriority.map((lang) => lang.trim().toLocaleLowerCase()).filter(Boolean);
|
||||
for (const preferredLang of normalizedPriority) {
|
||||
const match = translations.find((translation) => translation.lang.toLocaleLowerCase() === preferredLang);
|
||||
if (match) return match;
|
||||
}
|
||||
|
||||
return translations[0] ?? null;
|
||||
}
|
||||
|
||||
export interface LyricsWordTimingState {
|
||||
activeWordIndex: number;
|
||||
progressByIndex: number[];
|
||||
}
|
||||
|
||||
export function resolveLyricsWordTiming(words: LyricsWord[], currentTimeSeconds: number): LyricsWordTimingState {
|
||||
if (words.length === 0) {
|
||||
return {
|
||||
activeWordIndex: -1,
|
||||
progressByIndex: [],
|
||||
};
|
||||
}
|
||||
|
||||
const currentTimeMs = toPlaybackTimeMs(currentTimeSeconds);
|
||||
let activeWordIndex = -1;
|
||||
for (let index = 0; index < words.length; index += 1) {
|
||||
if (words[index].timestampMs <= currentTimeMs) {
|
||||
activeWordIndex = index;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const progressByIndex = words.map((word, index) => {
|
||||
if (index < activeWordIndex) return 1;
|
||||
if (index > activeWordIndex || activeWordIndex < 0) return 0;
|
||||
|
||||
const nextWord = words[index + 1] ?? null;
|
||||
if (!nextWord || nextWord.timestampMs <= word.timestampMs) return 1;
|
||||
return Math.max(0, Math.min(1, (currentTimeMs - word.timestampMs) / (nextWord.timestampMs - word.timestampMs)));
|
||||
});
|
||||
|
||||
return {
|
||||
activeWordIndex,
|
||||
progressByIndex,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasRenderableSyncedLines(lines: LyricsLine[]): boolean {
|
||||
return lines.some(isRenderableSyncedLine);
|
||||
}
|
||||
|
||||
function findCueIndexAtOrBefore(lines: LyricsLine[], currentTimeMs: number): number {
|
||||
if (lines.length === 0) return -1;
|
||||
|
||||
let low = 0;
|
||||
let high = lines.length - 1;
|
||||
let best = -1;
|
||||
|
||||
while (low <= high) {
|
||||
const mid = Math.floor((low + high) / 2);
|
||||
if (lines[mid].timestampMs <= currentTimeMs) {
|
||||
best = mid;
|
||||
low = mid + 1;
|
||||
continue;
|
||||
}
|
||||
high = mid - 1;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
function findDisplayIndexForCueIndex(displayLines: SyncedLyricsDisplayLine[], cueIndex: number): number {
|
||||
const match = displayLines.find((line) => line.cueIndex === cueIndex);
|
||||
return match?.displayIndex ?? -1;
|
||||
}
|
||||
|
||||
function findGapDisplayIndexAfterCue(displayLines: SyncedLyricsDisplayLine[], cueIndex: number): number {
|
||||
const match = displayLines.find((line) => line.kind === 'gap' && line.afterCueIndex === cueIndex);
|
||||
return match?.displayIndex ?? -1;
|
||||
}
|
||||
|
||||
function findPreviousDisplayIndex(displayLines: SyncedLyricsDisplayLine[], cueIndex: number): number {
|
||||
for (let index = displayLines.length - 1; index >= 0; index -= 1) {
|
||||
const displayLineCueIndex = displayLines[index].cueIndex ?? displayLines[index].afterCueIndex;
|
||||
if (displayLineCueIndex !== null && displayLineCueIndex <= cueIndex) return displayLines[index].displayIndex;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findNextDisplayIndex(displayLines: SyncedLyricsDisplayLine[], cueIndex: number): number {
|
||||
for (const line of displayLines) {
|
||||
const displayLineCueIndex = line.cueIndex ?? line.afterCueIndex;
|
||||
if (displayLineCueIndex !== null && displayLineCueIndex > cueIndex) return line.displayIndex;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function resolveNeutralFocusLineIndex(displayLines: SyncedLyricsDisplayLine[], cueIndex: number): number {
|
||||
const currentLineIndex = findDisplayIndexForCueIndex(displayLines, cueIndex);
|
||||
if (currentLineIndex >= 0) return currentLineIndex;
|
||||
const previousLineIndex = findPreviousDisplayIndex(displayLines, cueIndex);
|
||||
if (previousLineIndex >= 0) return previousLineIndex;
|
||||
const nextLineIndex = findNextDisplayIndex(displayLines, cueIndex);
|
||||
if (nextLineIndex >= 0) return nextLineIndex;
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function resolveSyncedLyricsTiming(
|
||||
lines: LyricsLine[],
|
||||
currentTimeSeconds: number,
|
||||
options: SyncedLyricsTimingOptions = {}
|
||||
): SyncedLyricsTimingState {
|
||||
const renderableLines = getRenderableSyncedLines(lines);
|
||||
const displayLines = getSyncedLyricsDisplayLines(lines, options);
|
||||
if (renderableLines.length === 0) {
|
||||
return {
|
||||
activeCueIndex: -1,
|
||||
activeLineIndex: -1,
|
||||
focusLineIndex: -1,
|
||||
isNeutral: true,
|
||||
};
|
||||
}
|
||||
|
||||
const currentTimeMs = toPlaybackTimeMs(currentTimeSeconds);
|
||||
const latestCueIndex = findCueIndexAtOrBefore(lines, currentTimeMs);
|
||||
if (latestCueIndex < 0) {
|
||||
return {
|
||||
activeCueIndex: -1,
|
||||
activeLineIndex: -1,
|
||||
focusLineIndex: 0,
|
||||
isNeutral: true,
|
||||
};
|
||||
}
|
||||
|
||||
const latestCue = lines[latestCueIndex];
|
||||
if (!isRenderableSyncedLine(latestCue)) {
|
||||
return {
|
||||
activeCueIndex: latestCueIndex,
|
||||
activeLineIndex: -1,
|
||||
focusLineIndex: resolveNeutralFocusLineIndex(displayLines, latestCueIndex),
|
||||
isNeutral: true,
|
||||
};
|
||||
}
|
||||
|
||||
const displayIndex = findDisplayIndexForCueIndex(displayLines, latestCueIndex);
|
||||
const postLineHoldMs = options.postLineHoldMs ?? LYRICS_POST_LINE_HOLD_MS;
|
||||
const neutralGapThresholdMs = options.neutralGapThresholdMs ?? LYRICS_INFERRED_GAP_THRESHOLD_MS;
|
||||
const nextCue = lines[latestCueIndex + 1] ?? null;
|
||||
const nextCueGapMs = nextCue ? nextCue.timestampMs - latestCue.timestampMs : null;
|
||||
const shouldNeutralizeForNextCue =
|
||||
nextCueGapMs !== null &&
|
||||
nextCueGapMs >= neutralGapThresholdMs &&
|
||||
currentTimeMs >= latestCue.timestampMs + postLineHoldMs;
|
||||
|
||||
const durationMs = toDurationMs(options.durationSeconds);
|
||||
const outroGapMs = durationMs === null ? null : durationMs - latestCue.timestampMs;
|
||||
const shouldNeutralizeForOutro =
|
||||
!nextCue &&
|
||||
outroGapMs !== null &&
|
||||
outroGapMs >= neutralGapThresholdMs &&
|
||||
currentTimeMs >= latestCue.timestampMs + postLineHoldMs;
|
||||
|
||||
if (shouldNeutralizeForNextCue || shouldNeutralizeForOutro) {
|
||||
const gapDisplayIndex = findGapDisplayIndexAfterCue(displayLines, latestCueIndex);
|
||||
return {
|
||||
activeCueIndex: latestCueIndex,
|
||||
activeLineIndex: -1,
|
||||
focusLineIndex: gapDisplayIndex >= 0 ? gapDisplayIndex : displayIndex,
|
||||
isNeutral: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
activeCueIndex: latestCueIndex,
|
||||
activeLineIndex: displayIndex,
|
||||
focusLineIndex: displayIndex,
|
||||
isNeutral: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function findActiveSyncedLineIndex(
|
||||
lines: LyricsLine[],
|
||||
currentTimeSeconds: number,
|
||||
options: SyncedLyricsTimingOptions = {}
|
||||
): number {
|
||||
return resolveSyncedLyricsTiming(lines, currentTimeSeconds, options).activeLineIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "MANUAL XLRC • SYNCED" style status chip. Trimmed from the desktop
|
||||
* variant so it takes only the pieces the mobile band has, not the full Track.
|
||||
*/
|
||||
export function getLyricsMetaChipText(options: {
|
||||
hasTrack: boolean;
|
||||
result: { status: 'hit'; lyrics: LyricsPayload; cached: boolean } | { status: string; reason?: string } | null;
|
||||
hasSyncedLyrics: boolean;
|
||||
isLoading: boolean;
|
||||
}): string {
|
||||
const { hasTrack, result, hasSyncedLyrics, isLoading } = options;
|
||||
if (!hasTrack) return 'No Track';
|
||||
if (isLoading && !result) return 'Loading';
|
||||
if (result?.status === 'hit') {
|
||||
const hit = result as { status: 'hit'; lyrics: LyricsPayload; cached: boolean };
|
||||
const sourceLabel = getLyricsPayloadSourceLabel(hit.lyrics);
|
||||
const syncLabel = hasSyncedLyrics ? 'Synced' : 'Unsynced';
|
||||
const cachedLabel = hit.cached ? ' • Cached' : '';
|
||||
return `${sourceLabel} • ${syncLabel}${cachedLabel}`;
|
||||
}
|
||||
if (result?.status === 'transient_error') return 'Error';
|
||||
if (result?.status === 'not_found') {
|
||||
const reason = (result as { reason?: string }).reason;
|
||||
if (reason === 'online-disabled') return 'Online Off';
|
||||
if (reason === 'provider-unavailable') return 'Lyrics Slow';
|
||||
return 'Not Found';
|
||||
}
|
||||
return 'Ready';
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Lyrics data contract — ported field-for-field from desktop
|
||||
// (astra/src/types/lyrics.ts) so the parsing/presentation logic ports cleanly.
|
||||
// v1 covers online lookup (xlrcdb + lrclib) + a fullscreen synced view. The
|
||||
// 'manual'/'embedded'/'lrc'/'xlrc' sources exist for label parity and the v2
|
||||
// local/embedded phase, even though v1 only produces 'xlrcdb'/'lrclib'.
|
||||
|
||||
export type LyricsProvider = 'lrclib' | 'xlrcdb';
|
||||
export type LyricsSource = 'embedded' | 'lrclib' | 'manual' | 'lrc' | 'xlrc' | 'xlrcdb';
|
||||
export type LyricsFormat = 'plain' | 'lrc' | 'xlrc';
|
||||
|
||||
export interface LyricsFurigana {
|
||||
start: number;
|
||||
end: number;
|
||||
base: string;
|
||||
reading: string;
|
||||
}
|
||||
|
||||
export interface LyricsWord {
|
||||
timestampMs: number;
|
||||
text: string;
|
||||
furigana?: LyricsFurigana[];
|
||||
}
|
||||
|
||||
export interface LyricsTranslation {
|
||||
lang: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface LyricsLine {
|
||||
timestampMs: number;
|
||||
text: string;
|
||||
kind?: 'silence';
|
||||
words?: LyricsWord[];
|
||||
furigana?: LyricsFurigana[];
|
||||
translations?: LyricsTranslation[];
|
||||
voice?: string | null;
|
||||
}
|
||||
|
||||
export interface LyricsTrackQuery {
|
||||
path: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
album?: string;
|
||||
durationSeconds?: number;
|
||||
}
|
||||
|
||||
export interface LyricsPayload {
|
||||
source: LyricsSource;
|
||||
provider: LyricsProvider | null;
|
||||
format: LyricsFormat;
|
||||
plainLyrics: string | null;
|
||||
syncedLyrics: string | null;
|
||||
syncedLines: LyricsLine[];
|
||||
}
|
||||
|
||||
export type LyricsLookupResult =
|
||||
| { status: 'hit'; lyrics: LyricsPayload; cached: boolean }
|
||||
| {
|
||||
status: 'not_found';
|
||||
reason: 'embedded-missing' | 'online-disabled' | 'provider-not-found' | 'provider-unavailable';
|
||||
}
|
||||
| { status: 'transient_error'; message: string; code?: string };
|
||||
Reference in New Issue
Block a user