0

Keep repeated lyric cues distinct with source-based occurrence keys

This article is AI-assisted, and I am associated with AI LRC Generator. The Python example is an independent teaching sketch, not a description of that product's internal implementation, a recognition benchmark, or a completed listening test. Its small fixtures use invented text so that the behavior can be checked without uploading a recording.

A repeated lyric is not necessarily a duplicate data record. A chorus can occur twice in a recording, one source row can carry two timestamps, and two review cues can even share the same timestamp. If an editor stores cues in a dictionary keyed by their text, a perfectly ordinary song structure can silently lose an occurrence. The visible words remain plausible, which makes this failure harder to spot than a parser exception. A useful first design decision is therefore to separate the identity of a review item from both its words and its current playback position.

Three different questions

Ask three questions when inspecting a cue: where did it come from, when should it appear, and what should it display? These are separate dimensions. The source location supplies provenance, the timestamp supplies ordering, and the lyric supplies presentation. None is a universal substitute for the others. A wording correction should not accidentally create a new occurrence, while an offset adjustment should not require a text change. Keeping the dimensions separate also makes a review log easier to explain: a reviewer can identify the same item before and after a timing correction.

For this example, an occurrence key combines a source line number with the timestamp's ordinal position on that line. A row with two timestamp prefixes creates two cues, even though both cues display the same text. Another source row containing identical words creates a third cue. Equal times are retained rather than collapsed. This is deliberately a provenance key for one source version, not a claim that line numbers remain permanent identifiers across arbitrary document edits. That distinction matters when someone later inserts a metadata row near the beginning of a file.

Define the accepted subset first

Basic LRC files vary in practice. The parser below accepts leading minute-and-second timestamps with an optional one-to-three-digit decimal fraction. It accepts multiple consecutive prefixes on a row and a small set of standalone metadata rows. It does not attempt to interpret every extension, infer missing times, or assign word boundaries. In particular, an offset header is rejected rather than silently ignored or applied twice. A production importer should explain unsupported material to the user and preserve the original input, rather than pretending that a narrow parser understands the entire format family.

Decimal fractions need a stated unit. In this subset, a single decimal digit represents tenths of a second, two digits represent hundredths, and three represent milliseconds. Right-padding the fraction converts all three representations to integer milliseconds. Repeated multiplication based on an unrelated display precision would give the wrong result. Integer arithmetic also keeps equality checks predictable for these fixtures, although it cannot establish that a cue matches an actual recording. Numerical consistency and perceptual synchronization are different kinds of evidence and need different checks.

Save the implementation as cues.py and the tests as test_cues.py. Run them with python3 -m unittest -v.

import re
from dataclasses import dataclass, replace

STAMP = re.compile(r'\[(\d+):([0-5]\d)(?:\.(\d{1,3}))?\]')
META = re.compile(r'^\[(?:ar|ti|al|by):[^\]]*\]$', re.I)

@dataclass(frozen=True)
class Cue:
    source_line: int
    stamp_number: int
    millis: int
    text: str

    @property
    def occurrence(self):
        return f'L{self.source_line}:T{self.stamp_number}'

def parse(text):
    cues = []
    for line_number, line in enumerate(text.splitlines(), 1):
        if not line.strip() or META.fullmatch(line):
            continue
        position, stamps = 0, []
        while match := STAMP.match(line, position):
            minutes, seconds, fraction = match.groups()
            millis = (int(minutes) * 60 + int(seconds)) * 1000
            millis += int((fraction or '').ljust(3, '0'))
            stamps.append(millis)
            position = match.end()
        if not stamps:
            raise ValueError(f'Unsupported or untimed line {line_number}')
        lyric = line[position:].strip()
        for stamp_number, millis in enumerate(stamps, 1):
            cues.append(Cue(line_number, stamp_number, millis, lyric))
    return cues

def shift(cues, delta):
    if any(c.millis + delta < 0 for c in cues):
        raise ValueError('Offset would produce a negative timestamp')
    return [replace(c, millis=c.millis + delta) for c in cues]

def chronological(cues):
    return sorted(cues, key=lambda c: (c.millis, c.source_line, c.stamp_number))
import unittest
from cues import parse, shift, chronological

class OccurrenceTests(unittest.TestCase):
    def test_repeated_text_and_equal_times_are_preserved(self):
        cues = parse('[00:10.00][00:20.000]Again\n[00:20.000]Again')
        self.assertEqual(len(cues), 3)
        self.assertEqual(len({c.occurrence for c in cues}), 3)
        self.assertEqual([c.millis for c in cues], [10000, 20000, 20000])

    def test_sorting_does_not_reassign_identity(self):
        cues = parse('[00:20][00:10]Again')
        self.assertEqual([c.occurrence for c in chronological(cues)], ['L1:T2', 'L1:T1'])

    def test_fractional_units_are_padded_not_scaled_twice(self):
        self.assertEqual([c.millis for c in parse('[00:01.2]A\n[00:01.20]B\n[00:01.200]C')], [1200] * 3)

    def test_offset_preserves_identity_and_input(self):
        cues = parse('[00:01]Again')
        moved = shift(cues, 250)
        self.assertEqual(moved[0].occurrence, cues[0].occurrence)
        self.assertEqual((cues[0].millis, moved[0].millis), (1000, 1250))

    def test_negative_offset_rejected_without_clamping(self):
        with self.assertRaises(ValueError):
            shift(parse('[00:01]Again'), -1001)

    def test_unsupported_offset_header_is_explicit(self):
        with self.assertRaises(ValueError):
            parse('[offset:100]\n[00:01]Again')

