mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
fix(lyrics): strip Enhanced LRC word markers from displayed text (#1266)
* fix(lyrics): strip Enhanced LRC word markers from displayed text The LRC parser only matched the leading line stamp and passed the rest of the line through as text, so Enhanced LRC inline markers rendered literally: `<00:12.34>Hello` instead of `Hello`. Offline and hot-cached tracks read their embedded tags through this parser without the server ever seeing them, so a track with embedded Enhanced LRC showed the markers in the pane. Parse the markers instead of ignoring them: the text is now marker-free, and where a line carries them they drive the same word-by-word highlighting the server's songLyrics v2 cues do. A line without markers becomes one full-line word, so word mode never drops a line. Move the parser out of the LRCLIB client into `utils/lrc`, since embedded tags and Netease feed it too, and move `LrcLine` next to the other lyrics types so the parser can produce word lines without an import cycle. * docs(changelog): Enhanced LRC marker fix (#1266)
This commit is contained in:
@@ -223,6 +223,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
* Pausing a large queue behind a reverse proxy (e.g. Nginx) could snap the player back to an earlier track — the save was one long URL that hit the HTTP 414 limit, failed silently, and idle auto-pull restored the stale server queue.
|
||||
* Servers advertising the OpenSubsonic `formPost` extension (Navidrome) now save via POST; others retry once as POST on 414. A failed save no longer lets auto-pull overwrite playback — it resumes only after a successful save.
|
||||
|
||||
### Lyrics — timing codes showing up in the text
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1266](https://github.com/Psychotoxical/psysonic/pull/1266)**
|
||||
|
||||
* Tracks whose embedded lyrics use Enhanced LRC displayed the raw word timing codes (`<00:12.34>`) inside each line. The codes are now read as word timing instead of printed, so those lyrics also highlight word by word.
|
||||
|
||||
|
||||
## [1.49.0] - 2026-06-29
|
||||
|
||||
|
||||
@@ -3,11 +3,6 @@ export interface LrclibLyrics {
|
||||
plainLyrics: string | null;
|
||||
}
|
||||
|
||||
export interface LrcLine {
|
||||
time: number; // seconds
|
||||
text: string;
|
||||
}
|
||||
|
||||
export async function fetchLyrics(
|
||||
artist: string,
|
||||
title: string,
|
||||
@@ -32,18 +27,3 @@ export async function fetchLyrics(
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseLrc(lrc: string): LrcLine[] {
|
||||
const lines: LrcLine[] = [];
|
||||
for (const line of lrc.split('\n')) {
|
||||
// \d+(?:\.\d*)? — decimal part is optional so [mm:ss] (no fraction) also matches.
|
||||
// parseFloat handles all forms: "15", "15.", "15.3", "15.32" correctly.
|
||||
const match = line.match(/^\[(\d+):(\d+(?:\.\d*)?)\](.*)/);
|
||||
if (!match) continue;
|
||||
const mins = parseInt(match[1], 10);
|
||||
const secs = parseFloat(match[2]);
|
||||
const text = match[3].trim();
|
||||
lines.push({ time: mins * 60 + secs, text });
|
||||
}
|
||||
return lines.sort((a, b) => a.time - b.time);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '@/featur
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import type { LrcLine } from '@/features/lyrics/api/lrclib';
|
||||
import type { LrcLine } from '@/features/lyrics/types';
|
||||
import { useLyrics } from '@/features/lyrics/hooks/useLyrics';
|
||||
import type { WordLyricsLine } from '@/features/lyrics/types';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { Track } from '@/lib/media/trackTypes';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { commands } from '@/generated/bindings';
|
||||
import { fetchLyrics, parseLrc, LrcLine } from '@/features/lyrics/api/lrclib';
|
||||
import { fetchLyrics } from '@/features/lyrics/api/lrclib';
|
||||
import { parseEnhancedLrc, parseLrc } from '@/features/lyrics/utils/lrc';
|
||||
import { fetchNeteaselyrics } from '@/features/lyrics/api/netease';
|
||||
import { fetchLyricsPlus, hasWordSync } from '@/features/lyrics/api/lyricsplus';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
@@ -13,7 +14,7 @@ import { getCachedLyrics, putCachedLyrics, lyricsCacheKey } from '@/features/lyr
|
||||
import { parseStructuredLyrics, parseStructuredWordLines } from '@/features/lyrics/utils/structuredLyrics';
|
||||
import { FEATURE_ENHANCED_LYRICS } from '@/lib/serverCapabilities/catalog';
|
||||
import { isFeatureActiveForServer } from '@/lib/serverCapabilities/storeView';
|
||||
import type { CachedLyrics, LyricsSource, WordLyricsLine } from '@/features/lyrics/types';
|
||||
import type { CachedLyrics, LrcLine, LyricsSource, WordLyricsLine } from '@/features/lyrics/types';
|
||||
|
||||
// L1 cache: RAM, survives tab switches and component remount within a session.
|
||||
// L2 (IndexedDB) lives in `utils/lyricsPersistentCache.ts` — only touched on
|
||||
@@ -120,12 +121,14 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
|
||||
const lrcString = await commands.getEmbeddedLyrics(filePath);
|
||||
if (!lrcString) return false;
|
||||
|
||||
const lines = parseLrc(lrcString);
|
||||
// Embedded tags may hold Enhanced LRC, whose inline `<mm:ss.xx>` markers
|
||||
// must not reach the text — and can drive word highlighting when present.
|
||||
const { lines, wordLines } = parseEnhancedLrc(lrcString);
|
||||
const synced = lines.length > 0 ? lines : null;
|
||||
const plain = synced ? null : (lrcString.trim() || null);
|
||||
if (!synced && !plain) return false;
|
||||
|
||||
store({ syncedLines: synced, wordLines: null, plainLyrics: plain, source: 'embedded', notFound: false });
|
||||
store({ syncedLines: synced, wordLines: synced ? wordLines : null, plainLyrics: plain, source: 'embedded', notFound: false });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -156,9 +159,10 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
|
||||
currentTrack.duration ?? 0,
|
||||
);
|
||||
if (!result || (!result.syncedLyrics && !result.plainLyrics)) return false;
|
||||
const lines = result.syncedLyrics ? parseLrc(result.syncedLyrics) : null;
|
||||
const synced = lines && lines.length > 0 ? lines : null;
|
||||
store({ syncedLines: synced, wordLines: null, plainLyrics: result.plainLyrics, source: 'lrclib', notFound: false });
|
||||
const parsed = result.syncedLyrics ? parseEnhancedLrc(result.syncedLyrics) : null;
|
||||
const synced = parsed?.lines.length ? parsed.lines : null;
|
||||
const wordLines = synced ? parsed?.wordLines ?? null : null;
|
||||
store({ syncedLines: synced, wordLines, plainLyrics: result.plainLyrics, source: 'lrclib', notFound: false });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@@ -12,5 +12,4 @@
|
||||
export { default as LyricsPane } from './components/LyricsPane';
|
||||
export { useLyrics } from './hooks/useLyrics';
|
||||
export { useWordLyricsSync } from './hooks/useWordLyricsSync';
|
||||
export type { LrcLine } from './api/lrclib';
|
||||
export type { WordLyricsLine, WordLyricsWord, LyricsSource } from './types';
|
||||
export type { LrcLine, WordLyricsLine, WordLyricsWord, LyricsSource } from './types';
|
||||
|
||||
@@ -4,10 +4,15 @@
|
||||
* the hook (dependency-cruiser counts type-only edges, and a back-edge would
|
||||
* close a cycle).
|
||||
*/
|
||||
import type { LrcLine } from '@/features/lyrics/api/lrclib';
|
||||
|
||||
export type LyricsSource = 'server' | 'lrclib' | 'netease' | 'embedded' | 'lyricsplus';
|
||||
|
||||
/** One timed line of lyrics. `time` is seconds. */
|
||||
export interface LrcLine {
|
||||
time: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Karaoke-style word/syllable timing inside a single line.
|
||||
* All times are seconds (aligned with `LrcLine.time`), converted from the
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseEnhancedLrc, parseLrc } from '@/features/lyrics/utils/lrc';
|
||||
|
||||
describe('parseLrc', () => {
|
||||
it('parses plain LRC with and without fractional seconds', () => {
|
||||
expect(parseLrc('[00:15]first\n[01:02.50]second')).toEqual([
|
||||
{ time: 15, text: 'first' },
|
||||
{ time: 62.5, text: 'second' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('sorts by time and skips unstamped lines', () => {
|
||||
expect(parseLrc('[ti:Title]\n[00:20]later\n\n[00:10]earlier')).toEqual([
|
||||
{ time: 10, text: 'earlier' },
|
||||
{ time: 20, text: 'later' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('strips inline Enhanced LRC word markers from the text', () => {
|
||||
// Regression: the markers used to survive into the rendered line.
|
||||
expect(parseLrc('[00:12.00]<00:12.00>Hello <00:12.90>world')).toEqual([
|
||||
{ time: 12, text: 'Hello world' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseEnhancedLrc', () => {
|
||||
it('returns null word lines for plain LRC', () => {
|
||||
const { lines, wordLines } = parseEnhancedLrc('[00:10]just a line');
|
||||
expect(lines).toEqual([{ time: 10, text: 'just a line' }]);
|
||||
expect(wordLines).toBeNull();
|
||||
});
|
||||
|
||||
it('derives word timing from inline markers, ending at the next line', () => {
|
||||
const { lines, wordLines } = parseEnhancedLrc(
|
||||
'[00:12.00]<00:12.00>Hello <00:12.90>world\n[00:14.00]<00:14.00>bye',
|
||||
);
|
||||
expect(lines).toEqual([
|
||||
{ time: 12, text: 'Hello world' },
|
||||
{ time: 14, text: 'bye' },
|
||||
]);
|
||||
expect(wordLines).toHaveLength(2);
|
||||
|
||||
// Durations are seconds derived by subtraction, so compare with tolerance.
|
||||
const [first, second] = wordLines!;
|
||||
expect(first.text).toBe('Hello world');
|
||||
expect(first.time).toBe(12);
|
||||
expect(first.duration).toBeCloseTo(2, 5);
|
||||
expect(first.words.map(w => w.text)).toEqual(['Hello ', 'world']);
|
||||
expect(first.words[0].time).toBe(12);
|
||||
expect(first.words[0].duration).toBeCloseTo(0.9, 5);
|
||||
expect(first.words[1].time).toBeCloseTo(12.9, 5);
|
||||
expect(first.words[1].duration).toBeCloseTo(1.1, 5);
|
||||
|
||||
expect(second).toEqual({
|
||||
time: 14,
|
||||
duration: 0,
|
||||
text: 'bye',
|
||||
words: [{ text: 'bye', time: 14, duration: 0 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('folds text before the first marker into the first word', () => {
|
||||
const { wordLines } = parseEnhancedLrc('[00:01.00]Oh <00:01.50>yeah\n[00:03.00]end');
|
||||
expect(wordLines?.[0].text).toBe('Oh yeah');
|
||||
expect(wordLines?.[0].words).toEqual([{ text: 'Oh yeah', time: 1.5, duration: 1.5 }]);
|
||||
});
|
||||
|
||||
it('keeps a marker-free line as one full-line word so no line is dropped', () => {
|
||||
const { wordLines } = parseEnhancedLrc('[00:00.00]<00:00.00>sung\n[00:02.00]instrumental\n[00:05.00]<00:05.00>again');
|
||||
expect(wordLines).toHaveLength(3);
|
||||
expect(wordLines?.[1]).toEqual({
|
||||
time: 2,
|
||||
duration: 3,
|
||||
text: 'instrumental',
|
||||
words: [{ text: 'instrumental', time: 2, duration: 3 }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* LRC parsing, shared by every provider that hands us an LRC string (embedded
|
||||
* file tags, LRCLIB, Netease).
|
||||
*
|
||||
* Enhanced LRC ("ELRC") carries per-word timing as inline `<mm:ss.xx>` markers
|
||||
* after the line timestamp:
|
||||
*
|
||||
* [00:12.00]<00:12.00>Hello <00:12.90>world
|
||||
*
|
||||
* The markers must never reach the rendered text, and where they exist they can
|
||||
* drive the same word-by-word highlighting as the server's `songLyrics` v2 cues.
|
||||
*/
|
||||
import type { LrcLine, WordLyricsLine, WordLyricsWord } from '@/features/lyrics/types';
|
||||
|
||||
/** `[mm:ss]`, `[mm:ss.x]`, `[mm:ss.xx]`, `[mm:ss.xxx]` — the leading line stamp. */
|
||||
const LINE_STAMP = /^\[(\d+):(\d+(?:\.\d*)?)\](.*)/;
|
||||
/** `<mm:ss.xx>` — an inline word stamp (Enhanced LRC). */
|
||||
const WORD_STAMP = /<(\d+):(\d+(?:\.\d*)?)>/g;
|
||||
|
||||
function toSeconds(minutes: string, seconds: string): number {
|
||||
return parseInt(minutes, 10) * 60 + parseFloat(seconds);
|
||||
}
|
||||
|
||||
interface ScannedLine {
|
||||
time: number;
|
||||
text: string;
|
||||
/** Word stamps in source order; empty for a plain (non-enhanced) line. */
|
||||
cues: { time: number; text: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Split one LRC body line into its timestamp, its marker-free text, and any
|
||||
* inline word cues. Text before the first word stamp has no timing of its own,
|
||||
* so it is folded into the first cue.
|
||||
*/
|
||||
function scanLine(line: string): ScannedLine | null {
|
||||
const stamp = line.match(LINE_STAMP);
|
||||
if (!stamp) return null;
|
||||
const time = toSeconds(stamp[1], stamp[2]);
|
||||
const rest = stamp[3];
|
||||
|
||||
const cues: { time: number; text: string }[] = [];
|
||||
let plain = '';
|
||||
let cursor = 0;
|
||||
WORD_STAMP.lastIndex = 0;
|
||||
for (let m = WORD_STAMP.exec(rest); m !== null; m = WORD_STAMP.exec(rest)) {
|
||||
const between = rest.slice(cursor, m.index);
|
||||
plain += between;
|
||||
if (cues.length === 0) {
|
||||
// Leading text before the first marker carries no timing of its own.
|
||||
cues.push({ time: toSeconds(m[1], m[2]), text: between });
|
||||
} else {
|
||||
cues[cues.length - 1].text += between;
|
||||
cues.push({ time: toSeconds(m[1], m[2]), text: '' });
|
||||
}
|
||||
cursor = m.index + m[0].length;
|
||||
}
|
||||
const tail = rest.slice(cursor);
|
||||
plain += tail;
|
||||
if (cues.length > 0) cues[cues.length - 1].text += tail;
|
||||
|
||||
return { time, text: plain.trim(), cues };
|
||||
}
|
||||
|
||||
function scan(lrc: string): ScannedLine[] {
|
||||
const scanned: ScannedLine[] = [];
|
||||
for (const line of lrc.split('\n')) {
|
||||
const parsed = scanLine(line);
|
||||
if (parsed) scanned.push(parsed);
|
||||
}
|
||||
return scanned.sort((a, b) => a.time - b.time);
|
||||
}
|
||||
|
||||
/**
|
||||
* Timed lines with the text stripped of any inline word markers.
|
||||
* Unstamped lines (metadata headers, blanks) are dropped.
|
||||
*/
|
||||
export function parseLrc(lrc: string): LrcLine[] {
|
||||
return scan(lrc).map(({ time, text }) => ({ time, text }));
|
||||
}
|
||||
|
||||
export interface ParsedLrc {
|
||||
lines: LrcLine[];
|
||||
/** Null unless at least one line carries inline word markers. */
|
||||
wordLines: WordLyricsLine[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an LRC string once into both line-level and (when present) word-level
|
||||
* timing. A line without markers becomes a single full-line word, so enabling
|
||||
* word mode never drops a line from the pane.
|
||||
*
|
||||
* The last word of a line and the line itself end at the next line's start —
|
||||
* ELRC has no explicit end marker.
|
||||
*/
|
||||
export function parseEnhancedLrc(lrc: string): ParsedLrc {
|
||||
const scanned = scan(lrc);
|
||||
const lines: LrcLine[] = scanned.map(({ time, text }) => ({ time, text }));
|
||||
if (!scanned.some(line => line.cues.length > 0)) return { lines, wordLines: null };
|
||||
|
||||
const wordLines = scanned.map((line, i) => {
|
||||
const nextStart = scanned[i + 1]?.time;
|
||||
const lineEnd = nextStart ?? line.time;
|
||||
const cues = line.cues.length > 0
|
||||
? line.cues
|
||||
: [{ time: line.time, text: line.text }];
|
||||
|
||||
const words: WordLyricsWord[] = cues.map((cue, j) => {
|
||||
const end = cues[j + 1]?.time ?? lineEnd;
|
||||
return { text: cue.text, time: cue.time, duration: Math.max(0, end - cue.time) };
|
||||
});
|
||||
|
||||
return {
|
||||
time: line.time,
|
||||
duration: Math.max(0, lineEnd - line.time),
|
||||
text: line.text,
|
||||
words,
|
||||
};
|
||||
});
|
||||
|
||||
return { lines, wordLines };
|
||||
}
|
||||
@@ -9,8 +9,7 @@ import type {
|
||||
SubsonicLyricCueLine,
|
||||
SubsonicStructuredLyrics,
|
||||
} from '@/lib/api/subsonicTypes';
|
||||
import type { LrcLine } from '@/features/lyrics/api/lrclib';
|
||||
import type { CachedLyrics, WordLyricsLine, WordLyricsWord } from '@/features/lyrics/types';
|
||||
import type { CachedLyrics, LrcLine, WordLyricsLine, WordLyricsWord } from '@/features/lyrics/types';
|
||||
|
||||
const MS_PER_SECOND = 1000;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user