Spaces:
Running
Running
No-fiddle: zaszyty łańcuch agy (mono+HPF75+LUFS-18+peak-1dB) w postproc.py; front=kroki+normalizacja; piny scipy/pyloudnorm
Browse files- README.md +89 -6
- app.py +57 -40
- postproc.py +409 -0
- 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 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
| 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,
|
| 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(
|
| 49 |
|
| 50 |
res = {}
|
| 51 |
def work():
|
| 52 |
try:
|
| 53 |
-
res["w"] = super_resolution(mdl, audio_path, seed=
|
| 54 |
-
guidance_scale=
|
| 55 |
-
ddim_steps=int(ddim_steps),
|
|
|
|
| 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 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 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 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 150 |
-
|
| 151 |
-
|
| 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,
|
| 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
|