From 02b9990004606c4cf91210debab4e77813caad35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C5=9Eafak?= Date: Fri, 2 May 2025 12:10:08 +0300 Subject: [PATCH] Added profile system to voice mixer, improvements in code --- CHANGELOG.md | 8 +- README.md | 16 +- abogen/assets/female.png | Bin 340 -> 442 bytes abogen/assets/flags/a.png | Bin 484 -> 571 bytes abogen/assets/flags/b.png | Bin 531 -> 786 bytes abogen/assets/flags/e.png | Bin 331 -> 521 bytes abogen/assets/flags/f.png | Bin 306 -> 372 bytes abogen/assets/flags/h.png | Bin 373 -> 465 bytes abogen/assets/flags/i.png | Bin 306 -> 381 bytes abogen/assets/flags/j.png | Bin 314 -> 441 bytes abogen/assets/flags/p.png | Bin 464 -> 617 bytes abogen/assets/flags/z.png | Bin 317 -> 431 bytes abogen/assets/male.png | Bin 305 -> 426 bytes abogen/assets/profile.png | Bin 0 -> 241 bytes abogen/assets/voice_mixer.png | Bin 252 -> 331 bytes abogen/conversion.py | 27 +- abogen/gui.py | 232 +++++++-- abogen/utils.py | 8 +- abogen/voice_formula_gui.py | 871 ++++++++++++++++++++++++++++++++-- abogen/voice_formulas.py | 6 +- abogen/voice_profiles.py | 59 +++ demo/voice_mixer.png | Bin 0 -> 28897 bytes 22 files changed, 1137 insertions(+), 90 deletions(-) create mode 100644 abogen/assets/profile.png create mode 100644 abogen/voice_profiles.py create mode 100644 demo/voice_mixer.png diff --git a/CHANGELOG.md b/CHANGELOG.md index 84cc88f..a987142 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ -- Added voice mixing functionality that enables combining multiple voices into a single "Mixed voice", as suggested by @PulsarFTW in #1. (Special thanks to @jborza for making this feature possible with his contributions in #5) +– Added voice mixing, allowing multiple voices to be combined into a single “Mixed Voice”, a feature mentioned by @PulsarFTW in #1. Special thanks to @jborza for making this possible through his contributions in #5. +- Added profile system to voice mixer, allowing users to create and manage multiple voice profiles. +- Improvements in the voice mixer, mostly for organizing controls and enhancing user experience. - Added icons for flags and genders in the GUI, making it easier to identify different options. -- Switched to platformdirs for determining the correct desktop path, instead of using old methods. \ No newline at end of file +- Switched to platformdirs for determining the correct desktop path, instead of using old methods. +- Fixed preview voices was not using GPU acceleration, which was causing performance issues. +- Improvements in code and documentation. \ No newline at end of file diff --git a/README.md b/README.md index ab5d746..55abb0c 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ abogen 1) Drag and drop any ePub, PDF, or text file (or use the built-in text editor) 2) Configure the settings: - Set speech speed - - Select a voice + - Select a voice (or create a custom voice using voice mixer) - Select subtitle generation style (by sentence, word, etc.) - Select output format - Select where to save the output @@ -83,6 +83,7 @@ Here’s Abogen in action: in this demo, it processes ∼3,000 characters of tex - **Supported formats**: `ePub`, `PDF`, or `.TXT` files (or use built-in text editor) - **Speed**: Adjust speech rate from `0.1x` to `2.0x` - **Voices**: First letter of the language code (e.g., `a` for American English, `b` for British English, etc.), second letter is for `m` for male and `f` for female. +- **Voice mixer**: Create custom voices by mixing different voice models with a profile system. - **Generate subtitles**: `Disabled`, `Sentence`, `Sentence + Comma`, `1 word`, `2 words`, `3 words`, etc. (Represents the number of words in each subtitle entry) - **Output formats**: `.WAV`, `.FLAC`, or `.MP3` - **Save location**: `Save next to input file`, `Save to desktop`, or `Choose output folder` @@ -93,10 +94,15 @@ Here’s Abogen in action: in this demo, it processes ∼3,000 characters of tex - **Create desktop shortcut**: Creates a shortcut on your desktop for easy access. - **Open config.json directory**: Opens the directory where the configuration file is stored. - **Open temp directory**: Opens the temporary directory where converted text files are stored. - - **Clear all teporary files**: Deletes all temporary files created during the conversion process. + - **Clear all temporary files**: Deletes all temporary files created during the conversion process. - **Check for updates at startup**: Automatically checks for updates when the program starts. - **After conversion**: `Open file`, `Go to folder`, `New conversion`, or `Go back`. +## `Voice Mixer` + + +With voice mixer, you can create custom voices by mixing different voice models. You can adjust the weight of each voice and save your custom voice as a profile for future use. The voice mixer allows you to create unique and personalized voices. (Huge thanks to @jborza for making this possible through his contributions in #5) + ## `Supported Languages` ``` # 🇺🇸 'a' => American English, 🇬🇧 'b' => British English @@ -136,7 +142,7 @@ Abogen is a standalone project, but it is inspired by and shares some similariti - [ ] Improve PDF support for better text extraction. - [ ] Add chapter metadata for .m4a files using ffmpeg-bin. - [ ] Add support for different languages in GUI. -- [ ] Add voice formula feature that enables mixing different voice models. https://github.com/denizsafak/abogen/issues/1 +- [x] Add voice formula feature that enables mixing different voice models. #1 - [ ] Add support for kokoro-onnx. - [ ] Add dark mode. @@ -154,7 +160,7 @@ If you'd like to modify the code and contribute to development, you can [downloa ```bash # Go to the directory where you extracted the repository and run: pip install -e . # Installs the package in editable mode -python -m build # Builds the package in dist folder +python -m build # Builds the package in dist folder (optional) abogen # Opens the GUI ``` Feel free to explore the code and make any changes you like. @@ -163,7 +169,7 @@ Feel free to explore the code and make any changes you like. - Abogen uses [Kokoro](https://github.com/hexgrad/kokoro) for its high-quality, natural-sounding text-to-speech synthesis. Huge thanks to the Kokoro team for making this possible. - Thanks to [@wojiushixiaobai](https://github.com/wojiushixiaobai) for [Embedded Python](https://github.com/wojiushixiaobai/Python-Embed-Win64) packages. These modified packages include pip pre-installed, enabling Abogen to function as a standalone application without requiring users to separately install Python in Windows. - Special thanks to the [PyQt](https://www.riverbankcomputing.com/software/pyqt/) team for providing the cross-platform GUI toolkit that powers Abogen's interface. -- [US](https://icons8.com/icon/aRiu1GGi6Aoe/usa), [Great Britain](https://icons8.com/icon/t3NE3BsOAQwq/great-britain), [Spain](https://icons8.com/icon/ly7tzANRt33n/spain), [France](https://icons8.com/icon/3muzEmi4dpD5/france), [India](https://icons8.com/icon/esGVrxg9VCJ1/india), [Italy](https://icons8.com/icon/PW8KZnP7qXzO/italy), [Japan](https://icons8.com/icon/McQbrq9qaQye/japan), [Brazil](https://icons8.com/icon/zHmH8HpOmM90/brazil), [China](https://icons8.com/icon/Ej50Oe3crXwF/china), [Female](https://icons8.com/icon/uI49hxbpxTkp/female), [Male](https://icons8.com/icon/12351/male) and [Mix](https://icons8.com/icon/21700/adjust) icons by [Icons8](https://icons8.com/). +- Icons: [US](https://icons8.com/icon/aRiu1GGi6Aoe/usa), [Great Britain](https://icons8.com/icon/t3NE3BsOAQwq/great-britain), [Spain](https://icons8.com/icon/ly7tzANRt33n/spain), [France](https://icons8.com/icon/3muzEmi4dpD5/france), [India](https://icons8.com/icon/esGVrxg9VCJ1/india), [Italy](https://icons8.com/icon/PW8KZnP7qXzO/italy), [Japan](https://icons8.com/icon/McQbrq9qaQye/japan), [Brazil](https://icons8.com/icon/zHmH8HpOmM90/brazil), [China](https://icons8.com/icon/Ej50Oe3crXwF/china), [Female](https://icons8.com/icon/uI49hxbpxTkp/female), [Male](https://icons8.com/icon/12351/male) and [Voice Id](https://icons8.com/icon/GskSeVoroQ7u/voice-id), [Person](https://icons8.com/icon/34105/person) icons by [Icons8](https://icons8.com/). ## `License` This project is available under the MIT License - see the [LICENSE](https://github.com/denizsafak/abogen/blob/main/LICENSE) file for details. diff --git a/abogen/assets/female.png b/abogen/assets/female.png index 250dca3d35b45f1b8764506b8df5feb82163d8fa..f252ccebd81cbf2ce1b17de2e5e1458d4a5a7ceb 100644 GIT binary patch delta 430 zcmV;f0a5mzPe{4xa zK~#90cJ07t>OYU)qLqJF=FCmZd0wW!0#3r=$J7wfj?;< Y6wEiDGy=l<00000NkvXX1g=70f-fGue*gdg delta 327 zcmV-N0l5CU1JnW`iBL{Q4GJ0x0000DNk~Le0000G0000G2nGNE03Y-JVUZyie+EfJ zK~#90wURMQ#6S>*gO#1O`U9-&6v4*URCnx*sYUHQ9JQ(h+Pm1e}Q05 zn0Gd6WKIMj#U=+nc(A6yaTL&Om$M+fGt!UI(%Q4zNIs4A*6*SKS5uZV;9^)W zZ@jn>WZ+rSS4Mi{?q3Xf3YJqNsoaxu$kXlsmI9>J<#7N2002ovPDHLkV1niEkN^Mx diff --git a/abogen/assets/flags/a.png b/abogen/assets/flags/a.png index a110f86bb8e2e112796442afbc7f6a7a5d7d7a1f..f770fcfff221beaa6793b6b32caadebf7a96ceb2 100644 GIT binary patch delta 560 zcmV-00?+;A1G@wviBL{Q4GJ0x0000DNk~Le0000O0000O2nGNE0N{5$_>mzPf9**` zK~#90V_+E8Kr3_=n@hy@$k^m7o(-UvzA`_(II=XdXzoe%8-wl4qVWES$@*qD_p z!)rUwWKEiL<#lb>+5d}H-~JDyC(OPA#`b|*|9^2d`~TU=?BQ2Ob3dXD4h>z!oLY2n z7RZ3G)P4W)(Qn>=MBsNHJBYHte<^rN@97J#|A(aP`;U!|%-sL~%8hsb-NJYL|Il1> z02zPEN~*$ZxP$-JV8`HX|AUkF{>MdoMehV-SP-FOoWT`ezn^2$oU5puG(U)a}Ti340g^s%XCH--iF0b759G?9Y8;0U76 zPG&zbEC6{6rV1V!^9due{&SnzE5#u*{yi0i>2_gHe1Fs>Sx%#WW05 ywtRLnz4zJK^cxsEncjnlnT3MXkY)Ig0RSvjwUx;I93}t&002ov22Mn-LSTZnO&E^= delta 472 zcmV;}0Vn>u1mpuDiBL{Q4GJ0x0000DNk~Le0000G0000G2nGNE03Y-JVUZyif0ju^ zK~#90V_={Ka17qA9h9)A-ZyU762I7;%j^QTR(*0a)*z_SC3H7`apS4&$%Tjh2l3-_5B_%v+m3L-k_E@!EnaZ! zV||4h+yIB5ZPg(E)^(lzpImt8Kg|CiepbcN|H(y%QCt9aMR{&E+yUGOO_rU!1ohuzz9(QP1vL3-c@ds4>P{c!S0&~+8*q_38WTlYJBmzOe*(lw zL_t(|oZVILOA~PzH^}~&-pUa(xD7JQ!nW^nM82>WfzVH5{dOX114S(iA)*k1q8HU( z#O{`C+HRsoy0ly=Xr|7)+ibhdO*eCQw+ShW} z^!kt%QKtR@u2Zno+gJ^ez=~~rf2PyZXE0_@&PfAhBoQ^IMKrhvIYzMDWjGB1Sh0=I z@Hef2p4IAU!!BII0WG4sl*=fi1qn8kG|&(6-EG~!cr#P4Gg9f@5@-<(HCH^=!fyvC z2Y_@kDNfck41eD#L0Od}MfdrKQvrS?M2kGnLq^2Ue-mMMMRjky z5P_x5`(-_u`mx;gz{8aiG4AM*2>>Y0A&5WkL0l;zuo_||+TO~5D2kXxlH>r(C#bCi z<37PT=U*9G|K^+GkLi5)JZRL9iNWA60647{o^~l5o)sGIxx?l&_R9)!fcQcFHU%J1JNRrH{HrZxr?ig>{I>g*N9CoK_T&)G2?|ocTY0^M<>}XtP zg+z9wER$^rNJPz$$X+-s`2?>+ycYAVO#FzFTCaLS_l^7kd#ZtomSa9{00000NkvXX Hu0mjfmK1Y+ delta 520 zcmV+j0{8ur29pFKiBL{Q4GJ0x0000DNk~Le0000G0000G2nGNE03Y-JVUZyhe*wiw zL_t(|oMT|11+WX)qIvz!yS%5CCJR6q#0H5G)M(?qMbIvA>oWVmE&m=r`*Q55rRjeV z2C+e6_5oX#xPS&g|Nnpa|NsANxB*YWp$$QVQ zYGe82RZ7y)n-?A*c=PthA-HYNzZ_e)?f#*^fB%2}dH(#hr;c`4Uq?o2pv1(BK)=ba z_Z&P>+jZtNu4oKR+57V0UX#x!|Ns5F5F;I2x$;5CC3O24LJ7$(aO?T}`tt%fV;dh<%_s&1ZPGx`Of~^q)d*@N2LJ%NfwgtOq|BoL0000< KMNUMnLSTZtXB@-; diff --git a/abogen/assets/flags/e.png b/abogen/assets/flags/e.png index f6474407b271343c652508af1fa784d7dab3f657..693cf2a8f10cdbd63aac2bc64862a532c99e8c19 100644 GIT binary patch delta 510 zcmVmzOe*wEm zL_t(|oZVGTC`3^dKB+fDit)2GMHZU-QlhM6p;(cBBNjGFHkMX)vh!OaiR33Uh@otR z89xic$_DoiqZCnAj0JtVcpDxw^B#&*-{zfnzw^#H_kADHzmDT{vI>>zxJolzr8BNl z@CA%cIU_fBQ0<|EEQZL1gDQnee*vKqLLk5ym4{6XqA0`%i~SsYed@~}11iJgf|TzYJgbP&`l5R}QYVLLWjbJ1O&g7)G9Y>&-jyQ2_d z$R0rl*1JzfbTMwn?mz)trEzF4%Exk*4F`k8I3Kf{9?{E^k3FgIPGn=DF$1nPCsvxW z;G4|BktfyUk~b9Rw_Q`OR8sx>KiZE=g%(m3O^975g(=s)MwArc$h8*IM5K#_Bz|?e zGBOg<4ml&IP-&Vo@^eP_Z}7`!CyZ(gTjrmMXGl>c9W%V)2LJ#707*qoM6N<$g0OGh Ai2wiq delta 319 zcmV-F0l@x=1j_;;iBL{Q4GJ0x0000DNk~Le0000G0000G2nGNE03Y-JVUZyhe*pbS zL_t(|oMT|11-Plppmax@q3o_U!%Pqcu^;F$C=%58T#teKt`5V(J30)1@9Hr8N5)_= zZHDw%1uq$B4lowJ4hZ@8yvHe~|sp|AXxRiNXii z|NcM7jtGmzOe*q>* zL_t(|oZXd8PQx$|gtb<^Ij~} za{xe233}V77uo4o^F0AX9LJ7$)HE1?;dc9SWws0ki16fyNUZ|`*1rp2cyrysB47uP zF2G$tz>NbiI|&%k(D~5paPtN*EWe$wTx6*?fFpI9*C1yefH9hjI(3L{J`WhT8rPql z$eFaSyAi;!pa_TX_%c&%%IlRW?zJgjtg3Z!<@II$nR*AqbKBtpdak1Y0000|_g^ny7r15m;?*~fy?OufKQjKj zxaZh^8;0rsT^RVWYBu)S#AP42^_YF&*8huEU&k=u%c9O>|7{um|F>Z{_1~U>2g3ln zz^(H^nh6>J!Shfw+XQV`$PQ6du3$^Whl+!zLcfJ^9Ze!NN9mSGys rq>K!}nd$wfGH7O+3uY6OnaBYE)WgE$C&8^z00000NkvXXu0mjf0ttwS diff --git a/abogen/assets/flags/h.png b/abogen/assets/flags/h.png index a3a420912fe13ddadef03daad727c5bf2ce8b800..015ec29ca282a4038f85e95d4b171fc7158bc488 100644 GIT binary patch delta 453 zcmV;$0XqKm0?`8@iBL{Q4GJ0x0000DNk~Le0000O0000O2nGNE0N{5$_>mzPe}hRx zK~#90-IKvf!eAK2-z5Eqgf`h1-Z~XpQS{$TJDU4!9^5=?MBpvSyI!}8c(l-*;EqD= z1EIqk-%yY?x3zhx2Ob#j-tYNs@5=}9uZ11RU;}PYhbPovf*P>3CvJ_u>p&)6cmpm` zgE{JeA+}J5i|D~Jq)>5sn*3aJUX<3Bu8}2Iv0aO-Q2#v%~-$ zrYOUq2e2|Lg|Fw6{yw}iD+LdLG4{Y1!(Co1me_6&UEB6@XW!aw55RGp*P!rv-9Xc{ z&@e1iRU;6>v-|+gt^=*s5DjC7rP2V)<$(+0wE%$Q2lxyEn5MM_m<0kDe`6nhfN{`> zRMl|*!ZhbmMl}BQl5KnGes%06cSpUV{Bqmhhr6R*(F4dfvuS~ncaaiIW@(a_^WKZb zm8+7|q<|xO?7d{$5~-xb1}NkXLM1~=P$U`yPz8j8aKL6Qt=Oa}h{(;1H9)QrGK{!e7!!!%$z!%`wN!(6f$Rr9sUYJZoh9Q(UOi8#DW<=Br4 zlhxn`eEIUF;_KJ1|B>;F7w`XXf7x;aj9(>S73bt*#@qfXB z^ZyqvJP&50%YXg)bsn+-^RXEK(u~J|xp=+s^5qBOy|C`WIOwfTo> zD@e?GF-Q$o&B(yK-%it_*SgZY*Jc3-gV>fmmKp>#k^=yOKe8{iVsg^}0000X diff --git a/abogen/assets/flags/i.png b/abogen/assets/flags/i.png index d602e54368fcccc479f1fe1567f0c5dc5b0e3d87..667820228014d56091e392e85dd0b0246a6facd9 100644 GIT binary patch delta 369 zcmV-%0gnE%0{sFZiBL{Q4GJ0x0000DNk~Le0000O0000O2nGNE0N{5$_>mzOe*rH^ zL_t(|oZXc{PQx$|MSoeecj*Cekesf&L!=bJ1xPlWp(m)I>~WBw0(u`ByxKhs zW0pvzX`RSNLOyzxjRynlgu)v|f3ZhK6#$tyE(mCp0wTdKzsxrREFq<3$(wHs$Yd|q z*{Iu!1bcM=NT`dJ<@9?eKh3uU5Or`=1jKCO48U=3xm<7N4B+FXn1Nsmhk(WJ0ysWw zC7L?S;MoFT4-Nr$ML^7s9hRtXZ8S67Y6CcCD<|x!U_os_2Z7Z*_nOXYM*xN>4MHOi zk<|giO5^%FlWXt5bayF$V@f{uQRB-Dq|&!@l|D+P-;A<#dQ|7{um|F>Z{_1~U>2g87j zc^30Qnh6>J!ShfwXU;WIN}q53dz)wjY#9Fhw`Wj6IDejb5ll0YE&v%)g>e3S^BH6q zuz*qn=2PYcTZT&N1tP`K$iTqBP`Jv1Gkw1KQ6du3$^Whl+!zLcK-x@We!NN9mSGys rq>K!}nd$wfGH7O+3uY6OnaBYE7A3*&j@7&400000NkvXXu0mjfI_!nd diff --git a/abogen/assets/flags/j.png b/abogen/assets/flags/j.png index 4bcf2f98f7134250fa09b717bf476bf61d233fc2..ad5569e2dda699487c537899dcd2e8a795635767 100644 GIT binary patch delta 430 zcmV;f0a5mzOe*tSr zL_t(|oZVF0PQpMCZ7}dLaU?)}WVP6P31VBHsRS+nPJ~or0g4Jss!#wN zZ}aLsA@QaZ7&eKpM?|&qmsbIYe-%l$T$ZZfu&5c#k4(}u7JBj*r}sWZj+>R<8wsR( zWe1NtNf6H%dDIY!AXf^O3h7lmGw# delta 302 zcmV+}0nz@s1G)ksiBL{Q4GJ0x0000DNk~Le0000G0000G2nGNE03Y-JVUZyhe*o-B zL_t(|oV8O+3c@fD?Fn>eTTfuUoOR>xEPg<3@1r7J>Q-h*CfV1>h@Uhy6hQ+6VZwWp z_wo`1|5%8+kAkKkXvM{QM4dDOg%E9&DhEMVl*-uk=w*o5@`6Fq!3EZcB<(+vpj3Gi z_+7NAX$I@sZ~=1~q^1ZXQ$(>jf2ZlaSCM0Tf`VrD1O^;~pi$vs^t4p00000NkvXX1g=70f&}1w A>i_@% diff --git a/abogen/assets/flags/p.png b/abogen/assets/flags/p.png index 9913de8531f27dcf9925a42cf524d821ef7a10d1..8abf47f0730402c3148037d9384b9c1bd8d423db 100644 GIT binary patch delta 607 zcmV-l0-*iS1L*`IiBL{Q4GJ0x0000DNk~Le0000O0000O2nGNE0N{5$_>mzOe*zmx zL_t(|oZVEc%tX6tDF}Kfw&~d(1EGi5C|(5d8bg$7X7j@| z5n9?3Xu%#@47h2NNDtja2#S(;>fw`bb(idBHXGx~4m?DZmQEy^QVxI>8lb!}dp>4a?h^_Y#Sbcg3 zPo9?{zFviopT0ro=Q$+a)}WYsXgO<4gSCVWBD+5Ls#zOkQz26t1HpTn!1Je2K6-6A zi{y}`#Muv#M8`PZog9TifBBkuNmGeM|85@|DlyNYtSGN8kwR6`1j%lrx~C~*76m3h zu(GzA{IaJ32ABQ0bx(ta=J>LXW`*>ywmj2xV78~huJ0M)p4+bC}8z}d{1^ZlGT)711IVeA;KgtH*wELzE+mosO>#-6Q# zW+LdO&RJO(3~b3+nQ#`<5}KoKf3q%VUTIW;ay3B+GFvVV?;DW1?1Id7Cyd68UqEzS z@vK{9&!qa`#q>Zf?T7ry5WE-Mu$0zpc5E{tf`+WDrJ@_AXP2ROFbVDbaR`MMpj>p> z)o?cP1Hf4nS>Fn7fX6nt%8tSA!7Usfn~*+zfW^oKoM%R4?c?56Q9~nEe}L7gBQHFg zUjQf@nK3(jaR|?4;U*W5H6%eY!r6*q!M{f#neUczQFaw1G&wKN&>q@UEOmzOe*s}h zL_t(|oZVErPC`KtJpsYcNk!ooU`u1-E|nHUqC~hp{=q~)!B~kHenNv^z_*ABCYF{4 z`(tJou2r=A4_VwIn6ysFK>OhW1w=@aAm%=(V_mt_$RT zW)YFVCGZ$J2w9ZyG)L~H7ZG@HHTNZSxGGrai9oScy(;Os1cp4wlHDTtzHQMh(%8m< zXE}1uqY{OoTR;va!;c+{>O{7cHQ#IcboJob9A4zf%RG6$VWA^|?M<@2dG1relS`lz z8&L7ie2n{P0U2Cz0d3FBe=2B93zU$d!t2r>O=A!{VPz_=K;Y0x9(hfzCCG1Pk{baa zMTOf0p5IRxVUrLGeAYjx25LX~UrnzQCbi$1*ZzvfvUx^g?hB6rrxn z53vdI?0Ay;sW1V@(-R72?m;K6f1%=bjI2e&L?+JyD|Qp)v7n)1l{83`4a+33c=o?^ zHxGG@dr~h+)(y81%>5RcmWGPe^;;HMi+o_Af}*d9!(5pEE2}8HzQpc}XI%T|pp1+= zB=5;5^E7Iatznd}smzOe*s)c zL_t(|ob8lLO2j}AhO^R@q6IKG21ZS5Q!^ z5?w|r(}oz5I0+-Tn1UjqAOBbB`a702jY)@8)RjPSRsL^4KD+>z1mNAD0KBZYqKAnB zyqvhx3s)uv@Cw9vd~ARh#Ir)tf1v~#NTATJo*Y{1giB zU7+Rc-d_P+c`DA;DcspQeV*fVG`0oM@EhXxKkPyaE9#a41kjNYe{_yt7xMEqye*~> zO@8H36ao}oCYyV>SGGn1bxaW#^#MS{o3o9}$HqytHtGSP)f8a&!_`U?bO6Ny=)E?; z*pi*lDJ??NzB!`v+5qEyu5QQ;&Ce%IR`g_wIH(OsYzlajI!R;O3tr|qU4^@;g8;9# z&*B-YNsP|eK%nrVUGtho;y=Dblh0O6e--?$$!}cwG^~>N0yr6MpJ;SP)c^nh07*qo IM6N<$f|BdDYXATM delta 292 zcmV+<0o(qn1F-@jiBL{Q4GJ0x0000DNk~Le0000G0000G2nGNE03Y-JVUZyhe*oi2 zL_t(|oXwFjZUR9JMJc((*egMDgybL`V;4#;5H66|fr>OK00D;qjz9|<|1!l!Gq51R zM!Tc|OJ5pU^WS`X8jXi9R!muuU#d314gD)CIzdkC>t;YvPSFd-MC|)!fGf;u{bQPP zfE(E}E0!d%AQmi0@K%_^oj(EYdSKp@*op)xDKb)9v*IWDz~G$5zmQ^f1>pFyt=u?o zo-U8`CMh=ICo1QpL+tdbugF+!`+tC-TMNK(VPzxOn#Cs7fOp?!#V#%}E)ZEbhhFmO qTa|zqSN&5=qVPw}1Gtv?ZuJW)1+1zig}}c60000FSZ>RclH7JO*v|qcobX|h_hNbHI{>dEqhAsP3?Yr674n5oJAD3}py&Ers8UtH{ zZ3J_`Bg;>AS9&ffuQUwGke01d5xu=L_sb;aDP41SOtI$u@Ms-_`|f?L4_3ZRZFt9U z!Lnh|#vE3L{=Uzv*aQw`GafiU=R8xA`jv2o8^y2P%Q_1`{@Q-Nu~z7TQSwTrFFW3x lS$+MhLgVc^i~R*x_{wymzPfBi{B zK~#90-P5s3LqQOQ;g2Xv8v7KMmSSmVCEBM~&|VB;msc?9RIIg9n}}Fw?E{zxNb3Vw z1;NOL4d`X>MsW%1fz#}nnV*4KKC33J+Jt;!%^5Zlc?@Ju-Y0f42E5U>P@f!ZYr%GY)6)5&>L922}yBBKgv37(1=9w1yG2j^MiF_}(N=0umV|scA6m<)3x&2G> QEdT%j07*qoM6N<$g23>C>i_@% delta 238 zcmV= 0: self.voice_combo.setCurrentIndex(idx) + # If a profile is selected at startup, load voices and language + if self.selected_profile_name: + from voice_profiles import load_profiles + + entry = load_profiles().get(self.selected_profile_name, {}) + if isinstance(entry, dict): + self.mixed_voice_state = entry.get("voices", []) + self.selected_lang = entry.get("language") + else: + self.mixed_voice_state = entry + self.selected_lang = entry[0][0] if entry and entry[0] else None if self.save_option == "Choose output folder" and self.selected_output_folder: self.save_path_label.setText(self.selected_output_folder) self.save_path_label.show() self.subtitle_combo.setCurrentText(self.subtitle_mode) - # Enable/disable subtitle options based on selected language - self.subtitle_combo.setEnabled( - self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION - ) + # Enable/disable subtitle options based on selected language (profile or voice) + enable = self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION + self.subtitle_combo.setEnabled(enable) # loading gif for preview button loading_gif_path = get_resource_path("abogen.assets", "loading.gif") if loading_gif_path: @@ -559,23 +583,10 @@ class abogen(QWidget): voice_layout.addWidget(QLabel("Select Voice:", self)) voice_row = QHBoxLayout() self.voice_combo = QComboBox(self) - - for v in VOICES_INTERNAL: - # Get flag icon for this voice - lang_code = v[0] - flag_path = get_resource_path("abogen.assets.flags", f"{lang_code}.png") - - icon = QIcon() - if flag_path and os.path.exists(flag_path): - icon = QIcon(flag_path) - - # Add item with flag icon and voice name - self.voice_combo.addItem(icon, f"{v}", v) - + self.voice_combo.currentIndexChanged.connect(self.on_voice_combo_changed) self.voice_combo.setStyleSheet( "QComboBox { min-height: 20px; padding: 6px 12px; }" ) - self.voice_combo.currentIndexChanged.connect(self.on_voice_changed) self.voice_combo.setToolTip( "The first character represents the language:\n" '"a" => American English\n"b" => British English\n"e" => Spanish\n"f" => French\n"h" => Hindi\n"i" => Italian\n"j" => Japanese\n"p" => Brazilian Portuguese\n"z" => Mandarin Chinese\nThe second character represents the gender:\n"m" => Male\n"f" => Female' @@ -650,10 +661,9 @@ class abogen(QWidget): ) self.subtitle_combo.setCurrentText(self.subtitle_mode) self.subtitle_combo.currentTextChanged.connect(self.on_subtitle_mode_changed) - # Enable/disable subtitle options based on selected language - self.subtitle_combo.setEnabled( - self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION - ) + # Enable/disable subtitle options based on selected language (profile or voice) + enable = self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION + self.subtitle_combo.setEnabled(enable) subtitle_layout.addWidget(self.subtitle_combo) controls_layout.addLayout(subtitle_layout) # Output format @@ -775,6 +785,7 @@ class abogen(QWidget): container_layout.addWidget(self.finish_widget) outer_layout.addWidget(container) self.setLayout(outer_layout) + self.populate_profiles_in_voice_combo() def open_file_dialog(self): if self.is_converting: @@ -957,6 +968,87 @@ class abogen(QWidget): else: self.subtitle_combo.setEnabled(False) + def on_voice_combo_changed(self, index): + data = self.voice_combo.itemData(index) + if isinstance(data, str) and data.startswith("profile:"): + pname = data.split(":", 1)[1] + self.selected_profile_name = pname + from voice_profiles import load_profiles + + entry = load_profiles().get(pname, {}) + # set mixed voices and language + if isinstance(entry, dict): + self.mixed_voice_state = entry.get("voices", []) + self.selected_lang = entry.get("language") + else: + self.mixed_voice_state = entry + self.selected_lang = entry[0][0] if entry and entry[0] else None + self.selected_voice = None + self.config["selected_profile_name"] = pname + self.config.pop("selected_voice", None) + save_config(self.config) + # enable subtitles based on profile language + self.subtitle_combo.setEnabled( + self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION + ) + else: + self.mixed_voice_state = None + self.selected_profile_name = None + self.selected_voice, self.selected_lang = data, data[0] + self.config["selected_voice"] = data + if "selected_profile_name" in self.config: + del self.config["selected_profile_name"] + save_config(self.config) + if self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION: + self.subtitle_combo.setEnabled(True) + self.subtitle_mode = self.subtitle_combo.currentText() + else: + self.subtitle_combo.setEnabled(False) + + def update_subtitle_combo_for_profile(self, profile_name): + from voice_profiles import load_profiles + + entry = load_profiles().get(profile_name, {}) + lang = entry.get("language") if isinstance(entry, dict) else None + enable = lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION + self.subtitle_combo.setEnabled(enable) + + def populate_profiles_in_voice_combo(self): + # preserve current voice or profile + current = self.voice_combo.currentData() + self.voice_combo.blockSignals(True) + self.voice_combo.clear() + # re-add profiles + profile_icon = QIcon(get_resource_path("abogen.assets", "profile.png")) + for pname in load_profiles().keys(): + self.voice_combo.addItem(profile_icon, pname, f"profile:{pname}") + # re-add voices + for v in VOICES_INTERNAL: + icon = QIcon() + flag_path = get_resource_path("abogen.assets.flags", f"{v[0]}.png") + if flag_path and os.path.exists(flag_path): + icon = QIcon(flag_path) + self.voice_combo.addItem(icon, f"{v}", v) + # restore selection + idx = -1 + if self.selected_profile_name: + idx = self.voice_combo.findData(f"profile:{self.selected_profile_name}") + elif current: + idx = self.voice_combo.findData(current) + if idx >= 0: + self.voice_combo.setCurrentIndex(idx) + # Also update subtitle combo for selected profile + data = self.voice_combo.itemData(idx) + if isinstance(data, str) and data.startswith("profile:"): + pname = data.split(":", 1)[1] + self.update_subtitle_combo_for_profile(pname) + self.voice_combo.blockSignals(False) + # If no profiles exist, clear selected_profile_name from config + if not load_profiles(): + if "selected_profile_name" in self.config: + del self.config["selected_profile_name"] + save_config(self.config) + def convert_input_box_to_log(self): self.input_box.hide() self.log_text.show() @@ -1065,14 +1157,23 @@ class abogen(QWidget): # if voice formula is not None, use the selected voice if self.mixed_voice_state: formula_components = [ - f"{weight} * {name}" for name, weight in self.mixed_voice_state + f"{name}*{weight}" for name, weight in self.mixed_voice_state ] voice_formula = " + ".join(filter(None, formula_components)) else: voice_formula = self.selected_voice - # selected language - use the first voice of the mix - match = re.search(r"\b([a-z])", voice_formula) - selected_lang = match.group(1) + # determine selected language: use profile setting if profile selected, else voice code + if self.selected_profile_name: + from voice_profiles import load_profiles + + entry = load_profiles().get(self.selected_profile_name, {}) + selected_lang = entry.get("language") + else: + selected_lang = self.selected_voice[0] if self.selected_voice else None + # fallback: extract from formula if missing + if not selected_lang: + m = re.search(r"\b([a-z])", voice_formula) + selected_lang = m.group(1) if m else None self.conversion_thread = ConversionThread( self.selected_file, @@ -1355,13 +1456,32 @@ class abogen(QWidget): self.btn_start.setEnabled(True) # Re-enable start button on error return - lang, voice, speed = ( - self.selected_voice[0], - self.selected_voice, - self.speed_slider.value() / 100.0, - ) + # Support preview for voice profiles + speed = self.speed_slider.value() / 100.0 + if self.mixed_voice_state: + # Build voice formula string + components = [f"{name}*{weight}" for name, weight in self.mixed_voice_state] + voice = " + ".join(filter(None, components)) + # determine language: use profile setting if available, else first voice code + if self.selected_profile_name: + from voice_profiles import load_profiles + + entry = load_profiles().get(self.selected_profile_name, {}) + lang = entry.get("language") + else: + lang = None + if not lang and self.mixed_voice_state: + lang = ( + self.mixed_voice_state[0][0][0] + if self.mixed_voice_state and self.mixed_voice_state[0][0] + else None + ) + else: + lang = self.selected_voice[0] + voice = self.selected_voice + self.preview_thread = VoicePreviewThread( - np_module, kpipeline_class, lang, voice, speed + np_module, kpipeline_class, lang, voice, speed, self.use_gpu ) self.preview_thread.finished.connect(self._play_preview_audio) self.preview_thread.error.connect(self._preview_error) @@ -1565,7 +1685,7 @@ class abogen(QWidget): menu.addAction(add_shortcut_action) # Add reveal config option - reveal_config_action = QAction("Open config.json directory", self) + reveal_config_action = QAction("Open configuration directory", self) reveal_config_action.triggered.connect(self.reveal_config_in_explorer) menu.addAction(reveal_config_action) @@ -1699,13 +1819,35 @@ class abogen(QWidget): save_config(self.config) def show_voice_formula_dialog(self): - # get the current voice mix - if self.mixed_voice_state is None: - # if no voice mix is set, use the selected voice - self.mixed_voice_state = [(self.selected_voice, 1.0)] - - dialog = VoiceFormulaDialog(self, initial_state=self.mixed_voice_state) + # If a profile is selected in combo, open mixer with that profile + initial_state = None + selected_profile = self.selected_profile_name + if selected_profile: + entry = load_profiles().get(selected_profile, {}) + # support new dict format + if isinstance(entry, dict): + initial_state = entry.get("voices", []) + else: + initial_state = entry + elif self.mixed_voice_state is not None: + initial_state = self.mixed_voice_state + else: + initial_state = [(self.selected_voice, 1.0)] if self.selected_voice else [] + dialog = VoiceFormulaDialog( + self, initial_state=initial_state, selected_profile=selected_profile + ) if dialog.exec_() == QDialog.Accepted: + # After OK, select the profile in combo and update config + if dialog.current_profile: + self.selected_profile_name = dialog.current_profile + self.config["selected_profile_name"] = dialog.current_profile + if "selected_voice" in self.config: + del self.config["selected_voice"] + save_config(self.config) + self.populate_profiles_in_voice_combo() + idx = self.voice_combo.findData(f"profile:{dialog.current_profile}") + if idx >= 0: + self.voice_combo.setCurrentIndex(idx) self.mixed_voice_state = dialog.get_selected_voices() def show_about_dialog(self): diff --git a/abogen/utils.py b/abogen/utils.py index 300faa8..f65bc8e 100644 --- a/abogen/utils.py +++ b/abogen/utils.py @@ -33,8 +33,10 @@ def get_resource_path(package, resource): pass # Always try to resolve as a relative path from this file - parts = package.split('.') - rel_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), *parts[1:], resource) + parts = package.split(".") + rel_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), *parts[1:], resource + ) if os.path.exists(rel_path): return rel_path @@ -56,7 +58,7 @@ def get_resource_path(package, resource): def get_version(): """Return the current version of the application.""" try: - with open(get_resource_path("abogen", "VERSION"), "r") as f: + with open(get_resource_path("/", "VERSION"), "r") as f: return f.read().strip() except Exception: return "Unknown" diff --git a/abogen/voice_formula_gui.py b/abogen/voice_formula_gui.py index 4329d7b..dc92b5d 100644 --- a/abogen/voice_formula_gui.py +++ b/abogen/voice_formula_gui.py @@ -1,3 +1,5 @@ +import json +import os from PyQt5.QtWidgets import ( QDialog, QVBoxLayout, @@ -14,11 +16,31 @@ from PyQt5.QtWidgets import ( QFrame, QLayout, QStyle, + QListWidget, + QListWidgetItem, + QInputDialog, + QFileDialog, + QSplitter, + QMenu, + QAction, + QComboBox, ) from PyQt5.QtCore import Qt, QTimer, QPoint, QRect, QSize -from PyQt5.QtGui import QPixmap -from constants import VOICES_INTERNAL +from PyQt5.QtGui import QPixmap, QIcon, QColor +from constants import ( + VOICES_INTERNAL, + SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION, + LANGUAGE_DESCRIPTIONS, +) from utils import get_resource_path +from voice_profiles import ( + load_profiles, + save_profiles, + delete_profile, + duplicate_profile, + export_profiles, +) + # Constants VOICE_MIXER_WIDTH = 100 @@ -27,7 +49,22 @@ MIN_WINDOW_WIDTH = 600 MIN_WINDOW_HEIGHT = 400 INITIAL_WINDOW_WIDTH = 1000 INITIAL_WINDOW_HEIGHT = 500 -FEMALE, MALE = "👩‍🦰", "👨" + +# Language options for the language selector loaded from constants +LANGUAGE_OPTIONS = list(LANGUAGE_DESCRIPTIONS.items()) + + +class SaveButtonWidget(QWidget): + def __init__(self, parent, profile_name, save_callback): + super().__init__(parent) + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + self.save_btn = QPushButton("Save", self) + self.save_btn.setFixedWidth(48) + self.save_btn.clicked.connect(lambda: save_callback(profile_name)) + layout.addStretch() + layout.addWidget(self.save_btn) + self.setLayout(layout) class FlowLayout(QLayout): @@ -141,22 +178,30 @@ class VoiceMixer(QWidget): icons_layout = QHBoxLayout() icons_layout.setSpacing(3) icons_layout.setAlignment(Qt.AlignCenter) # Center the icons horizontally - + # Flag icon - flag_icon_path = get_resource_path("abogen.assets.flags", f"{language_code}.png") - gender_icon_path = get_resource_path("abogen.assets", "female.png" if is_female else "male.png") + flag_icon_path = get_resource_path( + "abogen.assets.flags", f"{language_code}.png" + ) + gender_icon_path = get_resource_path( + "abogen.assets", "female.png" if is_female else "male.png" + ) flag_label = QLabel() gender_label = QLabel() flag_pixmap = QPixmap(flag_icon_path) - flag_label.setPixmap(flag_pixmap.scaled(16, 16, Qt.KeepAspectRatio, Qt.SmoothTransformation)) + flag_label.setPixmap( + flag_pixmap.scaled(16, 16, Qt.KeepAspectRatio, Qt.SmoothTransformation) + ) gender_pixmap = QPixmap(gender_icon_path) - gender_label.setPixmap(gender_pixmap.scaled(16, 16, Qt.KeepAspectRatio, Qt.SmoothTransformation)) + gender_label.setPixmap( + gender_pixmap.scaled(16, 16, Qt.KeepAspectRatio, Qt.SmoothTransformation) + ) icons_layout.addWidget(flag_label) icons_layout.addWidget(gender_label) - + # Add icons layout layout.addLayout(icons_layout) - + # Checkbox (now below icons) self.checkbox = QCheckBox() self.checkbox.setChecked(initial_status) @@ -253,7 +298,7 @@ class HoverLabel(QLabel): def resizeEvent(self, event): super().resizeEvent(event) # Position the button in the top-right corner with a small margin - self.delete_button.move(self.width() - 16, + 0) + self.delete_button.move(self.width() - 16, +0) def enterEvent(self, event): self.delete_button.show() @@ -263,8 +308,75 @@ class HoverLabel(QLabel): class VoiceFormulaDialog(QDialog): - def __init__(self, parent=None, initial_state=None): + def __init__(self, parent=None, initial_state=None, selected_profile=None): super().__init__(parent) + profiles = load_profiles() + self._virtual_new_profile = False + if not profiles: + # No profiles: show 'New profile' in the list, unsaved, not in JSON + self.current_profile = "New profile" + self._profile_dirty = {"New profile": True} + self._virtual_new_profile = True + profiles = {} # Do not add to JSON yet + else: + self.current_profile = ( + selected_profile + if selected_profile in profiles + else list(profiles.keys())[0] + ) + self._profile_dirty = {name: False for name in profiles} + # Track unsaved states per profile + self._profile_states = {} + # Add subtitle_combo reference if parent has it + self.subtitle_combo = None + if parent is not None and hasattr(parent, "subtitle_combo"): + self.subtitle_combo = parent.subtitle_combo + # Create main container layout with profile section and mixer section + splitter = QSplitter(Qt.Horizontal) + # Profile section + profile_widget = QWidget() + profile_layout = QVBoxLayout(profile_widget) + profile_layout.setContentsMargins(0, 0, 0, 0) + # Profile header and save/new buttons + header_layout = QHBoxLayout() + header_layout.addWidget(QLabel("Profiles:")) + header_layout.addStretch() + self.btn_new_profile = QPushButton("New profile") + header_layout.addWidget(self.btn_new_profile) + profile_layout.addLayout(header_layout) + # Profile list + self.profile_list = QListWidget() + icon = QIcon(get_resource_path("abogen.assets", "profile.png")) + if self._virtual_new_profile: + item = QListWidgetItem(icon, "New profile") + self.profile_list.addItem(item) + self.profile_list.setCurrentRow(0) + else: + for name in profiles: + item = QListWidgetItem(icon, name) + self.profile_list.addItem(item) + idx = list(profiles.keys()).index(self.current_profile) + self.profile_list.setCurrentRow(idx) + profile_layout.addWidget(self.profile_list) + self.profile_list.setContextMenuPolicy(Qt.CustomContextMenu) + self.profile_list.customContextMenuRequested.connect( + self.show_profile_context_menu + ) + self.profile_list.setItemWidget = ( + self.profile_list.setItemWidget + ) # for type hints + # Save and management buttons + mgmt_layout = QVBoxLayout() + self.btn_import_profiles = QPushButton("Import profile(s)") + mgmt_layout.addWidget(self.btn_import_profiles) + self.btn_export_profiles = QPushButton("Export profiles") + mgmt_layout.addWidget(self.btn_export_profiles) + profile_layout.addLayout(mgmt_layout) + # prepare mixer widget + mixer_widget = QWidget() + mixer_layout = QVBoxLayout(mixer_widget) + mixer_layout.setContentsMargins(5, 0, 0, 0) + self.setWindowTitle("Voice Mixer") self.setWindowFlags( Qt.Window | Qt.WindowCloseButtonHint | Qt.WindowMaximizeButtonHint @@ -274,37 +386,55 @@ class VoiceFormulaDialog(QDialog): self.voice_mixers = [] self.last_enabled_voice = None - # Main Layout - main_layout = QVBoxLayout() - - # Header + # Header label and language selector self.header_label = QLabel( "Adjust voice weights to create your preferred voice mix." ) self.header_label.setStyleSheet("font-size: 13px;") self.header_label.setWordWrap(True) - main_layout.addWidget(self.header_label) + header_row = QHBoxLayout() + header_row.addWidget(self.header_label, 1) + header_row.addStretch() + header_row.addWidget(QLabel("Language:")) + self.language_combo = QComboBox() + for code, desc in LANGUAGE_OPTIONS: + flag = get_resource_path("abogen.assets.flags", f"{code}.png") + if flag and os.path.exists(flag): + self.language_combo.addItem(QIcon(flag), desc, code) + else: + self.language_combo.addItem(desc, code) + # set current language for profile + prof = profiles.get(self.current_profile, {}) + lang = prof.get("language") if isinstance(prof, dict) else None + if not lang: + lang = list(LANGUAGE_DESCRIPTIONS.keys())[0] + idx = self.language_combo.findData(lang) + if idx >= 0: + self.language_combo.setCurrentIndex(idx) + self.language_combo.currentIndexChanged.connect(self.mark_profile_modified) + header_row.addWidget(self.language_combo) + mixer_layout.addLayout(header_row) # Error message self.error_label = QLabel( - "No voices selected or all weights are 0. Please select at least one voice and set its weight above 0." + "Please select at least one voice and set its weight above 0." ) self.error_label.setStyleSheet("color: red; font-weight: bold;") self.error_label.setWordWrap(True) self.error_label.hide() - main_layout.addWidget(self.error_label) + mixer_layout.addWidget(self.error_label) # Voice weights display self.weighted_sums_container = QWidget() self.weighted_sums_layout = FlowLayout(self.weighted_sums_container) self.weighted_sums_layout.setSpacing(5) self.weighted_sums_layout.setContentsMargins(5, 5, 5, 5) - main_layout.addWidget(self.weighted_sums_container) + mixer_layout.addWidget(self.weighted_sums_container) # Separator separator = QFrame() separator.setFrameShadow(QFrame.Sunken) - main_layout.addWidget(separator) + mixer_layout.addWidget(separator) # Voice list scroll area self.scroll_area = QScrollArea() @@ -320,7 +450,7 @@ class VoiceFormulaDialog(QDialog): QSizePolicy.Expanding, QSizePolicy.Expanding ) self.scroll_area.setWidget(self.voice_list_widget) - main_layout.addWidget(self.scroll_area, stretch=1) + mixer_layout.addWidget(self.scroll_area, stretch=1) # Buttons button_layout = QHBoxLayout() @@ -341,13 +471,179 @@ class VoiceFormulaDialog(QDialog): button_layout.addWidget(clear_all_button) button_layout.addWidget(ok_button) button_layout.addWidget(cancel_button) - main_layout.addLayout(button_layout) + mixer_layout.addLayout(button_layout) - self.setLayout(main_layout) - - # Setup voices and display self.add_voices(initial_state or []) self.update_weighted_sums() + self.update_subtitle_combo_enabled() + + # assemble splitter + splitter.addWidget(profile_widget) + splitter.addWidget(mixer_widget) + splitter.setStretchFactor(1, 1) + # set as main layout + self.setLayout(QHBoxLayout()) + self.layout().addWidget(splitter) + + # Connect profile actions + self.profile_list.currentRowChanged.connect(self.on_profile_selection_changed) + # Track initial profile for proper dirty-state saving + self.last_profile_row = self.profile_list.currentRow() + self.btn_new_profile.clicked.connect(self.new_profile) + self.btn_export_profiles.clicked.connect(self.export_all_profiles) + self.btn_import_profiles.clicked.connect(self.import_profiles_dialog) + # Detect modifications in voice mixers + for vm in self.voice_mixers: + vm.spin_box.valueChanged.connect(self.mark_profile_modified) + vm.checkbox.stateChanged.connect(lambda *_: self.mark_profile_modified()) + vm.spin_box.valueChanged.connect(self.update_subtitle_combo_enabled) + vm.checkbox.stateChanged.connect(self.update_subtitle_combo_enabled) + + def keyPressEvent(self, event): + # Bind Delete key to delete_profile when a profile is selected + if event.key() == Qt.Key_Delete and self.profile_list.hasFocus(): + item = self.profile_list.currentItem() + if item: + self.delete_profile(item) + return + super().keyPressEvent(event) + + def _has_unsaved_changes(self): + # Only return True if there are actually modified (yellow background) profiles + for i in range(self.profile_list.count()): + item = self.profile_list.item(i) + # Only consider as unsaved if profile is marked dirty (yellow background) + if item.text().startswith("*"): + return True + return False + + def _prompt_save_changes(self): + dirty_indices = [ + i + for i in range(self.profile_list.count()) + if self.profile_list.item(i).text().startswith("*") + ] + parent = self.parent() + if len(dirty_indices) > 1: + msg = f"You have unsaved changes in {len(dirty_indices)} profiles. Do you want to save all?" + ret = QMessageBox.question( + self, + "Unsaved Changes", + msg, + QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel, + QMessageBox.Save, + ) + if ret == QMessageBox.Save: + # Save all using stored states + profiles = load_profiles() + for i in dirty_indices: + name = self.profile_list.item(i).text().lstrip("*") + state = self._profile_states.get(name) + if state is not None: + profiles[name] = state + self._profile_dirty[name] = False + save_profiles(profiles) + # clear states + for name in list(self._profile_states.keys()): + if name not in profiles: + continue + del self._profile_states[name] + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + # clear markers + for i in dirty_indices: + item = self.profile_list.item(i) + n = item.text().lstrip("*") + item.setText(n) + self.update_profile_save_buttons() + self.update_profile_list_colors() + return True + elif ret == QMessageBox.Discard: + # Discard all modifications + self._profile_states.clear() + for i in dirty_indices: + item = self.profile_list.item(i) + n = item.text().lstrip("*") + item.setText(n) + self._profile_dirty[n] = False + self.update_profile_save_buttons() + self.update_profile_list_colors() + # reload current profile + profiles = load_profiles() + if self.current_profile in profiles: + self.load_profile_state(self.current_profile) + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + return True + else: + return False + else: + # Fallback to original logic for 0 or 1 dirty profile + box = QMessageBox(self) + box.setIcon(QMessageBox.Warning) + box.setWindowTitle("Unsaved Changes") + box.setText( + "You have unsaved changes in your profile. Do you want to save the changes?" + ) + box.setStandardButtons( + QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel + ) + box.setDefaultButton(QMessageBox.Save) + ret = box.exec_() + if ret == QMessageBox.Save: + for i in range(self.profile_list.count()): + item = self.profile_list.item(i) + name = item.text().lstrip("*") + if ( + self._profile_dirty.get(name, False) + or item.text().startswith("*") + or (name == self.current_profile) + ): + self.profile_list.setCurrentRow(i) + self.save_profile_by_name(name) + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + return True + elif ret == QMessageBox.Discard: + profiles = load_profiles() + for i in range(self.profile_list.count()): + item = self.profile_list.item(i) + name = item.text().lstrip("*") + self._profile_dirty[name] = False + if item.text().startswith("*"): + item.setText(name) + self.update_profile_save_buttons() + self.update_profile_list_colors() + if self.current_profile in profiles: + self.load_profile_state(self.current_profile) + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + return True + else: + return False + + def on_profile_selection_changed(self, row): + # Save dirty state for previous profile + if hasattr(self, "last_profile_row") and self.last_profile_row is not None: + prev_item = self.profile_list.item(self.last_profile_row) + if prev_item: + prev_name = prev_item.text().lstrip("*") + self._profile_dirty[prev_name] = prev_item.text().startswith("*") + # Do NOT auto-save if modifications pending + # load new profile + item = self.profile_list.item(row) + if item: + name = item.text().lstrip("*") + self.load_profile_state(name) + # Restore dirty state for this profile + dirty = self._profile_dirty.get(name, False) + if dirty and not item.text().startswith("*"): + item.setText("*" + item.text()) + elif not dirty and item.text().startswith("*"): + item.setText(item.text().lstrip("*")) + self.last_profile_row = row + self.update_profile_save_buttons() + self.update_profile_list_colors() def add_voices(self, initial_state): first_enabled_voice = None @@ -358,7 +654,9 @@ class VoiceFormulaDialog(QDialog): ) initial_status = matching_voice is not None initial_weight = matching_voice[1] if matching_voice else 1.0 - voice_mixer = self.add_voice(voice, language_code, initial_status, initial_weight) + voice_mixer = self.add_voice( + voice, language_code, initial_status, initial_weight + ) if initial_status and first_enabled_voice is None: first_enabled_voice = voice_mixer @@ -379,6 +677,13 @@ class VoiceFormulaDialog(QDialog): lambda state, vm=voice_mixer: self.handle_voice_checkbox(vm, state) ) voice_mixer.spin_box.valueChanged.connect(self.update_weighted_sums) + voice_mixer.checkbox.stateChanged.connect(self.update_weighted_sums) + voice_mixer.spin_box.valueChanged.connect(self.mark_profile_modified) + voice_mixer.checkbox.stateChanged.connect( + lambda *_: self.mark_profile_modified() + ) + voice_mixer.spin_box.valueChanged.connect(self.update_subtitle_combo_enabled) + voice_mixer.checkbox.stateChanged.connect(self.update_subtitle_combo_enabled) return voice_mixer def handle_voice_checkbox(self, voice_mixer, state): @@ -425,7 +730,10 @@ class VoiceFormulaDialog(QDialog): for name, weight in selected: percentage = weight / total * 100 # Make the voice name bold and include percentage - voice_label = HoverLabel(f"{name}: {percentage:.1f}%", name) + voice_label = HoverLabel( + f'{name}: {percentage:.1f}%', + name, + ) voice_label.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) voice_label.delete_button.clicked.connect( lambda _, vn=name: self.disable_voice_by_name(vn) @@ -435,6 +743,20 @@ class VoiceFormulaDialog(QDialog): self.error_label.show() self.weighted_sums_container.hide() + def update_subtitle_combo_enabled(self): + # Only enable subtitle_combo if at least one selected voice is from supported languages + selected_langs = set() + for vm in self.voice_mixers: + if vm.checkbox.isChecked() and vm.spin_box.value() > 0: + lang_code = vm.voice_name[0] + selected_langs.add(lang_code) + enable = any( + lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION + for lang in selected_langs + ) + if self.subtitle_combo: + self.subtitle_combo.setEnabled(enable) + def disable_voice_by_name(self, voice_name): for mixer in self.voice_mixers: if mixer.voice_name == voice_name: @@ -461,7 +783,130 @@ class VoiceFormulaDialog(QDialog): return True return super().eventFilter(source, event) + def load_profile_state(self, profile_name): + name = profile_name.lstrip("*") + profiles = load_profiles() + # load voices and language from state or JSON + if name in self._profile_states: + state = self._profile_states[name] + else: + state = profiles.get(name, {}) + voices = state.get("voices") if isinstance(state, dict) else state + lang = state.get("language") if isinstance(state, dict) else None + # apply language selection + if lang: + i = self.language_combo.findData(lang) + if i >= 0: + self.language_combo.blockSignals(True) + self.language_combo.setCurrentIndex(i) + self.language_combo.blockSignals(False) + self.current_profile = name + weights = {n: w for n, w in voices} + for vm in self.voice_mixers: + weight = weights.get(vm.voice_name, 0.0) + # block signals to avoid triggering updates + vm.checkbox.blockSignals(True) + vm.spin_box.blockSignals(True) + vm.slider.blockSignals(True) + vm.checkbox.setChecked(weight > 0) + val = weight if weight > 0 else 1.0 + vm.spin_box.setValue(val) + vm.slider.setValue(int(val * 100)) + # restore signals + vm.checkbox.blockSignals(False) + vm.spin_box.blockSignals(False) + vm.slider.blockSignals(False) + # sync enabled state + vm.toggle_inputs() + self.update_weighted_sums() + self.update_subtitle_combo_enabled() + + def save_profile_by_name(self, name): + profiles = load_profiles() + state = self._profile_states.get(name, None) + if state is not None: + # ensure dict format + if isinstance(state, dict): + entry = state + else: + entry = {"voices": state, "language": self.language_combo.currentData()} + profiles[name] = entry + save_profiles(profiles) + self._profile_dirty[name] = False + del self._profile_states[name] + self._virtual_new_profile = False + # Remove * marker + for i in range(self.profile_list.count()): + item = self.profile_list.item(i) + if item.text().lstrip("*") == name: + item.setText(name) + break + self.update_profile_list_colors() + self.update_profile_save_buttons() + self.update_weighted_sums() + + def _handle_zero_weight_profiles(self): + if self.profile_list.count() <= 1: + return False + zero = [] + profiles = load_profiles() + for i in range(self.profile_list.count()): + item = self.profile_list.item(i) + name = item.text().lstrip("*") + weights = profiles.get(name, {}).get("voices", []) + total = 0 + if isinstance(weights, list): + for entry in weights: + if ( + isinstance(entry, (list, tuple)) + and len(entry) == 2 + and isinstance(entry[1], (int, float)) + ): + total += entry[1] + if total == 0: + zero.append((i, name)) + if not zero: + return False + msg = f"{len(zero)} invalid profile(s) with no voices selected or their total weights are 0. They will be ignored and deleted. Do you want to delete?" + # Use Delete instead of Ignore + reply = QMessageBox.question( + self, + "Invalid Profiles", + msg, + QMessageBox.Yes | QMessageBox.Cancel, + QMessageBox.Yes, + ) + if reply == QMessageBox.Yes: + for i, name in reversed(zero): + self.profile_list.takeItem(i) + delete_profile(name) + parent = self.parent() + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + self.update_profile_list_colors() + self.update_profile_save_buttons() + return False + else: + idx, _ = zero[0] + self.profile_list.setCurrentRow(idx) + return True + def accept(self): + # If no profiles, treat as cancel + if self.profile_list.count() == 0: + # Update subtitle_mode to match combo before closing + if self.subtitle_combo: + parent = self.parent() + if parent is not None: + parent.subtitle_mode = self.subtitle_combo.currentText() + self.reject() + return + # Prompt to save if unsaved changes, then check for zero-weight error after save + if self._has_unsaved_changes(): + if not self._prompt_save_changes(): + return + if self._handle_zero_weight_profiles(): + return selected_voices = self.get_selected_voices() total_weight = sum(weight for _, weight in selected_voices) if total_weight == 0: @@ -470,5 +915,375 @@ class VoiceFormulaDialog(QDialog): "Invalid Weights", "The total weight of selected voices cannot be zero. Please select at least one voice or adjust the weights.", ) + self.update_weighted_sums() return + # Save weights to current profile + profiles = load_profiles() + profiles[self.current_profile] = { + "voices": selected_voices, + "language": self.language_combo.currentData(), + } + save_profiles(profiles) + # Mark this profile as not dirty + self._profile_dirty[self.current_profile] = False super().accept() + + def reject(self): + # Prompt to save if unsaved changes, then check for zero-weight error after save + if self._has_unsaved_changes(): + if not self._prompt_save_changes(): + return + if self._handle_zero_weight_profiles(): + return + super().reject() + + def closeEvent(self, event): + # Prompt to save if unsaved changes, then check for zero-weight error after save + if self._has_unsaved_changes(): + if not self._prompt_save_changes(): + event.ignore() + return + if self._handle_zero_weight_profiles(): + event.ignore() + return + super().closeEvent(event) + + def mark_profile_modified(self): + item = self.profile_list.currentItem() + if item and not item.text().startswith("*"): + item.setText("*" + item.text()) + # Flag profile as dirty and store unsaved state + name = self.current_profile + self._profile_dirty[name] = True + self._profile_states[name] = { + "voices": self.get_selected_voices(), + "language": self.language_combo.currentData(), + } + self.update_profile_save_buttons() + self.update_profile_list_colors() + + def new_profile(self): + name, ok = QInputDialog.getText(self, "New Profile", "Enter profile name:") + if ok and name: + profiles = load_profiles() + # Remove 'New profile' placeholder if not persisted in JSON + if ( + self.profile_list.count() == 1 + and self.profile_list.item(0).text() == "New profile" + and "New profile" not in profiles + ): + self.profile_list.takeItem(0) + self._virtual_new_profile = False + self._profile_dirty.pop("New profile", None) + if name in profiles: + QMessageBox.warning(self, "Duplicate Name", "Profile already exists.") + return + profiles[name] = { + "voices": [], + "language": self.language_combo.currentData(), + } + save_profiles(profiles) + self.profile_list.addItem( + QListWidgetItem( + QIcon(get_resource_path("abogen.assets", "profile.png")), name + ) + ) + self.profile_list.setCurrentRow(self.profile_list.count() - 1) + # reset UI mixers + for vm in self.voice_mixers: + vm.checkbox.setChecked(False) + vm.spin_box.setValue(1.0) + parent = self.parent() + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + self.update_profile_save_buttons() + self.update_profile_list_colors() + self.update_weighted_sums() + + def export_all_profiles(self): + # Prevent export if any profile has total weight 0 + profiles = load_profiles() + for name, weights in profiles.items(): + total = 0 + voices = weights.get("voices", []) + if isinstance(voices, list): + for entry in voices: + if ( + isinstance(entry, (list, tuple)) + and len(entry) == 2 + and isinstance(entry[1], (int, float)) + ): + total += entry[1] + if total == 0: + QMessageBox.warning( + self, + "Export Blocked", + f"Profile '{name}' has no voices selected (total weight is 0). Please fix before exporting.", + ) + return + path, _ = QFileDialog.getSaveFileName( + self, "Export Profiles", "voice_profiles", "JSON Files (*.json)" + ) + if path: + export_profiles(path) + + def import_profiles_dialog(self): + path, _ = QFileDialog.getOpenFileName( + self, "Import Profiles", "", "JSON Files (*.json)" + ) + if path: + from voice_profiles import load_profiles, save_profiles + + # Try to read the file and count profiles + try: + import json + + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + # always expect abogen_voice_profiles wrapper + if not (isinstance(data, dict) and "abogen_voice_profiles" in data): + QMessageBox.warning( + self, + "Invalid File", + "This file is not a valid abogen voice profiles file.", + ) + return + imported_profiles = data["abogen_voice_profiles"] + if not isinstance(imported_profiles, dict): + QMessageBox.warning( + self, + "Invalid File", + "This file is not a valid abogen voice profiles file.", + ) + return + count = len(imported_profiles) + except Exception: + QMessageBox.warning( + self, "Import Error", "Could not read the selected file." + ) + return + if count == 0: + QMessageBox.information( + self, "No Profiles", "No profiles found in the selected file." + ) + return + profiles = load_profiles() + collisions = [name for name in imported_profiles if name in profiles] + # Combine prompts: show both import count and overwrite count if any + if count == 1: + orig_name = next(iter(imported_profiles.keys())) + msg = f"Profile '{orig_name}' will be imported." + if collisions: + msg += f"\nThis will overwrite an existing profile." + msg += "\nContinue?" + reply = QMessageBox.question( + self, "Import Profile", msg, QMessageBox.Yes | QMessageBox.No + ) + if reply != QMessageBox.Yes: + return + profiles.update(imported_profiles) + save_profiles(profiles) + QMessageBox.information( + self, + "Profile Imported", + f"Profile '{orig_name}' imported successfully.", + ) + else: + msg = f"{count} profiles will be imported." + if collisions: + msg += f"\n{len(collisions)} profile(s) will be overwritten." + msg += "\nContinue?" + reply = QMessageBox.question( + self, "Import Profiles", msg, QMessageBox.Yes | QMessageBox.No + ) + if reply != QMessageBox.Yes: + return + profiles.update(imported_profiles) + save_profiles(profiles) + QMessageBox.information( + self, + "Profiles Imported", + f"{count} profiles imported successfully.", + ) + # Refresh list + self.profile_list.clear() + profiles = load_profiles() + for nm in profiles: + self.profile_list.addItem( + QListWidgetItem( + QIcon(get_resource_path("abogen.assets", "profile.png")), nm + ) + ) + if self.profile_list.count() > 0: + self.profile_list.setCurrentRow(0) + parent = self.parent() + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + self._virtual_new_profile = False + self.update_profile_save_buttons() + self.update_profile_list_colors() + + def show_profile_context_menu(self, pos): + item = self.profile_list.itemAt(pos) + if not item: + return + name = item.text().lstrip("*") + menu = QMenu(self) + rename_act = QAction("Rename", self) + delete_act = QAction("Delete", self) + dup_act = QAction("Duplicate", self) + export_act = QAction("Export this profile", self) + menu.addAction(rename_act) + menu.addAction(dup_act) + menu.addAction(export_act) + menu.addAction(delete_act) + act = menu.exec_(self.profile_list.viewport().mapToGlobal(pos)) + if act == rename_act: + self.rename_profile(item) + elif act == delete_act: + self.delete_profile(item) + elif act == dup_act: + self.duplicate_profile(item) + elif act == export_act: + self.export_selected_profile_item(item) + + def export_selected_profile_item(self, item): + if not item: + return + name = item.text().lstrip("*") + profiles = load_profiles() + weights = profiles.get(name, {}).get("voices", []) + total = 0 + if isinstance(weights, list): + for entry in weights: + if ( + isinstance(entry, (list, tuple)) + and len(entry) == 2 + and isinstance(entry[1], (int, float)) + ): + total += entry[1] + if total == 0: + QMessageBox.warning( + self, + "Export Blocked", + f"Profile '{name}' has no voices selected (total weight is 0). Please fix before exporting.", + ) + return + path, _ = QFileDialog.getSaveFileName( + self, "Export Profile", f"{name}.json", "JSON Files (*.json)" + ) + if path: + # Use abogen_voice_profiles wrapper for single profile export + with open(path, "w", encoding="utf-8") as f: + json.dump( + {"abogen_voice_profiles": {name: profiles.get(name, {})}}, + f, + indent=2, + ) + + def rename_profile(self, item): + old = item.text().lstrip("*") + new, ok = QInputDialog.getText( + self, "Rename Profile", f"Profile name:", text=old + ) + if ok and new and new != old: + profiles = load_profiles() + if new in profiles: + QMessageBox.warning(self, "Duplicate Name", "Profile already exists.") + return + profiles[new] = profiles.pop(old) + save_profiles(profiles) + item.setText(new) + parent = self.parent() + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + self.update_profile_save_buttons() + self.update_profile_list_colors() + + def delete_profile(self, item): + name = item.text().lstrip("*") + if self._virtual_new_profile and name == "New profile": + row = self.profile_list.row(item) + self.profile_list.takeItem(row) + self._virtual_new_profile = False + self._profile_dirty.pop("New profile", None) + self.update_profile_save_buttons() + self.update_profile_list_colors() + return + reply = QMessageBox.question( + self, + "Delete Profile", + f"Delete profile '{name}'?", + QMessageBox.Yes | QMessageBox.No, + ) + if reply == QMessageBox.Yes: + delete_profile(name) + row = self.profile_list.row(item) + self.profile_list.takeItem(row) + parent = self.parent() + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + self.update_profile_save_buttons() + self.update_profile_list_colors() + + def duplicate_profile(self, item): + src = item.text().lstrip("*") + profiles = load_profiles() + base = f"{src}_duplicate" + new = base + i = 1 + while new in profiles: + new = f"{base}{i}" + i += 1 + duplicate_profile(src, new) + self.profile_list.addItem( + QListWidgetItem( + QIcon(get_resource_path("abogen.assets", "profile.png")), new + ) + ) + parent = self.parent() + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + self.update_profile_save_buttons() + self.update_profile_list_colors() + + def update_profile_save_buttons(self): + # Remove all save buttons first + for i in range(self.profile_list.count()): + self.profile_list.setItemWidget(self.profile_list.item(i), None) + # Add save button to dirty profiles + for i in range(self.profile_list.count()): + item = self.profile_list.item(i) + name = item.text().lstrip("*") + if item.text().startswith("*"): + widget = SaveButtonWidget( + self.profile_list, name, self.save_profile_by_name + ) + self.profile_list.setItemWidget(item, widget) + + def update_profile_list_colors(self): + profiles = load_profiles() + for i in range(self.profile_list.count()): + item = self.profile_list.item(i) + name = item.text().lstrip("*") + if self._virtual_new_profile and name == "New profile": + item.setBackground(QColor("#fff59d")) # yellow + elif item.text().startswith("*"): + item.setBackground(QColor("#fff59d")) # yellow + else: + weights = profiles.get(name, {}).get("voices", []) + # Defensive: only sum if weights is a list of (voice, weight) pairs + total = 0 + if isinstance(weights, list): + for entry in weights: + if ( + isinstance(entry, (list, tuple)) + and len(entry) == 2 + and isinstance(entry[1], (int, float)) + ): + total += entry[1] + if total == 0: + item.setBackground(QColor("#ffcdd2")) # light red + else: + item.setBackground(QColor("white")) + self.update_profile_save_buttons() diff --git a/abogen/voice_formulas.py b/abogen/voice_formulas.py index fa867cd..c28dfbe 100644 --- a/abogen/voice_formulas.py +++ b/abogen/voice_formulas.py @@ -29,8 +29,8 @@ def parse_voice_formula(pipeline, formula): voices = formula.split("+") for term in voices: - # Parse each term (format: "0.333 * voice_name") - weight, voice_name = term.strip().split("*") + # Parse each term (format: "voice_name*0.333") + voice_name, weight = term.strip().split("*") weight = float(weight.strip()) # normalize the weight weight /= total_weight if total_weight > 0 else 1.0 @@ -52,6 +52,6 @@ def parse_voice_formula(pipeline, formula): def calculate_sum_from_formula(formula): - weights = re.findall(r"([\d.]+) \*", formula) + weights = re.findall(r"\* *([\d.]+)", formula) total_sum = sum(float(weight) for weight in weights) return total_sum diff --git a/abogen/voice_profiles.py b/abogen/voice_profiles.py new file mode 100644 index 0000000..a77a98a --- /dev/null +++ b/abogen/voice_profiles.py @@ -0,0 +1,59 @@ +import os +import json +from utils import get_user_config_path, get_resource_path + + +def _get_profiles_path(): + config_path = get_user_config_path() + config_dir = os.path.dirname(config_path) + return os.path.join(config_dir, "voice_profiles.json") + + +def load_profiles(): + """Load all voice profiles from JSON file.""" + path = _get_profiles_path() + if os.path.exists(path): + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + # always expect abogen_voice_profiles wrapper + if isinstance(data, dict) and "abogen_voice_profiles" in data: + return data["abogen_voice_profiles"] + # fallback: treat as profiles dict + if isinstance(data, dict): + return data + except Exception: + return {} + return {} + + +def save_profiles(profiles): + """Save all voice profiles to JSON file.""" + path = _get_profiles_path() + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + # always save with abogen_voice_profiles wrapper + json.dump({"abogen_voice_profiles": profiles}, f, indent=2) + + +def delete_profile(name): + """Remove a profile by name.""" + profiles = load_profiles() + if name in profiles: + del profiles[name] + save_profiles(profiles) + + +def duplicate_profile(src, dest): + """Duplicate an existing profile.""" + profiles = load_profiles() + if src in profiles and dest: + profiles[dest] = profiles[src] + save_profiles(profiles) + + +def export_profiles(export_path): + """Export all profiles to specified JSON file.""" + profiles = load_profiles() + with open(export_path, "w", encoding="utf-8") as f: + json.dump({"abogen_voice_profiles": profiles}, f, indent=2) diff --git a/demo/voice_mixer.png b/demo/voice_mixer.png new file mode 100644 index 0000000000000000000000000000000000000000..6b40823bf8afc39fec288250d9585eddbce55be2 GIT binary patch literal 28897 zcmbrl2UL?=*DlK5Y{Y_KLzJolqDWDCM+K<@QbR{Yx^yBn1VljuK|nxiq<2Dx1On2P zP^5+)dP#uLLTE|O3;X;3aqhX}j&aX98Og}ok~PojrH|Mo+1sW01sD z$xB+7c#Cq$4Hs+B#^F&y_SjnUiw>uTboLf1d#j97?2 zQwj|_fA7kzpnrZ%9aFxiq#{H}N$S1omVIG6U(GRyBx#sunlubi>KUaO3wpJ%uwcKt z(%;qFYh-JiJLys@eGZ_QhUSw#2$U!zD?2#H#kgaZ7 z_A>bV`SW7d9SqFZuB~jNg=@7>7(zpH#qD_CU0g!qj8&gJDK&~yzHn!`FYGx>Y`#_C z!D6z6bFxv1X^svAr+@qE-zPr^8afz;2j+nBBdasbA$|lD>HVUMI+Y(9+xX`975VL7 z+(^nHzGUa$hp#=jckhpuJjm@$ljWoH8&Zv8FK~jRjHqNRwM0GcZqk3A z7nAne&8KW}QFY;shlRZ`ML2_cvY10GK*Y0H#*^_MD~ftQSMNT5n76t((_Fh7!ljVG9fplD7ef3ke1F$9QBx>!Ay(4^u>c=o20j0i z1~V&O9n4FPIOX6=HGhByUigyQE<*!@g{f+%L=!AJ{A%8AG|Vc8gmvBFofS$nzj@Q+ zx7r_3bBf3TWqq5xe$pSF}JLM(K)AqD{Z+}$d^o|P_J+3(|2v=e7 zUR4Cy790~Q#9r44$m1y=6r>=v2>X+N5Blo0H|jrCXql8V zseZFLyKw@qIUa(-Tfz~$mm*k%M-rQkZ7Pjp4Xmu-x>+i#e=?M84TUk5{w3FiE#F?x zYXgS%TCc_=x8GLz!pdAh@oMBcp-n9wdXNb1#|;-%Lyq?H;lo8BycK29s(XxMv6-H0 zh-y~N?^w|Y5P1kKGFnZ+9_O0GUw7xjSyIJU<>hfxreZQ?B+Hnr!LYE^dZ<07^DaW1 z_Tf>S(3Cf}q(I*q zD@_>&Sq&!$nsv{8llIyCfy3i_UaRZb*yQADrCirb*dWoKP+Z_P+d|iqBB{egUQx|z zTk zmv*Klw0rGT8CQt6ltP8rVcms*Bt{Y^LddxX+bIb%lC-ndi9;ueE$(@w8! zwRLBdW$Pv_p)xz0CwuZRu=~|!aNq0ojM_#W{PI}_Dil|cHx~#$VPnv*?l+Jhv0l8f z`SdR5S?W~CkB`pDj}fB+2iy&?()DJ8qId7Xt)nE^9R|A|WepE~E2tK>7=lH6<5-}L z_gq1V4V2E>`|r|A5uVF?tdvH7lFJl?G!?68f?7&;+MV?5TU{Hgl=0syR@e&H+0_^+ z`TLyn%R?^8W>_=wM2T7Lx3IhiZ}W}xKTnr4k(~01OAkC;{kq-B1vR %7Wbo}Xy5 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