#!/usr/bin/env python3 """ NOOR ENGINE v5 — Voice Character Correction Usage: python3 noor_v5.py -i input.mp3 -o output.mp3 """ import argparse, os, subprocess, sys, tempfile import numpy as np from scipy.fft import rfft, irfft, rfftfreq from scipy.signal import get_window, lfilter, butter, sosfiltfilt from pathlib import Path SR = 48_000 _TMP = tempfile.gettempdir() def load(path, sr=SR): r = subprocess.run(['ffmpeg','-y','-i',path,'-ar',str(sr),'-ac','1', '-f','f32le','pipe:1'], capture_output=True) return np.frombuffer(r.stdout, dtype=np.float32).copy() def save_wav(a, path, sr=SR): subprocess.run(['ffmpeg','-y','-f','f32le','-ar',str(sr),'-ac','1', '-i','pipe:0','-c:a','pcm_s24le',path], input=a.astype(np.float32).tobytes(), capture_output=True, check=True) def to_mp3(wav, mp3): subprocess.run(['ffmpeg','-y','-i',wav,'-c:a','libmp3lame', '-b:a','320k','-q:a','0',mp3], capture_output=True, check=True) def rms(a): return float(np.sqrt(np.mean(a.astype(np.float64)**2)) + 1e-20) def db(a): return float(20*np.log10(rms(a))) # ── F0 detection ────────────────────────────────────────────────────────────── def f0(frame, sr=SR): win = frame * np.hanning(len(frame)) if np.sqrt(np.mean(win**2)) < 4e-5: return 0.0 lo, hi = int(sr/450), min(int(sr/75), len(frame)//2) c = np.correlate(win, win, 'full')[len(frame)-1:] c /= c[0] + 1e-12 seg = c[lo:hi] if not len(seg): return 0.0 pk = int(np.argmax(seg)) if seg[pk] < 0.35: return 0.0 lag = pk + lo if 0 < lag < len(c)-1: y0,y1,y2 = c[lag-1],c[lag],c[lag+1] d = y0-2*y1+y2 if abs(d)>1e-10: lag += 0.5*(y0-y2)/d return float(sr / max(lag, 1.0)) # ── Stage 1: STFT harmonic noise gate ──────────────────────────────────────── def harmonic_gate(audio, sharpness=8.0, floor=0.06, strength=0.90, fsize=2048, hop=1024, sr=SR): """ STFT spectral mask: gaussian peaks at F0 harmonics, floor between them. 50% overlap (hop=fsize//2) with hann^2 OLA — stable reconstruction. Edge samples (low weight) zeroed to avoid division spikes. """ n = len(audio) out = np.zeros(n, dtype=np.float64) wts = np.zeros(n, dtype=np.float64) hann = get_window('hann', fsize).astype(np.float64) freq = rfftfreq(fsize, d=1.0/sr) v = u = 0 for s in range(0, n - fsize, hop): frame = audio[s:s+fsize].astype(np.float64) wf = frame * hann F0 = f0(wf, sr) if F0 > 0: spec = rfft(wf) sigma = F0 / sharpness mask = np.full(len(freq), floor) k = 1 while k * F0 < freq[-1]: mask = np.maximum(mask, np.exp(-0.5*((freq - k*F0)/sigma)**2)) k += 1 rec = irfft(spec * (mask*strength + (1.0-strength)), n=fsize) v += 1 else: rec = irfft(rfft(wf), n=fsize) # pass-through u += 1 out[s:s+fsize] += rec * hann wts[s:s+fsize] += hann * hann # Avoid edge spikes: only divide where weight is large enough safe = wts > 0.01 result = np.zeros(n, dtype=np.float64) result[safe] = out[safe] / wts[safe] # RMS preserve result *= rms(audio) / (float(np.sqrt(np.mean(result**2))) + 1e-20) result = result.astype(np.float32) print(f' [gate] voiced={v} unvoiced={u} ' f'max={np.max(np.abs(result)):.4f} RMS={db(result):.2f}dBFS') return result # ── Stage 2: even-harmonic enrichment ──────────────────────────────────────── def even_harmonics(audio, drive=1.8, mix_db=-18.0, sr=SR): """ x² nonlinearity on body band → pure 2nd harmonic. Corrects odd/even balance toward reference. enchanted odd/even=+8.1dB, ref=+5.5dB → need ~2.6dB more even energy. """ sos_src = butter(4, [100/(sr/2), 600/(sr/2)], btype='band', output='sos') body = sosfiltfilt(sos_src, audio.astype(np.float64)) norm = body / (np.std(body) * drive + 1e-10) even = norm ** 2 even -= np.mean(even) # remove DC sos_bp = butter(4, [200/(sr/2), 1400/(sr/2)], btype='band', output='sos') even = sosfiltfilt(sos_bp, even) mix_lin = rms(audio) * 10**(mix_db/20.0) ev_rms = float(np.sqrt(np.mean(even**2)) + 1e-20) even *= mix_lin / ev_rms result = (audio.astype(np.float64) + even).astype(np.float32) print(f' [even] mix={mix_db:.1f}dB added_RMS={20*np.log10(ev_rms*mix_lin/ev_rms+1e-20):.1f}dBFS') return result # ── Stage 3: parametric EQ (scipy biquads, float64) ────────────────────────── def peak(a, freq, gain, q, sr=SR): A = 10**(gain/40.0); w0 = 2*np.pi*freq/sr; al = np.sin(w0)/(2*q) b = [1+al*A, -2*np.cos(w0), 1-al*A] a_ = [1+al/A, -2*np.cos(w0), 1-al/A] return lfilter([x/a_[0] for x in b], [1.0, a_[1]/a_[0], a_[2]/a_[0]], a) def loshelf(a, freq, gain, q=0.65, sr=SR): A = 10**(gain/40.0); w0 = 2*np.pi*freq/sr cw = np.cos(w0); sw = np.sin(w0) al = sw/2 * np.sqrt((A+1/A)*(1/q-1)+2) b = [ A*((A+1)-(A-1)*cw+2*np.sqrt(A)*al), 2*A*((A-1)-(A+1)*cw), A*((A+1)-(A-1)*cw-2*np.sqrt(A)*al)] a_ = [ (A+1)+(A-1)*cw+2*np.sqrt(A)*al, -2*((A-1)+(A+1)*cw), (A+1)+(A-1)*cw-2*np.sqrt(A)*al] return lfilter([x/a_[0] for x in b], [1.0,a_[1]/a_[0],a_[2]/a_[0]], a) def apply_eq(audio, nodes): """nodes: list of (freq, gain_db, q, type) where type='peak'|'lowshelf'""" eq = audio.astype(np.float64) for freq, gain, q, typ in nodes: if abs(gain) < 0.05: continue if typ == 'lowshelf': eq = loshelf(eq, freq, gain, q) else: eq = peak(eq, freq, gain, q) print(f' [eq] {typ:<10} {freq:>5}Hz {gain:>+5.1f}dB Q={q}') return eq # ── Band measurement ────────────────────────────────────────────────────────── def bands(audio, n=131072): spec = np.abs(rfft(audio[:n].astype(np.float64)*np.hanning(n))) freq = rfftfreq(n, d=1.0/SR) def b(lo, hi): return float(20*np.log10(np.mean(spec[(freq>=lo)&(freq7} {"Input":>7} {"Output":>7} {"Gap":>7}') print(' ' + '-'*46) for k in in_bands: r_ = ref_bands[k] if ref_bands else 0.0 i_ = in_bands[k]; o_ = out_bands[k] gap_i = i_ - r_; gap_o = o_ - r_ mark = '✓' if ref_bands and abs(gap_o)7.1f}' if ref_bands else ' ---' print(f' {k:<14} {ref_s} {i_:>7.1f} {o_:>7.1f} {gap_o:>+7.1f} {mark}') print(f'\n ✓ {out} ({Path(out).stat().st_size//1024} KB)') # ── CLI ─────────────────────────────────────────────────────────────────────── if __name__ == '__main__': p = argparse.ArgumentParser() p.add_argument('-i', required=True) p.add_argument('-o', required=True) p.add_argument('--ref', default=None) p.add_argument('--sharpness', type=float, default=8.0) p.add_argument('--floor', type=float, default=0.06) p.add_argument('--strength', type=float, default=0.90) p.add_argument('--even-drive', type=float, default=1.8) p.add_argument('--even-mix', type=float, default=-18.0) a = p.parse_args() run(a.i, a.o, ref=a.ref, sharpness=a.sharpness, floor=a.floor, strength=a.strength, even_drive=a.even_drive, even_mix=a.even_mix)