"""SonicForge audio_features - librosa-free DSP shim (numpy/scipy/soundfile only). Thay the toan bo phan librosa duoc dung trong app bang cac ham nhe, cung ngu nghia, khong keo theo numba/llvmlite (~171MB) + scikit-learn (~17MB). Cac ham duoc clone theo ngu nghia cua librosa 0.11 tai cac call-site: - load() ~ librosa.load (sr=None, mono=True) - frames_to_time() ~ librosa.frames_to_time - beat_track() ~ librosa.beat.beat_track (onset spectral flux + autocorrelation tempo + adaptive peak picking) - spectral_centroid() ~ librosa.feature.spectral_centroid - rms() ~ librosa.feature.rms - zero_crossing_rate() ~ librosa.feature.zero_crossing_rate - time_stretch() ~ librosa.effects.time_stretch (phase vocoder) - pitch_shift() ~ librosa.effects.pitch_shift - chroma_stft() ~ librosa.feature.chroma_cqt (xap xi STFT-based, dung cho fingerprint/similarity, KHONG dung cho hien thi pitch chinh xac) Chi phu thuoc: numpy, scipy.signal, soundfile - tat ca da co trong bundle. """ import numpy as np import soundfile as sf from scipy import signal as _signal __all__ = [ "load", "frames_to_time", "beat_track", "spectral_centroid", "rms", "zero_crossing_rate", "time_stretch", "pitch_shift", "chroma_stft", ] # Mat dinh giong librosa (hop_length=512, n_fft=2048, win_length=2048) HOP_LENGTH = 512 N_FFT = 2048 WIN_LENGTH = 2048 # ── Load / time ────────────────────────────────────────────────────────────── def load(path, sr=None, mono=True, offset=0.0, duration=None): """Doc audio giong librosa.load: float32 [-1,1], mono = mean cac channel. sr=None -> giu nguyen sample rate goc (tat ca call-site deu dung sr=None). Neu truyen sr -> resample bang scipy.signal.resample_poly. """ if offset or duration: info = sf.info(path) start = int(offset * info.samplerate) if offset else 0 n_frames = int(duration * info.samplerate) if duration else -1 data, file_sr = sf.read(path, dtype="float32", start=start, frames=n_frames) else: data, file_sr = sf.read(path, dtype="float32") if data.ndim > 1: if mono: data = data.mean(axis=1) else: data = data.T # (channels, samples) giong librosa if sr is not None and sr != file_sr: from fractions import Fraction ratio = Fraction(int(sr), int(file_sr)) up, down = ratio.numerator, ratio.denominator data = _signal.resample_poly(data, up, down).astype(np.float32) file_sr = sr return data, file_sr def frames_to_time(frames, sr=22050, hop_length=HOP_LENGTH, n_fft=None): """Chuyen frame index sang giay: frames * hop_length / sr (giong librosa).""" return np.asanyarray(frames) * float(hop_length) / float(sr) # ── Framing / STFT (center=True, reflect pad, giong librosa) ──────────────── def _frame(y, frame_length=WIN_LENGTH, hop_length=HOP_LENGTH): """Cua so hoa tin hieu voi center padding reflect (nhu librosa center=True).""" pad = frame_length // 2 yp = np.pad(np.asarray(y, dtype=np.float64), pad, mode="reflect") n_frames = 1 + (len(yp) - frame_length) // hop_length if n_frames < 1: n_frames = 1 idx = np.arange(frame_length)[:, None] + hop_length * np.arange(n_frames)[None, :] return yp[idx] def _stft(y, n_fft=N_FFT, hop_length=HOP_LENGTH, win_length=WIN_LENGTH): """STFT mot phia (rfft) voi cua so hann periodic, reflect pad.""" y = np.asarray(y, dtype=np.float64) window = _signal.get_window("hann", win_length, fftbins=False) f, _t, Zxx = _signal.stft( y, fs=1.0, window=window, nperseg=win_length, noverlap=win_length - hop_length, nfft=n_fft, boundary="even", padded=True, ) return Zxx def _istft(Zxx, n_fft=N_FFT, hop_length=HOP_LENGTH, win_length=WIN_LENGTH, length=None): """ISTFT nguoc voi _stft (boi so chinh xac, rate=1 -> ~identity). boundary=True: cat padding (nperseg//2 moi ben) nhu librosa center=True. """ window = _signal.get_window("hann", win_length, fftbins=False) _t, y = _signal.istft( Zxx, fs=1.0, window=window, nperseg=win_length, noverlap=win_length - hop_length, nfft=n_fft, input_onesided=True, boundary=True, ) if length is not None and len(y) > length: y = y[:length] return y # ── Features ───────────────────────────────────────────────────────────────── def spectral_centroid(y=None, sr=22050, n_fft=N_FFT, hop_length=HOP_LENGTH, S=None): """Trong tam pho (brightness) - (1, n_frames) Hz, dung power spectrogram.""" if S is None: S = np.abs(_stft(y, n_fft, hop_length)) ** 2 freqs = np.fft.rfftfreq(n_fft, d=1.0 / sr) mag = np.abs(S) denom = mag.sum(axis=0) cent = np.divide( np.sum(freqs[:, None] * mag, axis=0), denom, out=np.zeros_like(denom), where=denom > 1e-10, ) return cent[None, :] def rms(y=None, frame_length=WIN_LENGTH, hop_length=HOP_LENGTH, S=None): """RMS nang luong moi frame - (1, n_frames).""" if S is not None: frames = S # caller truyen power spectrogram else: frames = _frame(y, frame_length, hop_length) return np.sqrt(np.mean(frames ** 2, axis=0))[None, :] def zero_crossing_rate(y, frame_length=WIN_LENGTH, hop_length=HOP_LENGTH): """Ti le zero-crossing moi frame - (1, n_frames).""" frames = _frame(y, frame_length, hop_length) signs = np.signbit(frames).astype(np.int8) zcr = np.mean(np.abs(np.diff(signs, axis=0)), axis=0) return zcr[None, :] def chroma_stft(y=None, sr=22050, n_fft=4096, hop_length=HOP_LENGTH): """Chroma 12 pitch class (xap xi chroma_cqt bang STFT bin folding). Tra ve (12, n_frames), chuan hoa L2 tung frame - tuong thich voi cosine_similarity trong ai_dsp_engine. """ mag = np.abs(_stft(y, n_fft, hop_length)) freqs = np.fft.rfftfreq(n_fft, d=1.0 / sr) # Chi giu bin <= 5kHz (tranh nhieu alias o high freq) keep = freqs <= 5000.0 freqs = freqs[keep] mag = mag[keep] # note number -> pitch class note = 12.0 * np.log2(np.maximum(freqs, 1e-6) / 440.0) + 69.0 pc = np.mod(np.round(note).astype(int), 12) chroma = np.zeros((12, mag.shape[1]), dtype=np.float64) np.add.at(chroma, pc, mag) # L2 normalize tung frame (giong librosa) norms = np.linalg.norm(chroma, axis=0) chroma = np.divide(chroma, norms, out=np.zeros_like(chroma), where=norms > 1e-10) return chroma # ── Onset / tempo / beat (thay librosa.beat) ───────────────────────────────── def _onset_strength(y, sr, hop_length=HOP_LENGTH, n_fft=N_FFT): """Onset envelope: spectral flux (log-magnitude diff, chi chieu duong).""" mag = np.abs(_stft(y, n_fft, hop_length)) logmag = np.log1p(1000.0 * mag) flux = np.diff(logmag, axis=1) onset = np.maximum(flux, 0.0).sum(axis=0) if onset.size == 0: return onset # Tru moving-average ~1s de loai trend (giong librosa detrend) win = max(1, int(round(1.0 * sr / hop_length))) if len(onset) >= win: kernel = np.ones(win) / win ma = np.convolve(onset, kernel, mode="same") onset = np.maximum(onset - ma, 0.0) return onset def _autocorr(x): """Autocorrelation chuan hoa (FFT, O(n log n)), r[0]=1.""" n = len(x) if n < 2: return np.ones(n) x = x - x.mean() nfft = 2 ** int(np.ceil(np.log2(2 * n))) X = np.fft.rfft(x, nfft) r = np.fft.irfft(X * np.conj(X), nfft)[:n] denom = np.maximum(n - np.arange(n), 1) r = r / denom r0 = r[0] if r[0] != 0 else 1.0 return r / r0 def _estimate_tempo(onset, sr, hop_length=HOP_LENGTH, bpm_range=(30.0, 300.0), start_bpm=120.0): """Uoc luong BPM bang autocorrelation cua onset envelope. Co them prior Gaussian quanh start_bpm (mac dinh 120, nhu librosa) de chon dung octave (tranh roi vao nua/double tempo khi autocorrelation bi mo ho giua cac harmonic). """ if len(onset) < 4: return float(start_bpm) min_lag = int(np.ceil(60.0 * sr / (bpm_range[1] * hop_length))) max_lag = int(np.floor(60.0 * sr / (bpm_range[0] * hop_length))) if max_lag <= min_lag or max_lag >= len(onset): return float(start_bpm) ac = _autocorr(onset) lags = np.arange(min_lag, max_lag + 1) tempi = 60.0 * sr / (hop_length * lags) # prior rong ~0.7 octave quanh start_bpm (log2 scale) prior = np.exp(-0.5 * ((np.log2(np.maximum(tempi, 1.0)) - np.log2(start_bpm)) / 0.7) ** 2) seg = ac[lags] * prior best = lags[int(np.argmax(seg))] tempo = 60.0 * sr / (hop_length * best) # Neu tempo > 200 -> kha nang la harmonic (half-time) -> chia doi if tempo > 200.0 and best * 2 <= max_lag: tempo = 60.0 * sr / (hop_length * best * 2) return float(tempo) def _localmax(x): """Boolean mask cac diem cuc dai dia phuong (lon hon 2 lan can).""" n = len(x) if n < 3: return np.zeros(n, dtype=bool) out = np.zeros(n, dtype=bool) out[1:-1] = (x[1:-1] > x[:-2]) & (x[1:-1] >= x[2:]) return out def _beat_frames(onset, sr, hop_length=HOP_LENGTH, tempo=120.0): """Chon beat frames bang peak-picking thich nghi + rang buoc tempo grid.""" n = len(onset) if n == 0: return np.array([], dtype=int) period = 60.0 * sr / (hop_length * max(tempo, 1.0)) # frames/beat win = max(1, int(round(period))) kernel = np.ones(win) / win ma = np.convolve(onset, kernel, mode="same") thresh = 1.25 * ma + 1e-9 cand = np.where(_localmax(onset) & (onset >= thresh))[0] if cand.size == 0: cand = np.where(_localmax(onset))[0] if cand.size == 0: cand = np.arange(0, n, max(1, int(round(period)))) beats = [int(cand[0])] while True: expected = beats[-1] + period if expected >= n: break lo, hi = expected - 0.45 * period, expected + 0.45 * period in_win = cand[(cand >= lo) & (cand <= hi)] if in_win.size == 0: nxt = int(round(expected)) if nxt >= n: break beats.append(nxt) else: beats.append(int(in_win[np.argmin(np.abs(in_win - expected))])) # Chong beat kep (khoang cach < 0.5 period) if len(beats) >= 2 and beats[-1] - beats[-2] < 0.5 * period: beats.pop() continue if len(beats) > 2000: break return np.array(beats, dtype=int) def beat_track(y=None, sr=22050, hop_length=HOP_LENGTH, start_bpm=120.0, tightness=100): """Beat tracking don gian: (tempo: float, beat_frames: np.ndarray int). Tempo bang autocorrelation onset; beats bang peak-picking thich nghi. Tuong thich kieu tra ve cua librosa.beat.beat_track tai call-site (analyzer xu ly ca scalar lan ndarray). """ onset = _onset_strength(y, sr, hop_length) tempo = _estimate_tempo(onset, sr, hop_length, start_bpm=start_bpm) beats = _beat_frames(onset, sr, hop_length, tempo) return tempo, beats # ── Effects (thay librosa.effects) ─────────────────────────────────────────── def _phase_vocoder(D, rate, hop_length=HOP_LENGTH): """Phase vocoder time-stretch kinh dien (DAFX/Puckette). D: STFT (freq_bins, n_frames). rate > 1 -> nhanh hon (ngan hon). Tra ve STFT da stretch voi so frame ~ n_frames / rate. """ n_freq, n_frames = D.shape if rate <= 0: raise ValueError("rate phai > 0") if rate == 1.0: return D time_steps = np.arange(0, n_frames, rate, dtype=float) n_out = len(time_steps) if n_out == 0: return D[:, :0] out = np.zeros((n_freq, n_out), dtype=np.complex128) # Phase advance moi hop cua tung bin tan so phase_adv = np.linspace(0.0, np.pi * hop_length, n_freq) mag = np.abs(D) phase_acc = np.angle(D[:, 0]) for t, step in enumerate(time_steps): idx = int(step) if idx >= n_frames: break if idx + 1 >= n_frames: out[:, t] = mag[:, idx] * np.exp(1j * phase_acc) break # Phase difference that giua 2 frame lien tiep (true frequency) dphase = np.angle(D[:, idx + 1]) - np.angle(D[:, idx]) - phase_adv dphase -= 2.0 * np.pi * np.round(dphase / (2.0 * np.pi)) phase_acc = phase_acc + phase_adv + dphase out[:, t] = 0.5 * (mag[:, idx] + mag[:, idx + 1]) * np.exp(1j * phase_acc) return out def time_stretch(y, rate, **kwargs): """Time stretch giu nguyen pitch. rate > 1 -> nhanh/ngan hon.""" if rate <= 0: raise ValueError("rate phai > 0") if rate == 1.0: return np.asarray(y, dtype=np.float32) y = np.asarray(y, dtype=np.float64) D = _stft(y) D_stretch = _phase_vocoder(D, rate) y_out = _istft(D_stretch) # Cat ve dung do dai ky vong: len(y) / rate target = int(round(len(y) / rate)) if len(y_out) > target: y_out = y_out[:target] return y_out.astype(np.float32) def pitch_shift(y, sr=22050, n_steps=1, **kwargs): """Dich pitch n semitone (positive = cao hon), giu nguyen duration. Co che (giong librosa): time_stretch voi rate=2^(-n/12) roi resample nguoc lai ve dung do dai goc -> pitch doi, duration giu nguyen. """ if n_steps == 0: return np.asarray(y, dtype=np.float32) rate = 2.0 ** (-float(n_steps) / 12.0) y_shift = time_stretch(y, rate) # Resample (FFT) ve dung do dai goc: factor = rate target = int(round(len(y_shift) * rate)) if target != len(y_shift) and target > 0: y_shift = _signal.resample(y_shift, target) return np.asarray(y_shift, dtype=np.float32)