Files
songtrace/tests/test_align.py
T
branderson f3c43e0410 align: let a star token absorb non-lyric audio between lines
The aligner had to explain every frame of a section with the lyrics, so
background speech or laughter between lines pulled the next line's
letters onto it, which then came out as a burst of tiny notes before the
real line was rushed to catch up. MMS_FA has a star token that matches
any audio; put one before, between and after the lines of each section,
as ctc-forced-aligner does by default, and drop the stars' spans from the
result.
2026-10-10 19:21:58 -07:00

238 lines
9.5 KiB
Python

import json
import numpy as np
import pytest
import soundfile as sf
from songtrace.config import Config
from songtrace.lyrics import normalize_lyrics
from songtrace.pipeline import Ctx, PipelineError, SongInput
from songtrace.stages import align
from songtrace.whisper_client import TranscriptSegment
REAL_UNTRANSCRIBED_SINGING = align.untranscribed_singing
@pytest.fixture(autouse=True)
def no_untranscribed_singing(monkeypatch):
"""The tests have no vocals.wav; those about missed singing set their own."""
monkeypatch.setattr(align, "untranscribed_singing", lambda vocals, segments: [])
LYRICS = "it's one two three\nfour five six seven eight"
def make_ctx(tmp_path, lyrics=LYRICS, transcript=None):
work = tmp_path / "w"
work.mkdir(exist_ok=True)
if transcript is None:
transcript = [{"start": 1.0, "end": 4.0,
"text": "it's one two three four five six seven eight"}]
(work / "transcript.json").write_text(json.dumps(transcript))
inp = SongInput(title="T", artist="A", lyrics_text=lyrics)
return Ctx(inp=inp, cfg=Config(whisper_url="u", device="cpu"),
work=work, log=lambda m: None)
def fake_align(vocals_path, start, end, tokens, device):
step = (end - start) / len(tokens)
return [(start + i * step, start + (i + 1) * step, 0.9)
for i in range(len(tokens))]
def run_align(tmp_path, monkeypatch, lyrics=LYRICS, transcript=None):
monkeypatch.setattr(align, "force_align_section", fake_align)
ctx = make_ctx(tmp_path, lyrics, transcript)
align.align_stage().run(ctx)
return json.loads((ctx.work / "alignment.json").read_text())
def check_invariants(rows, lyrics):
ref = normalize_lyrics(lyrics)
assert [r["word"] for r in rows] == ref.words
assert [r["line_index"] for r in rows] == ref.word_line_index
prev = 0.0
for r in rows:
assert r["start"] is not None and r["end"] is not None
assert prev <= r["start"] <= r["end"]
prev = r["end"]
def test_alignment_covers_all_words(tmp_path, monkeypatch):
rows = run_align(tmp_path, monkeypatch)
assert [r["word"] for r in rows] == \
["it's", "one", "two", "three", "four", "five", "six", "seven", "eight"]
assert rows[0]["line_index"] == 0 and rows[-1]["line_index"] == 1
starts = [r["start"] for r in rows]
assert starts == sorted(starts)
def test_mismatched_lyrics_fail(tmp_path, monkeypatch):
monkeypatch.setattr(align, "force_align_section", fake_align)
ctx = make_ctx(tmp_path, lyrics="totally unrelated nonsense words banana")
with pytest.raises(PipelineError, match="similarity"):
align.align_stage().run(ctx)
def test_unalignable_word_interpolated(tmp_path, monkeypatch):
lyrics = "it's one ... three\nfour five six seven eight"
rows = run_align(tmp_path, monkeypatch, lyrics)
dots = next(r for r in rows if r["word"] == "...")
assert dots["score"] == 0.0
prev = next(r for r in rows if r["word"] == "one")
nxt = next(r for r in rows if r["word"] == "three")
assert prev["end"] <= dots["start"] <= dots["end"] <= nxt["start"] + 1e-6
check_invariants(rows, lyrics)
def test_leading_unalignable_word(tmp_path, monkeypatch):
lyrics = "... one two three\nfour five six seven eight"
rows = run_align(tmp_path, monkeypatch, lyrics)
assert rows[0]["word"] == "..." and rows[0]["score"] == 0.0
assert rows[0]["start"] >= 0.0
assert rows[0]["end"] <= rows[1]["start"] + 1e-6
check_invariants(rows, lyrics)
def test_trailing_unalignable_word(tmp_path, monkeypatch):
lyrics = "it's one two three\nfour five six seven ..."
rows = run_align(tmp_path, monkeypatch, lyrics)
assert rows[-1]["word"] == "..." and rows[-1]["score"] == 0.0
assert rows[-2]["end"] <= rows[-1]["start"]
check_invariants(rows, lyrics)
def test_consecutive_unalignable_words(tmp_path, monkeypatch):
lyrics = "it's one ... ... three\nfour five six seven eight"
rows = run_align(tmp_path, monkeypatch, lyrics)
dots = [r for r in rows if r["word"] == "..."]
assert len(dots) == 2
assert all(r["score"] == 0.0 for r in dots)
assert dots[0]["end"] <= dots[1]["start"]
check_invariants(rows, lyrics)
def test_all_words_unalignable_in_section(tmp_path, monkeypatch):
lyrics = "... ...\n... ..."
transcript = [{"start": 1.0, "end": 4.0, "text": "..."}]
monkeypatch.setattr(align, "force_align_section", fake_align)
ctx = make_ctx(tmp_path, lyrics, transcript)
align.align_stage().run(ctx)
rows = json.loads((ctx.work / "alignment.json").read_text())
check_invariants(rows, lyrics)
assert all(r["score"] == 0.0 for r in rows)
assert rows[-1]["end"] <= 5.0
def test_non_chronological_transcript(tmp_path, monkeypatch):
transcript = [{"start": 30.0, "end": 33.0, "text": "four five six seven eight"},
{"start": 1.0, "end": 4.0, "text": "it's one two three"}]
rows = run_align(tmp_path, monkeypatch, transcript=transcript)
check_invariants(rows, LYRICS)
assert len({r["start"] for r in rows}) == len(rows)
assert rows[3]["end"] <= 5.0
assert rows[4]["start"] >= 29.0
def test_inverted_section_does_not_collapse(tmp_path, monkeypatch):
monkeypatch.setattr(align, "vocal_sections", lambda segments: [(29.0, 5.0)])
rows = run_align(tmp_path, monkeypatch)
check_invariants(rows, LYRICS)
assert len({r["start"] for r in rows}) == len(rows)
assert rows[0]["start"] >= 5.0 and rows[-1]["end"] <= 29.0
def test_words_split_across_sections(tmp_path, monkeypatch):
transcript = [{"start": 1.0, "end": 4.0, "text": "it's one two three"},
{"start": 30.0, "end": 33.0, "text": "four five six seven eight"}]
rows = run_align(tmp_path, monkeypatch, transcript=transcript)
check_invariants(rows, LYRICS)
assert rows[3]["end"] <= 5.0
assert rows[4]["start"] >= 29.0
def test_align_drops_lines_never_sung(tmp_path, monkeypatch):
lyrics = "it's one two three\nfour five six seven eight\nnine ten eleven twelve"
transcript = [{"start": 1.0, "end": 4.0,
"text": "it's one two three four five six seven eight"}]
rows = run_align(tmp_path, monkeypatch, lyrics, transcript)
assert {r["line_index"] for r in rows} == {0, 1}
meta = json.loads((tmp_path / "w" / "align_meta.json").read_text())
assert meta["dropped_lines"] == [2]
assert meta["missing_spans"] == []
def test_align_flags_missing_span_for_unmatched_singing(tmp_path, monkeypatch):
lyrics = "it's one two three\nfour five six seven eight"
transcript = [
{"start": 1.0, "end": 4.0, "text": "it's one two three four five six seven eight"},
{"start": 20.0, "end": 23.0, "text": "completely unrelated ad lib bridge"},
]
run_align(tmp_path, monkeypatch, lyrics, transcript)
meta = json.loads((tmp_path / "w" / "align_meta.json").read_text())
assert meta["dropped_lines"] == []
assert meta["missing_spans"] == [[19.0, 24.0]]
def _tone(seconds, sr, level):
t = np.arange(int(seconds * sr)) / sr
return level * np.sin(2 * np.pi * 220 * t)
def test_untranscribed_singing_finds_sung_audio_the_transcript_skips(
tmp_path):
sr = 16000
rng = np.random.default_rng(0)
audio = np.concatenate([
_tone(2, sr, 0.3), # 0-2s: sung and transcribed
np.zeros(2 * sr), # 2-4s: silence
_tone(5, sr, 0.3), # 4-9s: sung, never transcribed
np.zeros(3 * sr), # 9-12s: silence
_tone(1, sr, 0.3), # 12-13s: too short to count
np.zeros(3 * sr),
]) + 0.001 * rng.standard_normal(16 * sr) # separation bleed
path = tmp_path / "vocals.wav"
sf.write(path, audio.astype(np.float32), sr)
segments = [TranscriptSegment(0.0, 2.0, "one two")]
spans = REAL_UNTRANSCRIBED_SINGING(path, segments)
assert len(spans) == 1
assert spans[0][0] == pytest.approx(4.0, abs=0.1)
assert spans[0][1] == pytest.approx(9.0, abs=0.1)
def test_lines_whisper_missed_are_aligned_in_the_untranscribed_singing(
tmp_path, monkeypatch):
lyrics = "one two three\nfour five six\nseven eight nine"
transcript = [{"start": 1.0, "end": 3.0, "text": "one two three"},
{"start": 40.0, "end": 42.0, "text": "seven eight nine"}]
monkeypatch.setattr(align, "untranscribed_singing",
lambda vocals, segments: [(20.0, 26.0)])
rows = run_align(tmp_path, monkeypatch, lyrics, transcript)
check_invariants(rows, lyrics)
middle = [r for r in rows if r["line_index"] == 1]
assert all(19.0 <= r["start"] and r["end"] <= 27.0 for r in middle)
meta = json.loads((tmp_path / "w" / "align_meta.json").read_text())
assert meta["dropped_lines"] == []
def test_stars_bracket_each_line_and_take_no_words(tmp_path, monkeypatch):
lyrics = "one two\n...\nthree four"
transcript = [{"start": 0.0, "end": 10.0, "text": "one two three four"}]
seen = []
def spy(vocals_path, start, end, tokens, device):
seen.append(tokens)
# each star claims a long stretch, so a word given a star's span shows
spans = [(start + i, start + i + (5.0 if t == "*" else 0.5), 0.9)
for i, t in enumerate(tokens)]
return spans
monkeypatch.setattr(align, "force_align_section", spy)
ctx = make_ctx(tmp_path, lyrics, transcript)
align.align_stage().run(ctx)
rows = json.loads((ctx.work / "alignment.json").read_text())
assert seen == [["*", "one", "two", "*", "three", "four", "*"]]
timed = [r for r in rows if r["word"] != "..."]
assert all(r["end"] - r["start"] == pytest.approx(0.5) for r in timed)
check_invariants(rows, lyrics)