lyrics support + lookup

This commit is contained in:
Boof2015
2026-07-08 23:16:10 -04:00
parent acc6bdf3eb
commit 6ccc73401c
19 changed files with 2478 additions and 29 deletions
Binary file not shown.
+76
View File
@@ -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);
});
+288
View File
@@ -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);
}
+108
View File
@@ -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'
);
});
+437
View File
@@ -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';
}
+62
View File
@@ -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 };