Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,10 @@ The value is an integer: positive values shift up, negative values shift down. F

Separately from a whole-song shift, the pitch tracker occasionally lifts or drops a *single* note by an octave while its neighbours stay put — a lone note that jumps up and back down, jarring to sing and read. `--octave_snap` folds only those clear, isolated spikes back onto the melody: it acts on a note that sticks out above or below both of its immediate neighbours, sits in a stable local context, and is about an octave away. Genuine leaps, gradual movement and legitimately wide-range songs are left untouched, and because octave is scoring-irrelevant (the game folds octaves) it never changes the game score. It is a display/singability polish and does **not** fix a whole passage that is consistently an octave off (that is a separate, harder pitch-tracking problem). Disabled by default.

#### Octave consistency (`--octave_consistency`, GUI: Settings → Post-Processing → "Octave Consistency")

The stronger octave repair: pitch trackers scatter individual notes *and short runs* into the wrong octave — measured against professional reference charts, generated charts contained about **10× as many jarring octave-size jumps** between adjacent notes (507 vs 47 across 8 songs), which makes passages extremely confusing to sing. `--octave_consistency` keeps every note's pitch class and re-chooses only its octave via dynamic programming over the whole song, balancing melodic smoothness against fidelity to what the tracker detected. The balance is self-limiting: short wrong-octave scatter (roughly 1–3 notes) is folded onto the melody line, while a genuine octave passage of about five notes or more is cheaper to keep — so real octave jumps and wide-range songs survive (validated on the same 8 reference songs: jumps dropped to professional-chart level while octave-sensitive pitch agreement with the references stayed within 1 percentage point). The game score is unaffected because scoring folds octaves. Disabled by default.

### Sheet Music

