"""Source-timed monophonic fallback for a hum SheetSage cannot notate.""" from __future__ import annotations from pathlib import Path def transcribe_f0(source: Path, target: Path) -> int: """Write MIDI from voiced F0 spans; reject inputs without a sustained pitch.""" import librosa import numpy as np import pretty_midi from scipy.ndimage import binary_closing, median_filter from scipy.signal import find_peaks import soundfile as sf audio, rate = sf.read(source, dtype="float32") if audio.ndim != 1 or rate != 24000: raise ValueError("Для резервной транскрипции нужен моно PCM 24 кГц") reduced = librosa.resample(audio, orig_sr=rate, target_sr=16000) f0, voiced, _ = librosa.pyin(reduced, sr=16000, fmin=55., fmax=1000., frame_length=1024, hop_length=160, center=True) flux = librosa.onset.onset_strength(y=reduced, sr=16000, hop_length=160, n_fft=512, n_mels=64)[:len(f0)] attack_threshold = max(2.5, float(np.percentile(flux, 95)) * .8) attacks, _ = find_peaks(flux, height=attack_threshold, prominence=attack_threshold * .8, distance=12) rms = librosa.feature.rms(y=reduced, frame_length=1024, hop_length=160, center=True)[0][:len(f0)] threshold = max(.001, float(np.percentile(rms, 95)) * 10 ** (-35 / 20)) active = voiced & np.isfinite(f0) & (rms >= threshold) # Bridge only pitch-tracker dropouts shorter than a consonant or breath. active = binary_closing(active, structure=np.ones(4), border_value=0) boundaries = np.flatnonzero(np.diff(np.r_[False, active, False])) score = pretty_midi.PrettyMIDI(initial_tempo=120) track = pretty_midi.Instrument(program=0, name="F0 fallback") for first, last in zip(boundaries[::2], boundaries[1::2]): if last - first < 18: # Reject short accidental pitched patches in noise. continue pitch = 69 + 12 * np.log2(np.maximum(f0[first:last], 55.) / 440) # pYIN may be unvoiced for a few interpolated frames. good = np.isfinite(pitch) if not good.any(): continue pitch = np.interp(np.arange(len(pitch)), np.flatnonzero(good), pitch[good]) pitch = median_filter(pitch, size=5, mode="nearest") cuts = [0] for attack in attacks: point = int(attack - first) # The first/last spectral burst is the span edge, not another keypress. if 12 <= point <= len(pitch) - 12 and point - cuts[-1] >= 12: cuts.append(point) # An abrupt stable F0 step can be a legato note even without a strong # energy attack. A continuous glide never has stable plateaus on both sides. for point in range(12, len(pitch) - 12): before, after = pitch[point - 10:point - 2], pitch[point + 2:point + 10] if (abs(np.median(after) - np.median(before)) >= 1.5 and np.ptp(before) < .65 and np.ptp(after) < .65 and all(abs(point - cut) >= 12 for cut in cuts)): cuts.append(point) cuts = sorted(cuts) + [len(pitch)] for start, end in zip(cuts, cuts[1:]): if end - start < 8: continue begin = max(0., (first + start) * .01 - .005) finish = min(len(audio) / rate, (first + end) * .01 - .005) if finish - begin < .075: continue note = int(np.clip(np.rint(np.median(pitch[start:end])), 0, 127)) track.notes.append(pretty_midi.Note(90, note, begin, finish)) if not track.notes: raise ValueError("Не удалось выделить устойчивую высоту напева") score.instruments.append(track) score.write(str(target)) return len(track.notes)