From c2126f20459931fc70d6c3043430b35540919503 Mon Sep 17 00:00:00 2001 From: VoiceGrab Date: Tue, 25 Aug 2026 20:19:18 -0600 Subject: [PATCH] Add AI voice separation (Demucs) as toggleable mode, v1.1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the GPU-upgrade.md plan: - Separator backend interface (DSSeparator = existing noisereduce path, DemucsSeparator = htdemucs_ft AI separation) — one mode at a time, never stacked - DemucsSeparator: lazy imports (core app stays dep-free), device auto-pick cuda -> mps -> cpu, model cache under appdata/models, chunk progress reporting, soft cancel between chunks - GUI: 'Isolate voice' checkbox replaced by a 3-way mode selector (fast DSP / AI Demucs / none), DSP strength controls follow the selection, AI radio disabled with guidance when torch+demucs are missing, Cancel button for in-flight AI runs - CLI: --mode none|dsp|ai (default dsp, unchanged behavior) - requirements-ai.txt (torch 2.x + demucs 4.1.x), THIRD_PARTY.md, README updates, optional AI pack flags in installer scripts, version bumped to 1.1.0 - tests/test_separators.py: 10 tests incl. graceful-degradation and full-pipeline regression (5.0 s ± 0.15 s, ~320 kbps) in all modes --- .gitignore | 6 + README.md | 80 +++++- THIRD_PARTY.md | 27 ++ __pycache__/voicegrab.cpython-314.pyc | Bin 42121 -> 0 bytes installer/VoiceGrab.iss | 2 +- installer/build_linux.sh | 9 + installer/build_win.ps1 | 18 +- requirements-ai.txt | 19 ++ tests/test_separators.py | 252 +++++++++++++++++ voicegrab.py | 383 +++++++++++++++++++++++--- 10 files changed, 741 insertions(+), 55 deletions(-) create mode 100644 .gitignore create mode 100644 THIRD_PARTY.md delete mode 100644 __pycache__/voicegrab.cpython-314.pyc create mode 100644 requirements-ai.txt create mode 100644 tests/test_separators.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fbacb9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.venv/ +__pycache__/ +*.pyc +dist/ +build/ +third_party/ diff --git a/README.md b/README.md index 865e5a2..9c2550b 100644 --- a/README.md +++ b/README.md @@ -6,25 +6,39 @@ Cut a time range out of any video file and export a clean, loudness-normalized ``` Voice / audio file: [ kage.mp4 ] [Open…] Start: 0:10.00 Stop: 0:35.00 [Load preview] [▶ Play range] -[x] Isolate voice (reduce background noise) strength: 80% +Voice processing (choose one): + (•) Fast noise reduction (CPU) — fan / hum / room tone; no downloads + ( ) AI voice separation (Demucs) — music & second speakers; GPU if available + ( ) No processing — just loudness-normalize + strength: 80% [x] stationary noise Output MP3: [ .../kage_voicegrab.mp3 ] [Browse…] ────────────────────────────────────────────────── -[ Export MP3 ] +[ Cancel ] [ Export MP3 ] ``` ## Features - Works with any container ffmpeg understands: mp4, mkv, mov, webm, avi, flv, m4a… -- **Isolate voice** checkbox: STFT-based noise reduction (runs on CPU, no - downloads, no GPU). Strength slider + a "stationary noise" mode for - constant fans/hum. +- **Voice processing — pick one mode** (toggle, mutually exclusive): + - **Fast noise reduction (CPU, default)** — STFT spectral gating via + `noisereduce`. Great for fan, hum, room tone. No downloads, no GPU; + strength slider + a "stationary noise" mode for constant fans/hum. + - **AI voice separation (Demucs)** — deep-learning 4-stem separation + (we keep the *vocals* stem). Handles background music and a second + speaker, which spectral gating cannot. Optional install; uses an + NVIDIA GPU (CUDA) or Apple GPU if present, otherwise CPU (slower). + - **No processing** — just loudness-normalize. - Export is **320 kbps MP3**, loudness-normalized to −16 LUFS (broadcast reference — good levels for voice-training datasets). - "▶ Play range" lets you audition the exact segment before saving. +- AI runs report chunk progress in the status bar and can be **cancelled** + between chunks. - Also a headless CLI (see below) for scripting/batch work. ## Requirements - Python 3.10+ - ffmpeg on your PATH (bundled automatically in installer builds) +- Optional, only for AI voice separation: `pip install -r requirements-ai.txt` + (pulls `torch` + `demucs`, ~1.5–2.5 GB with CUDA wheels) ## Run from source ```bash @@ -34,11 +48,44 @@ python3 -m venv .venv .venv/bin/python voicegrab.py ``` +## AI voice separation (optional — GPU recommended) +```bash +.venv/bin/pip install -r requirements-ai.txt +``` +- Pick **AI voice separation (Demucs)** in the GUI. The mode is disabled + (with an explanation) until `torch` + `demucs` are installed — the core + app never requires them. +- **First use downloads the `htdemucs_ft` model (~90 MB)** to the app data + dir (`/VoiceGrab/models/`) and caches it — later runs are + instant. GPU (NVIDIA CUDA / Apple) is used automatically when present, + otherwise CPU. +- Performance expectations (5 s → 60 s clip): + - GPU (≥ 6 GB VRAM): ~1–3 s → ~15–40 s + - CPU (8 cores): ~10–25 s → a few minutes +- **Cancel** stops an AI run at the next chunk boundary. Fast DSP runs + finish in seconds and are not cancellable (by design). +- Quality note: aggressive separation can thin sibilance/breath — for voice + *training* samples the fast DSP mode is usually enough; reach for AI mode + when there's music or a competing speaker. +- Licensing: Demucs code is MIT and the `htdemucs_ft` weights are MIT + (see `THIRD_PARTY.md`). + ## CLI mode ```bash -voicegrab.py --cli input.mp4 10 35 output.mp3 +voicegrab.py --cli input.mp4 10 35 output.mp3 # default: fast DSP +voicegrab.py --cli input.mp4 10 35 output.mp3 --mode dsp # fast noise reduction +voicegrab.py --cli input.mp4 10 35 output.mp3 --mode ai # Demucs (requirements-ai.txt) +voicegrab.py --cli input.mp4 10 35 output.mp3 --mode none # just loudness-normalize ``` +## Tests +```bash +.venv/bin/python tests/test_separators.py # or: .venv/bin/pytest tests/ +``` +Covers: DSP shape/dtype/NaN invariants, AI graceful degradation without +torch, device selection, AI unit separation (44.1 kHz output), and the full +CLI pipeline in all three modes (valid 320 kbps MP3, duration 5.0 s ± 0.15 s). + ## Windows installer (single .exe + installer) 1. `pip install pyinstaller` 2. `python voicegrab.py.spec` is not needed — run: @@ -55,17 +102,26 @@ voicegrab.py --cli input.mp4 10 35 output.mp3 (Copy `dist\VoiceGrab.exe` to `installer\` first — the script assumes `dist\VoiceGrab.exe` relative to the project root.) + To ship the AI mode inside the frozen exe (bigger, ~2 GB), install + `requirements-ai.txt` into the venv first and add + `--collect-all demucs --collect-all torch` to the PyInstaller command. + ## Linux - **AppImage**: `installer/build_linux.sh` (needs PyInstaller + `linuxdeploy` or `appimagetool` + your ffmpeg in PATH). - **Debian/Ubuntu**: just run the PyInstaller binary, or install ffmpeg via apt and run from source. A `.desktop` entry template is in `installer/`. - **Flatpak** is also a fine route if you want it in your store. +- AI mode: `WITH_AI=1 ./installer/build_linux.sh` bundles `demucs`/`torch` + into the binary (bigger build). ## Notes on voice-isolation quality -The built-in reducer is classic DSP (spectral gating / STFT noise -estimation) — great for fan, hum, light room tone. For music or heavy -background speech, a deep-learning model (e.g. Demucs / UVR) gives -better separation but costs ~1–2 GB of downloads and runs far slower -without a GPU. For voice *training* samples, the DSP route is usually -more than enough, and it keeps the installer small and startup instant. +- **Fast mode** is classic DSP (spectral gating / STFT noise estimation) — + great for fan, hum, light room tone; it can't remove music or a second + speaker. +- **AI mode** (Demucs `htdemucs_ft`) is a source separator — the right tool + for speech-over-music or competing voices. It's a product feature behind a + radio button, never a hard dependency: the core app stays small and starts + in < 2 s without `torch`/`demucs` installed. +- The AI path *replaces* the DSP path — one or the other, never stacked + (double-processing adds artifacts). diff --git a/THIRD_PARTY.md b/THIRD_PARTY.md new file mode 100644 index 0000000..d48146f --- /dev/null +++ b/THIRD_PARTY.md @@ -0,0 +1,27 @@ +# Third-Party Components + +## Core (always) + +| Component | License | Notes | +|---|---|---| +| PySide6 (Qt for Python) | LGPL v3 / GPL | GUI toolkit | +| numpy, scipy, soundfile | BSD / BSD-3 / 3-clause BSD | audio math & IO | +| noisereduce | Unlicense / MIT (see repo) | DSP voice isolation ("fast" mode) | +| ffmpeg / ffprobe | LGPL v2.1+ (GPL builds exist) | media I/O; bundled binary must match license | + +## Optional — AI voice separation (only if the user installs `requirements-ai.txt`) + +| Component | License | Notes | +|---|---|---| +| demucs 4.1.x (Meta/Facebook) | Code: MIT | source separation engine | +| PyTorch (torch 2.x) | BSD-3 | deep-learning runtime | +| htdemucs_ft weights (~90 MB) | **MIT** (per the model card of `adefossez/HTDemucs-ft` on Hugging Face) | downloaded on first use from Hugging Face, cached in the user data dir — never bundled | +| huggingface_hub | Apache-2.0 | model download plumbing | + +### License gate (project policy) + +We only use checkpoints whose license we can point to. Demucs code = MIT, +`htdemucs_ft` weights = MIT (model card). They are only ever *downloaded by* +the *user* on first use — never bundled by us. RoFormer / UVR community +checkpoints have mixed or research-only licensing and are therefore **not** +used. diff --git a/__pycache__/voicegrab.cpython-314.pyc b/__pycache__/voicegrab.cpython-314.pyc deleted file mode 100644 index 8e2cb3f89063d5e6d7b58a647b6c8b14b2fc784c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 42121 zcmeIbdw5&Nl_z*F9t1#u@An&gNFpS@Nr@Eoped4ii=sf9lx<23NP;9Hl7KG&Js8Jk z5>JPU(-9ThEhu(AK^u3Q+Rkq1*-Ts6&TizynKeM->&fS)plFY&V?T=piY{w~%`y2WpxlA#@ z-_s;<+&pLHJltu{${*qnsywPP3abvOJ!pLn9t@7N7Akr8qP2v>k+>@MFRfoU*DIE7KN@_dDag!K#R3FEk+PYqir&Y1(q*5P|UY#htSMAy6sqxf4R?l8R&$8Da+0&ZX8jk(pDRtkl z(i17vHzI|U=Q=5EN(w2oPRcRqz4AP@O&SyFxlUfYl1B=ylX6meuRI^FHDT*5=4W58;f;oYZRt-@guxfB!4i*2WBPSu`j zZPAF)p0-nF#I{NOJ%=^t+39INRk$UZZ@h%fUKz^YX3ewKv`VXT*Qs577ov$`9;bGm zYM0x*bDdF1G@q^3S~=gIQ{`yY9#&Qq&tNSi^*t9;Q8b8jspWDnt##IVYlF4X+H`7< zwb|NY-EQ4sZMC+YYN<;_>vu}6F9YVJe-ai$>9q3NPqmo1CV9P4s!Fy7PgN>)NMkj{ z(_1rlsyK-o&!tiFlvoQqg{MlF#_cuG1;dQ?Z9V#vWexLxWJ&Ve~9V;IAM za<*~Om``Gii#Z40m2hS3-_brXy=Axe%b4Opr(?{1K(LLPe>wk(dCcoE+sq!vjNL5Q zW+(0D3Bfson6r*?yVE@3n6{g3v*Tv_d6!e5lw;F&+pPImU$glvm1B0>W?a+uy5!_q zbDv}E3=*3ivn~`W7EnFoYN;{LoH<)#K4%}DsWIEmI`G?x-*t6$7Ub%;d(3W+4Taa^ z>2$HSAY#Vl#@pjAkHa}@n{GEd+|Fs6#~!WroWnC^o^?9hXtsUaJ4UI@)w53Xfxair zPQg6tm_#k(_G!0y)@~oSk5g4WVkfBa)y+)}<};%%ca3@4=^dZ7yWO?3PGQD2?U=KV zn>|i*ZQ~B}(I@x!vr(FKI>*fuD7W)av;iK$=9qQNPExr;wzGD;oH4ru`&ozmoSC)G zGlkKyPus^lC{P^RDaYj0H2x5iY~dqD+w82Q4?(* zHp=!84*m#PHk{!!JFqk z;WYe?kI7riVvl=>=ElgAoE8BzQ+qFfz&tlBb%`5}q5}?OiL#s|Ko&POB~713Do}Y= za>F2QMAxU6Ldvrb8*IGWAO|+-csHCX#YhWNdRB54_1yk$hk#jj3Kz_tDVxVU=A88a zINWBZS70m1ZLS{m&W=w54#sUBn|a(JV3E*Do3qcFN4<{eakr%o6T~9+Tl5j#q#bbK z5iolf+!6iAv7SSHo&Eh0mD3&3xNM%Ohz^C!+Ggw#oo&?3V)P>;v|KSo1~gZoehAck zq;R=LkX*>eFE(|%5SZtFrAz&)d-0&p9@bU8udBL~mU-#9i_Zo0MR$xTms&5jUh25m z@p{v>)~l`8I<9tnyDe1I5;ATNXtuMme?RCH2pCucExbUqs|b#)p$xQ`S4l4<_)6we zKIY&p_OpjrITFZ@Ab`PFG2jXCCBNvC;Y*GJ5h`tJR-OpF@eUf3VHvjiWO!2Y^wEIE zIjbrr4jzZ;YSLO9+Ha>MCToLmkcQ%>}tBc-8dIXB2#^;UzjWe);1T9W6Q`s_lj*KHSQ z5k?m+;dM7q__(-ZM7I@};H2f`u8Zh~9JAxjbMA<)v#+nabD%TAKOafzwh8APvvVcS z*B^C^3O3t&fnSIR<$Lf_PiVKe18ckx^}tvr}k9;oOHrR}?@`Bh9Y&Tk9fId|h+xV|%3 z-x=6v4b}ID(gqeZcl61ZbQg7(Oczay1FyG*^i}hncQi>~KJvvQi@dM4ojY;M@zBwA74++K`Z#*euShvf}TLSw_U zAv4z}Z&OkXEd(i@RmIQ;5G78t>SMfBBQK_n&>v2h@otclmbToZA}YBkwyeYple222 zn#(}PRB~1gQNqIn*UW(;#;6v9b}ius@Lr;KElC2Qhy4O-;^vYkCT3jr$vXRaJ9bVM z{`KEs4sI9)LKgxxnk^r;V1&)|lQuvB2$T~l;6;+y(sM_&V^g3NBf8`LJ%ZpAZtxK` zN@Y7!bj3s~8$q5J2^x4yRXyHjsF|E)8$;3=4dfAGh=p+}Jp*#0 z5ZOFG7LcER`-Ok|La6oNdxut9pS(U3cxF75YEKO0s`Y_9dtD$GTG5;_{K*arog9A; zXJ;(JQ=EqfgHv_7*Q2)b9t|C?E$WDIKf?=bRWqWq*P;bAJwk-@$Veo4WCZKpOEfbb za-Q|trlW7th?W-Io@vLdeb$NBIU^%DlX@IuBSe-vM!g=p`yonO*n`F}uNMI~Z4H0G z{amAGi-N_kS(X~S2Ri2OnGsm;NM}`BHTBdRtw39Zh|K7uBk15ov=h@#n`c9U+KRO9 zQh0P=Gk^z7i}eui%eu>+ezt}W@ygzcq|DKF!`j$}5uYdY9(}=aiK**h>NRun#=}l` z)DNrHnl#AO8LT?1?(}Y}zLiD*&czQ?XP6u0ANL$5m7j>xmfo#pj!9b<+iv8|QlTzm z`j%kpYF1-o%At+Pr5eUlbXLOv7t8h;P)(_<$u3ogN@3JkjGvlnr@&Ad8O1@`E_|x0 zJsZ*5X|I_}sdY~|&yCn;0abSQT$*@D-!twN7$G^Qu60h#WsKV=Y~E?lp4oGI8rx^j z>}edhp^l`E*<3i{+ee&U4>&FnjmLi86VVYb2QhO+T~6=xIH*|^$0T z?zI>r$!_l`5X2Z*9}%_SofT-!3*GQQeR{@GL_`bTfy0HAAZ*!Ma7U5{ZPQ*mTTzkZ zL$l-d^I|xyUoK5tZnPFGYLQx1Q(@Rs$J~G=ZnH#(rZW6ABK1`m!b=9JR}h%z?yB_K zL;Ri8{7bW6nZ1*e|I#U6?^5^jmerKvKxzGQPN40`djogXd`dt6QBj$He0kq}ohB`L zL47}&OUH(E<>|{$hjTUsb2f!?wuEz9?0+wbm}a$iq=<8ZiY zZ?I}_z}SKPmDN}fC~m!}Sw0&m+#5>mxYZxfAN)sDgm<61iH7)MXGP~G?ww8MeFf?t zYSZ>*tACiSMR?8frb7~2I>eEE)bhr%&{&heQPhEhqs3C!$1Qc52QbLQxzP}lETTK- z6wc6@o)*rA%l|YIxe4>r@=xK@J|=H*>@Ge0o7iK?%N{q~>~YJ@l8E>~gN%}+^9VYn zz4t|i4`Nmf9WdftpNtEX7lXjh68Y!SDC=HYu^0vkQ}9+@3dBl-7#KVagAglOjaHL2 z#hPkOv!)Mf>atwgVVUdGm*7;2bD-9mfq$9SEE(G*4JP%;M6=Se(;O4bfKUWKK#op? z<@yqxQZ*8GkqF|+I#!ZQJX^C-Zvwp1C*u;iQiW*GiDmA`;+ZLa5M;9@cWzhDdGI2~ zJm5V&#Ba2@aij%Pw582_=HQ%psB_R<-Pd)@+_}_wzI;1R4yDK z?;t#2s0*K}Yu)Zo)pgAi2zi`{av*Sa7AVO!7U3J|DMYj_Z4C_#pK5By+HDboYiwr3 z-MC}gKHxYBfDjI$ths_t@3_MW7BXumF={7lI9ZRkC;!jAjyr%6V54|SorrAV7#sO;km_US9JMrsNU3F*S(o~J$1<)HaEX-ZoZQWOtfS9*|*QX zb^h(=-+DgOdf+|n?bhDFj^lyCzEJ9kfd0g)F?Ig1vT;WVqkjp%I1bxvh)rfOJ~I7p z;!8f};L!mY52YbU#2gJe!x+DL42*&GN{8hrE_qlG<>EmfB)}>X_He22o@$J9@_RMj zC*bA{%3>QAXh-@d9oVCSa;-}{s7=I8R(N_Y!Yc{5ekW1lu#7PVWjq0aY7EZAub6)E z^6|Qii^-cL;TYg5;$4CwEapzCP6A^#tHBFba1Dg>gGuQ!9+C1TDyv3jDPZ?as*&+2 z=rSFZJxn;Cxj~tqN40Grbb381bkfZ=S(zjP;+Y8ngtxmUDl`IxW3Mp_Gj5C7IbpU% zu@Ygax`;{`?TzqLe=AkZc2?;Jz=>-n!ubgVD!?b02cKYnHl*trRxY+X7}S{Voag4X zW!w>*7)aJ`q?&FLsQ((R3Z&NFX!P3_54>vh^WccRpH}{-jsM`L4fTt~rTiqG za36Jm^`}9h_K3#mvd>16!RCvORm6la8#Wp(!h^PPaTFt3n;_UOL<}Ub8@+(@NJKr2 z%xbrHhEWP2BQPXfgg+PIXCo>%7=^_26P{<0Hqd-CZZ{1N;X`sFbI>67PFTQ8w%Ff6 z02tAba;e}e1&b}d?69G9#Zc-mUp1v%I(qTwOT7#HYD(&*(-%*NQc4z7t7+*ZO7L~Q z^umJX6CIaac2Cb~Gk_Z}87~?ayF&VWpgxWE%SXO&WKk2yvAp8;wY+@6-}&nEOZ@FT zOCWvgie}p#UGhTXm(DI`y!bg3xLC0`wlEja7u+$X25DSFskI?v?RVA7o&QtPZR3uc z&9{^H&Ub-FxG?$(@bb%B1IFTjrudT_RFC$4>Lxh%H+5~+{%EtYYlmSCu|`t@u=+jE zViDb_ckGPagDG%;kS7H5DDlSU@t(m5kz*Nr?#JXUs_)XnH^zR6cr*q-aD>$u9vwjJ z#!`+A@Th7~^+-Ib#(SzU&JD^EjCJs&hT+Ks7&Z=1#^4;O3Wg_T{P}y}NhPn0CtbQh zC@?Wx3G7zbH{Z~;; zt)1K3?L0RtO7z#@W0Hqg&)CjGa_^Y6m|YN`hyc%I;;w0@#{*G#o!~-$dxdA=3m$kN z>j)hRbL4%FJc4|NfP@z)G!HMLM!^v+6Oc!eAepewjw25Bq=*#VS19Ed$@>y{|Asum zVZtB4i|{iMoQUkMhz7+#IEsw!2=5@Cu6Oo~n_z+wd?JdIket?Kx1Dhdbk1ap@$V4W z5W%fMYFa{SNxuhD(;F8$UrJ(>-$Lh$XIIlQ7Mm8%E~EghEj+te{%TFgST^6invu0= zU8uT~lDXKpX!{_g6bLpwW3ltqgNrE(T2PvVNMGslX1f!6xn2DO`v)G<+hFPvP#keal|AGHP)xAT zj@hAff>X+WF}{u%fSm2KZinZBc$7|NEIp`~2p1qIb2_IVg}EaIu|27a)rt7;Fhnvw zS8Gc@=HSt}8js}x_DjU)z~8c?F#ZgTh=~;(4e}Bu802O_GmCD>JR6-7;*sEuT$U9g zk{f)lMZ-EJypHt3H{nGLOh0Bsuup&$s(~uXgz{2k#BAOy?)hMeLYc)we9pK|iqSD0 zyi-u>yAUd{ENH%(x>{KE)wI=uk_FAzQXk&1sJX0zbmpDshSG_Y5FIsHxQ;M(Nh;f- z6265;M9W$%(p}M}rMT^$5xFCg!Zqz%^X^TQz>awH+-gCo)FK){X%Im!i<^8E0vm8- zRP`WnA#tKS5&puQY0IpuZg!l}Tw8<}5i7hz9&M730vq8P68!i0bC<(o>{qP?+*hr| zX9Dw8YgwvfkL4N``7oRig~o>Qhb+IMJu{R!90LP`T%QIB;+(Xb;VpF}&XLwlYxLCN zQX@?au`DIELzq?!cuVvSyd3prBpjAGFNydnP>s{nbA#%Ayav;5(N0b<_xBz9!PEXz zWM`upNdYquYAW_IkhqX)GYHm0zCGf3c6=tHyC4FAIMh0jvW_v2ut>ROfM=i-QOybw z6VnN^i&?NR2)JUZN;-4SCQP~+9Zu{3aS=daijY(!YYm8rk!%9{Q3~XJ89A$|X?F@s zeB=J^H+!%5h6c%L^m=r?i_>Y;SGvf zBJaE8?IVxqE>;}tk?;-rNglzVy8zxFa35-V9%z7 zZLD=B(BgjNIv|eQpW{#F9H@DS*5NIJ*NBG*`O;EFLXIGlRo;%kyr{emSpiP)GTc)# z(ShCt=TOr3IERJ{l|z+8{a7@=rX;-)9g~$bH$mw_JMOvQvO_<@;C4bYixfQ~8ai76 zSlFRS<(?2JjTezs1Qhv%1n9Dj6U%61qPa=<0TQr&nGu+WxTg?XI%z;Gdi*;V&V|xy zzi<%yxiM{_ZT`5@NvWe6LDBRQUs)49pqiyL6wU?sCGejmij_W4(v?SdPrN6PI$VUf<2-=;W^-S zL<}c9LymDA&fOMGMAvtrAL5-I!h1+7{FJ<(k@pjLY^RlBCw0UzLyAEot_yDD5dIP= zC~0(6{6fCLJdr96wQp0~G_iC{3*yL~!%#cb0_3xKoKr(rGzQg!nr4-i2l=SCs`}N5 zc`zXP^9UfMOzG&5ycB%I(y}ssat>0+%H@?>H_k>I}I9GsU;`wUZ zYF75bL7XvO)%vurrUdk*4^Ov-^&~pHyHz%nD+}H~^r&;WrxP+)Psyr>o}_it=#VNp z)E@mH*9?7T0uPS?>kwoL3n%05k#UgQ#d}PIS0Z^2^?e9ZF$`qvY|~-~Oc?GpAh4SU zgo?d@->(|vK7%=vAQj6#J(kLrh*Zpy+lvWedu~MiS?4IG=UXU1xC1X@7#SIxwz;7X z5T&Z;#qIYEdTCy#jW|GvwDn&h6B2-59%wi4me;=+g4Jn^Rz&sF9v|nd z+E@wJI%$#~BaLoN8kKYj#idqwISO}*?f*UN)TWOuX-l-d<6u%i?gwgDbW;2zMjP?) zMQ1fAifVE4xW^y=e3HaNp<(p(J0Pg z)b!R;{42AXB{Ion0LQR8E+KwK<5AA9`Mp<2rPMYC1BANcTtd87`dGb=!aI?t^iP2{B3B>HE2R`7x75rHH%U!` zUQi>AoGW>N>wDa`Rt?k6%-n#C52O`As;^ewmE4g`HHx7aeWFbwW#OgO9ToqB|7x}| zIXmN|{&N0LK21JLH?-(NPc2khlW;j{!U0YzyPhRHg{(|Y9??Z_R?U^LgKzYr6^ZG+ zv$nI4)Z0d0VdkMGoMoAUQBwe*asyRJru#oD3 zm!PR<7P5Uxvhn3CR>yv7{sHn1l6Q(c8$4(z*e2$TtRhsr=g^*rcG^4Pfo4_vK;NF) z#=7n8N3ETE8XE;Wl1GxK9iua@=4r?!p~El=9R>|*UjiPAs4h)qDBiZogs=%T--yp>pvAboyrD!Z#Pr!+vj`HKFs z{z}T_ltAgumGt&IIVItohWB$CZkob5J-2gu-pjmGxXC~M=G676rE|A8x8BVDLH_sh zZ*94~^YDA!|MB2Y4+frm=JwIy!0FF~3T=yBcZ#c*vO>kRmwOgFKLVfT{N?k0LpX2i zO5WCAlx=?P+?|TrK*O=WbOw%{4%Cf=Dn1h^{>*BA@iqNb{k4>@#I>L=CjRKK_T_P&#$(x-gt)#9>ihp!&?KZ|Ov@*l-!?pVox;=|(2 z{%2o1=Hq`=R(|c=)pOULyZYSHzUAal*{;Qdce2a<)l0%kcGE}M`Bx5JK6vHWqu0wR;0K9XDSHlpSBqFZG+2 zCYC2|j{jildsDa0-`?AI*Pt!RUF^DV;tDFSrG6vT@4ne`YdkP8w32^vv2!&q|H^Zh zpYuPvl2`p*)!VwabZ@7=m3q_jgY(}z|DOJK$BA%*_5B9xol;AnX5X#GpR~Qx_LIHu z>u*`g{!adl{N?J~ zmR&2^yFb~<D% zyOf4c-Q56|FSj4w%e}*QHXq)q{o(eW?RdPmt&<)nW%P1mlCnplKLH!xNgc8hVY*VfRmh~pT$KX;WzjpGTjKS zg?;ocB}(~^*{7#RMsO$~VqRnduysLak0<8Ef9lVfhD~MapVJW%WT# z@%;XUp5JJ+;1;HC)i&Ja;4S7}DZN~JpMv)XdCru(IN`G|C*4dA8M^|SuKxn2h43G! zwHq-ibZD#0sCZ685_Ix!sl}&QKXp`c6vkxm_pHci<7~W51$Kpg9!J1F#KgNtUpkdY z6DI1`#peY(oUkf$Qc4gp36+PGyE{rs#voA*>Ic=$YGRpyMZ%+%sZyEBQzBekNh}Z8 z8DOfgld@Hf9O+6*DJ11^>IajQn?WQbPb6@bNIKSfS@r`26F@m1f(H3SuNfph8yG9>e6K|ha4(64lTPUriK5gJNELOEJBpS@3}k5pwn$)W z1lCAkX~bh<`N`gh-jl+@us4Emsx=jF)5z9{C!MT~kfjmWEy+ObYSd0PNA#XdmIHQ2 z5YDn@d$Jj)AqTz7iIgg9OJTGe!*Ua^20BatBE~qEhCzb>JEyZD;MZ^+_;{`ZsMc6u zjh3OD%yAM~V^{#`X@$f&lIAG%qZPwEE0=23;Fo;TXYXXdMQOxUh;!1AgGwegsMbs8 zI(8R6{_a1S5b)RqhYt(PF^}E6R}hdE@{6D2A@WR8A*mCGY5GP5Ox5C@S$pg3ZgEvEFa zsVrzJd!x<2HBfUfU_P{BI=l)I&XJ2pd}?3+im80Hv|^$AtH-g|<8f@YuoRCY(Ij6x zrl33&&K!+4gMbgxun1H@HKs(UMjB^uDY-;UbwwInMkUBI8m$EnB?a*)6KU<7P8I)2 zJQFNvRU!yFEo-K*x@69dQkUFX5&_8iHp+Xn5WS6eIAcYTtn_5%KLY9itulmTTr%TJ z#O;#=2q~7YmAHM0%Q`J$Fkk ztFYE28c#U|#z}sU+DM)e=laUyBJdLzFr)JUNJu!3iOY(~rRuAExLk$*wjunGHtplX zkF3w7v;dHn0Di2phAKuzh8pxn=E^9o?js}==T6TmH6(6f;sr($^kSdicJsA{q z#x)r~rOCvYmP>EZQaY~2xxSvb2>d+FogPbE!8bOF%KyU zYXGZ5-t#e10}r>oZ|LE;(|)Bi><$T9lenz7QGXQnGy<*??b$g}&1ktyVW75?54SR5 z%d4gQ7{|o9WUQ)`XwBU)*W<&-(@MEgD5iAdI-{AOe@d=()?l8L0%KJt)rDRUOJU`? zA^usfT?xvHo#l0C2bq$Ae!^*~6%rzv#IDCQQWN1xo0M94TJwiTrC;Er6g0_qSlAr5 zN$-?rpIr${A#0&DTHuHjK_-VkOYyIXEjO1n$c!b{M+I;s?P>Sc;!W3-)9EGyism}l zL4<+BI#@zv4<~z&Nks5e{)FAi_v(=2IIcgC49!R7rj9wMokDx%j+WMzw$WX4N&UFK zi>6_9imcC(S9(PrV?7E$IW8D6Xw6=Kb!wy`sl0x3_5+WMA(xq{xc!a$gF zg!=xZXR1BIZ}K*yXtbZ1bA+WlQE*s2ftJ*pr@S-_ub?r+NisVo$c7%2^JhJlIpc9= z-GBsx?Q<>Nq9Ft~-ALKZ_(pq2WHBcOxf$jj&*Lt%Q@joh3w&dCFR>E#39#U0C&6Rc zkYuhAw+DQGqB8g&ZL^Q#qX;|{O8eFv4{vW=6hvmFJR8`}^wmt+M8|3aOYkjpQ> z;g3pJMZs*GB-UIV;Vt`nEzRKkiyDZLba2yRl-!xLVyx6jj`1LwI?!Gp`RX|MsUIz zAr1e2&xL7w|CHVCiBJQg4cB?-nk89LLz4ZD^LA<(Y#$~giPZQcN&|xk1}aayM8sYL z^gLroikt(u*)ae!7ABZV86Sx*6KLE4#-lk5C%UHWV`nf55#!j@nUSbOoaJV3s2?8Z z(vlat1Vx(OvHZiQ-eKF7z|(pgO&7;Vz&J%D5OIOrP*E{i5;enI(z;xFwazg)xC5XhEut>!upC zr!k_ZlG$BV`rQ;U(r4%73m~sJiA)=$UvjEFV_CQ8)nc`Ow^gU=bgsbhsiLHf;-&D>ZN*fzTD&P-ToWv=S+a(Tsj_4A_SnT^VbhkNX-l9QiFQgETCVN6x@W~?p*|sr zuWAJn_eb|>>0qd6`-*9Y+@+-pp`x8@I<+}y+U$2z52C}t`m|!Irr*ae9`|8L%A|ME zp;|fsOQI{L?W={A;lkQrVeOJ5RM-~7P7j+l1x=g$V>C;0vAeGBS}|=|E!i9{sSTFY zE{%pt8j+_rnj&nf4w|Y1mYpl6b|ur^t9zq`P+K}luaYXQ4;I!hr-lkU7Qn&J6Q?~; zIkIB<4Esrv0C5PHG~w!;7bn=Rq8reIl9pm{h{c50z}#Z-)TOS4Y%x1?Dy)yBjTB!o>hK~v4r zK)7~au$Frn2C=F=(h!Pw}ozz)_nF*VpQ=*3)XEiU`t{&WB2ASt}J$v4O2v47=83TEdhA(8V590uui= ztMKJkXaa@PD}(8k;q-06^lgE<<12k5f#aVEe8v{28C^*qV+qW`bhDUXspo_ArjNBS zlPC(~Ac>S6wgXWZ_a6`-wP_r4-pgJ6i5*&RMF&}JL9EjSmknd3@2~-A@`=D+H>dxT? zkq7!2cWyHgFOc;%p%Y&8G8_y^&x&`dX#o}sLVsBEAfUb9S7Di&k)(RQMY4E zq5m7|GAZWLb(v&C87#ya%6!dt>EYkbekpxm9e@|@L(0ATEF<7h>YbDVPYLE+1gC(W zc&7+dVLFdsn>htSPOIBqfq57cQ|G0QGpC40VJ#lFoQ7#2m{W2B%FVd4gS8{H#HjYm zLLBJ4s7~*Wz34KimH8|v%cVL}A;RXc%=Ur0fvlK7x*~H8onxecXl`uUUSn=-Z;1MJ z(I*bKHRcAyvt#ad9Cagn`&`zc*|V_D58M3otpd?Zo~US`6O|ja%u1awk5&l8WQ!Q= zxLacMxdMg)Ji@D#{x+rO&$?+fi}a_c9c6@8f|8b}A;%UbBXlV9%N^g(|0Wet{2c7d<_<%5ena{E-T?)RN|_3+nuV7da!eGqVY zi*8@%%ew-m=#3qMCkdv0pA{p}6=QM4*qR}2rblXIK?97tj6ZoTXbrej3eN|da(FD^ z{umw)V`Um`rXsP#MoPKbJjkEkEn!jdDyQ;Jx%8|UZHi01Z&0%ZC6W`rtud$p)j|=IEfxd)D2DJbJ~XObxjkvOqw+AI17WK z>^>$`@V-UK*kE3!5WyX@jlm3q##kXUj7-dUL=daq2*jk6Y`lq82tAf8fQQfe@}7&` zfSas>*DHOt*EahrLRp(A<`-${tA#}{DBSt_n7{nB34gzDcqwD4ap}n=OCYZ$klu19 zm*8t+@ucsB?^%D2&k@L~4w%Gy$%&Pke#Cce(kNkBnATRjgPs6jNCqy>b9d)Y`59@;q0nl zHjasJ4qqP@kBXs9ZQ)HFflVDZyF%GdEcC2q<%P2F;i5gE+`WPHy{lRIi)%4UbUv9mBDQw((UA4%DQRBEPeoa_ z2_QpGgTZ2KUBX#$E|F3b8No=T#FQBRB<3w!%UmM*N`gjQS&?@;QTYueK5m<0F4i%% zInA7*J8H?*JZFH~E#n568GE-Edy$@C!$4j+c~6jMhZix5oT(8q=?YqfPzkfu9JPvp zE9eu@o|_UmXd4kNv5BvtboVHV1Kgx#U3%ft3kxs!Ti)FB<{tkZ(!Gb?eK>tnFntpa zGJXv+M(Fv@=Wl#IWZn}f+k4aTo+fMSpk>p9mm1;*%lT!@irZ|iCId6(IhEVuCDj>XMLVL@24g7_a~e0n9cb&C zmCQ&9-o9oX3)4uoF|EMLWz+j7Nbf#?YRTfQCZ(Q?Nqp||=U)Ci?frzhyL@^6B>%HZ zS^mkTrD|8rVNDA3^^v?f(v+|Nl-^iNvL1*xF;c)SinvQDl)nnr0J1Xu!y}0?qjdfV)Z1#)H04$rc$9onP4Vn zlBG23taq8xl+rQPJK`nAISC6$&oVM-Evu0!^+O=0ld_`y|4mwgeXc;NQ+Y~Dp$rxd z6REB!?`hnz1Tv+(SOS{~qq@|{DJz6YZ=t0wi%$n+ftWHbxPGQ~sXIUyLDa=UG1t#D zbA?gKmiUn`d_P6JdPb5jQFL|sm~3qtUq9N)#9#0s{elnS*VZ~F``TIuxf4aT*vNCmW^}~enhy|trbIYRUl@vnp#>+;Lu_D^9S#EiI z&s%#!Rh=v8`+&w%GDLo%$hQ^4ADGIRZT>RCG)FSMZeC%OdIzqWr{n|tqND^t#k{ung zbg>4>w+yPKnHYzTF(cDtM5DAO5t@nB2_~~&^k>oZeww;gY&&P{hYX0_{yP(c_CaUcxYV`QlMgGq|lC7h7Uk` zvD2A_(lUCSR=RMG-m_&+@W~hf(RIFy%B$Jnbhbt)_XFf`Qzf*}v+}NVTY&rYwvbV}Y<(;=$0*9Z*UA$2CGobG!zO#%=7H9lV;({PJ?RlLHmHcg5)*1@W zKmI}bwvW|ZdIH-^v?>^R|207MM&x~T_-lA`T1L7HE&$7%#%+GWJeO>Sse7EXj`f>`5hSssiKs)S-i5PBEHZ`vp33=u z$eSkb3V93U?IG_1JSMLn5zU2GQ$8XmfQ=aX5Lr4DJ4os8llOqUm&tpDyf2aWB6(ki z$7~QYyn+_OiH2f`;@FUgh*e(rDKfbK3FWOtEqC#8ow_y0hr3Bh*#FnNLG8TmTz>YZ zHB|Tnu1S|ej{B{`%ZC@c@04#{YW`L!f}Or*SZ~ISUD&gO1c-^8Na*I*_{SBQZ&Yml zBzQ`~|FC1Za;XMamYZ%?-Tdr(S?~3~X9!eUKQQ(G?tTuJjXR@{A|OEQh}VPv@xGqR z-iX>biUIhe%&t`KMp<_@_l~}~o7cXxOONo6QjJ}E)jujI?AodR(M~PG@AAg(48yx7 z4c@++p(a1u*rQRuTUOZJp?I-lDGHn5 zSVhSYmj-B7ru!uXO!nEt5Ah5{Kd&(m{k%qEM5=spV?a}v0aPlZz(gvq<=lW4pXQ){ zFt{R_mV^?Yi~ti|h>B@_CNtFxWl5OaQMQN#*P2jM443BEk;kqfi`9Vkpa!KcX3Tb+ z!zPrCKF4r$j^%HdBWG<6jA;Ti9IGz+-x7t#w7w#)v-qhPu4z^Qd7I_4W1=494X9&H zyD)Pl>uw&F{NA}7SxHY)6Qa|eupbr2oADrpPlmXK&rrxl-Y9v*?G*l&y#EtkMAO*R zc4k2MzY&HlFxY#B2A6P{UNg2UVF&i5K!L!A*`S`&pC6yBCAB4QIpRpkkxY(=EFOH2 zSN!ItnH0n90;vk-DxXlINHJx**^;&Xjv^DNA~8xE472A?W(bh^oLLx%)7@ zjdm1BoO1K83|$_&GJJXXjcO=#ykD_BRM8sP*%!(=v9SNHDlKLAud+*DKI*SquDYqa zmAwk_mCdjA4f(yx8OzPfx|>zEG(SmuCyie0@b6yEy_tI}_pVNpm$^uvZptXSlKS;j zpL?n5rsut?z{zKBrw`xBD!S6~^^Q~2>^zCIGl*}PP} zlGAi2yA;gu_p__~6H60;P1|p0??7v!QJ-Dj9N4sTCA&R(gbfsTe31Uc@9vjxv@`wV z{R%FpESy~z%&z;cE|Aqm``B)&l+u>v{^g!PaeFv@*Zb+a*lBq8r_8>?A7ysebMF*) zW%N{V@75c8%GB?+7xomYe_W_V_{SAR`-{~--jlsQPyO%nv(>V0?F&{(R>rElI)r%c^y3Z_5dCa3+k{1iksnXmIEv?JmXy= zLAAnCBcYCDPfsOupn8##ieBq}iP}T`jhdupG}?xu5K)hj^(7m50lSh)3)8mg`XG@M ziw>^nx#1ptWt1*vC+?0NC9;Dqbw181av`x>C_xjK1V6V#qb94Ur2eGG=7YxUec-l6 z>lbTc2do5)0cn314_MfD9`krEq3EjxH7f?fFtjH^C6P&rA7TW8gQLezsgGz@60WY}!0QnM85tCd2^Q-T_+ z5qavAcq!D^D20@#oX520@SB1Xnv_&h=#jPJ({T0j1-1e9`5G}F%909*dG<+F$HukB z{D=mTo3K-ylc11|b=*7pneZxW;?mn6&c0#kbV!YJQg=jb0p>uelmIjxXb+MHJU%8G zh`~fLOcX_4Amb*;3)ZLq(tEvxjyk8wPSFK38TJ6(MFdt+RE~gKAnaNIWBS3vr&sHQ zIqV7hy|e(ya*~+CT2be=SodapNZn=;{s!q3t9HWwML&N<-s|ut@dx+R z6U?@{@P8oexckrl{Lhv$A(^%!kt2W*qzcFIK}_4Eoz(etuoFLvikZC zNKFE)7?W(DqY|GZj~1=K_>pwf5cW`9CwX=7@Hq;1^m2jFOux0zZ}Qp&;(IaEQ$iVq zeuJF&I<2U_58Lnfekd_4@pY$fQJHILLg7h@V_eOjQRqLzi}}Wrq)xw=s@q7NUaaxA zNb3F@c*Mqp!1!|)KlgsxW{L7CZdgfgd~1iy-&{>E5T(cA^x9y0?b7&n4+i!=vywgx z#wT$n`uI5Z1P9J&?d95VW>qk=DwMf7oLLjhtO;e-Ep+`NEB8)b!Ig8D&xP~01oO6h zTlF2?4PCgpEm++Kn{Q>`D+^U03gsPsukm)?u|>@tkfoECC$CIjo_?e0&DQI!Z+2Yo z_-@nNt#7ry-SJk(t(Kp3yweeC?hjQCtX6J;7V7QFcH)ihUQ~Z1Q4LIY+be2alUOB? zPny6V7MfSn^L*LY%C43zZCP%FnKj#LdcLm`z4f(->i^-Q?ZKk$p(1E+75N6jCH29Q z`f$niV9EASN$br`w@W%!(~Eskwd8Q=u3+gds2NkSS$9)(6{+_)T~QizWslrVCr%6M zR`hK}jg6>!E)({wdozH&C2emevDp$OYcc8%&&&76ZdLyI)Jpn+|1FmhtbNgMo4!+! zd8nTIX}$4qz51u^TMutl|7@!k;j!pi2Sjm-_QwPuV}0>WB9c(i?E#L0(Hq6!@}R5- zG!8nG(O!wpo)Ay0J$ph6m!x-bE|HGMAg5T|BCSNQRW-N{80{Igr;@0_M72WiF%e%z z!nbSbkjHAD%#>Cd9@{=>x2w06;cHmgI%WfZ=f`w1B*Nu-|PhWsNOQ&~oihagu3U}IS z=^NPiK90HbQ1lMk*tC-{V4atUv0uLaM`qY{Qt+69JTDPSG17%DSSF_nO(M=bfm{*& z80`By#%$Ay_VF2kD1K4aLM7uf98pQj%T&-t8ZEKtFbf;A`zp#H47Fw@dsa*}QA16? zNXv-gEZ>=>%B7q@as5g7qS6ONKaZ zg!)As&-8wZ=qD3xkpj1<2Gm9&#%yNiB(~Ae4k0R)F;OIQ&3~VaK;m7Qg6~)5lEUX>>`g?(u{{7ZjH2I2sAUy?xFBcs2y|p zSJVi$&m5yyY_p-sgvmp3G9$@~jWj`nG$#DHX)}i1!v`vzq42lacHYqZpiiwfY{CF1 z=iXOAd-|Rlp}Q1%kPpq#-=t~{W#nxoul_-Ln!)^_LT{+~O?H}Lfd5TJo?-WcCY)S< zn^a^lf6{I=bo2K(^4U>Vcn!6Vp;FmJNjd)BUPP@mWN(c#P-lf{Iu8ufF*9{h({xs? zN5_oA(I>8wBE^ZgEYg)WM>#56!&#;g63?GhIX@q3kp3alaCN{(3Wj9AWIPN^B@1-q zu5qW&pYD@*z#>MosD6DO?|Y%tAX{WpBoq;{%@(~k*pGXz0-GZmA!$XvfpB7KLF%Mz zU#)`HBAsEd7Gv$Jm73YLmFPpVw&qv+AsL7&n#$cmZVIy@nm_;pg!vPl!cV}k$gwf+ zWp-`suq8(666zL7W#Kw%03T697Z+njC&}x=S2OLB?&ix##RNN1Q~#nqo-mKLL^eMP z4(c$0>iFeXlp=(fsud32M6m@hXAkmHqQ!?C8OMhA-7*+Tu(x*I_*@~D_ zrP9~QFHf`7PnJIcU*vS`hZp6O?_lgl1LRmqV9d17(xnzC&n?m5BskQdP7>bIS*N|q zi75)Sti71YcqfjUBHr##?aPi&gL5SvC*qtm65s$NiLXez4d0f>zZgn_bleN1teEQr z{gg^_C9TsNSp@CU4a;WlhD#n@D(WIee4LXol~gD0pIj5~pSbjlm-UUIFRi(4lC%c) zj6+WiYENro6+&ZE$Hu!%Lr*_;+LXkzt9$@?J$O@QZcsL25fdkyyo!la(h*W%F6M2b z9J1jndGvq{$l+$#_2|v81oQrAA-)xmA$_u$ag*nCl?@Y(txYxrnwwi1w{J&aXN#?Q zv~8}8bnVPG@wg6S3v?(aQwq@CBYP4vy_BbIbbO-Ck`d9L=!2zH+`_?!p4k2Uq$%-! zx*pPwX{#N!0g_I1k;#Ne60l?+K4PpN7i{Oq>VWudE5f>B95XbaIssz?ozt+?OjZx@ zU6y&g7oNt_im2xtE>ZCQhlsPJvqN%3Wk1WV_=s2Do~6?8h0$>)oR4UkwFY(%D59D< zkH1;rEG1E0XvANW@=+%@EtWN36O}Hg!6G>={RNg9;gtfF0d=9N9!Zm}L zRQ7Aa8JkuzHZ5h)IeG5#+{@2FssEYl&wT6DV&iIVp0E5>`=T0O>A8}8Ioa0;xk`rL z`{r}kpIbf@+Ol^g9jD9;GN10txmJ9&*zX7x&;eaej~gJBuT3rS-)>%V|0yWe7OLgK zl?#g(=wcF#kNcjyHhgt>$r37QS;^i`k%uoI#-)&Q{|V$d`<)9nE`;m5gZ15^Z9Rbl zQ!Cky)$GDgvbfC6!HiA!v$>pnpXQq3s^R5cs4nN1Tr*uY`JW2q*Dq@CRMvdEAF4|Q zZx!6^{=uQ|9SYU%|Df`~yqIis_0xR;u~bx!;q^!L@@@4BV>^8@epznA%=p-|hg53+kdHgZ)5 z`A-VaSPU}$zn`(`Qzip>t*iB5zV_YXt|mO*%Qqgh7@+MfZ}o}4#3z#2wDAkyBX1vI zLnMr2&4B+Dv5ZszO)M&VFjD!O$U%f5)0kwmum)tVNo^?nBstS?oWIYJFH*0R!D5V< zPIS7U@irzNuITU>(ZHC;S!UiN<`vU^rL8KGgT)l{V$d$QaW2Ek>FXOA?6LMAI^Ij{ zF4>APyDEwgg-*d-gT{W|K8B9JN4=s>h;%-Y_{`)#K2qg!F~S<33vkQ|)JQi`qx5Y@ zQgC@f?Cn2O2J&>2;429H6M#KAE1YZ&CY#^LdNcoeekge>zT2(Qg*AmiP2uZpE1D|y zt0br?dHu|aX6q*!t42#-BV&)nr@}c~gE?F8i$6d4tiDux949sM7k7rUs)AWn_t~$H zpWr!T&Pz3{bowgXi-%W@sh8R=w!K6j!)3!8!M9JD-Sm+;ZEY=-iRRk9#Qtj+&%gBb z3^R+D%*~j0GePZMdOBTu&CiH7I7pm*im@z(kEqXo4R7)rSIPal1CqFJ;`f|UG@dg~ zI?f4Y-!{tnzf;nZm`_;LHttCD@d`y2F8mr(7s>lAdEbQ>(V-XgJq#ws$I$-)BHbv8YY=aa z>UZNNpm*B7R|p`MUbwGg+;K&N=Relyc+G=!j<5V1&hT?C?dM#|&$*1BbIB}h{K%L) zuU}2gnKwR2)$ltWq$cwz52}*+><5JgKIeWu&l?tNAb{o#5A+895dR=Qg-?56Ht{8& zl;R8WcN=uP`RAJ4pKFR&Q#0oetmfv=AOFOZ%|plw??D?rw)wz>&prMoQ^i+3NTyHw zJ#g`4K>zM@22NvIn7gemSkVL`ocVv+x_#O!Ct;k8pR zt58z6Fm`F`;?&}^UpuqB@9hI`9eDfbTd=H>dg;K$1H>rx?Ynm1>Va#=uO46Of+>^` znVw)TMSB`P>$Rb)Ls7#aW1*6!kcn6~rX7Kuhi;n=LEC=&5q?2^FNrf0ucqXLQ>x!j zsa`gPQ}*0W*>fxN!^{%jn7`3)TgnV&)+}^=n2pATvNv7qS?F94evy{@VQz)LdAVgJ zw+)*!x+-p(6-a_@P~l6%vdrg0x!V^Gd}u20Z9##f%pOZFeU|UT^um`-zS*VmOtcXx+UIDN)_eBaZ&nwm9#;6vQ@ta{n;2gm>Mt`>3s z^c!5%&ffV+CQ3tpKXo?(RNvWRI%4F0#OaT0)%++W3I2O!`}9ZZwSQsM9oeG(i_GLB R73#mJ&{DYGc%;Md{{q&FhD`tf diff --git a/installer/VoiceGrab.iss b/installer/VoiceGrab.iss index e793e8e..10749ff 100644 --- a/installer/VoiceGrab.iss +++ b/installer/VoiceGrab.iss @@ -3,7 +3,7 @@ ; Compile: ISCC.exe installer\VoiceGrab.iss (from project root, after build_win.ps1) #define MyAppName "VoiceGrab" -#define MyAppVersion "1.0.0" +#define MyAppVersion "1.1.0" #define MyAppExeName "VoiceGrab.exe" [Setup] diff --git a/installer/build_linux.sh b/installer/build_linux.sh index f463d60..be3b8b8 100644 --- a/installer/build_linux.sh +++ b/installer/build_linux.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash # VoiceGrab — Linux build (produces dist/VoiceGrab- binary; AppImage if linuxdeploy available) +# WITH_AI=1 ./installer/build_linux.sh → also bundle torch+demucs (bigger build) set -euo pipefail cd "$(dirname "$0")/.." @@ -8,6 +9,13 @@ PY="${PYTHON:-.venv/bin/python}" command -v ffmpeg >/dev/null || { echo "ffmpeg not found in PATH (needed to resolve the binary)"; exit 1; } "$PY" -m pip install pyinstaller +if [ "${WITH_AI:-0}" = "1" ]; then + echo "Building with AI voice separation (torch+demucs)…" + "$PY" -m pip install -r requirements-ai.txt + AI_FLAGS=(--collect-all demucs --collect-all torch) +else + AI_FLAGS=() +fi FFMPEG_BIN="$(command -v ffmpeg)" FFPROBE_BIN="$(command -v ffprobe || true)" @@ -20,6 +28,7 @@ fi "$PY" -m PyInstaller --noconfirm --onefile --windowed --name VoiceGrab \ --add-binary "$FFMPEG_BIN:." \ "${EXTRA[@]}" \ + "${AI_FLAGS[@]}" \ --add-data "assets/icon.png:assets" \ --icon assets/icon.ico \ voicegrab.py diff --git a/installer/build_win.ps1 b/installer/build_win.ps1 index 5e3777f..ddf58d5 100644 --- a/installer/build_win.ps1 +++ b/installer/build_win.ps1 @@ -1,5 +1,10 @@ # VoiceGrab — Windows build script -# Usage (from project root): powershell -ExecutionPolicy Bypass -File installer\build_win.ps1 +# Usage (from project root): powershell -ExecutionPolicy Bypass -File installer\build_win.ps1 [-BuildAI] +# -BuildAI also bundle the optional AI voice separation (torch+demucs; +# exe grows by ~2 GB). Core users don't need it. +param( + [switch]$BuildAI +) $ErrorActionPreference = "Stop" $root = Split-Path -Parent $PSScriptRoot Set-Location $root @@ -10,6 +15,16 @@ if (-not (Test-Path ".venv\Scripts\python.exe")) { } & ".venv\Scripts\python.exe" -m pip install --upgrade pip & ".venv\Scripts\python.exe" -m pip install -r requirements.txt pyinstaller +if ($BuildAI) { + Write-Host "Building with AI voice separation (torch+demucs)…" + & ".venv\Scripts\python.exe" -m pip install -r requirements-ai.txt +} + +# AI-mode collection flags (no-op when not building the AI pack) +$AI_FLAGS = @() +if ($BuildAI) { + $AI_FLAGS = @("--collect-all", "demucs", "--collect-all", "torch") +} # 2. Grab a static ffmpeg build (BtbN build — includes libmp3lame) $ffdir = "third_party\ffmpeg\bin" @@ -33,6 +48,7 @@ if (-not (Test-Path "$ffdir\ffmpeg.exe")) { --add-binary "$ffdir\ffprobe.exe;." ` --add-data "assets\icon.png;assets" ` --icon "assets\icon.ico" ` + @AI_FLAGS ` voicegrab.py Write-Host "Built dist\VoiceGrab.exe" diff --git a/requirements-ai.txt b/requirements-ai.txt new file mode 100644 index 0000000..bc61f71 --- /dev/null +++ b/requirements-ai.txt @@ -0,0 +1,19 @@ +# VoiceGrab — OPTIONAL AI voice separation (heavy mode) +# +# The core app does NOT need this file; it only enables the +# "AI voice separation (Demucs)" mode. +# +# Install: +# pip install -r requirements-ai.txt +# +# Notes: +# - `torch` from PyPI on Windows/Linux ships CUDA wheels (~2 GB with the +# nvidia-* deps). CPU-only machines can install the smaller CPU build: +# pip install torch --index-url https://download.pytorch.org/whl/cpu +# - The Demucs `htdemucs_ft` model (~90 MB) downloads on first use and is +# cached under the app data dir (/VoiceGrab/models/). +# - Licenses: see THIRD_PARTY.md (code MIT; weights usable per Meta's +# model license — fine for personal use). + +torch>=2.4,<3 +demucs>=4.1,<4.2 diff --git a/tests/test_separators.py b/tests/test_separators.py new file mode 100644 index 0000000..53d693f --- /dev/null +++ b/tests/test_separators.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +"""VoiceGrab separator tests (GPU-upgrade.md §6 automation). + +Run with the project venv: + .venv/bin/python tests/test_separators.py +or with pytest: + .venv/bin/pytest tests/ + +The AI (Demucs) end-to-end test needs the model cached or a network +connection (~90 MB, first run only) and takes ~15 s on CPU. +""" +from __future__ import annotations + +import math +import os +import subprocess +import sys +import tempfile +import wave + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) +sys.path.insert(0, ROOT) + +import numpy as np # noqa: E402 + +import voicegrab as vg # noqa: E402 + + +# --------------------------------------------------------------------------- +# fixtures / helpers +# --------------------------------------------------------------------------- + +def _synthetic_voice(n: int, sr: int = 48000) -> np.ndarray: + """Speech-like signal (130 Hz fundamental + harmonics, syllable AM).""" + t = np.arange(n) / sr + sig = np.zeros_like(t) + for h in range(1, 31): + sig += (0.6 / h) * np.sin(2 * np.pi * 130.0 * h * t + h * 0.7) + sig *= 0.35 + 0.65 * (0.5 + 0.5 * np.sin(2 * np.pi * 4.5 * t)) + sig /= np.abs(sig).max() + rng = np.random.default_rng(7) + noise = 0.5 * np.cumsum(rng.normal(0, 1, len(t))) + noise /= np.abs(noise).max() + return np.clip(0.7 * sig + 0.5 * noise, -1, 1).astype(np.float32) + + +def _write_test_mp3(path: str, seconds: int = 5) -> str: + """Create a seconds-long mp3 with ffmpeg (voice-like tone + noise).""" + cmd = [ + "ffmpeg", "-hide_banner", "-v", "error", "-y", + "-f", "lavfi", "-i", f"sine=frequency=220:duration={seconds}", + "-f", "lavfi", "-i", f"anoisesrc=d={seconds}:c=pink:a=0.3", + "-filter_complex", + "[0][1]amix=inputs=2:weights=1 0.5,atrim=0:" + str(seconds) + ",asetpts=PTS-STARTPTS[out]", + "-map", "[out]", "-c:a", "libmp3lame", path, + ] + subprocess.run(cmd, check=True, capture_output=True) + return path + + +def _mp3_duration(path: str) -> float: + out = subprocess.run( + ["ffprobe", "-v", "error", "-show_entries", "format=duration", + "-of", "default=nw=1:nk=1", path], + capture_output=True, text=True, check=True).stdout.strip() + return float(out) + + +def _mp3_bitrate(path: str) -> int: + out = subprocess.run( + ["ffprobe", "-v", "error", "-show_entries", "format=bit_rate", + "-of", "default=nw=1:nk=1", path], + capture_output=True, text=True, check=True).stdout.strip() + return int(out) + + +def _ai_available() -> bool: + return vg._ai_deps_present() + + +# --------------------------------------------------------------------------- +# unit tests +# --------------------------------------------------------------------------- + +def test_modes_constants(): + assert vg.MODE_NONE == "none" + assert vg.MODE_DSP == "dsp" + assert vg.MODE_AI == "ai" + assert vg.AI_MODEL_NAME == "htdemucs_ft" + + +def test_dsp_available_and_shape(): + sep = vg.DSSeparator(intensity=0.8, stationary=True) + ok, reason = sep.available() + assert ok, f"DSP should be available in this venv: {reason}" + y = _synthetic_voice(48000) + out, sr = sep.separate(y, 48000, lambda m: None) + assert sr == 48000, "DSP must not change the sample rate" + assert out.dtype == np.float32 + assert out.ndim == 1 and len(out) == len(y) + assert np.isfinite(out).all(), "no NaN/Inf allowed" + assert float(np.sqrt(np.mean(out**2))) > 0, "must not be all-silence" + + +def test_ai_available_report(): + sep = vg.DemucsSeparator() + ok, reason = sep.available() + if _ai_available(): + assert ok + else: + assert not ok + assert "requirements-ai.txt" in reason + + +def test_ai_graceful_without_torch(): + """DemucsSeparator.available() must degrade gracefully when torch is absent.""" + code = ( + "import sys, types\n" + "class Blocker:\n" + " def find_spec(self, name, path=None, target=None):\n" + " if name == 'torch' or name.startswith('torch.'):\n" + " raise ModuleNotFoundError('blocked: ' + name)\n" + " return None\n" + "sys.meta_path.insert(0, Blocker())\n" + "sys.path.insert(0, %r)\n" + "import voicegrab as vg\n" + "ok, reason = vg.DemucsSeparator().available()\n" + "assert not ok, 'should report unavailable when torch is blocked'\n" + "assert 'requirements-ai.txt' in reason\n" + "print('graceful-degradation OK')\n" + ) % ROOT + r = subprocess.run([sys.executable, "-c", code], + capture_output=True, text=True, timeout=120) + assert r.returncode == 0, r.stderr + assert "graceful-degradation OK" in r.stdout + + +def test_ai_pick_device_order(): + if not _ai_available(): + print(" (skipped: torch/demucs not installed)") + return + sep = vg.DemucsSeparator(device="cuda") + assert sep._pick_device() == "cuda", "explicit device must be honored" + auto = vg.DemucsSeparator()._pick_device() + assert auto in ("cpu", "cuda", "mps") + + +def test_ai_separate_unit(): + if not _ai_available(): + print(" (skipped: torch/demucs not installed)") + return + sep = vg.DemucsSeparator() + y = _synthetic_voice(3 * 48000) # 3 s @ 48 kHz + out, sr = sep.separate(y, 48000, lambda m: None) + assert sr == 44100, "Demucs runs at 44.1 kHz" + assert out.dtype == np.float32 and out.ndim == 1 + assert np.isfinite(out).all() + expected = 3 * 44100 + assert abs(len(out) - expected) <= 44100 * 0.02, f"duration drift: {len(out)} vs {expected}" + assert float(np.sqrt(np.mean(out**2))) > 0, "must not be all-silence" + + +# --------------------------------------------------------------------------- +# regression: full CLI pipeline (GPU-upgrade.md §6.2) +# --------------------------------------------------------------------------- + +def test_cli_dsp_regression(): + with tempfile.TemporaryDirectory(prefix="voicegrab-test-") as wd: + src = os.path.join(wd, "in.mp3") + out = os.path.join(wd, "out.mp3") + _write_test_mp3(src, 5) + r = subprocess.run( + [sys.executable, os.path.join(ROOT, "voicegrab.py"), + "--cli", src, "0", "5", out, "--mode", "dsp"], + capture_output=True, text=True, timeout=300) + assert r.returncode == 0, r.stderr + assert os.path.exists(out) + dur = _mp3_duration(out) + assert abs(dur - 5.0) <= 0.15, f"duration {dur} not within 5.0 ± 0.15 s" + br = _mp3_bitrate(out) + assert 300_000 <= br <= 350_000, f"expected ~320 kbps, got {br}" + + +def test_cli_ai_regression(): + if not _ai_available(): + print(" (skipped: torch/demucs not installed)") + return + with tempfile.TemporaryDirectory(prefix="voicegrab-test-") as wd: + src = os.path.join(wd, "in.mp3") + out = os.path.join(wd, "out.mp3") + _write_test_mp3(src, 5) + r = subprocess.run( + [sys.executable, os.path.join(ROOT, "voicegrab.py"), + "--cli", src, "0", "5", out, "--mode", "ai"], + capture_output=True, text=True, timeout=600) + assert r.returncode == 0, r.stderr + assert os.path.exists(out) + dur = _mp3_duration(out) + assert abs(dur - 5.0) <= 0.15, f"duration {dur} not within 5.0 ± 0.15 s" + br = _mp3_bitrate(out) + assert 300_000 <= br <= 350_000, f"expected ~320 kbps, got {br}" + + +def test_cli_none_mode(): + with tempfile.TemporaryDirectory(prefix="voicegrab-test-") as wd: + src = os.path.join(wd, "in.mp3") + out = os.path.join(wd, "out.mp3") + _write_test_mp3(src, 5) + r = subprocess.run( + [sys.executable, os.path.join(ROOT, "voicegrab.py"), + "--cli", src, "0", "5", out, "--mode", "none"], + capture_output=True, text=True, timeout=300) + assert r.returncode == 0, r.stderr + assert abs(_mp3_duration(out) - 5.0) <= 0.15 + + +def test_cli_rejects_bad_mode(): + with tempfile.TemporaryDirectory(prefix="voicegrab-test-") as wd: + src = os.path.join(wd, "in.mp3") + _write_test_mp3(src, 5) + r = subprocess.run( + [sys.executable, os.path.join(ROOT, "voicegrab.py"), + "--cli", src, "0", "5", os.path.join(wd, "o.mp3"), "--mode", "bogus"], + capture_output=True, text=True, timeout=120) + assert r.returncode == 2 + assert "none | dsp | ai" in r.stdout + + +# --------------------------------------------------------------------------- + +def main() -> int: + tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")] + failed = 0 + for t in tests: + name = t.__name__ + try: + t() + print(f"PASS {name}") + except AssertionError as exc: + failed += 1 + print(f"FAIL {name}: {exc}") + except Exception as exc: # noqa: BLE001 + failed += 1 + print(f"ERROR {name}: {type(exc).__name__}: {exc}") + total = len(tests) + print(f"\n{total - failed}/{total} passed") + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/voicegrab.py b/voicegrab.py index 003ec5d..75df080 100644 --- a/voicegrab.py +++ b/voicegrab.py @@ -3,22 +3,27 @@ - Pick an input video (mp4, mkv, webm, avi, mov, ...) - Set start / stop timestamps -- Optional: isolate voice with noise reduction (no GPU or big models needed) +- Optional voice processing (one of, mutually exclusive): + * fast noise reduction (CPU DSP via noisereduce — no downloads) + * AI voice separation (Demucs htdemucs_ft — GPU if available, optional install) + * no processing (loudness-normalize only) - Export MP3 (320 kbps, loudness-normalized to -16 LUFS — good for AI voice training) - Waveform preview with the selected range highlighted """ from __future__ import annotations +import math import os import platform import shutil import subprocess import sys import tempfile +import threading import wave APP_NAME = "VoiceGrab" -APP_VERSION = "1.0.0" +APP_VERSION = "1.1.0" # --------------------------------------------------------------------------- # Resource / executable helpers @@ -33,7 +38,7 @@ def app_dir() -> str: def appdata_dir() -> str: - """Per-user directory for saved model noise profiles.""" + """Per-user directory for cached AI models (and any saved profiles).""" if platform.system() == "Windows": base = os.environ.get("APPDATA", os.path.expanduser("~")) elif platform.system() == "Darwin": @@ -179,6 +184,203 @@ class VoiceReducer: return nr.reduce_noise(**kwargs) +# --------------------------------------------------------------------------- +# Separation backends — one mode at a time (DSP and AI are alternatives, not +# stacked: running noisereduce after Demucs adds double-processing artifacts) +# --------------------------------------------------------------------------- + +MODE_NONE = "none" # loudness-normalize only +MODE_DSP = "dsp" # fast STFT noise reduction (noisereduce) +MODE_AI = "ai" # deep-learning separation (Demucs htdemucs_ft) + +AI_MODEL_NAME = "htdemucs_ft" # MIT-licensed weights; we keep its "vocals" stem + + +class Separator: + """Common interface for the voice-processing modes.""" + + name = "base" + + def available(self) -> tuple[bool, str]: + """Cheap check (no heavy imports). Returns (ok, human-readable reason).""" + raise NotImplementedError + + def separate(self, y: "np.ndarray", sr: int, log=print) -> tuple["np.ndarray", int]: + """Return (float32 mono audio, sample_rate).""" + raise NotImplementedError + + def cancel(self): + """Best-effort cancel of a running separation (no-op for fast modes).""" + + +def _ai_deps_present() -> bool: + """Fast presence check for torch+demucs without importing them (importing + torch takes ~1-2 s and ~400 MB RAM — too expensive for a startup UI check).""" + import importlib.util + try: + return (importlib.util.find_spec("torch") is not None + and importlib.util.find_spec("demucs") is not None) + except Exception: + return False + + +class DSSeparator(Separator): + """Current noisereduce STFT path — unchanged behavior.""" + + name = MODE_DSP + + def __init__(self, intensity: float = 0.8, stationary: bool = False): + self.intensity = float(intensity) + self.stationary = stationary + + def available(self) -> tuple[bool, str]: + if not HAVE_NR: + return False, (f"noisereduce is not installed " + f"({NR_IMPORT_ERROR or 'unknown import error'})") + return True, "" + + def separate(self, y, sr, log=print) -> tuple["np.ndarray", int]: + log("Isolating voice — fast noise reduction (CPU)…") + red = VoiceReducer(self.intensity, self.stationary) + return red.reduce(y, sr), sr + + +class DemucsSeparator(Separator): + """AI voice separation with Demucs (htdemucs_ft). Lazy imports; GPU-aware. + + Only imported/used when the AI mode is actually selected, so the core app + stays a hard-dependency-free ~100 MB tool. + """ + + name = MODE_AI + + def __init__(self, model_name: str = AI_MODEL_NAME, device: str | None = None): + self.model_name = model_name + self.device = device # None → auto-pick: cuda → mps → cpu + self._api = None # demucs.api.Separator (lazy, worker thread) + self._device_used = None + self._cancel = threading.Event() + + # -- cheap checks (safe to call from the GUI thread) ------------------- + def available(self) -> tuple[bool, str]: + if not _ai_deps_present(): + return False, ("demucs/torch not installed — " + "pip install -r requirements-ai.txt " + "(pulls torch, ~2 GB with CUDA wheels)") + return True, "" + + def cancel(self): + self._cancel.set() + + # -- heavy work (worker thread only) ------------------------------------ + def _pick_device(self) -> str: + if self.device: + return self.device + import torch + if torch.cuda.is_available(): + return "cuda" + mps = getattr(torch.backends, "mps", None) + if mps is not None and mps.is_available(): + return "mps" + return "cpu" + + def _get_api(self, log): + if self._api is None: + # Cache downloads under /VoiceGrab/models. Must be set + # before torch/huggingface_hub are first imported in this thread. + root = os.path.join(appdata_dir(), "models") + os.makedirs(root, exist_ok=True) + os.environ.setdefault("TORCH_HOME", os.path.join(root, "torch")) + os.environ.setdefault("HF_HOME", os.path.join(root, "huggingface")) + from demucs.api import Separator as _DemucsApi + self._device_used = self._pick_device() + log(f"Loading AI model '{self.model_name}' " + f"(first use downloads ~90 MB, cached afterwards)…") + self._api = _DemucsApi(self.model_name, device=self._device_used) + log("AI model ready.") + return self._api + + @staticmethod + def _model_segment(model) -> float: + try: + for sub in model.models: # BagOfModels + seg = getattr(sub, "segment", None) + if seg: + return float(seg) + except AttributeError: + pass + seg = getattr(model, "segment", None) + return float(seg) if seg else 8.0 + + @staticmethod + def _estimate_total_chunks(d: dict, api) -> int: + """Expected 'end' callback count = submodels × shifts × ceil(len/stride).""" + try: + length = int(d["audio_length"]) + seg = DemucsSeparator._model_segment(api.model) + n_subs = int(d.get("models", 1) or 1) + stride = int(0.75 * seg * api.samplerate) # overlap=0.25 + if stride <= 0: + return 0 + return max(1, math.ceil(length / stride)) * n_subs + except Exception: + return 0 # unknown → log raw chunk counts instead + + def separate(self, y, sr, log=print) -> tuple["np.ndarray", int]: + import numpy as np + import torch + + api = self._get_api(log) + device = self._device_used + if device == "cuda": + log("AI voice separation on GPU (CUDA)…") + elif device == "mps": + log("AI voice separation on GPU (Apple MPS)…") + else: + log("AI voice separation on CPU (no GPU detected — expect a wait)…") + + # (C, T) float32 at the original rate; demucs resamples to 44.1 kHz + # and duplicates mono→stereo for us (see demucs.audio.convert_audio). + wav = torch.from_numpy(np.ascontiguousarray(y, dtype=np.float32)) + if wav.ndim == 1: + wav = wav[None, :] + + state = {"done": 0, "total": None, "last_pct": -1} + + def _progress(d): + if self._cancel.is_set(): + raise KeyboardInterrupt() # demucs' documented way to abort + if state["total"] is None: + state["total"] = self._estimate_total_chunks(d, api) + if d.get("state") == "end": + state["done"] += 1 + total = state["total"] + if total: + pct = min(99, int(state["done"] * 100 / total)) + if state["done"] >= total or pct >= state["last_pct"] + 10: + state["last_pct"] = pct + log(f"AI separation: {state['done']}/{total} chunks ({pct}%)…") + else: + log(f"AI separation: chunk {state['done']} done…") + + self._cancel.clear() + try: + api.update_parameter(callback=_progress) + _ref, stems = api.separate_tensor(wav, sr) + except KeyboardInterrupt: + raise FfmpegError("Cancelled by user.") + + key = ("vocals" if "vocals" in stems + else "speech" if "speech" in stems + else next(iter(stems))) + v = stems[key] + while v.dim() > 1: + v = v[0] # first channel → mono + out = v.detach().cpu().numpy().astype(np.float32) + out = np.where(np.isfinite(out), out, 0.0) # defensive: never emit NaN/Inf + return out, int(api.samplerate) + + # --------------------------------------------------------------------------- # Audio IO helpers (numpy/soundfile) # --------------------------------------------------------------------------- @@ -199,21 +401,24 @@ def write_wav_f32(path: str, y, sr: int): # GUI # --------------------------------------------------------------------------- + def _import_pyside(): from PySide6 import QtCore, QtGui, QtWidgets # noqa: F401 return QtCore, QtGui, QtWidgets +def _peak_normalize(y): + """Peak-normalize (99.5th percentile) to ~ -1 dBFS reference.""" + import numpy as _np + p = _np.percentile(_np.abs(y), 99.5) + if p < 1e-6: + return y + return (y / p * 0.891) # ~ -1 dBFS reference peak + + def run_gui() -> int: QtCore, QtGui, QtWidgets = _import_pyside() - def _normalize(y): - import numpy as _np - p = _np.percentile(_np.abs(y), 99.5) - if p < 1e-6: - return y - return (y / p * 0.891) # ~ -1 dBFS reference peak - class Worker(QtCore.QThread): log = QtCore.Signal(str) done = QtCore.Signal(object, str) # (success, message) @@ -221,6 +426,11 @@ def run_gui() -> int: def __init__(self, job): super().__init__() self.job = job + self._sep: Separator | None = None + + def cancel(self): + if self._sep is not None: + self._sep.cancel() def run(self): import numpy as _np @@ -230,15 +440,26 @@ def run_gui() -> int: wav = extract_wav(self.job["input"], self.job["start"], self.job["stop"], wd) y, sr = read_wav_f32(wav) - if self.job["isolate"]: - if not HAVE_NR: - raise FfmpegError(f"Voice isolation unavailable: {NR_IMPORT_ERROR}") - self.log.emit("Isolating voice (noise reduction)\u2026") - red = VoiceReducer(self.job["intensity"], self.job["stationary"]) - y = red.reduce(y, sr) + mode = self.job.get("mode", MODE_DSP) + if mode == MODE_AI: + self._sep = DemucsSeparator() + elif mode == MODE_DSP: + self._sep = DSSeparator(self.job["intensity"], self.job["stationary"]) + else: + self._sep = None + if self._sep is not None: + ok, reason = self._sep.available() + if not ok: + raise FfmpegError(f"Voice processing unavailable: {reason}") + y, sr = self._sep.separate(y, sr, self.log.emit) + if mode == MODE_AI: + # Separated vocals sit a few dB below the mix; bring + # peaks up before loudnorm (post-gain step). + self.log.emit("Leveling separated vocals…") + y = _peak_normalize(y) else: self.log.emit("Normalizing loudness…") - y = _normalize(y) + y = _peak_normalize(y) work_wav = os.path.join(wd, "work.wav") write_wav_f32(work_wav, y, sr) @@ -257,10 +478,10 @@ def run_gui() -> int: self.done.emit(True, self.job["output"]) except Exception as exc: self.done.emit(False, str(exc)) - return _main_loop(QtCore, QtGui, QtWidgets, Worker, _normalize) + return _main_loop(QtCore, QtGui, QtWidgets, Worker) -def _main_loop(QtCore, QtGui, QtWidgets, Worker, _normalize) -> int: +def _main_loop(QtCore, QtGui, QtWidgets, Worker) -> int: app = QtWidgets.QApplication(sys.argv) app.setApplicationName(APP_NAME) app.setApplicationVersion(APP_VERSION) @@ -321,11 +542,26 @@ def _main_loop(QtCore, QtGui, QtWidgets, Worker, _normalize) -> int: row2.addWidget(btn_listen) lay.addLayout(row2) - # Row 3: isolation + output - self.chk_isolate = QtWidgets.QCheckBox("Isolate voice (reduce background noise)") - self.chk_isolate.setChecked(True) - self.chk_isolate.toggled.connect(self._isolate_toggled) - lay.addWidget(self.chk_isolate) + # Row 3: processing mode (one of: fast DSP / AI / none) + proc_box = QtWidgets.QGroupBox("Voice processing (choose one)") + proc_lay = QtWidgets.QVBoxLayout(proc_box) + self.rb_dsp = QtWidgets.QRadioButton( + "Fast noise reduction (CPU) — fan / hum / room tone; no downloads") + self.rb_ai = QtWidgets.QRadioButton( + "AI voice separation (Demucs) — background music & second speakers; " + "uses GPU if available") + self.rb_none = QtWidgets.QRadioButton( + "No processing — just loudness-normalize") + self.rb_dsp.setChecked(True) + self.rb_ai.setToolTip( + "Deep-learning vocal separation. First use downloads ~90 MB of models. " + "GPU (NVIDIA CUDA / Apple) is strongly recommended; on CPU a 60 s clip " + "takes a few minutes. Long AI runs can be cancelled between chunks.") + for rb in (self.rb_dsp, self.rb_ai, self.rb_none): + proc_lay.addWidget(rb) + lay.addWidget(proc_box) + self.rb_dsp.toggled.connect(self._proc_mode_changed) + self.rb_ai.toggled.connect(self._proc_mode_changed) iso_row = QtWidgets.QHBoxLayout() iso_row.addWidget(QtWidgets.QLabel("Noise reduction strength:")) @@ -339,11 +575,12 @@ def _main_loop(QtCore, QtGui, QtWidgets, Worker, _normalize) -> int: "Stationary noise (fan / hum — better if constant)") iso_row.addWidget(self.chk_stationary) iso_row.addStretch(1) - self.lbl_iso_status = QtWidgets.QLabel("") - self.lbl_iso_status.setStyleSheet("color:#64748b;") - iso_row.addWidget(self.lbl_iso_status) lay.addLayout(iso_row) - self.iso_row_widget = iso_row + + self.lbl_proc_status = QtWidgets.QLabel("") + self.lbl_proc_status.setStyleSheet("color:#64748b;") + self.lbl_proc_status.setWordWrap(True) + lay.addWidget(self.lbl_proc_status) out_row = QtWidgets.QHBoxLayout() out_row.addWidget(QtWidgets.QLabel("Output MP3:")) @@ -363,6 +600,14 @@ def _main_loop(QtCore, QtGui, QtWidgets, Worker, _normalize) -> int: # Bottom bar bar = QtWidgets.QHBoxLayout() + self.btn_cancel = QtWidgets.QPushButton("Cancel") + self.btn_cancel.setMinimumHeight(40) + self.btn_cancel.setEnabled(False) + self.btn_cancel.setToolTip( + "Cancels an in-progress AI separation at the next chunk boundary. " + "Fast DSP runs finish in seconds and are not cancelable.") + self.btn_cancel.clicked.connect(self._cancel) + bar.addWidget(self.btn_cancel) self.btn_export = QtWidgets.QPushButton("Export MP3") self.btn_export.setMinimumHeight(40) self.btn_export.setStyleSheet( @@ -375,15 +620,32 @@ def _main_loop(QtCore, QtGui, QtWidgets, Worker, _normalize) -> int: bar.addWidget(self.lbl_status, 1) lay.addLayout(bar) - if not HAVE_NR: - self.lbl_iso_status.setText(f"⚠ voice isolation unavailable ({NR_IMPORT_ERROR})") - self.chk_isolate.setEnabled(False) + # ----- availability / default mode (cheap checks only) ----- + dsp_ok, dsp_reason = DSSeparator().available() + ai_ok, ai_reason = DemucsSeparator().available() + self.spn_intensity.setEnabled(dsp_ok) + self.chk_stationary.setEnabled(dsp_ok) + if not dsp_ok: + self.rb_dsp.setEnabled(False) + self.rb_none.setChecked(True) + self.rb_ai.setEnabled(ai_ok) + self._proc_mode_changed(self.rb_dsp.isChecked()) + hints = [] + if not ai_ok: + hints.append(f"⚠ AI separation unavailable — {ai_reason}") + else: + hints.append( + "AI mode ready — GPU (NVIDIA/Apple) used if present, otherwise CPU " + "(slower); first use downloads ~90 MB of models, cached afterwards.") + if not dsp_ok: + hints.append(f"⚠ fast noise reduction unavailable — {dsp_reason}") + self.lbl_proc_status.setText(" ".join(hints)) # ---------- helpers ---------- - def _isolate_toggled(self, on): - for w in self.iso_row_widget.items(): - if isinstance(w, QtWidgets.QWidget) and w not in (self.lbl_iso_status,): - w.setEnabled(on) + def _proc_mode_changed(self, *_): + dsp = self.rb_dsp.isChecked() + self.spn_intensity.setEnabled(dsp) + self.chk_stationary.setEnabled(dsp) @staticmethod def _parse_time(s: str) -> float: @@ -515,11 +777,18 @@ def _main_loop(QtCore, QtGui, QtWidgets, Worker, _normalize) -> int: out = os.path.join(os.path.dirname(os.path.abspath(self.input_path)), base + "_voicegrab.mp3") self.btn_export.setEnabled(False) + self.btn_cancel.setEnabled(True) self.status("Working… (see log in status area)") + if self.rb_ai.isChecked(): + mode = MODE_AI + elif self.rb_none.isChecked(): + mode = MODE_NONE + else: + mode = MODE_DSP self.worker = Worker({ "input": self.input_path, "start": s, "stop": e, "output": out, - "isolate": self.chk_isolate.isChecked(), + "mode": mode, "intensity": self.spn_intensity.value() / 100.0, "stationary": self.chk_stationary.isChecked(), "lufts": -16, @@ -528,8 +797,15 @@ def _main_loop(QtCore, QtGui, QtWidgets, Worker, _normalize) -> int: self.worker.done.connect(self._export_done) self.worker.start() + def _cancel(self): + if self.worker is None: + return + self.worker.cancel() + self.status("Cancelling… (AI separation stops at the next chunk boundary)") + def _export_done(self, ok, msg): self.btn_export.setEnabled(True) + self.btn_cancel.setEnabled(False) self.worker = None if ok: self.status(f"✔ Saved: {msg}") @@ -580,10 +856,23 @@ def _main_loop(QtCore, QtGui, QtWidgets, Worker, _normalize) -> int: if __name__ == "__main__": if "--cli" in sys.argv: - # Simple headless mode: voicegrab --cli [output.mp3] + # Simple headless mode: + # voicegrab --cli [output.mp3] [--mode none|dsp|ai] sys.argv = [a for a in sys.argv if a != "--cli"] + mode = MODE_DSP + if "--mode" in sys.argv: + i = sys.argv.index("--mode") + try: + mode = sys.argv[i + 1] + except IndexError: + print("usage: --mode needs a value: none | dsp | ai") + raise SystemExit(2) + del sys.argv[i:i + 2] + if mode not in (MODE_NONE, MODE_DSP, MODE_AI): + print(f"unknown --mode {mode!r} (use: none | dsp | ai)") + raise SystemExit(2) if len(sys.argv) < 4: - print("usage: voicegrab --cli [output.mp3]") + print("usage: voicegrab --cli [output.mp3] [--mode none|dsp|ai]") raise SystemExit(2) inp, s, e = sys.argv[1], float(sys.argv[2]), float(sys.argv[3]) out = sys.argv[4] if len(sys.argv) > 4 else os.path.join( @@ -593,9 +882,21 @@ if __name__ == "__main__": with tempfile.TemporaryDirectory(prefix="voicegrab-") as wd: wav = extract_wav(inp, s, e, wd) y, sr = read_wav_f32(wav) - if HAVE_NR: - red = VoiceReducer(0.8, stationary=True) - y = red.reduce(y, sr) + if mode == MODE_AI: + sep = DemucsSeparator() + ok, reason = sep.available() + if not ok: + print(f"AI separation unavailable: {reason}") + raise SystemExit(3) + y, sr = sep.separate(y, sr, print) + y = _peak_normalize(y) # post-gain before loudnorm + elif mode == MODE_DSP: + sep = DSSeparator(intensity=0.8, stationary=True) + ok, reason = sep.available() + if not ok: + print(f"Fast noise reduction unavailable: {reason}") + raise SystemExit(3) + y, sr = sep.separate(y, sr, print) red_wav = os.path.join(wd, "reduced.wav") write_wav_f32(red_wav, y, sr) subprocess.run([