Skip to content

syncalong.align

The core alignment algorithm: a Needleman–Wunsch-style dynamic-programming matcher that maps lyric words onto transcript words while preserving order.

align

Align lyrics to a Whisper transcript with dynamic-programming alignment.

The algorithm is a variant of the Needleman–Wunsch / Smith–Waterman family: both the lyric word sequence and the transcript word sequence are in temporal order, so we find a monotonic mapping that maximises the total fuzzy-match score between paired words.

Complexity is O(N·M) where N = lyric words, M = transcript words. For a typical song (< 600 words each) this takes a few milliseconds.

align_lyrics_to_transcript

align_lyrics_to_transcript(lyric_lines: list[LyricLine], transcript: list[WordTimestamp], *, threshold: float = 55.0) -> list[tuple[LyricLine, float | None]]

Align parsed lyrics to a word-level transcript.

Parameters:

Name Type Description Default
lyric_lines list[LyricLine]

Parsed lyrics (from :func:~syncalong.lyrics.parse_lyrics).

required
transcript list[WordTimestamp]

Word timestamps (from :func:~syncalong.transcribe.transcribe_audio).

required
threshold float

Minimum fuzzy score (0–100) to accept a match.

55.0

Returns:

Type Description
list[tuple[LyricLine, float | None]]

One (LyricLine, timestamp_or_None) pair per lyric line. The

list[tuple[LyricLine, float | None]]

timestamp (seconds) is the start time of the first matched word in the

list[tuple[LyricLine, float | None]]

line, or None if no word in the line could be aligned.

Source code in src/syncalong/align.py
def align_lyrics_to_transcript(
    lyric_lines: list[LyricLine],
    transcript: list[WordTimestamp],
    *,
    threshold: float = 55.0,
) -> list[tuple[LyricLine, float | None]]:
    """Align parsed lyrics to a word-level transcript.

    Args:
        lyric_lines: Parsed lyrics (from
            :func:`~syncalong.lyrics.parse_lyrics`).
        transcript: Word timestamps (from
            :func:`~syncalong.transcribe.transcribe_audio`).
        threshold: Minimum fuzzy score (0–100) to accept a match.

    Returns:
        One ``(LyricLine, timestamp_or_None)`` pair per lyric line. The
        timestamp (seconds) is the start time of the first matched word in the
        line, or ``None`` if no word in the line could be aligned.
    """
    # Flatten all lyric words into a single ordered list, keeping a back-
    # reference so we can map results back to lines.
    flat_words: list[str] = []
    word_to_line: list[int] = []  # flat index → index into lyric_lines

    for li, line in enumerate(lyric_lines):
        for w in line.words:
            flat_words.append(w)
            word_to_line.append(li)

    # Run DP alignment
    mapping = _dp_align(flat_words, transcript, threshold)

    # For each lyric line, find the earliest matched word's timestamp
    line_timestamps: dict[int, float] = {}
    for flat_idx, trans_idx in sorted(mapping.items()):
        li = word_to_line[flat_idx]
        ts = transcript[trans_idx].start
        if li not in line_timestamps:
            line_timestamps[li] = ts

    # Interpolate timestamps for unmatched non-blank lines that sit between
    # two matched lines — gives a rough estimate rather than leaving gaps.
    matched_indices = sorted(line_timestamps.keys())
    if matched_indices:
        _interpolate_gaps(lyric_lines, line_timestamps, matched_indices)
        _extrapolate_edges(
            lyric_lines,
            line_timestamps,
            matched_indices,
            t_min=transcript[0].start,
            t_max=transcript[-1].end,
        )

    return [(line, line_timestamps.get(i)) for i, line in enumerate(lyric_lines)]