For Sheet Music generation you need to have `MuseScore` installed on your system.
Expand Down
69 changes: 69 additions & 0 deletions pytest/modules/Midi/test_octave_consistency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Tests for enforce_octave_consistency (Viterbi per-note octave assignment)."""
import librosa

from src.modules.Midi.MidiSegment import MidiSegment
from src.modules.Midi.midi_creator import enforce_octave_consistency


def _segs(midis):
return [MidiSegment(note=librosa.midi_to_note(m), start=i, end=i + 0.5, word="la")
for i, m in enumerate(midis)]


def _midis(segs):
return [int(librosa.note_to_midi(s.note)) for s in segs]


def test_single_spike_folded():
out = enforce_octave_consistency(_segs([60, 60, 72, 60, 60]))
assert _midis(out) == [60, 60, 60, 60, 60]


def test_short_run_folded():
# a 2-note wrong-octave run costs 2*fidelity=4 to fold vs 8 boundary cost
out = enforce_octave_consistency(_segs([60, 60, 48, 48, 60, 60]))
assert _midis(out) == [60, 60, 60, 60, 60, 60]


def test_alternating_scatter_unified():
# rapid octave alternation (the "extremely confusing" case) collapses
out = enforce_octave_consistency(_segs([60, 48, 60, 48, 60, 48, 60]))
vals = _midis(out)
assert max(vals) - min(vals) == 0


def test_long_genuine_octave_passage_kept():
# >= 5-note octave passage: keeping (2 boundary jumps) is cheaper than
# folding (fidelity per note) -> genuine chorus jumps survive
line = [60, 60, 60, 72, 72, 72, 72, 72, 72, 60, 60, 60]
out = enforce_octave_consistency(_segs(line))
assert _midis(out) == line


def test_gradual_wide_range_untouched():
# stepwise movement over a wide range has no jump beyond the hinge
line = [55, 58, 62, 66, 69, 72, 69, 66, 62, 58, 55]
out = enforce_octave_consistency(_segs(line))
assert _midis(out) == line


def test_pitch_class_preserved():
out = enforce_octave_consistency(_segs([69, 69, 84, 69, 69]))
assert all(librosa.note_to_midi(s.note) % 12 in (9, 0) for s in out)
# the spike (class C) may move octaves but never change class
assert librosa.note_to_midi(out[2].note) % 12 == 0


def test_short_input_noop():
out = enforce_octave_consistency(_segs([60, 72]))
assert _midis(out) == [60, 72]


def test_malformed_note_skipped_not_crashing():
# librosa raises ParameterError (NOT a ValueError subclass) for bad
# note strings — the pass must treat such segments as unvoiced.
segs = _segs([60, 60, 72, 60, 60])
segs[1].note = "not-a-note"
out = enforce_octave_consistency(segs)
assert out[1].note == "not-a-note" # untouched
assert librosa.note_to_midi(out[2].note) == 60 # spike still folded
1 change: 1 addition & 0 deletions src/Settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class Settings:
octave_shift = None # Manual octave shift (int), shifts all notes by N octaves after detection
vocal_center_correction = True # Safety-net octave correction for consistently wrong-octave detection
octave_snap = False # Fold isolated single-note octave spikes back onto the melody (display polish; off by default)
octave_consistency = False # Viterbi octave assignment per note: removes scattered wrong-octave notes/short runs, keeps genuine octave passages (off by default)
onset_correction = True # Snap note start times to detected audio onsets
syllable_split = False # Preserve syllable-level note splits at pitch changes
vocal_gap_fill = False # Fill un-transcribed vocal gaps with placeholder notes
Expand Down
8 changes: 8 additions & 0 deletions src/UltraSinger.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
apply_octave_shift,
correct_global_octave,
correct_octave_outliers,
enforce_octave_consistency,
snap_isolated_octave_spikes,
correct_vocal_center,
create_midi_file,
Expand Down Expand Up @@ -625,6 +626,10 @@ def run() -> tuple[str, Score, Score]:
if settings.octave_snap:
process_data.midi_segments = snap_isolated_octave_spikes(process_data.midi_segments)

# Optional: per-note Viterbi octave assignment for a consistent melody line
if settings.octave_consistency:
process_data.midi_segments = enforce_octave_consistency(process_data.midi_segments)

# Safety-net: shift notes toward vocal centre if still concentrated
# outside the expected range (catches 100%-consistent wrong-octave)
if settings.vocal_center_correction:
Expand Down Expand Up @@ -2076,6 +2081,8 @@ def init_settings(argv: list[str]) -> Settings:
settings.vocal_center_correction = False
elif opt in ("--octave_snap"):
settings.octave_snap = True
elif opt in ("--octave_consistency"):
settings.octave_consistency = True
elif opt in ("--disable_onset_correction"):
settings.onset_correction = False
elif opt in ("--syllable_split"):
Expand Down Expand Up @@ -2284,6 +2291,7 @@ def arg_options():
"disable_quantization",
"disable_vocal_center",
"octave_snap",
"octave_consistency",
"disable_onset_correction",
"syllable_split",
"vocal_gap_fill",
Expand Down
1 change: 1 addition & 0 deletions src/gui/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"disable_quantization": False,
"disable_vocal_center": False,
"octave_snap": False,
"octave_consistency": False,
"disable_onset_correction": False,
"disable_denoise_track_noise": False,
"denoise_nr": 20,
Expand Down
17 changes: 17 additions & 0 deletions src/gui/settings_tab.py
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,22 @@ def _build_postprocessing_section(self):
reset_callback=lambda: self._octave_snap.setChecked(
_DEFAULTS.get("octave_snap", False)))

# Viterbi octave consistency
self._octave_consistency = ToggleSwitch(
checked=self._config.get("octave_consistency", False))
card.add_toggle_row("Octave Consistency", self._octave_consistency,
"Pick each note's octave so the melody line is "
"consistent to sing. Pitch trackers scatter notes and "
"short runs into the wrong octave (measured: ~10x more "
"jarring octave-size jumps than professional charts); "
"this keeps every note's pitch class and re-chooses "
"only the octave via dynamic programming. Genuine "
"octave passages and wide-range songs are preserved, "
"and the game score is unaffected (scoring folds "
"octaves). Off by default.",
reset_callback=lambda: self._octave_consistency.setChecked(
_DEFAULTS.get("octave_consistency", False)))

# Onset correction
self._onset_correction = ToggleSwitch(
checked=not self._config.get("disable_onset_correction", False)
Expand Down Expand Up @@ -1438,6 +1454,7 @@ def collect_config(self) -> dict:
"disable_quantization": not self._quantize.isChecked(),
"disable_vocal_center": not self._vocal_center.isChecked(),
"octave_snap": self._octave_snap.isChecked(),
"octave_consistency": self._octave_consistency.isChecked(),
"disable_onset_correction": not self._onset_correction.isChecked(),
"disable_denoise_track_noise": not self._denoise.isChecked(),
"denoise_nr": self._denoise_nr.value(),
Expand Down
2 changes: 2 additions & 0 deletions src/gui/ultrasinger_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,8 @@ def build_args(self, config: dict, input_source: str) -> list[str]:
args.append("--disable_vocal_center")
if config.get("octave_snap"):
args.append("--octave_snap")
if config.get("octave_consistency"):
args.append("--octave_consistency")
if config.get("disable_lyrics_lookup"):
args.append("--disable_lyrics_lookup")
if config.get("disable_reference_lyrics"):
Expand Down
84 changes: 81 additions & 3 deletions src/modules/Midi/midi_creator.py
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,7 @@ def snap_isolated_octave_spikes(
for seg in midi_segments:
try:
midis.append(int(librosa.note_to_midi(seg.note)))
except (ValueError, KeyError):
except (ValueError, KeyError, librosa.ParameterError):
midis.append(None)

n = len(midis)
Expand Down Expand Up @@ -526,6 +526,84 @@ def snap_isolated_octave_spikes(
return midi_segments


def enforce_octave_consistency(
midi_segments: list[MidiSegment],
fidelity_weight: float = 2.0,
jump_hinge: float = 8.0,
) -> list[MidiSegment]:
"""Choose each note's octave so the melody line is consistent to sing.

