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 @@ -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.
Expand Down
55 changes: 55 additions & 0 deletions pytest/modules/Midi/test_octave_snap.py
Original file line number Diff line number Diff line change
@@ -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]
1 change: 1 addition & 0 deletions src/Settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
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,
snap_isolated_octave_spikes,
correct_vocal_center,
create_midi_file,
)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"):
Expand Down Expand Up @@ -2276,6 +2283,7 @@ def arg_options():
"keep_numbers",
"disable_quantization",
"disable_vocal_center",
"octave_snap",
"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 @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions src/gui/settings_tab.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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(),
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 @@ -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"):
Expand Down
74 changes: 74 additions & 0 deletions src/modules/Midi/midi_creator.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import math
import os
import statistics

import librosa
import numpy as np
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/modules/common_print.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading