DriiftKing commited on
Commit
a6791a4
·
verified ·
1 Parent(s): 3f00ed4

No-fiddle: zaszyty łańcuch agy (mono+HPF75+LUFS-18+peak-1dB) w postproc.py; front=kroki+normalizacja; piny scipy/pyloudnorm

Browse files
Files changed (4) hide show
  1. README.md +89 -6
  2. app.py +57 -40
  3. postproc.py +409 -0
  4. requirements.txt +2 -0
README.md CHANGED
@@ -13,16 +13,99 @@ pinned: false
13
  # AudioSR — wzbogacanie krótkich próbek głosu (CPU / darmowy)
14
 
15
  Twój Space do super-rozdzielczości audio (AudioSR, k2-fsa/haoheliu): krótki klip → 48 kHz.
 
16
  UI w kolorystyce Lektor AI. Pole do wrzucenia próbki → odtwarzacz + przycisk pobierania WAV.
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  ## Hosting (darmowy)
19
- - Nowy Space → SDK **Gradio**, Hardware **CPU basic (free)**, Visibility **Private** (Twój głos zostaje u Ciebie).
20
- - Wgraj: `app.py`, `requirements.txt`, `README.md`.
21
- - **CPU**: 1 klip ≈ ~2 min (dyfuzja). Domyślnie model **basic**, **24 kroki** (sprawdzone do tego głosu).
22
 
23
- ## Pipeline (jak w sr_dataset.py)
24
- super-res → przycięcie paddingu AudioSR do długości wejścia → wyrównanie poziomu (RMS) do
25
- oryginału → limiter 0,99 → zapis 48 kHz (odtwarzacz + pobieranie).
 
26
 
27
  ## Uwaga
 
28
  Pierwsze uruchomienie pobiera checkpointy AudioSR z HF (kilka GB) — pierwszy klip wolniejszy.
 
13
  # AudioSR — wzbogacanie krótkich próbek głosu (CPU / darmowy)
14
 
15
  Twój Space do super-rozdzielczości audio (AudioSR, k2-fsa/haoheliu): krótki klip → 48 kHz.
16
+ Próbki służą jako **referencja do klonowania głosu** (RVC/Applio + zero-shot TTS OmniVoice).
17
  UI w kolorystyce Lektor AI. Pole do wrzucenia próbki → odtwarzacz + przycisk pobierania WAV.
18
 
19
+ ## Tryb NO-FIDDLE — backend ustawiony za Ciebie
20
+
21
+ Cały „know-how" przetwarzania jest **zaszyty w backendzie** — nie ma czego dłubać.
22
+ Frontend ma **TYLKO dwie kontrolki**:
23
+
24
+ - **DDIM steps** — suwak jakość vs czas (więcej kroków = lepiej, ale wolniej).
25
+ - **Normalizuj** — przełącznik dwustopniowej normalizacji (LUFS → sufit szczytu).
26
+
27
+ Stałe backendu (hardcode, nie do ruszania): model `basic`, guidance `3.5`, seed `42`,
28
+ `latent_t_per_second = 12.8`.
29
+
30
+ ## Pipeline
31
+
32
+ ```
33
+ (opcjonalny zewnętrzny UVR5 de-reverb)
34
+ ↓
35
+ AudioSR (super-rozdzielczość, 48 kHz) ← AudioSR ZAWSZE przed normalizacją
36
+ ↓
37
+ przycięcie paddingu AudioSR do długości wejścia
38
+ ↓
39
+ postproc.process(...) ← czyszczenie + normalizacja (czysty DSP, testowalny)
40
+ ↓
41
+ zapis WAV 48 kHz (odtwarzacz + pobieranie)
42
+ ```
43
+
44
+ ### Normalizuj **WŁĄCZONE** (kolejność istotna)
45
+
46
+ 1. **force-mono** — embeddery (hubert / contentvec / spin-v2) oczekują mono; stereo grozi
47
+ kasowaniem fazy.
48
+ 2. **usunięcie DC** — odjęcie składowej stałej.
49
+ 3. **high-pass ~75 Hz** — Butterworth rz. 2, **zero-phase** (`scipy` filtfilt) — zdejmuje
50
+ rumble/szum sieciowy, **nie rusza barwy** głosu, nie rozmywa transjentów.
51
+ 4. **LUFS normalize → -18 LUFS** — wg **ITU-R BS.1770-4** (`pyloudnorm` Meter, K-weighting);
52
+ podnosi percepcyjną głośność/gęstość, by model uczył się barwy także z cichszych miejsc.
53
+ Odporny fallback dla bardzo krótkich/cichych klipów (mniejszy block_size → RMS).
54
+ 5. **sufit szczytu -1 dB** — **wyłącznie redukcja wzmocnienia** (gain-down), gdy szczyt > -1 dB.
55
+ **Zero kompresji / limitera / clipingu** — czysta zmiana skali (nie myli embedderów).
56
+
57
+ Wynik zapisywany jako **WAV 16-bit PCM** (`subtype=PCM_16`) — sufit -1 dB istnieje właśnie po to,
58
+ by kontener nigdy nie obciął na ±1.0.
59
+
60
+ > **Kompromis LUFS ↔ szczyt (uczciwie):** przy pojedynczym głośnym transjencie (plozja, klik)
61
+ > sufit -1 dB może zejść wzmocnieniem na tyle, że cel **-18 LUFS nie zostanie dotrzymany** — bez
62
+ > limitera nie da się mieć obu naraz, a limitera świadomie NIE dodajemy (psułby barwę). W takim
63
+ > wypadku apka **wypisuje pomarańczowe ostrzeżenie** z brakującą liczbą dB (zamiast cicho udawać
64
+ > sukces) i sugeruje ręczne wyciszenie transjentu w Audacity. Na normalnej mowie cel -18 wchodzi.
65
+
66
+ ### Normalizuj **WYŁĄCZONE**
67
+
68
+ force-mono → dopasowanie poziomu **RMS** wyjścia do oryginału → bezpiecznik szczytu **0.99**
69
+ (bez high-passu / LUFS). To dotychczasowe, łagodne zachowanie.
70
+
71
+ ## Co ŚWIADOMIE pominięto (i dlaczego)
72
+
73
+ Backend bierze tylko **uniwersalnie bezpieczne, automatyzowalne** kroki. Pomijamy te, które
74
+ agy (red-team Gemini 3.1 Pro) sam ostrzega, że są **niebezpieczne w ciemno**:
75
+
76
+ - **Spektralna redukcja szumu** — #1 zabójca barwy; nie da się wiarygodnie auto-profilować.
77
+ - **Wycięcie rezonansu EQ** — szkodliwe na już-czystym audio.
78
+ - **UVR5 de-reverb** — narzędzie **zewnętrzne**, nie wbudowane w apkę.
79
+ - **Ręczne usuwanie klików/plozji** — nieautomatyzowalne bezpiecznie (wymaga ucha).
80
+ - **Kompresja / limiter / noise gate / boost EQ** — spłaszczają dynamikę, zabijają transjenty,
81
+ podbijają sybilanty → szkodzą jako referencja TTS/RVC.
82
+
83
+ **Warunkowy de-esser** (tnij 6–8 kHz o ≤3 dB tylko gdy pasmo gorące) jest **zaimplementowany,
84
+ ale domyślnie WYŁĄCZONY** (`postproc.DEESS_ENABLED = False`): w trybie w pełni automatycznym
85
+ de-essing w ciemno ryzykuje zmatowienie spółgłosek, które AudioSR dopiero odbudował.
86
+
87
+ > Źródło prawdy o jakości to jakość **nagrania** (cichy pokój) > każdy post-processing.
88
+ > Próbkowania **nie schodzimy** w dół — RVC sam zresampluje do 32k/40k w preprocesie Applio.
89
+
90
+ ## Architektura plików
91
+
92
+ - `app.py` — UI Gradio + orkiestracja AudioSR (model cache, progress/ETA, padding trim).
93
+ - `postproc.py` — **czysty DSP** (numpy/scipy/soundfile/pyloudnorm), bez gradio →
94
+ testowalny lokalnie: `python postproc.py` (samotest: DC, LUFS, sufit, krótki klip).
95
+ - `viz.py` — wykres widma (wierzchołki / góra pasma) jako `<img>`.
96
+
97
+ `scipy==1.10.1` i `pyloudnorm==0.1.1` są **przypięte** pod `numpy==1.23.5`/py3.10 (koła
98
+ manylinux cp310 zweryfikowane) — bez tego resolver mógłby ściągnąć scipy zbudowane pod
99
+ numpy 2.x i wywalić build. Importy DSP są dodatkowo **miękkie**: brak scipy → pomiń HPF,
100
+ brak pyloudnorm → RMS-fallback (apka degraduje, nie pada).
101
+
102
  ## Hosting (darmowy)
 
 
 
103
 
104
+ - Nowy Space → SDK **Gradio**, Hardware **CPU basic (free)**, Visibility **Private**
105
+ (Twój głos zostaje u Ciebie).
106
+ - Wgraj: `app.py`, `postproc.py`, `viz.py`, `requirements.txt`, `README.md`.
107
+ - **CPU**: 1 klip ≈ ~2 min (dyfuzja). Domyślnie model **basic**, **24 kroki**.
108
 
109
  ## Uwaga
110
+
111
  Pierwsze uruchomienie pobiera checkpointy AudioSR z HF (kilka GB) — pierwszy klip wolniejszy.
app.py CHANGED
@@ -1,7 +1,14 @@
1
  """AudioSR — wzbogacanie krótkich próbek głosu (HF Space, CPU/DARMOWY). UI Lektor AI.
2
  Wejście: gr.Audio (upload) → BEZPOŚREDNIO do enhance (bez State — pewne okablowanie).
3
  Zamiast pustej nuty: WYKRES WIDMA (wierzchołki/góra pasma) jako <img>. Pasek postępu + ETA.
4
- CPU: 1 klip ≈ ~2 min. Model „basic", 24 kroki."""
 
 
 
 
 
 
 
5
  import os
6
  import time
7
  import threading
@@ -12,9 +19,17 @@ import gradio as gr
12
  import torch
13
  from audiosr import build_model, super_resolution
14
  import viz
 
15
 
16
  DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
17
  OUTPUT_SR = 48000
 
 
 
 
 
 
 
18
  CYAN, ORANGE, MUTED = "#00ffff", "#ff9900", "#8a8aa0"
19
  HINT = ("<div style='color:#8a8aa0;font-size:12px;text-align:center;padding:10px'>"
20
  "Wrzuć próbkę, aby zobaczyć widmo (wierzchołki / góra pasma)</div>")
@@ -37,22 +52,20 @@ def on_audio(audio_path):
37
  return f"<div style='color:#ff5a5a;font-size:12px;padding:8px'>Nie udało się narysować widma: {e}</div>"
38
 
39
 
40
- def enhance(audio_path, model_name, ddim_steps, guidance_scale, seed, norm, norm_db, progress=gr.Progress()):
41
  if not audio_path:
42
  raise gr.Error("Najpierw wrzuć plik audio.")
43
- seed = int(seed or 0)
44
- if seed == 0:
45
- seed = int.from_bytes(os.urandom(4), "big") % (2 ** 31)
46
 
47
  progress(0.03, desc="Ładowanie modelu (pierwszy raz: pobieranie ~GB)…")
48
- mdl = _model(model_name)
49
 
50
  res = {}
51
  def work():
52
  try:
53
- res["w"] = super_resolution(mdl, audio_path, seed=seed,
54
- guidance_scale=float(guidance_scale),
55
- ddim_steps=int(ddim_steps), latent_t_per_second=12.8)
 
56
  except Exception as e:
57
  res["err"] = e
58
 