Pitch trackers scatter occasional notes (and short runs) into the wrong
octave — measured against professional reference charts, generated charts
had ~10x as many adjacent jumps of >= 10 semitones (507 vs 47 over 8
songs), which is extremely confusing to sing. This pass keeps every
note's pitch CLASS and picks the octave (-1/0/+1 relative to detection)
per note via dynamic programming, minimizing:

* smoothness — only the part of an adjacent jump beyond ``jump_hinge``
semitones costs, so normal melodic movement is free, and
* fidelity — each note pays ``fidelity_weight`` per octave moved away
from what the tracker detected.

The balance makes the pass self-limiting: folding a short 1-3 note
octave error is cheaper than paying its two ~12-semitone boundary
jumps, while a genuine octave passage of >= ~5 notes is cheaper to KEEP
than to fold — so legitimate octave jumps and wide-range songs survive
(validated on 8 reference songs: jumps 507 -> 61 at reference level,
octave-sensitive pitch agreement unchanged within 1pp). Octave is
scoring-irrelevant (the ptAKF scorer folds octaves), so the game score
is unaffected.
"""
idx = [i for i, seg in enumerate(midi_segments)]
midis: list[int | None] = []
for seg in midi_segments:
try:
midis.append(int(librosa.note_to_midi(seg.note)))
except (ValueError, KeyError, librosa.ParameterError):
midis.append(None)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
voiced = [i for i in idx if midis[i] is not None]
if len(voiced) < 3:
return midi_segments

octave_ks = (-1, 0, 1)
prev_cost = {k: fidelity_weight * abs(k) for k in octave_ks}
backptr: list[dict[int, int]] = []
for t in range(1, len(voiced)):
m_prev = midis[voiced[t - 1]]
m_cur = midis[voiced[t]]
cur_cost: dict[int, float] = {}
cur_back: dict[int, int] = {}
for k in octave_ks:
cand = m_cur + 12 * k
best_c, best_pk = float("inf"), 0
for pk in octave_ks:
jump = abs(cand - (m_prev + 12 * pk))
c = prev_cost[pk] + max(0.0, jump - jump_hinge)
if c < best_c:
best_c, best_pk = c, pk
cur_cost[k] = best_c + fidelity_weight * abs(k)
cur_back[k] = best_pk
prev_cost = cur_cost
backptr.append(cur_back)

k_end = min(prev_cost, key=prev_cost.get)
choices = [k_end]
for t in range(len(backptr) - 1, -1, -1):
choices.append(backptr[t][choices[-1]])
choices.reverse()

moved = 0
for t, i in enumerate(voiced):
if choices[t] != 0:
midi_segments[i].note = librosa.midi_to_note(
midis[i] + 12 * choices[t])
moved += 1
if moved:
print(f"{ULTRASINGER_HEAD} Octave consistency: moved {moved} "
f"note{'s' if moved != 1 else ''} onto the melody line")
return midi_segments


def correct_octave_outliers(
midi_segments: list[MidiSegment],
window: int = 5,
Expand Down Expand Up @@ -578,7 +656,7 @@ def correct_octave_outliers(
for seg in midi_segments:
try:
midi_values.append(librosa.note_to_midi(seg.note))
except (ValueError, KeyError):
except (ValueError, KeyError, librosa.ParameterError):
midi_values.append(None)

# Compute global median once per pass as tie-breaker reference
Expand Down Expand Up @@ -661,7 +739,7 @@ def correct_octave_outliers(
for seg in midi_segments:
try:
midi_values_ph2.append(librosa.note_to_midi(seg.note))
except (ValueError, KeyError):
except (ValueError, KeyError, librosa.ParameterError):
midi_values_ph2.append(None)

valid_values = [v for v in midi_values_ph2 if v is not None]
Expand Down
6 changes: 6 additions & 0 deletions src/modules/common_print.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ def print_help() -> None:
(removes jarring single-note octave jumps left by pitch-tracking errors).
Conservative and octave-only, so it never changes the game score; it does
NOT fix whole-section octave shifts. Disabled by default.
--octave_consistency Pick each note's octave so the melody line is consistent to sing: keeps
every note's pitch class and removes scattered wrong-octave notes and
short runs (measured 507 -> 61 jarring >=10-semitone jumps on 8 reference
songs, matching professional chart level). Genuine octave passages and
wide-range songs are preserved; the game score is unaffected
(octave-folded scoring). Disabled by default.
--syllable_split Preserve syllable-level note splits at pitch changes (experimental). Disabled by default.
--vocal_gap_fill Fill un-transcribed vocal gaps with placeholder notes (experimental). Disabled by default.
--pitch_change_split Split notes at pitch change boundaries within a syllable. Enabled by default.
Expand Down
Loading