if __name__ == '__main__':
    unittest.main()

What the example preserves

The immutable record keeps the original line and stamp ordinal alongside the current time and text. Parsing walks prefixes from the current source position, not by searching for bracket-like strings throughout the lyric. The remaining text is preserved apart from intentional surrounding whitespace removal. A timed empty row is not automatically discarded: some workflows use it to clear a previous display. Conversely, an untimed nonempty row raises an explicit error. These choices are small, visible policies, not universal format rules, and should be adjusted only with corresponding tests and user-facing explanations.

Consider an invented fixture containing two timestamp prefixes before the word Again, followed by a second row with the same word. The expected result contains three cues, not one. Two can have an equal time while retaining different occurrence keys. A set of strings would erase this information, and a map keyed only by milliseconds would also lose a cue. The lesson is not that dictionaries are unsuitable in general; it is that a key must represent the invariant the application actually intends to preserve. Here, neither wording nor time is that invariant.

Sorting is another place where identity can disappear. An implementation that assigns occurrence numbers after chronological sorting can make an existing review note point at a different item when one timestamp changes. This example sorts existing records without recreating their keys. Source line and stamp ordinal provide a deterministic tie-breaker when times are equal. An application may choose a different stable ordering policy, but it should document that choice and test it explicitly. A stable sort alone is not a substitute for a clear identity model when records are reconstructed elsewhere.

Offsets are transformations, not cleanup

An offset can model a consistent delay between a file and a particular playback setup. It should not be used as a general repair for uneven line timing. The shift function creates new records while preserving provenance and text. It rejects a transformation that would produce a negative timestamp rather than quietly clamping the result to zero. Silent clamping can merge several distinct early cues at the start of playback and conceal a mistaken offset. The original records remain unchanged, so a preview can compare the proposed transformation with its input before accepting it.

The same separation helps an interface present useful review controls. A global offset belongs in a clearly labeled preview operation, while a local cue correction belongs with that cue. A user should be able to see which operation produced a change and undo it at the appropriate scope. Exporting a file immediately after an offset adjustment does not establish that every line has been reviewed. The interface can make this distinction visible without inventing a score or implying that a single click replaces listening to the matching recording.

Give identifiers a version boundary

A source-based key is useful only within its declared boundary. If a user reloads a revised file with an inserted line, the old line-number key may now refer to different material. One practical response is to associate review notes with a source revision as well as an occurrence. A content digest can identify exact input bytes, while a separate document identifier can connect related revisions. Those identifiers serve different purposes: changing line endings affects a byte digest even when visible words look unchanged. Migration between revisions should be an explicit operation with a reviewable mapping, not an accidental consequence of reusing a key.

Do not automatically merge repeated text when attempting that migration. A chorus may repeat with different surrounding cues, and the same short phrase can appear in several sections. Context and source positions can suggest possible matches, but ambiguous matches should remain ambiguous until reviewed. This is an engineering limitation worth showing to the user. A matching algorithm that produces one answer for every item is not necessarily more trustworthy than one that reports uncertainty. The data model should leave room for that uncertainty instead of making an incorrect correspondence look definitive.

Tests before a listening workflow

The accompanying local tests check that repeated text and equal timestamps survive, sorting does not reassign identity, fractional units are normalized consistently, offsets preserve input records, negative results are rejected, and unsupported offset headers do not disappear silently. These deterministic tests passed locally for this small example. They do not test audio recognition, language coverage, alignment quality, or playback behavior. Keeping those claims separate prevents a successful parser test from being presented as evidence of an accurate transcription system. Add fixtures for any newly supported syntax before broadening the importer contract.

For an interactive review step, a lrc maker can provide editable lyric text, timestamp corrections, and playback preview. AI LRC Generator offers free manual editing and exports; automatic audio processing requires a verified account and credits. Its export options include ordinary LRC and several subtitle or text formats. Enhanced LRC requires actual word timestamps, so a list of line cues must not be relabeled as word-level alignment. The example above does not assert that the product uses these Python records or exposes their particular occurrence keys.

Project-provided AI LRC Generator branding image

The image is project-provided branding, not evidence of a measured transcription result. Before processing material, confirm that you have permission to use the recording and lyrics. Identify the exact recording version, review repeated sections separately, listen around changed cues, and test an exported file in its destination player. Format support alone cannot promise identical rendering everywhere. The final useful invariant is modest but concrete: editing, sorting, and previewing should not silently erase a review item just because its words or timestamp happen to match another one.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí