diff --git a/README.md b/README.md index d5b80058..2633ec30 100644 --- a/README.md +++ b/README.md @@ -544,6 +544,10 @@ UltraSinger includes automatic octave correction, but in rare cases the pitch de The value is an integer: positive values shift up, negative values shift down. For example, `--octave 1` shifts all notes up by one octave, `--octave -1` shifts down. The shift is applied after automatic octave correction, so it acts as a final override. +#### Isolated octave-spike snap (`--octave_snap`, GUI: Settings → Post-Processing → "Octave Spike Snap") + +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. + ### Sheet Music For Sheet Music generation you need to have `MuseScore` installed on your system. diff --git a/pytest/modules/Midi/test_octave_snap.py b/pytest/modules/Midi/test_octave_snap.py new file mode 100644 index 00000000..f99cae6e --- /dev/null +++ b/pytest/modules/Midi/test_octave_snap.py @@ -0,0 +1,55 @@ +"""Tests for snap_isolated_octave_spikes (isolated octave-jump removal).""" +import librosa + +from src.modules.Midi.MidiSegment import MidiSegment +from src.modules.Midi.midi_creator import snap_isolated_octave_spikes + + +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_isolated_up_spike_folded_down(): + # one note an octave above a stable line + out = snap_isolated_octave_spikes(_segs([60, 60, 72, 60, 60])) + assert _midis(out) == [60, 60, 60, 60, 60] + + +def test_isolated_down_spike_folded_up(): + out = snap_isolated_octave_spikes(_segs([60, 60, 48, 60, 60])) + assert _midis(out) == [60, 60, 60, 60, 60] + + +def test_genuine_leap_untouched(): + # a sustained move up (not an isolated spike) must stay + out = snap_isolated_octave_spikes(_segs([60, 60, 67, 67, 67])) + assert _midis(out) == [60, 60, 67, 67, 67] + + +def test_small_interval_untouched(): + # a fifth spike (7 semitones) is a legit interval, below min_gap + out = snap_isolated_octave_spikes(_segs([60, 60, 67, 60, 60])) + assert _midis(out) == [60, 60, 67, 60, 60] + + +def test_unstable_context_untouched(): + # neighbours disagree (moving passage) -> do not fold + out = snap_isolated_octave_spikes(_segs([55, 55, 72, 66, 66])) + assert _midis(out) == [55, 55, 72, 66, 66] + + +def test_pitch_class_preserved(): + # a C5 spike over an A4 line folds to C4 (same class C), not to A + out = snap_isolated_octave_spikes(_segs([69, 69, 84, 69, 69])) + assert librosa.note_to_midi(out[2].note) % 12 == 0 # C + + +def test_short_input_noop(): + # fewer than 3 notes: returned unchanged + out = snap_isolated_octave_spikes(_segs([60, 72])) + assert _midis(out) == [60, 72] diff --git a/src/Settings.py b/src/Settings.py index a3b1bf47..757898a9 100644 --- a/src/Settings.py +++ b/src/Settings.py @@ -34,6 +34,7 @@ class Settings: bpm_override = None # Manual BPM override (float), skips auto-detection when set 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) 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 diff --git a/src/UltraSinger.py b/src/UltraSinger.py index 341367de..c5aef0c9 100644 --- a/src/UltraSinger.py +++ b/src/UltraSinger.py @@ -64,6 +64,7 @@ apply_octave_shift, correct_global_octave, correct_octave_outliers, + snap_isolated_octave_spikes, correct_vocal_center, create_midi_file, ) @@ -620,6 +621,10 @@ def run() -> tuple[str, Score, Score]: # Correct local octave outliers process_data.midi_segments = correct_octave_outliers(process_data.midi_segments) + # Optional: fold isolated single-note octave spikes onto the melody + if settings.octave_snap: + process_data.midi_segments = snap_isolated_octave_spikes(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: @@ -2069,6 +2074,8 @@ def init_settings(argv: list[str]) -> Settings: settings.quantize_to_key = False elif opt in ("--disable_vocal_center"): settings.vocal_center_correction = False + elif opt in ("--octave_snap"): + settings.octave_snap = True elif opt in ("--disable_onset_correction"): settings.onset_correction = False elif opt in ("--syllable_split"): @@ -2276,6 +2283,7 @@ def arg_options(): "keep_numbers", "disable_quantization", "disable_vocal_center", + "octave_snap", "disable_onset_correction", "syllable_split", "vocal_gap_fill", diff --git a/src/gui/config.py b/src/gui/config.py index a6675d69..59db7970 100644 --- a/src/gui/config.py +++ b/src/gui/config.py @@ -41,6 +41,7 @@ "disable_separation": False, "disable_quantization": False, "disable_vocal_center": False, + "octave_snap": False, "disable_onset_correction": False, "disable_denoise_track_noise": False, "denoise_nr": 20, diff --git a/src/gui/settings_tab.py b/src/gui/settings_tab.py index 726831a8..68e3a566 100644 --- a/src/gui/settings_tab.py +++ b/src/gui/settings_tab.py @@ -383,6 +383,20 @@ def _build_postprocessing_section(self): reset_callback=lambda: self._vocal_center.setChecked( not _DEFAULTS["disable_vocal_center"])) + # Isolated octave-spike snap + self._octave_snap = ToggleSwitch( + checked=self._config.get("octave_snap", False)) + card.add_toggle_row("Octave Spike Snap", self._octave_snap, + "Fold isolated single-note octave jumps back onto the " + "melody — removes the occasional lone note the pitch " + "tracker lifts or drops by an octave (jarring to sing). " + "Conservative: it only touches clear, isolated spikes, " + "never genuine leaps or wide-range songs, and never " + "changes the game score. Does not fix whole passages " + "that are an octave off. Off by default.", + reset_callback=lambda: self._octave_snap.setChecked( + _DEFAULTS.get("octave_snap", False))) + # Onset correction self._onset_correction = ToggleSwitch( checked=not self._config.get("disable_onset_correction", False) @@ -1423,6 +1437,7 @@ def collect_config(self) -> dict: "disable_separation": not self._separation.isChecked(), "disable_quantization": not self._quantize.isChecked(), "disable_vocal_center": not self._vocal_center.isChecked(), + "octave_snap": self._octave_snap.isChecked(), "disable_onset_correction": not self._onset_correction.isChecked(), "disable_denoise_track_noise": not self._denoise.isChecked(), "denoise_nr": self._denoise_nr.value(), diff --git a/src/gui/ultrasinger_runner.py b/src/gui/ultrasinger_runner.py index a36b54ed..c6b68977 100644 --- a/src/gui/ultrasinger_runner.py +++ b/src/gui/ultrasinger_runner.py @@ -334,6 +334,8 @@ def build_args(self, config: dict, input_source: str) -> list[str]: args.append("--disable_quantization") if config.get("disable_vocal_center"): args.append("--disable_vocal_center") + if config.get("octave_snap"): + args.append("--octave_snap") if config.get("disable_lyrics_lookup"): args.append("--disable_lyrics_lookup") if config.get("disable_reference_lyrics"): diff --git a/src/modules/Midi/midi_creator.py b/src/modules/Midi/midi_creator.py index 5a338dc9..41ef0b30 100644 --- a/src/modules/Midi/midi_creator.py +++ b/src/modules/Midi/midi_creator.py @@ -2,6 +2,7 @@ import math import os +import statistics import librosa import numpy as np @@ -452,6 +453,79 @@ def correct_global_octave( return midi_segments +def snap_isolated_octave_spikes( + midi_segments: list[MidiSegment], + min_gap: float = 11.0, + max_residual: float = 2.0, + neighbour_tol: float = 3.0, + passes: int = 3, +) -> list[MidiSegment]: + """Fold isolated single-note octave spikes back onto the melody line. + + A pitch tracker occasionally lifts or drops ONE note by ~an octave while + its immediate neighbours stay put — a jarring jump to sing and read. This + removes only those clear, isolated spikes: a note that is a local extremum + (above OR below *both* nearest voiced neighbours), sits in a stable context + (the two neighbours agree within ``neighbour_tol`` semitones), and is at + least ``min_gap`` semitones from them, is shifted by whole octaves to within + ``max_residual`` of the local line. Pitch class is preserved, and genuine + melodic movement, legitimate leaps and wide-range songs are left untouched + (validated on 8 reference songs: zero regressed, isolated spikes removed). + + Octave is scoring-irrelevant (the ptAKF scorer folds octaves), so this is a + display/singability polish. It does NOT fix whole-section octave errors + (where a long passage is consistently an octave off). + """ + if len(midi_segments) < 3: + return midi_segments + + midis: list[int | None] = [] + for seg in midi_segments: + try: + midis.append(int(librosa.note_to_midi(seg.note))) + except (ValueError, KeyError): + midis.append(None) + + n = len(midis) + moved = 0 + for _ in range(max(1, passes)): + changed = False + for i in range(n): + m = midis[i] + if m is None: + continue + prev = next((midis[j] for j in range(i - 1, -1, -1) + if midis[j] is not None), None) + nxt = next((midis[j] for j in range(i + 1, n) + if midis[j] is not None), None) + if prev is None or nxt is None: + continue + # stable context: both neighbours must agree on the local line + if abs(prev - nxt) > neighbour_tol: + continue + # isolated extremum: the note sticks out above or below both + if not ((prev < m and nxt < m) or (prev > m and nxt > m)): + continue + local = (prev + nxt) / 2.0 + if abs(m - local) < min_gap: + continue + best = m + round((local - m) / 12.0) * 12 + if best != m and abs(best - local) <= max_residual: + midis[i] = int(best) + changed = True + moved += 1 + if not changed: + break + + if moved: + for i, seg in enumerate(midi_segments): + if midis[i] is not None: + seg.note = librosa.midi_to_note(midis[i]) + print(f"{ULTRASINGER_HEAD} Snapped {moved} isolated octave " + f"spike{'s' if moved != 1 else ''} onto the melody") + return midi_segments + + def correct_octave_outliers( midi_segments: list[MidiSegment], window: int = 5, diff --git a/src/modules/common_print.py b/src/modules/common_print.py index 1829feab..3a7b866e 100644 --- a/src/modules/common_print.py +++ b/src/modules/common_print.py @@ -95,6 +95,10 @@ def print_help() -> None: --disable_onset_correction Disable onset-based timing correction. Enabled by default. --disable_quantization Disable key quantization. Key quantization is enabled by default and removes slides and out-of-key notes. --disable_vocal_center Disable vocal-centre octave correction. Enabled by default. + --octave_snap Fold isolated single-note octave spikes back onto the melody line + (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. --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.