notes: judge low confidence against the song, and flag unsingable words
A fixed 0.5 alignment-score cutoff flagged 73-99% of notes on sung vocals, so the flag pointed nowhere. Scores still separate bad words from good ones, but their scale differs from song to song: flag a word below a quarter of the song's median score, or one shorter than 0.08s or longer than 3s, which no singer produces and which a word stretched over something else does. Flagged notes less than 2s apart share one warning.
This commit is contained in:
+27
-12
@@ -7,7 +7,17 @@ import pyphen
|
|||||||
from .languages import LANGUAGES
|
from .languages import LANGUAGES
|
||||||
|
|
||||||
CONF_MASK = 0.5 # pitch frames below this are unvoiced
|
CONF_MASK = 0.5 # pitch frames below this are unvoiced
|
||||||
LOW_SCORE = 0.5 # word alignment score below this -> low_confidence
|
# A word is low-confidence when it scores below this share of the song's
|
||||||
|
# median alignment score: the scale differs a lot from song to song, so no
|
||||||
|
# fixed cutoff holds across them.
|
||||||
|
LOW_SCORE_OF_MEDIAN = 0.25
|
||||||
|
# ...or when no one sings a word that fast, or it was stretched over more than
|
||||||
|
# it can be holding.
|
||||||
|
MIN_WORD_SEC = 0.08
|
||||||
|
MAX_WORD_SEC = 3.0
|
||||||
|
# Flagged notes closer than this share one warning, so the list reads as
|
||||||
|
# places to look rather than one line per note.
|
||||||
|
WARN_GAP_SEC = 2.0
|
||||||
SPLIT_SEMITONES = 0.8
|
SPLIT_SEMITONES = 0.8
|
||||||
MIN_SEG_SEC = 0.05
|
MIN_SEG_SEC = 0.05
|
||||||
MIN_NOTE_SEC = 0.05 # alignment may hand us zero-length words
|
MIN_NOTE_SEC = 0.05 # alignment may hand us zero-length words
|
||||||
@@ -84,6 +94,7 @@ def build_notes(rows: list[dict], times, f0, conf, language: str | None,
|
|||||||
) -> tuple[list[TimedNote], list[float], list[str]]:
|
) -> tuple[list[TimedNote], list[float], list[str]]:
|
||||||
notes: list[TimedNote] = []
|
notes: list[TimedNote] = []
|
||||||
breaks: list[float] = []
|
breaks: list[float] = []
|
||||||
|
floor = LOW_SCORE_OF_MEDIAN * float(np.median([r["score"] for r in rows])) if rows else 0.0
|
||||||
prev_line, prev_end = None, 0.0
|
prev_line, prev_end = None, 0.0
|
||||||
for row in rows:
|
for row in rows:
|
||||||
if prev_line is not None and row["line_index"] > prev_line:
|
if prev_line is not None and row["line_index"] > prev_line:
|
||||||
@@ -97,7 +108,8 @@ def build_notes(rows: list[dict], times, f0, conf, language: str | None,
|
|||||||
_text(row["word"], 0, line_initial), True,
|
_text(row["word"], 0, line_initial), True,
|
||||||
row["line_index"]))
|
row["line_index"]))
|
||||||
continue
|
continue
|
||||||
low = row["score"] < LOW_SCORE
|
low = (row["score"] < floor
|
||||||
|
or not MIN_WORD_SEC <= row["end"] - row["start"] <= MAX_WORD_SEC)
|
||||||
sylls = syllabify(row["word"], language)
|
sylls = syllabify(row["word"], language)
|
||||||
if len(segs) < len(sylls):
|
if len(segs) < len(sylls):
|
||||||
segs = _subdivide(segs, len(sylls))
|
segs = _subdivide(segs, len(sylls))
|
||||||
@@ -160,16 +172,19 @@ def _fmt(t: float) -> str:
|
|||||||
|
|
||||||
def _warnings(notes: list[TimedNote], dropped_lines=(), missing_spans=(),
|
def _warnings(notes: list[TimedNote], dropped_lines=(), missing_spans=(),
|
||||||
ref_lines=()) -> list[str]:
|
ref_lines=()) -> list[str]:
|
||||||
warnings, run = [], []
|
runs: list[list[TimedNote]] = []
|
||||||
for n in list(notes) + [None]:
|
for n in notes:
|
||||||
if n is not None and n.low_confidence:
|
if not n.low_confidence:
|
||||||
run.append(n)
|
continue
|
||||||
elif run:
|
last = runs[-1][-1] if runs else None
|
||||||
first, last = run[0], run[-1]
|
if last is not None and n.start - (last.start + last.duration) < WARN_GAP_SEC:
|
||||||
warnings.append(
|
runs[-1].append(n)
|
||||||
f"{len(run)} low-confidence note{'' if len(run) == 1 else 's'} "
|
else:
|
||||||
f"near {_fmt(first.start)}–{_fmt(last.start + last.duration)}")
|
runs.append([n])
|
||||||
run = []
|
warnings = [
|
||||||
|
f"{len(run)} low-confidence note{'' if len(run) == 1 else 's'} "
|
||||||
|
f"near {_fmt(run[0].start)}–{_fmt(run[-1].start + run[-1].duration)}"
|
||||||
|
for run in runs]
|
||||||
for group in _consecutive_runs(dropped_lines):
|
for group in _consecutive_runs(dropped_lines):
|
||||||
n = len(group)
|
n = len(group)
|
||||||
text = " ".join(ref_lines[group[0]]) if group[0] < len(ref_lines) else ""
|
text = " ".join(ref_lines[group[0]]) if group[0] < len(ref_lines) else ""
|
||||||
|
|||||||
+40
-5
@@ -4,7 +4,7 @@ import numpy as np
|
|||||||
import pytest
|
import pytest
|
||||||
from songtrace.config import Config
|
from songtrace.config import Config
|
||||||
from songtrace.notes import (
|
from songtrace.notes import (
|
||||||
MIN_NOTE_SEC, TimedNote, build_notes, hz_to_ultrastar, segment_span,
|
MIN_NOTE_SEC, TimedNote, _warnings, build_notes, hz_to_ultrastar, segment_span,
|
||||||
syllabify,
|
syllabify,
|
||||||
)
|
)
|
||||||
from songtrace.pipeline import Ctx, SongInput
|
from songtrace.pipeline import Ctx, SongInput
|
||||||
@@ -127,11 +127,36 @@ def test_build_notes_unvoiced_word_flagged():
|
|||||||
assert warnings[0] == "1 low-confidence note near 0:01–0:01"
|
assert warnings[0] == "1 low-confidence note near 0:01–0:01"
|
||||||
|
|
||||||
|
|
||||||
def test_build_notes_low_score_word_flagged():
|
def _scored_rows(scores, dur=0.4):
|
||||||
|
return [{"word": w, "start": 1.0 + i, "end": 1.0 + i + dur, "score": sc,
|
||||||
|
"line_index": 0}
|
||||||
|
for i, (w, sc) in enumerate(zip(["one", "two", "three", "four", "five"], scores))]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_notes_flags_a_word_scoring_far_below_the_rest():
|
||||||
times, f0, conf = flat_pitch()
|
times, f0, conf = flat_pitch()
|
||||||
rows = [{"word": "hey", "start": 1.0, "end": 1.4, "score": 0.1, "line_index": 0}]
|
notes, _, warnings = build_notes(_scored_rows([0.4, 0.4, 0.05, 0.4, 0.4]),
|
||||||
notes, _, warnings = build_notes(rows, times, f0, conf, "English")
|
times, f0, conf, "English")
|
||||||
assert all(n.low_confidence for n in notes) and len(warnings) == 1
|
assert [n.low_confidence for n in notes] == [False, False, True, False, False]
|
||||||
|
assert len(warnings) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_notes_judges_scores_against_the_song_not_a_fixed_cutoff():
|
||||||
|
# sung vocals score low throughout; uniformly low is not a reason to flag
|
||||||
|
times, f0, conf = flat_pitch()
|
||||||
|
notes, _, warnings = build_notes(_scored_rows([0.05] * 5), times, f0, conf, "English")
|
||||||
|
assert not any(n.low_confidence for n in notes) and warnings == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_notes_flags_words_too_short_or_too_long_to_be_sung():
|
||||||
|
times, f0, conf = flat_pitch()
|
||||||
|
for dur in (0.05, 3.5):
|
||||||
|
rows = _scored_rows([0.4] * 3)
|
||||||
|
rows[1]["end"] = rows[1]["start"] + dur
|
||||||
|
rows[2]["start"], rows[2]["end"] = rows[1]["end"] + 0.1, rows[1]["end"] + 0.5
|
||||||
|
notes, _, _ = build_notes(rows, times, f0, conf, "English")
|
||||||
|
# flat pitch: one note per word
|
||||||
|
assert [n.low_confidence for n in notes] == [False, True, False], dur
|
||||||
|
|
||||||
|
|
||||||
def test_build_notes_unvoiced_word_inherits_previous_pitch():
|
def test_build_notes_unvoiced_word_inherits_previous_pitch():
|
||||||
@@ -334,3 +359,13 @@ def test_notes_carry_line_index():
|
|||||||
rows = [{"word": "hey", "start": 1.0, "end": 1.4, "score": 0.9, "line_index": 3}]
|
rows = [{"word": "hey", "start": 1.0, "end": 1.4, "score": 0.9, "line_index": 3}]
|
||||||
notes, _, _ = build_notes(rows, times, f0, conf, "English")
|
notes, _, _ = build_notes(rows, times, f0, conf, "English")
|
||||||
assert notes[0].line_index == 3
|
assert notes[0].line_index == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_warnings_group_flagged_notes_close_together():
|
||||||
|
def note(start, low):
|
||||||
|
return TimedNote(start, 0.3, 0, "la", low, 0)
|
||||||
|
notes = [note(10.0, True), note(10.5, False), note(11.0, True), # 0.7s apart
|
||||||
|
note(20.0, True)] # far from both
|
||||||
|
assert _warnings(notes) == [
|
||||||
|
"2 low-confidence notes near 0:10–0:11",
|
||||||
|
"1 low-confidence note near 0:20–0:20"]
|
||||||
|
|||||||
Reference in New Issue
Block a user