@@ -73,32 +86,42 @@ def enhance(audio_path, model_name, ddim_steps, guidance_scale, seed, norm, norm
73
  wav = np.asarray(res["w"]).squeeze()
74
  if wav.ndim > 1:
75
  wav = wav.T
 
 
 
 
 
76
  info = sf.info(audio_path)
77
  exp = int(round(info.frames / info.samplerate * OUTPUT_SR))
78
- if exp and wav.shape[0] > exp:
79
  wav = wav[:exp]
80
- if norm:
81
- # Normalizacja jak w Audacity: usuń DC offset + peak do docelowego poziomu (dB)
82
- wav = wav - float(np.mean(wav))
83
- pk = float(np.max(np.abs(wav))) or 1.0
84
- wav = wav * (10.0 ** (float(norm_db) / 20.0) / pk)
85
- else:
86
- # bez normalizacji: dopasuj poziom (RMS) do oryginału + limiter bezpieczeństwa
87
- src, _ = sf.read(audio_path, dtype="float32")
88
- if getattr(src, "ndim", 1) > 1:
89
- src = src.mean(axis=1)
90
- r_in = float(np.sqrt((src ** 2).mean())) + 1e-9
91
- r_out = float(np.sqrt((wav ** 2).mean())) + 1e-9
92
- wav = wav * (r_in / r_out)
93
- pk = float(np.abs(wav).max()) or 1.0
94
- if pk > 0.99:
95
- wav = wav / pk * 0.99
96
  base = os.path.splitext(os.path.basename(audio_path))[0]
97
  out_path = os.path.join(tempfile.gettempdir(), f"{base}_48k.wav")
98
- sf.write(out_path, wav.astype(np.float32), OUTPUT_SR)
99
-
100
- html = viz.spectrum_html([(audio_path, "wejście", CYAN), (out_path, "wyjście", ORANGE)],
101
- "Widmo: wejście vs wyjście — odbudowane wierzchołki")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  progress(1.0, desc="Gotowe")
103
  return (gr.Audio(value=out_path, visible=True),
104
  gr.DownloadButton(value=out_path, visible=True, interactive=True),
@@ -146,21 +169,15 @@ with gr.Blocks(title="AudioSR · Lektor AI", css=CSS, theme=gr.themes.Base()) as
146
  gr.HTML(MENU)
147
  audio = gr.Audio(sources=["upload"], type="filepath", elem_id="ain", label="Próbka — wrzuć plik (krótki klip)")
148
  plot = gr.HTML(HINT, elem_id="plot")
149
- with gr.Row():
150
- model = gr.Radio(["basic", "speech"], value="basic", label="Model", scale=2)
151
- steps = gr.Slider(10, 100, value=24, step=1, label="DDIM steps", scale=3)
152
- with gr.Row():
153
- guid = gr.Slider(1.0, 10.0, value=3.5, step=0.5, label="Guidance", scale=3)
154
- seed = gr.Number(value=0, label="Seed (0=losowy)", scale=2)
155
- with gr.Row():
156
- norm = gr.Checkbox(value=True, label="Normalizuj jak Audacity (peak + DC offset)", scale=3)
157
- norm_db = gr.Slider(-6.0, 0.0, value=-1.0, step=0.5, label="Docelowy peak (dB)", scale=2)
158
  btn = gr.Button("WZBOGAĆ", variant="primary", elem_id="go")
159
  out = gr.Audio(type="filepath", label="Wynik — 48 kHz", interactive=False, elem_id="aout", visible=False)
160
  dl = gr.DownloadButton("Pobierz WAV", elem_id="dl", interactive=False, visible=False)
161
 
162
  audio.change(on_audio, audio, plot)
163
- btn.click(enhance, [audio, model, steps, guid, seed, norm, norm_db], [out, dl, plot])
164
 
165
  if __name__ == "__main__":
166
  demo.queue(max_size=8).launch()
 
1
  """AudioSR — wzbogacanie krótkich próbek głosu (HF Space, CPU/DARMOWY). UI Lektor AI.
2
  Wejście: gr.Audio (upload) → BEZPOŚREDNIO do enhance (bez State — pewne okablowanie).
3
  Zamiast pustej nuty: WYKRES WIDMA (wierzchołki/góra pasma) jako <img>. Pasek postępu + ETA.
4
+ CPU: 1 klip ≈ ~2 min. Model „basic", domyślnie 24 kroki.
5
+
6
+ Tryb NO-FIDDLE: backend jest w pełni ustawiony (model='basic', guidance=3.5, seed=42,
7
+ latent_t_per_second=12.8). UŻYTKOWNIK steruje TYLKO dwoma rzeczami:
8
+ • suwak DDIM steps (jakość vs czas),
9
+ • przełącznik „Normalizuj" (włącza dwustopniową normalizację LUFS → sufit szczytu).
10
+ Całe czyszczenie + normalizacja (force-mono, DC, high-pass 75 Hz, LUFS -18, sufit -1 dB)
11
+ żyje w postproc.py (czysty DSP, testowalny lokalnie). app.py tylko orkiestruje."""
12
  import os
13
  import time
14
  import threading
 
19
  import torch
20
  from audiosr import build_model, super_resolution
21
  import viz
22
+ import postproc
23
 
24
  DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
25
  OUTPUT_SR = 48000
26
+
27
+ # ── BACKEND HARDCODE (user tego NIE rusza) ───────────────────────────────────
28
+ MODEL_NAME = "basic" # „basic" — sprawdzony do tego głosu (vs „speech")
29
+ GUIDANCE_SCALE = 3.5 # classifier-free guidance — stały punkt pracy
30
+ SEED = 42 # stały seed → powtarzalny wynik dla tej samej próbki
31
+ LATENT_T_PER_SECOND = 12.8 # rozdzielczość czasowa latentu AudioSR (zalecana)
32
+
33
  CYAN, ORANGE, MUTED = "#00ffff", "#ff9900", "#8a8aa0"
34
  HINT = ("<div style='color:#8a8aa0;font-size:12px;text-align:center;padding:10px'>"
35
  "Wrzuć próbkę, aby zobaczyć widmo (wierzchołki / góra pasma)</div>")
 
52
  return f"<div style='color:#ff5a5a;font-size:12px;padding:8px'>Nie udało się narysować widma: {e}</div>"
53
 
54
 
55
+ def enhance(audio_path, ddim_steps, norm, progress=gr.Progress()):
56
  if not audio_path:
57
  raise gr.Error("Najpierw wrzuć plik audio.")
 
 
 
58
 
59
  progress(0.03, desc="Ładowanie modelu (pierwszy raz: pobieranie ~GB)…")
60
+ mdl = _model(MODEL_NAME)
61
 
62
  res = {}
63
  def work():
64
  try:
65
+ res["w"] = super_resolution(mdl, audio_path, seed=SEED,
66
+ guidance_scale=GUIDANCE_SCALE,
67
+ ddim_steps=int(ddim_steps),
68
+ latent_t_per_second=LATENT_T_PER_SECOND)
69
  except Exception as e:
70
  res["err"] = e
71
 
 
86
  wav = np.asarray(res["w"]).squeeze()
87
  if wav.ndim > 1:
88
  wav = wav.T
89
+ # Przytnij padding AudioSR do długości wejścia (frames * OUTPUT_SR / src_sr).
90
+ # UWAGA: dla mp3 (główne wejście, „spłaszczone mp3") sf.info().frames bywa
91
+ # PRZYBLIŻONE lub 0 na niektórych buildach libsndfile → tnij TYLKO gdy wynik jest
92
+ # WYRAŹNIE dłuższy (>1%), żeby nie ściąć kilku ważnych próbek ogona; przy frames=0
93
+ # warunek `exp` i tak pomija trim.
94
  info = sf.info(audio_path)
95
  exp = int(round(info.frames / info.samplerate * OUTPUT_SR))
96
+ if exp and wav.shape[0] > int(exp * 1.01):
97
  wav = wav[:exp]
98
+
99
+ # Czyszczenie + normalizacja → JEDNO wejście postproc (czysty DSP, testowalny).
100
+ # norm=True : mono → DC → high-pass 75 Hz → LUFS -18 → sufit szczytu -1 dB.
101
+ # norm=False : mono → dopasowanie RMS do oryginału + bezpiecznik 0.99.
102
+ wav, _pp = postproc.process(wav, OUTPUT_SR, audio_path, normalize=bool(norm))
103
+
 
 
 
 
 
 
 
 
 
 
104
  base = os.path.splitext(os.path.basename(audio_path))[0]
105
  out_path = os.path.join(tempfile.gettempdir(), f"{base}_48k.wav")
106
+ # Jawny subtype: WAV 16-bit PCM (w spec agy: „16-bit PCM lub 32-bit float").
107
+ # Sufit szczytu -1 dB istnieje WŁAŚNIE dlatego, że kontener obcina na ±1.0 —
108
+ # nie rozluźniaj go zakładając, że plik jest float.
109
+ sf.write(out_path, wav.astype(np.float32), OUTPUT_SR, subtype="PCM_16")
110
+
111
+ # Anty-silent-skip: jeśli przy normalizacji cel LUFS -18 NIE został osiągnięty
112
+ # (pojedynczy głośny transjent — plozja/klik — wymusił zejście sufitem szczytu),
113
+ # POWIEDZ to wprost zamiast cicho udawać sukces.
114
+ warn = ""
115
+ short_db = _pp.get("lufs_shortfall_db", 0.0) if isinstance(_pp, dict) else 0.0
116
+ if bool(norm) and short_db > 3.0:
117
+ warn = ("<div style='color:#ff9900;font-size:12px;padding:8px;border:1px solid #ff9900;"
118
+ "border-radius:8px;margin-bottom:8px'>Uwaga: docelowy poziom LUFS -18 nie został "
119
+ f"w pełni osiągnięty (brak ~{short_db:.1f} dB) — pojedynczy głośny transjent "
120
+ "(plozja/klik) wymusił zejście sufitem szczytu -1 dB. Rozważ ręczne wyciszenie "
121
+ "tego transjentu w Audacity i ponów.</div>")
122
+
123
+ html = warn + viz.spectrum_html([(audio_path, "wejście", CYAN), (out_path, "wyjście", ORANGE)],
124
+ "Widmo: wejście vs wyjście — odbudowane wierzchołki")
125
  progress(1.0, desc="Gotowe")
126
  return (gr.Audio(value=out_path, visible=True),
127
  gr.DownloadButton(value=out_path, visible=True, interactive=True),
 
169
  gr.HTML(MENU)
170
  audio = gr.Audio(sources=["upload"], type="filepath", elem_id="ain", label="Próbka — wrzuć plik (krótki klip)")
171
  plot = gr.HTML(HINT, elem_id="plot")
172
+ # TYLKO dwie kontrolki: DDIM steps + przełącznik normalizacji. Reszta = backend.
173
+ steps = gr.Slider(10, 100, value=24, step=1, label="DDIM steps (więcej = lepiej / wolniej)")
174
+ norm = gr.Checkbox(value=True, label="Normalizuj (LUFS -18 + sufit szczytu -1 dB, mono, high-pass 75 Hz)")
 
 
 
 
 
 
175
  btn = gr.Button("WZBOGAĆ", variant="primary", elem_id="go")
176
  out = gr.Audio(type="filepath", label="Wynik — 48 kHz", interactive=False, elem_id="aout", visible=False)
177
  dl = gr.DownloadButton("Pobierz WAV", elem_id="dl", interactive=False, visible=False)
178
 
179
  audio.change(on_audio, audio, plot)
180
+ btn.click(enhance, [audio, steps, norm], [out, dl, plot])
181
 
182
  if __name__ == "__main__":
183
  demo.queue(max_size=8).launch()
postproc.py ADDED
@@ -0,0 +1,409 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """postproc.py — czyszczenie + normalizacja próbek głosu PO AudioSR.
2
+
3
+ Czysty DSP: numpy / scipy / soundfile / pyloudnorm. BEZ gradio → moduł da się
4
+ testować lokalnie (jak viz.py). app.py woła JEDNO wejście: process(...).
5
+
6
+ Kontekst: próbki służą jako REFERENCJA do klonowania głosu (RVC/Applio + zero-shot
7
+ TTS OmniVoice). Kolejność całego pipeline'u (potwierdzona przez red-team Gemini 3.1 Pro):
8
+ (opcjonalny zewnętrzny UVR5 de-reverb) → AudioSR (super-rozdzielczość) →
9
+ czyszczenie w stylu Audacity → normalizacja.
10
+ AudioSR MUSI poprzedzać normalizację (normalizacja przed super-res wstrzykuje
11
+ artefakty w wierzchołki). Ten moduł obsługuje DWA ostatnie etapy: czyszczenie +
12
+ DWUSTOPNIOWĄ normalizację (LUFS → sufit szczytu), na sygnale już po AudioSR.
13
+
14
+ ────────────────────────────────────────────────────────────────────────────
15
+ CO ROBIMY, gdy normalizacja WŁĄCZONA (kolejność istotna):
16
+ 1. force-mono — embeddery (hubert / contentvec / spin-v2) oczekują mono;
17
+ stereo grozi kasowaniem fazy (phase cancellation).
18
+ 2. usunięcie DC — odjęcie składowej stałej (średniej) przed filtrem.
19
+ 3. high-pass ~75 Hz — Butterworth rz. 2, ZERO-PHASE (filtfilt) → usuwa rumble/
20
+ szum sieciowy/resztki DC, NIE rusza barwy głosu. filtfilt
21
+ (dwukierunkowo) nie przesuwa fazy → brak rozmycia transjentów.
22
+ 4. (opcjonalnie) łagodny WARUNKOWY de-esser — patrz DEESS_ENABLED niżej.
23
+ 5. LUFS normalize — BS.1770-4 (pyloudnorm Meter, K-weighting) do stałego celu
24
+ (TARGET_LUFS = -18). Podnosi PERCEPCYJNĄ głośność/gęstość,
25
+ żeby model uczył się barwy także z cichszych fragmentów.
26
+ 6. sufit szczytu -1 dB — TYLKO redukcja wzmocnienia (gain-down), gdy szczyt > -1 dB.
27
+ ŻADNEJ kompresji/limitera/clipingu — czysta zmiana skali.
28
+
29
+ Gdy normalizacja WYŁĄCZONA: zachowanie jak dotychczas — dopasowanie poziomu RMS
30
+ wyjścia do oryginału + bezpieczny współczynnik 0.99 (bez high-passu / LUFS).
31
+
32
+ ────────────────────────────────────────────────────────────────────────────
33
+ ŚWIADOMIE POMINIĘTE (każde z UZASADNIENIEM — patrz komentarze przy stałych):
34
+ • ślepa spektralna redukcja szumu — RED-TEAM: #1 zabójca barwy; nie da się
35
+ wiarygodnie auto-profilować szumu w ciemno.
36
+ • ślepe wycięcie rezonansu EQ — szkodliwe na czystym, już-dobrym audio.
37
+ • UVR5 de-reverb — narzędzie ZEWNĘTRZNE, nie wbudowane w apkę.
38
+ • ręczne usuwanie klików/plozji — nieautomatyzowalne bezpiecznie (wymaga ucha).
39
+ • kompresja dynamiki / limiter — spłaszcza dynamikę → monotonny TTS.
40
+ • noise gate — zabija transjenty końców wyrazów.
41
+ • boost EQ — podbija sybilanty (syki).
42
+ Źródłem prawdy o jakości jest jakość NAGRANIA (cichy pokój) > każdy post-processing.
43
+ """
44
+ import numpy as np
45
+ import soundfile as sf
46
+
47
+ # scipy i pyloudnorm to zależności ścieżki ON (high-pass / LUFS). Import MIĘKKI:
48
+ # gdyby koło scipy zostało zbudowane pod inne ABI niż przypięty numpy==1.23.5
49
+ # (resolver drift), twardy import na poziomie modułu wywaliłby CAŁY Space na starcie
50
+ # (app.py importuje postproc BEZ osłony). Bez scipy degradujemy do „DC-only" (bez HPF),
51
+ # bez pyloudnorm do RMS-fallbacku LUFS — apka działa dalej, nie pada.
52
+ try:
53
+ from scipy.signal import butter, filtfilt, sosfiltfilt
54
+ _HAVE_SCIPY = True
55
+ except Exception: # pragma: no cover - środowisko bez scipy
56
+ butter = filtfilt = sosfiltfilt = None
57
+ _HAVE_SCIPY = False
58
+
59
+ try:
60
+ import pyloudnorm as pyln
61
+ _HAVE_PYLN = True
62
+ except Exception: # pragma: no cover - środowisko bez pyloudnorm
63
+ pyln = None
64
+ _HAVE_PYLN = False
65
+
66
+ # ── Parametry (HARDCODE — user nie ma nic kręcić) ────────────────────────────
67
+ HPF_HZ = 75.0 # high-pass ~75 Hz: między 60–100 Hz wg agy; zdejmuje rumble,
68
+ # nie wchodzi w pasmo głosu (F0 mowy ≳ 85 Hz nawet u basów).
69
+ HPF_ORDER = 2 # Butterworth rz. 2 (~12 dB/oct). filtfilt podwaja rząd
70
+ # efektywnie do ~4 → łagodny rolloff bez dzwonienia.
71
+ TARGET_LUFS = -18.0 # cel integrated loudness. RVC lubi -18..-20, referencja
72
+ # -19..-23; -18 to bezpieczny, gęsty wspólny punkt.
73
+ PEAK_CEIL_DB = -1.0 # sufit szczytu (true-ish peak na próbkach): -1 dB zapas.
74
+
75
+ # WARUNKOWY de-esser — DOMYŚLNIE WYŁĄCZONY.
76
+ # DECYZJA PANELU: w trybie no-fiddle, w pełni automatycznym, de-essing w ciemno
77
+ # jest ryzykowny — zbyt agresywny matuje spółgłoski (sz/cz/s), które AudioSR
78
+ # DOPIERO ODBUDOWAŁ (to cały sens super-rozdzielczości tu). agy dopuszcza go tylko
79
+ # „jeśli »s« jest ostre", a tego nie da się wiarygodnie ocenić bez ucha. Zostawiamy
80
+ # kod (zachowawczy: tnie 6–8 kHz o ≤3 dB TYLKO gdy pasmo jest GORĄCE względem
81
+ # reszty widma), ale wpięty za flagą — domyślnie OFF, by NIE dotykać barwy.
82
+ DEESS_ENABLED = False
83
+ DEESS_LO_HZ = 6000.0
84
+ DEESS_HI_HZ = 8000.0
85
+ DEESS_MAX_CUT_DB = 3.0 # nigdy nie tnij więcej niż 3 dB
86
+ DEESS_HOT_MARGIN_DB = 6.0 # „gorące" = pasmo s o ≥6 dB nad medianą reszty widma
87
+
88
+ _EPS = 1e-12
89
+
90
+
91
+ # ── Narzędzia podstawowe ─────────────────────────────────────────────────────
92
+ def sanitize(wav: np.ndarray) -> np.ndarray:
93
+ """Twardy strażnik NaN/Inf → 0 oraz gwarancja float32 contiguous 1-D-friendly.
94
+
95
+ Wołane na WEJŚCIU process() i po KAŻDYM etapie DSP. Bez tego pojedynczy NaN/Inf
96
+ w wyniku AudioSR (zdarza się przy dyfuzji na CPU) zatruwa np.mean (DC), filtfilt
97
+ rozsmarowuje go po całym sygnale, a sf.write zapisuje plik z NaN-ami (cisza/trzask).
98
+ nan_to_num zeruje też ±inf, więc dalsze obliczenia wzmocnienia są skończone."""
99
+ wav = np.asarray(wav, dtype=np.float32)
100
+ if not np.all(np.isfinite(wav)):
101
+ wav = np.nan_to_num(wav, nan=0.0, posinf=0.0, neginf=0.0).astype(np.float32)
102
+ return wav
103
+
104
+
105
+ def to_mono(wav: np.ndarray) -> np.ndarray:
106
+ """Wymuś mono float32 1-D. (N,C) lub (C,N) → uśrednienie kanałów.
107
+
108
+ Embeddery RVC oczekują mono; miks zamiast wyboru kanału unika utraty energii,
109
+ a praca na sygnale już zsumowanym wyklucza kasowanie fazy w dalszych krokach.
110
+ NaN/Inf czyszczone PRZED uśrednieniem — inaczej jeden zły kanał psuje mono."""
111
+ wav = sanitize(wav)
112
+ if wav.ndim == 1:
113
+ return wav
114
+ # heurystyka osi kanałów: kanałów jest mało (≤8), próbek dużo.
115
+ if wav.ndim == 2:
116
+ if wav.shape[0] <= 8 and wav.shape[0] < wav.shape[1]:
117
+ wav = wav.T # (C,N) → (N,C)
118
+ return sanitize(wav.mean(axis=1))
119
+ return sanitize(wav.reshape(-1))
120
+
121
+
122
+ def remove_dc(wav: np.ndarray) -> np.ndarray:
123
+ """Usuń składową stałą (DC offset) — odjęcie średniej."""
124
+ wav = np.asarray(wav, dtype=np.float32)
125
+ return (wav - float(np.mean(wav))).astype(np.float32)
126
+
127
+
128
+ def highpass(wav: np.ndarray, sr: int, cutoff_hz: float = HPF_HZ,
129
+ order: int = HPF_ORDER) -> np.ndarray:
130
+ """Zero-phase Butterworth high-pass (filtfilt).
131
+
132
+ filtfilt filtruje w przód i w tył → ZEROWE przesunięcie fazy (brak rozmycia
133
+ transjentów spółgłosek) kosztem podwojenia efektywnego rzędu. Współczynniki
134
+ liczone w SOS (drugie sekcje) → stabilne numerycznie przy fs=48000."""
135
+ if not _HAVE_SCIPY:
136
+ return sanitize(wav) # bez scipy: pomiń HPF (degradacja, NIE crash)
137
+ wav = sanitize(wav).astype(np.float64) # filtfilt liczy w float64 dla stabilności
138
+ nyq = 0.5 * sr
139
+ wn = cutoff_hz / nyq
140
+ if not (0.0 < wn < 1.0):
141
+ return wav.astype(np.float32)
142
+ # filtfilt wymaga sygnału dłuższego niż „padlen". Dla SOS scipy domyślnie bierze
143
+ # padlen = 3*(2*n_sekcji+1); dla krótkich klipów dobierz mniejszy, a w ostateczności
144
+ # pomiń filtr, żeby nie wywalić przetwarzania na ułamkowo-sekundowej próbce.
145
+ sos = butter(order, wn, btype="highpass", output="sos")
146
+ default_padlen = 3 * (2 * sos.shape[0] + 1) # ZGODNY z domyślnym padlen scipy dla SOS
147
+ if wav.shape[0] <= default_padlen:
148
+ padlen = max(0, wav.shape[0] - 1)
149
+ if padlen < 6: # zbyt krótko, by sensownie filtrować
150
+ return wav.astype(np.float32)
151
+ try:
152
+ return sanitize(sosfiltfilt(sos, wav, padlen=padlen))
153
+ except Exception:
154
+ return wav.astype(np.float32)
155
+ return sanitize(sosfiltfilt(sos, wav))
156
+
157
+
158
+ # ── WARUNKOWY de-esser (domyślnie OFF — patrz DEESS_ENABLED) ─────────────────
159
+ def _band_db_above_rest(wav: np.ndarray, sr: int, lo: float, hi: float) -> float:
160
+ """O ile dB pasmo [lo,hi] góruje nad MEDIANĄ reszty widma (miara „gorącości")."""
161
+ n = 1 << int(np.ceil(np.log2(max(len(wav), 2))))
162
+ n = min(n, 1 << 16)
163
+ seg = wav[:n] if len(wav) >= n else np.pad(wav, (0, n - len(wav)))
164
+ mag = np.abs(np.fft.rfft(seg * np.hanning(len(seg))))
165
+ f = np.fft.rfftfreq(len(seg), 1.0 / sr)
166
+ band = (f >= lo) & (f <= hi)
167
+ rest = (f > 200.0) & ~band
168
+ if not band.any() or not rest.any():
169
+ return 0.0
170
+ band_e = float(np.sqrt(np.mean(mag[band] ** 2)) + _EPS)
171
+ rest_e = float(np.median(mag[rest]) + _EPS)
172
+ return 20.0 * np.log10(band_e / rest_e)
173
+
174
+
175
+ def conditional_deesser(wav: np.ndarray, sr: int) -> np.ndarray:
176
+ """Tnij 6–8 kHz o ≤3 dB TYLKO gdy pasmo jest wyraźnie gorące. Zachowawcze."""
177
+ if not DEESS_ENABLED or not _HAVE_SCIPY:
178
+ return wav
179
+ hot = _band_db_above_rest(wav, sr, DEESS_LO_HZ, DEESS_HI_HZ)
180
+ if hot < DEESS_HOT_MARGIN_DB:
181
+ return wav # nie gorące → nie ruszaj (nie psuj odbudowanej góry)
182
+ # ile ciąć: proporcjonalnie do przekroczenia, ale max DEESS_MAX_CUT_DB
183
+ cut_db = min(DEESS_MAX_CUT_DB, (hot - DEESS_HOT_MARGIN_DB) * 0.5 + 1.0)
184
+ nyq = 0.5 * sr
185
+ lo, hi = DEESS_LO_HZ / nyq, min(DEESS_HI_HZ, nyq * 0.99) / nyq
186
+ if not (0.0 < lo < hi < 1.0):
187
+ return wav
188
+ b, a = butter(2, [lo, hi], btype="bandstop") # wycina pasmo
189
+ # bandstop usuwa pasmo całkowicie; by uzyskać DELIKATNY cut o cut_db, mieszamy
190
+ # sygnał oryginalny z przefiltrowanym (notch) proporcjonalnie.
191
+ notched = filtfilt(b, a, wav.astype(np.float64))
192
+ g = 10.0 ** (-cut_db / 20.0) # docelowa amplituda pasma s
193
+ mix = g * wav + (1.0 - g) * notched
194
+ return mix.astype(np.float32)
195
+
196
+
197
+ # ── Pomiar głośności (LUFS) z odpornym fallbackiem ───────────────────────────
198
+ def measure_lufs(wav: np.ndarray, sr: int):
199
+ """Integrated loudness wg BS.1770-4 (pyloudnorm). Zwraca (lufs, method).
200
+
201
+ Odporność:
202
+ • pyloudnorm.Meter wymaga sygnału dłuższego niż block_size (domyślnie 400 ms)
203
+ — dla krótszych klipów próbujemy zmniejszać block_size (do ~50 ms).
204
+ • cisza/near-silence → integrated_loudness = -inf → traktujemy jak brak
205
+ sygnału i sygnalizujemy fallbackiem (gain nie zostanie policzony z -inf).
206
+ • brak pyloudnorm w środowisku → RMS-fallback.
207
+ """
208
+ wav = np.asarray(wav, dtype=np.float64)
209
+ if _HAVE_PYLN and wav.size:
210
+ dur = wav.shape[0] / float(sr)
211
+ # dobierz block_size: domyślnie 0.4 s; dla krótkich klipów zmniejsz tak,
212
+ # by zmieścić co najmniej jeden pełny blok.
213
+ for block in (0.4, 0.2, 0.1, 0.05):
214
+ if dur <= block:
215
+ continue
216
+ try:
217
+ meter = pyln.Meter(sr, block_size=block)
218
+ lufs = float(meter.integrated_loudness(wav))
219
+ if np.isfinite(lufs):
220
+ return lufs, ("bs1770" if block == 0.4 else f"bs1770@{block:g}s")
221
+ except Exception:
222
+ continue
223
+ # ── Fallback RMS → przybliżony LUFS ──────────────────────────────────────
224
+ # Dla mono, bez K-weightingu, BS.1770 sprowadza się do:
225
+ # loudness ≈ -0.691 + 10*log10(mean(x^2)) (offset bramki absolutnej -70 LKFS
226
+ # pomijamy — dla pojedynczej próbki mowy nie ma ciszy do zbramkowania).
227
+ ms = float(np.mean(wav ** 2))
228
+ if ms <= _EPS:
229
+ return float("-inf"), "silence"
230
+ lufs = -0.691 + 10.0 * np.log10(ms)
231
+ return lufs, "rms-fallback"
232
+
233
+
234
+ def lufs_normalize(wav: np.ndarray, sr: int, target_lufs: float = TARGET_LUFS):
235
+ """Skaluj sygnał do docelowego LUFS (stała zmiana wzmocnienia). (wav, info)."""
236
+ wav = np.asarray(wav, dtype=np.float32)
237
+ lufs, method = measure_lufs(wav, sr)
238
+ if not np.isfinite(lufs):
239
+ # cisza / brak sygnału → nie ruszaj (uniknij gain = +inf)
240
+ return wav, {"in_lufs": lufs, "out_lufs": lufs, "gain_db": 0.0, "method": method}
241
+ gain_db = target_lufs - lufs
242
+ gain = 10.0 ** (gain_db / 20.0)
243
+ out = (wav * gain).astype(np.float32)
244
+ return out, {"in_lufs": lufs, "out_lufs": target_lufs, "gain_db": gain_db, "method": method}
245
+
246
+
247
+ # ── Sufit szczytu: WYŁĄCZNIE redukcja wzmocnienia (bez limitera) ─────────────
248
+ def peak_ceiling(wav: np.ndarray, ceil_db: float = PEAK_CEIL_DB):
249
+ """Zejdź wzmocnieniem tak, by szczyt = ceil_db, ALE TYLKO gdy szczyt jest WYŻEJ.
250
+
251
+ To czysta zmiana skali całego sygnału (jeden mnożnik), nigdy nie zniekształca
252
+ kształtu fali → ZERO kompresji/limitera/clipingu (te myliłyby embeddery).
253
+ Jeśli szczyt jest już poniżej sufitu — NIE podbijamy (LUFS już ustawił poziom)."""
254
+ wav = np.asarray(wav, dtype=np.float32)
255
+ peak = float(np.max(np.abs(wav))) if wav.size else 0.0
256
+ if peak <= _EPS:
257
+ return wav, {"peak_db_in": float("-inf"), "peak_db_out": float("-inf"), "gain_db": 0.0}
258
+ ceil_lin = 10.0 ** (ceil_db / 20.0)
259
+ peak_db_in = 20.0 * np.log10(peak)
260
+ if peak <= ceil_lin:
261
+ return wav, {"peak_db_in": peak_db_in, "peak_db_out": peak_db_in, "gain_db": 0.0}
262
+ g = ceil_lin / peak
263
+ out = (wav * g).astype(np.float32)
264
+ return out, {"peak_db_in": peak_db_in, "peak_db_out": ceil_db, "gain_db": 20.0 * np.log10(g)}
265
+
266
+
267
+ # ── Ścieżka OFF: dopasowanie RMS do oryginału (zachowanie dotychczasowe) ──────
268
+ def rms_match(wav: np.ndarray, src_path: str, safety: float = 0.99):
269
+ """Dopasuj RMS wyjścia do oryginału + bezpiecznik szczytu 0.99 (bez HPF/LUFS)."""
270
+ wav = np.asarray(wav, dtype=np.float32)
271
+ try:
272
+ src, _ = sf.read(src_path, dtype="float32")
273
+ src = to_mono(src)
274
+ r_in = float(np.sqrt(np.mean(src ** 2))) + 1e-9
275
+ r_out = float(np.sqrt(np.mean(wav ** 2))) + 1e-9
276
+ wav = wav * (r_in / r_out)
277
+ except Exception:
278
+ pass # gdy nie da się odczytać źródła — zostaw poziom, zadziała tylko bezpiecznik
279
+ pk = float(np.max(np.abs(wav))) if wav.size else 0.0
280
+ if pk > safety:
281
+ wav = wav / pk * safety
282
+ return wav.astype(np.float32)
283
+
284
+
285
+ # ── JEDYNE wejście, które woła app.py ────────────────────────────────────────
286
+ def process(wav: np.ndarray, sr: int, src_path: str, normalize: bool):
287
+ """Przetwórz sygnał PO AudioSR. Zwraca (wav_float32_mono, info: dict).
288
+
289
+ normalize=True : mono → DC → high-pass 75 Hz → [de-ess warunkowo] →
290
+ LUFS -18 → sufit szczytu -1 dB (gain-down only).
291
+ normalize=False: mono → dopasowanie RMS do src + bezpiecznik 0.99.
292
+
293
+ Zawsze zwraca mono (embeddery RVC). Próbkowanie zostaje sr (app pisze 48 kHz);
294
+ RVC sam zresampluje do 32k/40k w preprocesie Applio — nie schodzimy w dół tu.
295
+ """
296
+ wav = to_mono(wav)
297
+ info = {"normalize": bool(normalize), "sr": int(sr), "mono": True}
298
+
299
+ if not normalize:
300
+ out = sanitize(rms_match(wav, src_path))
301
+ info["path"] = "off/rms-match"
302
+ info["peak_db_out"] = (20.0 * np.log10(float(np.max(np.abs(out))) + _EPS)
303
+ if out.size else float("-inf"))
304
+ return out, info
305
+
306
+ out = remove_dc(wav)
307
+ out = highpass(out, sr)
308
+ out = conditional_deesser(out, sr) # no-op gdy DEESS_ENABLED=False
309
+ out, lu = lufs_normalize(out, sr)
310
+ out, pk = peak_ceiling(out)
311
+ out = sanitize(out) # ostateczny strażnik przed sf.write
312
+ # PRAWDZIWY osiągnięty LUFS: sufit szczytu działa PO LUFS i przy pojedynczym
313
+ # głośnym transjencie (plozja/klik) może zejść wzmocnieniem tyle, że cel -18 NIE
314
+ # zostaje dotrzymany. Zmierz po fakcie i policz braki — app.py to UJAWNI userowi
315
+ # (zamiast cicho udawać, że -18 wszedł). Patrz: dyscyplina „diagnose-silent-skip".
316
+ out_lufs_actual, _m = measure_lufs(out, sr)
317
+ lu["out_lufs_actual"] = out_lufs_actual
318
+ info["path"] = "on/dc+hpf+lufs+peak"
319
+ info["deess"] = DEESS_ENABLED
320
+ info["lufs"] = lu
321
+ info["peak"] = pk
322
+ info["lufs_shortfall_db"] = (float(TARGET_LUFS - out_lufs_actual)
323
+ if np.isfinite(out_lufs_actual) else 0.0)
324
+ return out, info
325
+
326
+
327
+ # ── Samotest (uruchom: python postproc.py) ───────────────────────────────────
328
+ if __name__ == "__main__":
329
+ sr = 48000
330
+ rng = np.random.default_rng(42)
331
+ t = np.arange(int(2.0 * sr)) / sr
332
+ # ton 200 Hz + lekki szum + CELOWY DC offset, by udowodnić jego usunięcie
333
+ tone = 0.20 * np.sin(2 * np.pi * 200 * t)
334
+ noise = 0.01 * rng.standard_normal(t.shape).astype(np.float32)
335
+ rumble = 0.05 * np.sin(2 * np.pi * 35 * t) # podziemny rumble < 75 Hz (HPF go zdejmie)
336
+ dc = 0.03
337
+ x = (tone + noise + rumble + dc).astype(np.float32)
338
+
339
+ print("=== WEJSCIE ===")
340
+ li, mi = measure_lufs(x, sr)
341
+ print(f"DC offset in : {np.mean(x):+.5f}")
342
+ print(f"peak in : {20*np.log10(np.max(np.abs(x))+_EPS):+.2f} dBFS")
343
+ print(f"LUFS in : {li:.2f} ({mi})")
344
+
345
+ out, info = process(x, sr, src_path="<none>", normalize=True)
346
+ lo, mo = measure_lufs(out, sr)
347
+ print("\n=== WYJSCIE (normalize=ON) ===")
348
+ print(f"DC offset out: {np.mean(out):+.5f} (ma byc ~0)")
349
+ print(f"peak out : {20*np.log10(np.max(np.abs(out))+_EPS):+.2f} dBFS (cel <= {PEAK_CEIL_DB:+.1f})")
350
+ print(f"LUFS out : {lo:.2f} (cel {TARGET_LUFS:+.1f}, {mo})")
351
+ print(f"HPF gain @35Hz: rumble tłumiony — RMS in={np.sqrt(np.mean(x**2)):.4f} "
352
+ f"out={np.sqrt(np.mean(out**2)):.4f}")
353
+ print(f"info: {info}")
354
+
355
+ # asercje poprawności DSP
356
+ assert abs(float(np.mean(out))) < 1e-3, "DC nie usunięte"
357
+ assert 20*np.log10(np.max(np.abs(out))+_EPS) <= PEAK_CEIL_DB + 0.1, "szczyt przekracza sufit"
358
+ if np.isfinite(lo):
359
+ assert abs(lo - TARGET_LUFS) < 1.5, f"LUFS poza celem: {lo}"
360
+ print("\nOK: DC usunięte, szczyt pod sufitem, LUFS w celu.")
361
+
362
+ # ── Matryca przypadków brzegowych: ŻADEN nie może wywalić procesu ani zwrócić
363
+ # NaN/Inf/stereo. Każdy musi zwrócić mono float32, skończony, szczyt pod sufitem.
364
+ import os
365
+ import tempfile
366
+
367
+ def synth(dur, level=0.2, dc=0.0, stereo=False, freq=220.0):
368
+ tt = np.arange(int(sr * dur)) / sr
369
+ s = 0.7 * np.sin(2 * np.pi * freq * tt) + 0.3 * rng.standard_normal(tt.size)
370
+ s = (s / (np.max(np.abs(s)) + 1e-9) * level + dc).astype(np.float32)
371
+ return np.stack([s, s * 0.9], axis=1) if stereo else s
372
+
373
+ # plik źródłowy dla ścieżki OFF (rms_match czyta src_path)
374
+ src_path = os.path.join(tempfile.gettempdir(), "_postproc_selftest_src.wav")
375
+ sf.write(src_path, synth(2.0, 0.15), sr)
376
+
377
+ bad = synth(1.0)
378
+ bad[10], bad[20], bad[30] = np.nan, np.inf, -np.inf
379
+
380
+ cases = [
381
+ ("stereo 2.0s", synth(2.0, 0.2, stereo=True), True, "<none>"),
382
+ ("near-silent 1.0s", synth(1.0, 1e-7), True, "<none>"),
383
+ ("cisza 1.0s", np.zeros(sr, np.float32), True, "<none>"),
384
+ ("już-przesterowany x3", synth(1.0, 1.0) * 3.0, True, "<none>"),
385
+ ("NaN/Inf 1.0s", bad, True, "<none>"),
386
+ ("ultra-krótki 10ms", synth(0.01, 0.2), True, "<none>"),
387
+ ("krótki 200ms", synth(0.2, 0.2), True, "<none>"),
388
+ ("OFF-path 2.0s", synth(2.0, 0.2, dc=0.05), False, src_path),
389
+ ("OFF przesterowany x3", synth(1.0, 1.0) * 3.0, False, src_path),
390
+ ("OFF zły src 2.0s", synth(2.0, 0.2), False, "<brak>"),
391
+ ]
392
+ print("\n=== MATRYCA PRZYPADKÓW BRZEGOWYCH ===")
393
+ all_ok = True
394
+ for name, w, norm, sp in cases:
395
+ out, inf = process(w, sr, src_path=sp, normalize=norm)
396
+ finite = bool(np.all(np.isfinite(out)))
397
+ mono = out.ndim == 1
398
+ f32 = out.dtype == np.float32
399
+ peak = float(np.max(np.abs(out))) if out.size else 0.0
400
+ ceil = 10.0 ** (PEAK_CEIL_DB / 20.0) if norm else 0.99
401
+ within = peak <= ceil + 1e-3
402
+ good = finite and mono and f32 and within
403
+ all_ok = all_ok and good
404
+ print(f" {'OK ' if good else '!! '}{name:22s} finite={finite} mono={mono} "
405
+ f"f32={f32} peak={peak:.4f} within={within} "
406
+ f"method={inf.get('lufs', {}).get('method', inf.get('path'))}")
407
+ assert good, f"przypadek brzegowy zawiódł: {name}"
408
+
409
+ print("\nWSZYSTKIE PRZYPADKI BRZEGOWE:", "PASS" if all_ok else "FAIL")
requirements.txt CHANGED
@@ -8,3 +8,5 @@ soundfile
8
  matplotlib
9
  librosa
10
  huggingface_hub
 
 
 
8
  matplotlib
9
  librosa
10
  huggingface_hub
11
+ scipy==1.10.1
12
+ pyloudnorm==0.1.1