mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-26 13:45:16 -07:00
Compare commits
619
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bcb98d1a8 | ||
|
|
95b0c4123c | ||
|
|
2c499fe2a6 | ||
|
|
0dbdec4531 | ||
|
|
627d40b42d | ||
|
|
a6a5717eb2 | ||
|
|
4427af918e | ||
|
|
2c3df3873d | ||
|
|
3f2c22b793 | ||
|
|
2a1bb6f936 | ||
|
|
29f99a8622 | ||
|
|
166250856f | ||
|
|
2e5b8d2d67 | ||
|
|
c7f50d5668 | ||
|
|
9b3fa177e2 | ||
|
|
e7846eaf77 | ||
|
|
9f183e5832 | ||
|
|
935efedae0 | ||
|
|
d526a9e337 | ||
|
|
4e7f8f9bda | ||
|
|
3d4d0a9335 | ||
|
|
c43f2d45cc | ||
|
|
2a937983c5 | ||
|
|
fe14df5fda | ||
|
|
6cf8da2698 | ||
|
|
70ec8d995b | ||
|
|
c113faf131 | ||
|
|
51c46cd89e | ||
|
|
0aa3a8d6b4 | ||
|
|
c5b7760a8c | ||
|
|
9525bff28a | ||
|
|
800e390108 | ||
|
|
5f62a0ed1b | ||
|
|
7ad91f5767 | ||
|
|
b97c565a45 | ||
|
|
736a661059 | ||
|
|
24833242b5 | ||
|
|
271ecd924b | ||
|
|
c7cd7fd0fd | ||
|
|
c6114b69bc | ||
|
|
1ca7895ffb | ||
|
|
ef63faf33a | ||
|
|
66ca56bb0b | ||
|
|
f21778fcf3 | ||
|
|
687ab2aa16 | ||
|
|
67a9e308a9 | ||
|
|
c9103a24da | ||
|
|
30c2cf1a2c | ||
|
|
c0eba9c628 | ||
|
|
0081e97ad7 | ||
|
|
239523d797 | ||
|
|
27798dd46f | ||
|
|
ea7a4e9f6d | ||
|
|
c65531bed1 | ||
|
|
53a7693101 | ||
|
|
0b2a3cdc78 | ||
|
|
3f1ab75d49 | ||
|
|
abf5dfda8c | ||
|
|
7c50e189cb | ||
|
|
be73a33ee1 | ||
|
|
ef00570145 | ||
|
|
85a3e1363f | ||
|
|
868a40fb7e | ||
|
|
6b75e097e1 | ||
|
|
0cef2c9fe1 | ||
|
|
87c582ad54 | ||
|
|
ed2eec591a | ||
|
|
d61e884104 | ||
|
|
74e004400f | ||
|
|
0047352df1 | ||
|
|
7e7feeac54 | ||
|
|
328bdca61c | ||
|
|
abb752d623 | ||
|
|
f0924d19d3 | ||
|
|
0f97300b4d | ||
|
|
6787f65701 | ||
|
|
a72ef81dc1 | ||
|
|
21dd3b8315 | ||
|
|
5aa1677a25 | ||
|
|
5964af5dea | ||
|
|
115de231d0 | ||
|
|
8929947c7a | ||
|
|
e3f7cd9d00 | ||
|
|
27a5a62581 | ||
|
|
d3a44338a2 | ||
|
|
28aa963b09 | ||
|
|
ae91aa9a88 | ||
|
|
da6070155e | ||
|
|
3c1e8512b9 | ||
|
|
bf58750447 | ||
|
|
2d56309bdd | ||
|
|
0445be295c | ||
|
|
8d550a5f7c | ||
|
|
795bd54381 | ||
|
|
a6ab5f3858 | ||
|
|
9d7e4a417e | ||
|
|
882cabc7d2 | ||
|
|
9c76b5de2c | ||
|
|
4560b7378a | ||
|
|
abd9943430 | ||
|
|
c8cb12f1bc | ||
|
|
54a3bf322e | ||
|
|
476abe07fc | ||
|
|
67bf8e906a | ||
|
|
625e1ba549 | ||
|
|
cfe6770639 | ||
|
|
00452b51a8 | ||
|
|
106aec46a8 | ||
|
|
c9e5c5d9a7 | ||
|
|
48cd1f369a | ||
|
|
2bfe400457 | ||
|
|
0aa19a9994 | ||
|
|
73170d0e92 | ||
|
|
0317626677 | ||
|
|
a5d5c780c2 | ||
|
|
7184a25e44 | ||
|
|
479bc7fc5e | ||
|
|
3e7727d1d2 | ||
|
|
be7c0cec12 | ||
|
|
c9d8142a78 | ||
|
|
13ba5f1aa6 | ||
|
|
9a3c307c75 | ||
|
|
1da16cfc57 | ||
|
|
a1807be04d | ||
|
|
fdba18e9ee | ||
|
|
07a845cece | ||
|
|
615d604ceb | ||
|
|
a383ff6863 | ||
|
|
75abbb02c3 | ||
|
|
b49f14a814 | ||
|
|
05686efbfd | ||
|
|
e2c03fef9a | ||
|
|
4347eaed4c | ||
|
|
60aac279ce | ||
|
|
8b1c7552be | ||
|
|
9a955a77d2 | ||
|
|
c18591c0c3 | ||
|
|
ea3469f2dc | ||
|
|
b108bb1cb1 | ||
|
|
8b796bc6b4 | ||
|
|
e6f419cd70 | ||
|
|
72c13fd3fc | ||
|
|
4e0c731db8 | ||
|
|
d70b878b71 | ||
|
|
a71011741d | ||
|
|
d6f48ace3e | ||
|
|
0fc2192204 | ||
|
|
9e726ad048 | ||
|
|
3584283d84 | ||
|
|
e4def9365f | ||
|
|
707046237c | ||
|
|
12ed2d51ce | ||
|
|
83ebababe7 | ||
|
|
eb5869e59f | ||
|
|
2e95b7c5d8 | ||
|
|
ffc1b54812 | ||
|
|
fc5ed1ff40 | ||
|
|
c9f38dd496 | ||
|
|
58b19e4e9f | ||
|
|
0245c31dba | ||
|
|
81864e831a | ||
|
|
7bd72ea9f7 | ||
|
|
f96eae2567 | ||
|
|
7d53699c96 | ||
|
|
28e91ce2c1 | ||
|
|
88be097b62 | ||
|
|
564d787927 | ||
|
|
2c1ee94891 | ||
|
|
e789c937ad | ||
|
|
273483ffcf | ||
|
|
5774a168a9 | ||
|
|
6bf40bd2d0 | ||
|
|
12cda2e090 | ||
|
|
7a90290a76 | ||
|
|
b02ce8e2f3 | ||
|
|
4e7772a21d | ||
|
|
51fb320b8c | ||
|
|
8ac202aa58 | ||
|
|
ac68052945 | ||
|
|
e601fd2ca4 | ||
|
|
7b25e0ba0b | ||
|
|
a6817cd082 | ||
|
|
df50b8a925 | ||
|
|
a672ac5279 | ||
|
|
d35e6f0cc5 | ||
|
|
b1069b4521 | ||
|
|
f9e1aa153d | ||
|
|
01800f196f | ||
|
|
606da1c894 | ||
|
|
664178f0cf | ||
|
|
f1541701fb | ||
|
|
a2adc3b506 | ||
|
|
15ba824472 | ||
|
|
2ad4776a76 | ||
|
|
a8469b39f1 | ||
|
|
7dd70a52e4 | ||
|
|
1526f2de26 | ||
|
|
2c63dfff25 | ||
|
|
e0a798dc0d | ||
|
|
5933cba8e9 | ||
|
|
1b2d492398 | ||
|
|
faa825290f | ||
|
|
4a8a9eac14 | ||
|
|
3e4d9ff641 | ||
|
|
192979a762 | ||
|
|
e16cc42d53 | ||
|
|
f10e965003 | ||
|
|
a8968d4081 | ||
|
|
7c4afbe4df | ||
|
|
a180fcc56f | ||
|
|
1860b8dc92 | ||
|
|
1597937535 | ||
|
|
ac41a89359 | ||
|
|
60c0fe3b92 | ||
|
|
c99828cf76 | ||
|
|
5c4b979480 | ||
|
|
2d1b0ae820 | ||
|
|
69486c2a77 | ||
|
|
e9f63d6c57 | ||
|
|
8906bee23e | ||
|
|
0dabb121c9 | ||
|
|
944ba227ca | ||
|
|
473bb3e9fb | ||
|
|
0d0b62ea93 | ||
|
|
798cd40f05 | ||
|
|
3187344f01 | ||
|
|
8efcc95606 | ||
|
|
87cab9473d | ||
|
|
7b0fbfb567 | ||
|
|
7c1ea0a1e1 | ||
|
|
b3012ed10c | ||
|
|
88536d27f7 | ||
|
|
89d6e364d4 | ||
|
|
b7781951df | ||
|
|
fe19a9ca47 | ||
|
|
439fedcbf2 | ||
|
|
0813a3d9d6 | ||
|
|
9514c6596c | ||
|
|
4e84415da7 | ||
|
|
82cd4bf2ef | ||
|
|
3c30c5bec1 | ||
|
|
c9d7bc4f27 | ||
|
|
34e17bd469 | ||
|
|
aada13a5c9 | ||
|
|
de8558d197 | ||
|
|
9d79ea367a | ||
|
|
04316f7adc | ||
|
|
4e4361d350 | ||
|
|
e9a249587c | ||
|
|
d8a9ed7d15 | ||
|
|
3dbf1c200e | ||
|
|
40fcb8d917 | ||
|
|
ad64d1c3d9 | ||
|
|
f826e45250 | ||
|
|
3d53c06c5b | ||
|
|
9835b9f6d4 | ||
|
|
a15dd30b1e | ||
|
|
1d343ac071 | ||
|
|
ca602de0ae | ||
|
|
cdc0293ca8 | ||
|
|
e7f749f082 | ||
|
|
d42e926e5c | ||
|
|
32768ea874 | ||
|
|
b585e18ccf | ||
|
|
655910457f | ||
|
|
d6984f1057 | ||
|
|
a637aebe69 | ||
|
|
a5269d23db | ||
|
|
fc450e5024 | ||
|
|
a99c2b572d | ||
|
|
96289e95f1 | ||
|
|
e316b0b4bb | ||
|
|
732270b571 | ||
|
|
0c6aa15746 | ||
|
|
410413dc57 | ||
|
|
e239be5bbb | ||
|
|
f80782a90a | ||
|
|
f1ba73a386 | ||
|
|
f1963740b4 | ||
|
|
4d6c976ad9 | ||
|
|
8377152d86 | ||
|
|
7a511e3756 | ||
|
|
6d261c44a1 | ||
|
|
103e98b38f | ||
|
|
1c61b47a64 | ||
|
|
626e3740e1 | ||
|
|
310a4acb02 | ||
|
|
899b90202b | ||
|
|
e8d54d52d3 | ||
|
|
00c5b75ffb | ||
|
|
25134b4ba9 | ||
|
|
3d922ec846 | ||
|
|
638820c839 | ||
|
|
7cbf5a1ded | ||
|
|
e89a7eb7e7 | ||
|
|
e18757bab3 | ||
|
|
0e6c678fc3 | ||
|
|
942dabbcac | ||
|
|
b915825165 | ||
|
|
f3fc63942f | ||
|
|
9044b986f3 | ||
|
|
b01076b6b3 | ||
|
|
5121c76e39 | ||
|
|
49ebf6222e | ||
|
|
509b0e71cc | ||
|
|
81f8be1a94 | ||
|
|
655a60ca81 | ||
|
|
52285362ce | ||
|
|
3ea587797f | ||
|
|
325714bb83 | ||
|
|
9aa7080c51 | ||
|
|
97292ecef7 | ||
|
|
837f8525d8 | ||
|
|
70ca7f66cb | ||
|
|
c12b5d6f0a | ||
|
|
139fa38e3f | ||
|
|
0e9f5db40f | ||
|
|
2f535a772f | ||
|
|
b420637957 | ||
|
|
bfd7b815a5 | ||
|
|
cac80f6af0 | ||
|
|
47ce4cafdf | ||
|
|
bfe912e41a | ||
|
|
5ccf79a8f7 | ||
|
|
1d32170c2e | ||
|
|
ca74c155e2 | ||
|
|
1f770a157d | ||
|
|
d64e24d422 | ||
|
|
77d86ba835 | ||
|
|
986a748420 | ||
|
|
50e01d17f8 | ||
|
|
084c51b983 | ||
|
|
efbbbc7ec1 | ||
|
|
8e7f0cb9ad | ||
|
|
3357a06cba | ||
|
|
f58c7c1cf3 | ||
|
|
ea41213123 | ||
|
|
b5801891b8 | ||
|
|
8f77c041f5 | ||
|
|
5a3f3ba030 | ||
|
|
3c25ee6e2c | ||
|
|
b92b0dd508 | ||
|
|
670900bf5a | ||
|
|
219cfb1605 | ||
|
|
bf728a780c | ||
|
|
3e6513c0fb | ||
|
|
c54ee14173 | ||
|
|
cc07d4d3c9 | ||
|
|
9beb9d7fec | ||
|
|
76bb207b2b | ||
|
|
3576521d62 | ||
|
|
2df4ece388 | ||
|
|
cbb4979ed6 | ||
|
|
753158c1c9 | ||
|
|
573f82a7e6 | ||
|
|
1e5afc2bef | ||
|
|
163528bf69 | ||
|
|
e1ad7a6e73 | ||
|
|
411e91bb19 | ||
|
|
05cf163744 | ||
|
|
d46eb5bcc6 | ||
|
|
6359dee406 | ||
|
|
a69c216794 | ||
|
|
2867421550 | ||
|
|
758577fd4b | ||
|
|
3d2506767d | ||
|
|
cdef2163c1 | ||
|
|
9955e1dcb7 | ||
|
|
19a28bf6c5 | ||
|
|
a8ecf3f31d | ||
|
|
d744e634a8 | ||
|
|
a362d7de2a | ||
|
|
3f10a70d4c | ||
|
|
d0dfe78701 | ||
|
|
172addd918 | ||
|
|
ada309cfb9 | ||
|
|
30ee07c2e3 | ||
|
|
d21c63b52c | ||
|
|
5ad67d7ecb | ||
|
|
6cc96c2614 | ||
|
|
38bf96ff20 | ||
|
|
0b14cb1b2c | ||
|
|
4d24e69012 | ||
|
|
90436e428d | ||
|
|
e4bb288904 | ||
|
|
6f8bc7f23b | ||
|
|
baca111d50 | ||
|
|
cc298fe6d8 | ||
|
|
46b8f6b882 | ||
|
|
edfc6e99fe | ||
|
|
d00e28ffda | ||
|
|
162cf4fb84 | ||
|
|
68558243d9 | ||
|
|
8d5ad926f9 | ||
|
|
334f037dce | ||
|
|
f6522eea80 | ||
|
|
7615a08f81 | ||
|
|
28a4fd4824 | ||
|
|
31ea3c68a5 | ||
|
|
54d72ddfd0 | ||
|
|
d4794f78e1 | ||
|
|
aa7c9a9a8d | ||
|
|
ca6ed0998a | ||
|
|
829d4d6d5b | ||
|
|
80c87c8e2c | ||
|
|
427d811954 | ||
|
|
0be7975db5 | ||
|
|
40e4af828a | ||
|
|
0e57826ea5 | ||
|
|
eb2cd861b1 | ||
|
|
701cc647a7 | ||
|
|
be6ccaf044 | ||
|
|
2e6efa00a2 | ||
|
|
788a04f265 | ||
|
|
1040625a88 | ||
|
|
6f4503b521 | ||
|
|
f5b6edc2e7 | ||
|
|
8197f0724c | ||
|
|
d40f7d2676 | ||
|
|
99fbcca7f4 | ||
|
|
04f9880c9a | ||
|
|
b9c858295d | ||
|
|
610f64c762 | ||
|
|
220333b3bb | ||
|
|
e194e95512 | ||
|
|
e796412c2c | ||
|
|
cb541521d2 | ||
|
|
2bc243f93e | ||
|
|
0209008d73 | ||
|
|
5cb54ee03c | ||
|
|
0922845101 | ||
|
|
64dd29d35a | ||
|
|
9bde534860 | ||
|
|
97eb570b28 | ||
|
|
7d0557a099 | ||
|
|
60a03c56a9 | ||
|
|
d3393fb940 | ||
|
|
07c0aba883 | ||
|
|
77418a52ae | ||
|
|
46f6806e14 | ||
|
|
20851ccc2b | ||
|
|
e5f4606a6c | ||
|
|
146ef5aaeb | ||
|
|
971604d14f | ||
|
|
0b17073345 | ||
|
|
17106b1e40 | ||
|
|
953e6ec7d8 | ||
|
|
d3c65fc6c2 | ||
|
|
7fcca09f24 | ||
|
|
b6e772c6ac | ||
|
|
a6b070201b | ||
|
|
30352e2419 | ||
|
|
bfa38b36b7 | ||
|
|
1b66a528d1 | ||
|
|
bef4092e6e | ||
|
|
9654f7b642 | ||
|
|
eba1244add | ||
|
|
94487f32a5 | ||
|
|
081f45e680 | ||
|
|
86768288ce | ||
|
|
0fd063442a | ||
|
|
a0c2493e98 | ||
|
|
b39f48cc81 | ||
|
|
4ff775bc98 | ||
|
|
6351aa75e9 | ||
|
|
43873a883b | ||
|
|
60012b81c0 | ||
|
|
89f3127c37 | ||
|
|
ef3c3a7f8c | ||
|
|
7b5e73cfa8 | ||
|
|
462f104494 | ||
|
|
fadb57164e | ||
|
|
e870d65136 | ||
|
|
3df40278cc | ||
|
|
deeef5a474 | ||
|
|
236e464525 | ||
|
|
cf3cf3f002 | ||
|
|
9d98e1e768 | ||
|
|
3be8980f48 | ||
|
|
76bc070f5b | ||
|
|
01838f4773 | ||
|
|
39e4f9d08c | ||
|
|
341d71470c | ||
|
|
bb6cea24ba | ||
|
|
fa7ac88abc | ||
|
|
c68ddc45b1 | ||
|
|
f89dc66d0c | ||
|
|
cba7d7bc23 | ||
|
|
3c89b068f3 | ||
|
|
229841e05e | ||
|
|
123e8215e4 | ||
|
|
2a3afec2ca | ||
|
|
99ddd5a0b4 | ||
|
|
8d730621bc | ||
|
|
e23118f610 | ||
|
|
d4bfdc0d68 | ||
|
|
116c108906 | ||
|
|
2d23c8e06a | ||
|
|
3973a59ba3 | ||
|
|
d9aa75253a | ||
|
|
b22bf36565 | ||
|
|
33f4ed9b44 | ||
|
|
cc37e04221 | ||
|
|
2b4fbe5173 | ||
|
|
423d69b7cc | ||
|
|
ea943876dc | ||
|
|
b55d8cc567 | ||
|
|
036d90dc8e | ||
|
|
c513451277 | ||
|
|
27ae6dfbab | ||
|
|
51b9e2fd3d | ||
|
|
be25ddbe0e | ||
|
|
9cd4921291 | ||
|
|
2349bd24ba | ||
|
|
cd82ed0664 | ||
|
|
c4884a0443 | ||
|
|
232d231788 | ||
|
|
1cf90c81dd | ||
|
|
3204e193fa | ||
|
|
9d5d6cb56a | ||
|
|
153eaba5f3 | ||
|
|
3370e3b419 | ||
|
|
615bd188a0 | ||
|
|
7208f51eee | ||
|
|
07a91a2381 | ||
|
|
70cc36857d | ||
|
|
7f66d02591 | ||
|
|
9f7a5a492e | ||
|
|
cb44377b09 | ||
|
|
a42a946586 | ||
|
|
a3cbe7f2b6 | ||
|
|
d8d9eeaa6a | ||
|
|
f7cb219f6d | ||
|
|
7f18c09628 | ||
|
|
d9c7121c5b | ||
|
|
008b58f91c | ||
|
|
c7404411d5 | ||
|
|
ac08c4fcf4 | ||
|
|
3ce7498495 | ||
|
|
ce2f09d29e | ||
|
|
f1be633dca | ||
|
|
5f58c4dc3d | ||
|
|
892f363e3a | ||
|
|
535cf362de | ||
|
|
f1116e05a6 | ||
|
|
f9aca9d418 | ||
|
|
d913a9ae2a | ||
|
|
36031a0df5 | ||
|
|
e59f86aa63 | ||
|
|
b59c0f44e5 | ||
|
|
f8c5e54962 | ||
|
|
8b67faf96d | ||
|
|
30ea627ae8 | ||
|
|
cd77b80b4f | ||
|
|
12174010f9 | ||
|
|
57c68040bc | ||
|
|
d65704aa68 | ||
|
|
83a6aca1ff | ||
|
|
f18abc0da6 | ||
|
|
0f616ff1c1 | ||
|
|
7c4b1d4dd2 | ||
|
|
48acc10422 | ||
|
|
1fdf61ca2e | ||
|
|
6a0601bd6c | ||
|
|
88d41f342b | ||
|
|
b1cf7926c7 | ||
|
|
834323068d | ||
|
|
8cd868d33f | ||
|
|
446182e16c | ||
|
|
c7c401b98c | ||
|
|
595d735143 | ||
|
|
85935c1bbb | ||
|
|
8058360744 | ||
|
|
47e4da7ce2 | ||
|
|
b7ab4410a6 | ||
|
|
afd0381243 | ||
|
|
2ceccaec51 | ||
|
|
551abc9856 | ||
|
|
333cb262e0 | ||
|
|
04bc1aded4 | ||
|
|
b2659e6a6d | ||
|
|
090b1f6dde | ||
|
|
d943e1d6d4 | ||
|
|
d1273c3d33 | ||
|
|
1ce62e8b15 | ||
|
|
240b9b71a7 | ||
|
|
9396c6c86d | ||
|
|
82431dc5f5 | ||
|
|
f6e5111f68 | ||
|
|
658e967558 | ||
|
|
777e73f195 | ||
|
|
c7004d5776 | ||
|
|
5d731d900b | ||
|
|
fd89831d83 | ||
|
|
b4b3762ef0 | ||
|
|
af2f37ccb1 | ||
|
|
5feda1519c | ||
|
|
e56bfdc694 | ||
|
|
caff18dabe | ||
|
|
36bd2d2656 | ||
|
|
9ca0f43afb | ||
|
|
c14fb937ef | ||
|
|
7d43938b49 | ||
|
|
a7463968e4 | ||
|
|
d9de8f04f2 | ||
|
|
1f075c1c15 | ||
|
|
9125a4abe0 | ||
|
|
c62f615162 | ||
|
|
6acad47335 | ||
|
|
75520e0c29 | ||
|
|
e6a05f7208 | ||
|
|
57880fc2c7 | ||
|
|
dc44a128de | ||
|
|
2617936d39 | ||
|
|
05adc4e013 | ||
|
|
b479178e91 | ||
|
|
a57e7dbc54 | ||
|
|
530ee407ee | ||
|
|
bc21b4c422 |
@@ -0,0 +1,120 @@
|
||||
---
|
||||
name: add-tts-engine
|
||||
description: Use this skill to add a new TTS engine to Voicebox. It walks through dependency research, backend implementation, frontend wiring, PyInstaller bundling, and frozen-build testing. Always start with Phase 0 (dependency audit) before writing any code.
|
||||
---
|
||||
|
||||
# Add TTS Engine
|
||||
|
||||
## Goal
|
||||
|
||||
Integrate a new text-to-speech engine into Voicebox end-to-end: dependency research, backend protocol implementation, frontend UI wiring, PyInstaller bundling, and frozen-build verification. The user should only need to test the final build locally.
|
||||
|
||||
## Reference Doc
|
||||
|
||||
The full phased guide lives at `docs/content/docs/developer/tts-engines.mdx`. **Read this file in its entirety before starting.** It contains:
|
||||
|
||||
- Phase 0: Dependency research (mandatory before writing code)
|
||||
- Phase 1: Backend implementation (`TTSBackend` protocol)
|
||||
- Phase 2: Route and service integration (usually zero changes)
|
||||
- Phase 3: Frontend integration (5 files)
|
||||
- Phase 4: Dependencies (`requirements.txt`, justfile, CI, Docker)
|
||||
- Phase 5: PyInstaller bundling (`build_binary.py` + `server.py`)
|
||||
- Phase 6: Common upstream workarounds
|
||||
- Implementation checklist (gate between phases)
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Read the guide
|
||||
|
||||
```bash
|
||||
# Read the full TTS engines doc
|
||||
cat docs/content/docs/developer/tts-engines.mdx
|
||||
```
|
||||
|
||||
Internalize all phases, especially Phase 0 and Phase 5. The v0.2.3 release was three patch releases because Phase 0 was skipped.
|
||||
|
||||
### 2. Dependency research (Phase 0)
|
||||
|
||||
Clone the model library into a temporary directory and audit it. Do NOT skip this.
|
||||
|
||||
```bash
|
||||
mkdir /tmp/engine-research && cd /tmp/engine-research
|
||||
git clone <model-library-url>
|
||||
```
|
||||
|
||||
Run the grep searches from Phase 0.2 in the guide against the cloned source and its transitive dependencies. Produce a written dependency audit covering:
|
||||
|
||||
1. PyPI vs non-PyPI packages
|
||||
2. PyInstaller directives needed (`--collect-all`, `--copy-metadata`, `--hidden-import`)
|
||||
3. Runtime data files that must be bundled
|
||||
4. Native library paths that need env var overrides in frozen builds
|
||||
5. Monkey-patches needed (`torch.load`, float64, MPS, HF token)
|
||||
6. Sample rate
|
||||
7. Model download method (`from_pretrained` vs `snapshot_download` + `from_local`)
|
||||
|
||||
Test model loading and generation on CPU in the throwaway venv before proceeding.
|
||||
|
||||
### 3. Implement (Phases 1–4)
|
||||
|
||||
Follow the guide's phases in order. Key files to modify:
|
||||
|
||||
**Backend (Phase 1):**
|
||||
- Create `backend/backends/<engine>_backend.py`
|
||||
- Register in `backend/backends/__init__.py` (ModelConfig + TTS_ENGINES + factory)
|
||||
- Update regex in `backend/models.py`
|
||||
|
||||
**Frontend (Phase 3):**
|
||||
- `app/src/lib/api/types.ts` — engine union type
|
||||
- `app/src/lib/constants/languages.ts` — ENGINE_LANGUAGES
|
||||
- `app/src/components/Generation/EngineModelSelector.tsx` — ENGINE_OPTIONS, ENGINE_DESCRIPTIONS
|
||||
- `app/src/lib/hooks/useGenerationForm.ts` — Zod schema, model-name mapping
|
||||
- `app/src/components/ServerSettings/ModelManagement.tsx` — MODEL_DESCRIPTIONS
|
||||
|
||||
**Dependencies (Phase 4):**
|
||||
- `backend/requirements.txt`
|
||||
- `justfile` (setup-python, setup-python-release targets)
|
||||
- `.github/workflows/release.yml`
|
||||
- `Dockerfile` (if applicable)
|
||||
|
||||
### 4. PyInstaller bundling (Phase 5)
|
||||
|
||||
Register the engine in `backend/build_binary.py`:
|
||||
- `--hidden-import` for the backend module and model package
|
||||
- `--collect-all` for packages using `inspect.getsource`, shipping data files, or native libraries
|
||||
- `--copy-metadata` for packages using `importlib.metadata`
|
||||
|
||||
If the engine has native data paths, add `os.environ.setdefault()` in `backend/server.py` inside the `if getattr(sys, 'frozen', False):` block.
|
||||
|
||||
### 5. Verify in dev mode
|
||||
|
||||
```bash
|
||||
just dev
|
||||
```
|
||||
|
||||
Test the full chain: model download → load → generate → voice cloning.
|
||||
|
||||
### 6. Use the checklist
|
||||
|
||||
Walk through the Implementation Checklist at the bottom of `tts-engines.mdx`. Every item must be checked before handing the build to the user.
|
||||
|
||||
## Key Lessons (from v0.2.3)
|
||||
|
||||
These are the most common failure modes. Phase 0 research catches all of them:
|
||||
|
||||
| Pattern | Symptom in Frozen Build | Fix |
|
||||
|---------|------------------------|-----|
|
||||
| `@typechecked` / `inspect.getsource()` | "could not get source code" | `--collect-all <package>` |
|
||||
| Package ships pretrained model files | `FileNotFoundError` for `.pth.tar`, `.yaml` | `--collect-all <package>` |
|
||||
| C library with hardcoded system paths | `FileNotFoundError` for `/usr/share/...` | `--collect-all` + env var in `server.py` |
|
||||
| `importlib.metadata.version()` | "No package metadata found" | `--copy-metadata <package>` |
|
||||
| `torch.load` without `map_location` | CUDA device not available on CPU build | Monkey-patch `torch.load` |
|
||||
| `torch.from_numpy` on float64 data | dtype mismatch RuntimeError | Cast to `.float()` |
|
||||
| `token=True` in HF download calls | Auth failure without stored HF token | Use `snapshot_download(token=None)` + `from_local()` |
|
||||
|
||||
## Notes
|
||||
|
||||
- The route and service layers have zero per-engine dispatch points. `main.py` requires zero changes.
|
||||
- The model config registry in `backends/__init__.py` handles all dispatch automatically.
|
||||
- Use `get_torch_device()` and `model_load_progress()` from `backends/base.py` — don't reimplement device detection or progress tracking.
|
||||
- Always test with a **clean HuggingFace cache** (no pre-downloaded models from dev).
|
||||
- Do NOT push or create a release. Hand the build to the user for local testing.
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: draft-release-notes
|
||||
description: Use this skill to draft or update the [Unreleased] section of CHANGELOG.md from the actual changes since the last tag. Run this at any point during development to keep a working copy of the release narrative. Does NOT bump versions or create tags.
|
||||
---
|
||||
|
||||
# Draft Release Notes
|
||||
|
||||
## Goal
|
||||
|
||||
Update the `[Unreleased]` section at the top of `CHANGELOG.md` with a narrative release story based on the real changes since the last tag. This is a **non-destructive working copy** — run it as many times as you want during development.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Identify the last release tag and gather changes.**
|
||||
|
||||
```bash
|
||||
LAST_TAG=$(git tag --list "v*" --sort=-v:refname | head -n 1)
|
||||
echo "Last tag: $LAST_TAG"
|
||||
```
|
||||
|
||||
Then collect raw material from three sources:
|
||||
|
||||
a. **Commit log since last tag:**
|
||||
```bash
|
||||
git log --oneline "$LAST_TAG"..HEAD
|
||||
```
|
||||
|
||||
b. **GitHub-generated release notes preview** (PR titles, new contributors):
|
||||
```bash
|
||||
gh api repos/:owner/:repo/releases/generate-notes \
|
||||
-f tag_name="vNEXT" \
|
||||
-f target_commitish="$(git rev-parse HEAD)" \
|
||||
-f previous_tag_name="$LAST_TAG" \
|
||||
--jq '.body'
|
||||
```
|
||||
|
||||
c. **Diff stat for theme analysis:**
|
||||
```bash
|
||||
git diff --stat "$LAST_TAG"..HEAD
|
||||
```
|
||||
|
||||
2. **Draft the release narrative.**
|
||||
|
||||
Write markdown for the `[Unreleased]` section following the format below. Do not include the `## [Unreleased]` heading itself — just the body content.
|
||||
|
||||
3. **Update CHANGELOG.md.**
|
||||
|
||||
Replace everything between `## [Unreleased]` and the next `## [` heading with the new draft. Preserve the HTML comment header and all existing release sections below.
|
||||
|
||||
The `[Unreleased]` section must always exist and always be the first section after the header comments.
|
||||
|
||||
4. **Do NOT commit, tag, or bump versions.** Just leave the file modified in the working tree.
|
||||
|
||||
## Release Story Format
|
||||
|
||||
Structure the `[Unreleased]` section like this:
|
||||
|
||||
```markdown
|
||||
## [Unreleased]
|
||||
|
||||
<One strong opening paragraph: what this release is about and why it matters.
|
||||
Tie it to concrete shipped changes. No vague hype.>
|
||||
|
||||
<One paragraph on major technical shifts, if applicable.>
|
||||
|
||||
### <Feature/Theme Group>
|
||||
- Bullet points with specifics
|
||||
- Reference PRs where available: ([#123](https://github.com/jamiepine/voicebox/pull/123))
|
||||
|
||||
### <Another Group>
|
||||
- ...
|
||||
|
||||
### Bug Fixes
|
||||
- ...
|
||||
```
|
||||
|
||||
### Style Guidelines
|
||||
|
||||
- **Factual and specific.** Every claim should trace to a real commit or PR.
|
||||
- **Narrative over list.** Lead with paragraphs that tell the story, then support with bullets.
|
||||
- **Group by theme, not by commit.** Cluster related changes under descriptive headings.
|
||||
- **Reference PRs** where they exist, but don't fabricate them.
|
||||
- **Skip trivial chores** (typo fixes, CI tweaks) unless they're the bulk of the release.
|
||||
- **Match the voice of existing releases** — look at the v0.2.1 and v0.2.3 entries in CHANGELOG.md for tone reference.
|
||||
|
||||
## When There Are No Changes
|
||||
|
||||
If `git log "$LAST_TAG"..HEAD` is empty, leave the `[Unreleased]` section empty (just the heading) and tell the user there's nothing to draft.
|
||||
|
||||
## Notes
|
||||
|
||||
- This skill only touches the `[Unreleased]` section. It never modifies stamped release sections.
|
||||
- The agent can be asked to run this skill at any point — mid-feature, before a PR, or right before cutting a release.
|
||||
- The `release-bump` skill depends on this draft being up to date before it finalizes.
|
||||
@@ -0,0 +1,124 @@
|
||||
---
|
||||
name: release-bump
|
||||
description: Use this skill to finalize a release. It stamps the [Unreleased] changelog section with a version and date, runs bumpversion to update all version files, and creates the release commit and tag. Only run this when you're ready to ship.
|
||||
---
|
||||
|
||||
# Release Bump
|
||||
|
||||
## Goal
|
||||
|
||||
Finalize the changelog draft, bump the version across all tracked files, and create a tagged release commit. After this skill runs, the repo has a clean release commit and tag ready to push.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `gh` CLI installed and authenticated (`gh auth status`).
|
||||
- `bumpversion` installed (`pip install bumpversion` or available in the project venv).
|
||||
- The `[Unreleased]` section of `CHANGELOG.md` should already contain the release narrative. If it's empty or stale, run the `draft-release-notes` skill first.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Verify the working tree is clean** (except `CHANGELOG.md` which may have the draft).
|
||||
|
||||
```bash
|
||||
git status --porcelain
|
||||
```
|
||||
|
||||
Only `CHANGELOG.md` (and optionally `.agents/` files) should be modified. If there are other uncommitted changes, stop and ask the user to commit or stash them first.
|
||||
|
||||
2. **Determine the bump level.**
|
||||
|
||||
Ask the user if not specified: `patch`, `minor`, or `major`. Check the current version:
|
||||
|
||||
```bash
|
||||
grep '^current_version' .bumpversion.cfg
|
||||
```
|
||||
|
||||
3. **Stamp the changelog.**
|
||||
|
||||
Read the current `[Unreleased]` content from `CHANGELOG.md`. Compute the new version (based on bump level and current version). Then:
|
||||
|
||||
a. Replace the `## [Unreleased]` section body with an empty placeholder.
|
||||
b. Insert a new stamped section immediately after `## [Unreleased]`:
|
||||
|
||||
```markdown
|
||||
## [Unreleased]
|
||||
|
||||
## [X.Y.Z] - YYYY-MM-DD
|
||||
|
||||
<the content that was in [Unreleased]>
|
||||
```
|
||||
|
||||
c. Update the reference links at the bottom of the file:
|
||||
- Change the `[Unreleased]` link to compare against the new tag
|
||||
- Add a new link for the new version
|
||||
|
||||
```markdown
|
||||
[Unreleased]: https://github.com/jamiepine/voicebox/compare/vX.Y.Z...HEAD
|
||||
[X.Y.Z]: https://github.com/jamiepine/voicebox/compare/vPREVIOUS...vX.Y.Z
|
||||
```
|
||||
|
||||
4. **Stage the changelog.**
|
||||
|
||||
```bash
|
||||
git add CHANGELOG.md
|
||||
```
|
||||
|
||||
5. **Run bumpversion.**
|
||||
|
||||
```bash
|
||||
bumpversion --allow-dirty <patch|minor|major>
|
||||
```
|
||||
|
||||
The `--allow-dirty` flag is needed because `CHANGELOG.md` is already staged. bumpversion will:
|
||||
- Update version strings in all tracked files (see `.bumpversion.cfg`)
|
||||
- Create a commit with message `Bump version: X.Y.Z -> A.B.C`
|
||||
- Create a tag `vA.B.C`
|
||||
|
||||
The staged `CHANGELOG.md` will be included in this commit automatically.
|
||||
|
||||
6. **Verify results.**
|
||||
|
||||
```bash
|
||||
git show --name-only --stat HEAD
|
||||
git tag --list "v*" --sort=-v:refname | head -n 5
|
||||
```
|
||||
|
||||
Confirm the commit contains:
|
||||
- `CHANGELOG.md`
|
||||
- `.bumpversion.cfg`
|
||||
- `tauri/src-tauri/tauri.conf.json`
|
||||
- `tauri/src-tauri/Cargo.toml`
|
||||
- `package.json`
|
||||
- `app/package.json`
|
||||
- `tauri/package.json`
|
||||
- `landing/package.json`
|
||||
- `web/package.json`
|
||||
- `backend/__init__.py`
|
||||
|
||||
Confirm the new tag exists.
|
||||
|
||||
7. **Do NOT push** unless the user explicitly asks. Report the tag name and suggest:
|
||||
|
||||
```
|
||||
Ready to push. When you're ready:
|
||||
git push origin main --follow-tags
|
||||
```
|
||||
|
||||
## Version Calculation Reference
|
||||
|
||||
Given current version `X.Y.Z`:
|
||||
- `patch` -> `X.Y.(Z+1)`
|
||||
- `minor` -> `X.(Y+1).0`
|
||||
- `major` -> `(X+1).0.0`
|
||||
|
||||
## Error Recovery
|
||||
|
||||
- If bumpversion fails, the tag won't exist. Fix the issue and re-run — bumpversion is idempotent as long as the tag doesn't already exist.
|
||||
- If you need to undo a release commit (before pushing): `git tag -d vX.Y.Z && git reset --soft HEAD~1`
|
||||
- Never amend a release commit that has been pushed.
|
||||
|
||||
## Notes
|
||||
|
||||
- When the tag is pushed, the release CI (`.github/workflows/release.yml`) automatically extracts the matching version section from `CHANGELOG.md` and uses it as the GitHub Release body. No manual copy-paste needed.
|
||||
- The release commit message is controlled by `.bumpversion.cfg` (`Bump version: X.Y.Z -> A.B.C`). Do not override it.
|
||||
- If you need to manually update the GitHub Release body after the fact: `gh release edit vX.Y.Z --notes-file <(sed -n '/## \[X.Y.Z\]/,/## \[/p' CHANGELOG.md | head -n -1)`
|
||||
@@ -0,0 +1,299 @@
|
||||
---
|
||||
name: triage-prs
|
||||
description: Use this skill to triage the open PR queue before a release. Classifies every open PR into must-merge, candidate, superseded, or deferred; writes a working triage doc; and runs the merge loop end-to-end. Designed for the pre-release "PR speedrun" pass where a solo maintainer wants to clear the inbound backlog in a single session.
|
||||
---
|
||||
|
||||
# Triage PRs
|
||||
|
||||
## Goal
|
||||
|
||||
Turn a backlog of open PRs into a shipped set of merges in a single focused session. Produce a tracked, resumable plan (`<VERSION>_PR_TRIAGE.md`), then work it — rebasing where needed, merging in isolation-safe batches, applying post-merge follow-ups, and closing superseded or partially-applicable PRs with credit to their authors.
|
||||
|
||||
This skill pairs with `draft-release-notes` and `release-bump`: triage first, then draft notes against the new main, then cut the release.
|
||||
|
||||
## When to use
|
||||
|
||||
- Before a minor or major release when 10+ open PRs have accumulated
|
||||
- When you want to unblock merging without losing the narrative of what's landing
|
||||
- When you know you can't personally review every PR deeply, but need to land the critical subset fast
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `gh` CLI authenticated against the repo
|
||||
- A dedicated worktree for PR review (avoid contaminating `main` with checkouts of contributor branches)
|
||||
- Clarity on the target version — the triage doc is named after it (e.g. `0.4.0_PR_TRIAGE.md`)
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Set up an isolated PR-review worktree
|
||||
|
||||
```bash
|
||||
git worktree list # check for stale ones first
|
||||
git worktree prune
|
||||
git worktree add ../voicebox-pr-review -b pr-review-<VERSION> main
|
||||
```
|
||||
|
||||
Keep the main worktree for release-prep work (changelog drafts, direct-to-main follow-ups). Keep the review worktree for `gh pr checkout` — each checkout moves HEAD to a contributor branch, which you don't want to do in the main worktree.
|
||||
|
||||
### 2. Gather metadata for every open PR
|
||||
|
||||
```bash
|
||||
gh pr list --state open --limit 50 --json \
|
||||
number,title,author,isDraft,mergeable,mergeStateStatus,files,additions,deletions,reviewDecision,statusCheckRollup,maintainerCanModify \
|
||||
--jq '.[] | {num: .number, title, author: .author.login, mergeable, state: .mergeStateStatus, canModify: .maintainerCanModify, changes: "+\(.additions)/-\(.deletions)", files: [.files[].path]}'
|
||||
```
|
||||
|
||||
You want, for each PR:
|
||||
- Size (`+additions/-deletions`)
|
||||
- Mergeable state (`CLEAN`, `UNSTABLE`, `DIRTY` = conflicts, `UNKNOWN` = GitHub still computing)
|
||||
- Whether maintainer edits are allowed on the branch (needed later if you rebase for the author)
|
||||
- File paths touched (helps spot overlaps between PRs)
|
||||
|
||||
`UNKNOWN` is common right after a push to main — just try the merge and see.
|
||||
|
||||
### 3. Classify into tiers
|
||||
|
||||
Sort each PR into exactly one bucket:
|
||||
|
||||
**Tier 1 — Merge:** small, mergeable, fixes a real bug, clean CI, low review cost. One-liners, dependency relaxations, targeted safety hardening. These are the easy wins.
|
||||
|
||||
**Tier 2 — Candidate, review:** medium size (50-200 lines), touches more surface area, looks sound but needs a closer read. New user-facing features that fit the product direction.
|
||||
|
||||
**Supersede:** the fix or feature is already covered by something merged. Close with a comment pointing to the superseding PR. Check carefully — "similar title" isn't proof; compare the actual diffs.
|
||||
|
||||
**Defer to next release:** big features, dirty conflicts, draft PRs, anything touching the release pipeline in ways that would introduce risk. Don't merge these in a speedrun — they need dedicated focus.
|
||||
|
||||
### 4. Write the triage doc
|
||||
|
||||
Create `<VERSION>_PR_TRIAGE.md` in the PR-review worktree root. Structure:
|
||||
|
||||
```markdown
|
||||
# <Repo> <VERSION> — PR Triage
|
||||
|
||||
Working doc for tracking which open PRs land in <VERSION>. Delete after release cut.
|
||||
|
||||
Last updated: <DATE>
|
||||
|
||||
## Progress
|
||||
|
||||
**Tier 1: 0 / N merged**
|
||||
**Tier 2: 0 / M handled**
|
||||
**Supersede triage: pending**
|
||||
|
||||
---
|
||||
|
||||
## Merge for <VERSION> — critical bug fixes
|
||||
|
||||
| PR | Status | Size | What it fixes | Why must-have |
|
||||
|---|---|---|---|---|
|
||||
| [#123](url) | [ ] | +5/-0 | ... | ... |
|
||||
|
||||
## Strong candidate — needs a quick review
|
||||
|
||||
| PR | Status | Size | Summary |
|
||||
|---|---|---|---|
|
||||
|
||||
## Close as superseded
|
||||
|
||||
| PR | Status | Reason |
|
||||
|---|---|---|
|
||||
|
||||
## Defer to <NEXT_VERSION>
|
||||
|
||||
- [#xxx](url) ... — reason
|
||||
|
||||
---
|
||||
|
||||
## Order of attack
|
||||
|
||||
1. Close superseded PRs (one-liner comments)
|
||||
2. Merge tier-1 in dependency-free batches — check file paths don't overlap
|
||||
3. Review tier-2 individually
|
||||
4. Rerun `draft-release-notes` to pick up everything
|
||||
5. Run `release-bump`
|
||||
```
|
||||
|
||||
The **Progress** header is the most important part — it's your scoreboard and lets you resume cleanly if the session gets interrupted.
|
||||
|
||||
### 5. Work the loop — per PR
|
||||
|
||||
For each PR in the tier-1 / tier-2 list:
|
||||
|
||||
**a. Checkout in the review worktree:**
|
||||
```bash
|
||||
cd ../voicebox-pr-review
|
||||
git checkout pr-review-<VERSION> # reset to neutral base
|
||||
gh pr checkout <N>
|
||||
```
|
||||
|
||||
**b. Read the *actual* commit, not `main..HEAD`:**
|
||||
|
||||
```bash
|
||||
git show HEAD # the PR's actual changes
|
||||
git show --stat HEAD # files touched + line counts
|
||||
```
|
||||
|
||||
**Do NOT review via `git diff main..HEAD`** if the PR branch is older than main. That diff includes *every commit that landed on main after the PR was forked* as `-` (deletion) lines. A 3-line PR can look like a 700-line revert. This is the single easiest way to misjudge a PR.
|
||||
|
||||
**c. Evaluate concerns:** correctness, scope, interaction with already-merged work, version compatibility (e.g. can't use an API that requires a dependency version we don't yet pin).
|
||||
|
||||
**d. Rebase if the branch is behind main:**
|
||||
```bash
|
||||
git fetch origin main
|
||||
git rebase origin/main
|
||||
```
|
||||
|
||||
This is **essential** before squash-merging. GitHub's squash computes `diff(PR-head, merge-base)` — on a stale branch, that diff includes reverting every in-between commit. Rebasing moves the merge-base forward so the squash is clean.
|
||||
|
||||
**e. If maintainer edits are allowed, push the rebase back to the contributor's fork:**
|
||||
```bash
|
||||
git remote add <author> https://github.com/<author>/<repo>.git
|
||||
git fetch <author> <branch> # get their ref first
|
||||
git push <author> HEAD:<branch> --force-with-lease
|
||||
```
|
||||
|
||||
This keeps GitHub's PR UI in sync with the rebased state and makes the merge clean from the GitHub side.
|
||||
|
||||
**f. Merge:**
|
||||
```bash
|
||||
gh pr merge <N> --squash
|
||||
```
|
||||
|
||||
**g. Update the triage doc** — flip the checkbox to `✅ merged <sha>` (use the short SHA from `gh pr view <N> --json mergeCommit --jq '.mergeCommit.oid[0:7]'`). Update the Progress header.
|
||||
|
||||
### 6. Batch tiny fixes
|
||||
|
||||
PRs with ≤5 line changes, clean CI, non-overlapping file paths, and obviously-correct intent (e.g. one-line dependency relax, env var add, import path fix) can be merged in a single loop without the review-per-PR ceremony:
|
||||
|
||||
```bash
|
||||
for pr in 425 384 416 429; do
|
||||
echo "=== Merging PR $pr ==="
|
||||
gh pr merge $pr --squash
|
||||
done
|
||||
```
|
||||
|
||||
Verify afterward that each landed cleanly:
|
||||
```bash
|
||||
for pr in 425 384 416 429; do
|
||||
gh pr view $pr --json state,mergeCommit --jq "{pr: $pr, state, sha: .mergeCommit.oid[0:7]}"
|
||||
done
|
||||
```
|
||||
|
||||
### 7. Post-merge follow-ups
|
||||
|
||||
Sometimes a PR is worth merging despite a known minor issue (e.g. incomplete dtype map, stale sentinel cleanup). Don't block the merge; apply the follow-up as a normal branch + PR right after:
|
||||
|
||||
```bash
|
||||
cd <main-worktree>
|
||||
git pull --ff-only origin main
|
||||
git checkout -b fix/<short-name>
|
||||
# edit...
|
||||
git commit -m "fix(<area>): <one-liner>"
|
||||
git push -u origin fix/<short-name>
|
||||
gh pr create --title "..." --body "Follow-up to #<N>. ..."
|
||||
```
|
||||
|
||||
Record both SHAs in the triage doc (`✅ merged <pr-sha> + follow-up <pr>`).
|
||||
|
||||
**Direct-to-main exception:** only under an explicit, scoped policy (e.g. "release speedrun"). Don't default to it.
|
||||
|
||||
### 8. Supersede: close with a credit-pointing comment
|
||||
|
||||
```bash
|
||||
gh pr close <N> --comment "Closing — superseded by merged #<M> which landed <brief description>. Thanks!"
|
||||
```
|
||||
|
||||
Check the diffs first — "similar title" is not enough. If the PR is *partially* superseded (the diagnosis is right but only half the changes are still needed), do a partial-apply instead.
|
||||
|
||||
### 9. Partial-apply pattern
|
||||
|
||||
When a PR has both valuable and questionable changes bundled:
|
||||
|
||||
```bash
|
||||
cd <main-worktree>
|
||||
git pull --ff-only origin main
|
||||
|
||||
# Cherry-pick specific files from the PR branch
|
||||
git checkout <pr-commit-sha> -- <file1> <file2>
|
||||
|
||||
# Review the staged changes, adjust as needed
|
||||
git diff --cached
|
||||
|
||||
# Apply any surgical edits to files you don't want to bulk-replace
|
||||
# (e.g. the PR's file predates a recent main commit you need to preserve)
|
||||
|
||||
# Commit with a trailer crediting the original author
|
||||
git commit -m "$(cat <<'EOF'
|
||||
<subject>
|
||||
|
||||
<body explaining what was kept vs dropped>
|
||||
|
||||
Co-Authored-By: <author> <[email protected]>
|
||||
EOF
|
||||
)"
|
||||
git push ... # branch + PR, unless under the direct-to-main exception
|
||||
```
|
||||
|
||||
Then close the PR with a comment explaining what was applied and what was dropped, referencing the commit SHA.
|
||||
|
||||
### 10. Keep the doc current
|
||||
|
||||
Every merge, every close, every follow-up → update `<VERSION>_PR_TRIAGE.md`. The doc is your session log. If you're interrupted and resume tomorrow, the doc is the only source of truth for "where am I."
|
||||
|
||||
### 11. When triage is done
|
||||
|
||||
- Every PR in the doc has a terminal status (✅ merged / ✅ closed / deferred)
|
||||
- Progress header shows N/N for each tier
|
||||
- Next skill to run is `draft-release-notes` (to regenerate `[Unreleased]` against the new main), then `release-bump`
|
||||
|
||||
You can delete the triage doc after the release ships, or keep it in version history as a record.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **`main..HEAD` on a stale branch lies.** It shows everything main gained since the branch split as deletions. Always review via `git show HEAD` for the PR's actual commit.
|
||||
- **Squash-merging an unrebased branch reverts in-between work.** The squash computes `diff(PR-head, merge-base)`. Rebase moves the merge-base forward.
|
||||
- **`mergeable=UNKNOWN`** is transient — GitHub is recomputing after a push. Just try the merge.
|
||||
- **Route ordering matters (FastAPI and similar):** `DELETE /history/failed` must be registered *before* `DELETE /history/{id}`, or the parameterized path will consume `"failed"` as an ID.
|
||||
- **Apple's `-weak_framework` overrides `-framework`** for the same framework, regardless of order — use it via `cargo:rustc-link-arg=-Wl,-weak_framework,Name` when a dependency hard-links something optional.
|
||||
- **Dependency version floors constrain what you can apply.** Before accepting a kwarg rename like `torch_dtype=` → `dtype=`, check the min-version pin supports it. Sometimes the right move is to cherry-pick half the PR.
|
||||
- **`cpal::Stream` and similar `!Send` audio types** can't cross `await` points or `spawn_blocking`. Sometimes a "not-ideal but correct" sync wait is the best available fix; flag but don't block.
|
||||
- **PyTorch nightly builds are not shippable for releases** — non-deterministic, can regress between runs. If a PR suggests switching to nightly to fix a GPU issue, prefer `TORCH_CUDA_ARCH_LIST=...+PTX` or wait for stable support instead.
|
||||
|
||||
## Canonical commands reference
|
||||
|
||||
```bash
|
||||
# Bulk PR metadata
|
||||
gh pr list --state open --limit 50 --json number,title,author,mergeable,mergeStateStatus,additions,deletions,maintainerCanModify,files
|
||||
|
||||
# Detailed single-PR view
|
||||
gh pr view <N> --json body,author,headRefName,baseRefName,mergeable,maintainerCanModify,files,statusCheckRollup
|
||||
|
||||
# The actual commit, not the branch-vs-main diff
|
||||
git show HEAD
|
||||
git show --stat HEAD
|
||||
gh pr diff <N>
|
||||
|
||||
# Rebase contributor branch onto current main
|
||||
git fetch origin main && git rebase origin/main
|
||||
|
||||
# Push rebase back to contributor fork (maintainerCanModify=true required)
|
||||
git remote add <author> https://github.com/<author>/<repo>.git
|
||||
git fetch <author> <branch>
|
||||
git push <author> HEAD:<branch> --force-with-lease
|
||||
|
||||
# Merge
|
||||
gh pr merge <N> --squash
|
||||
|
||||
# Confirm merge SHA for triage doc
|
||||
gh pr view <N> --json state,mergeCommit --jq '{state, sha: .mergeCommit.oid[0:7]}'
|
||||
|
||||
# Close superseded
|
||||
gh pr close <N> --comment "Closing — superseded by merged #<M>. Thanks!"
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- **Never review a stale branch via `main..HEAD`.** This is the single most important line in this skill.
|
||||
- **The triage doc is the session state.** Lose the doc, lose the session. Update it after every action.
|
||||
- **Credit contributors even on partial-applies.** Use `Co-Authored-By:` trailers and close comments that link to the applied commit.
|
||||
- **Don't let perfect be the enemy of shipped.** A fix that goes from "broken" to "works with a minor known issue" is a strict improvement. Flag the issue, file a follow-up, merge the fix.
|
||||
@@ -0,0 +1,39 @@
|
||||
[bumpversion]
|
||||
current_version = 0.5.0
|
||||
commit = True
|
||||
tag = True
|
||||
tag_name = v{new_version}
|
||||
tag_message = Release v{new_version}
|
||||
message = Bump version: {current_version} → {new_version}
|
||||
|
||||
[bumpversion:file:tauri/src-tauri/tauri.conf.json]
|
||||
search = "version": "{current_version}"
|
||||
replace = "version": "{new_version}"
|
||||
|
||||
[bumpversion:file:tauri/src-tauri/Cargo.toml]
|
||||
search = version = "{current_version}"
|
||||
replace = version = "{new_version}"
|
||||
|
||||
[bumpversion:file:package.json]
|
||||
search = "version": "{current_version}"
|
||||
replace = "version": "{new_version}"
|
||||
|
||||
[bumpversion:file:app/package.json]
|
||||
search = "version": "{current_version}"
|
||||
replace = "version": "{new_version}"
|
||||
|
||||
[bumpversion:file:tauri/package.json]
|
||||
search = "version": "{current_version}"
|
||||
replace = "version": "{new_version}"
|
||||
|
||||
[bumpversion:file:landing/package.json]
|
||||
search = "version": "{current_version}"
|
||||
replace = "version": "{new_version}"
|
||||
|
||||
[bumpversion:file:web/package.json]
|
||||
search = "version": "{current_version}"
|
||||
replace = "version": "{new_version}"
|
||||
|
||||
[bumpversion:file:backend/__init__.py]
|
||||
search = __version__ = "{current_version}"
|
||||
replace = __version__ = "{new_version}"
|
||||
@@ -0,0 +1,45 @@
|
||||
# Version control
|
||||
.git
|
||||
.github
|
||||
.gitignore
|
||||
|
||||
# Desktop-only (not needed in web container)
|
||||
tauri/
|
||||
landing/
|
||||
docs/
|
||||
mlx-test/
|
||||
scripts/
|
||||
|
||||
# Dependencies & build artifacts (rebuilt in Docker)
|
||||
node_modules/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
*.spec
|
||||
|
||||
# Data (will be bind-mounted)
|
||||
data/
|
||||
backend/data/
|
||||
|
||||
# IDE & OS
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Config files not needed in container
|
||||
biome.json
|
||||
.biomeignore
|
||||
.bumpversion.cfg
|
||||
.npmrc
|
||||
Makefile
|
||||
CONTRIBUTING.md
|
||||
SECURITY.md
|
||||
LICENSE
|
||||
README.md
|
||||
backend/README.md
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 187 KiB |
@@ -0,0 +1,66 @@
|
||||
name: Build Windows
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-windows:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pyinstaller
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
- name: Build Python server
|
||||
shell: bash
|
||||
run: |
|
||||
cd backend
|
||||
python build_binary.py
|
||||
python build_binary.py --shim
|
||||
|
||||
PLATFORM=$(rustc --print host-tuple)
|
||||
mkdir -p ../tauri/src-tauri/binaries
|
||||
cp dist/voicebox-server.exe ../tauri/src-tauri/binaries/voicebox-server-${PLATFORM}.exe
|
||||
cp dist/voicebox-mcp.exe ../tauri/src-tauri/binaries/voicebox-mcp-${PLATFORM}.exe
|
||||
echo "Built voicebox-server-${PLATFORM}.exe"
|
||||
echo "Built voicebox-mcp-${PLATFORM}.exe"
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: "./tauri/src-tauri -> target"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
projectPath: tauri
|
||||
tagName: v__VERSION__
|
||||
releaseName: "voicebox v__VERSION__ (test build)"
|
||||
releaseBody: "Test build for audio export fix"
|
||||
releaseDraft: true
|
||||
prerelease: true
|
||||
args: ""
|
||||
includeUpdaterJson: false
|
||||
@@ -0,0 +1,26 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
frontend-quality:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Typecheck app + web
|
||||
run: bun run typecheck
|
||||
|
||||
- name: Build web smoke test
|
||||
run: bun run build:web
|
||||
+253
-32
@@ -4,7 +4,7 @@ on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
release:
|
||||
@@ -14,48 +14,93 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: 'macos-latest'
|
||||
args: '--target aarch64-apple-darwin'
|
||||
python-version: '3.12'
|
||||
- platform: 'macos-15-intel'
|
||||
args: '--target x86_64-apple-darwin'
|
||||
python-version: '3.12'
|
||||
- platform: 'ubuntu-22.04'
|
||||
args: ''
|
||||
python-version: '3.12'
|
||||
- platform: 'windows-latest'
|
||||
args: ''
|
||||
python-version: '3.12'
|
||||
- platform: "macos-latest"
|
||||
args: "--target aarch64-apple-darwin"
|
||||
python-version: "3.12"
|
||||
backend: "mlx"
|
||||
- platform: "macos-15-intel"
|
||||
args: "--target x86_64-apple-darwin"
|
||||
python-version: "3.12"
|
||||
backend: "pytorch"
|
||||
- platform: "windows-latest"
|
||||
args: ""
|
||||
python-version: "3.12"
|
||||
backend: "pytorch"
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Ubuntu runners ship with ~14 GB free; pip + PyInstaller + torch can
|
||||
# peak well above that during the build. Reclaim ~25 GB by pruning
|
||||
# preinstalled toolchains we don't use. This is what likely tripped
|
||||
# the March 2026 Linux release attempts (see commit 103e98b
|
||||
# "github runners suck") — not a code issue, a disk-pressure one.
|
||||
- name: Free up disk space (ubuntu)
|
||||
if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'namespace')
|
||||
# Pinned to v1.3.1 (SHA) — this job runs with contents: write and
|
||||
# handles signing secrets later, so we don't want a floating ref.
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
|
||||
with:
|
||||
tool-cache: false
|
||||
android: true
|
||||
dotnet: true
|
||||
haskell: true
|
||||
# large-packages: true would `apt-get remove '^llvm-.*'`, which
|
||||
# cascade-removes reverse deps that won't be pulled back in by the
|
||||
# `llvm-dev` install below. The other flags already free ~20 GB,
|
||||
# enough for the Python + torch + PyInstaller build.
|
||||
large-packages: false
|
||||
swap-storage: true
|
||||
|
||||
- name: Install dependencies (ubuntu only)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'namespace')
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf llvm-dev
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf llvm-dev libasound2-dev
|
||||
|
||||
- name: Install LLVM (macOS)
|
||||
if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-15-intel'
|
||||
run: |
|
||||
brew install llvm
|
||||
echo "$(brew --prefix llvm)/bin" >> $GITHUB_PATH
|
||||
echo "LLVM_CONFIG=$(brew --prefix llvm)/bin/llvm-config" >> $GITHUB_ENV
|
||||
brew install llvm@20
|
||||
echo "$(brew --prefix llvm@20)/bin" >> $GITHUB_PATH
|
||||
echo "LLVM_CONFIG=$(brew --prefix llvm@20)/bin/llvm-config" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'pip'
|
||||
cache: "pip"
|
||||
|
||||
- name: Install CPU-only PyTorch (Linux)
|
||||
if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'namespace')
|
||||
run: |
|
||||
pip install torch torchaudio --index-url https://download.pytorch.org/whl/cpu
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pyinstaller
|
||||
pip install -r backend/requirements.txt
|
||||
pip install --no-deps chatterbox-tts
|
||||
pip install --no-deps hume-tada
|
||||
|
||||
- name: Install MLX dependencies (Apple Silicon only)
|
||||
if: matrix.backend == 'mlx'
|
||||
run: |
|
||||
pip install -r backend/requirements-mlx.txt
|
||||
# mlx-audio>=0.3.1 and mlx-lm>=0.31.1 both declare transformers>=5.x,
|
||||
# which conflicts with our 4.57.x cap. The runtime APIs we use work
|
||||
# fine on transformers 4.57.x in practice (verified in dev), so install
|
||||
# them --no-deps. mlx-audio's other runtime deps (huggingface_hub,
|
||||
# librosa, numpy, numba, pyloudnorm) are already in requirements.txt;
|
||||
# miniaudio is in requirements-mlx.txt (needed by mlx_audio.stt,
|
||||
# not transitively pulled by anything else — see issue #505); the
|
||||
# rest (sounddevice, protobuf, sentencepiece, pyyaml, jinja2) are
|
||||
# pulled in by other engines.
|
||||
pip install --no-deps mlx-lm==0.31.1
|
||||
pip install --no-deps mlx-audio==0.4.1
|
||||
|
||||
- name: Build Python server (Linux/macOS)
|
||||
if: matrix.platform != 'windows-latest'
|
||||
@@ -69,6 +114,7 @@ jobs:
|
||||
run: |
|
||||
cd backend
|
||||
python build_binary.py
|
||||
python build_binary.py --shim
|
||||
|
||||
# Get platform tuple
|
||||
PLATFORM=$(rustc --print host-tuple)
|
||||
@@ -78,7 +124,9 @@ jobs:
|
||||
|
||||
# Copy with platform suffix
|
||||
cp dist/voicebox-server.exe ../tauri/src-tauri/binaries/voicebox-server-${PLATFORM}.exe
|
||||
cp dist/voicebox-mcp.exe ../tauri/src-tauri/binaries/voicebox-mcp-${PLATFORM}.exe
|
||||
echo "Built voicebox-server-${PLATFORM}.exe"
|
||||
echo "Built voicebox-mcp-${PLATFORM}.exe"
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
@@ -91,31 +139,204 @@ jobs:
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: './tauri/src-tauri -> target'
|
||||
workspaces: "./tauri/src-tauri -> target"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
- name: Install Apple API key
|
||||
if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-15-intel'
|
||||
run: |
|
||||
mkdir -p ~/.appstoreconnect/private_keys/
|
||||
cd ~/.appstoreconnect/private_keys/
|
||||
echo ${{ secrets.APPLE_API_KEY_BASE64 }} >> AuthKey_${{ secrets.APPLE_API_KEY }}.p8.base64
|
||||
base64 --decode -i AuthKey_${{ secrets.APPLE_API_KEY }}.p8.base64 -o AuthKey_${{ secrets.APPLE_API_KEY }}.p8
|
||||
rm AuthKey_${{ secrets.APPLE_API_KEY }}.p8.base64
|
||||
|
||||
- name: Install Codesigning Certificate
|
||||
if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-15-intel'
|
||||
uses: apple-actions/import-codesign-certs@v3
|
||||
with:
|
||||
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
|
||||
- name: Disk / environment snapshot (pre-bundle debug)
|
||||
if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'namespace')
|
||||
run: |
|
||||
echo "=== df -h ==="
|
||||
df -h
|
||||
echo "=== free -h ==="
|
||||
free -h
|
||||
echo "=== Rust / Cargo ==="
|
||||
rustc --version
|
||||
cargo --version
|
||||
echo "=== Bun ==="
|
||||
bun --version
|
||||
echo "=== Tauri CLI ==="
|
||||
cd tauri && bun run tauri --version
|
||||
|
||||
- name: Extract release notes from CHANGELOG.md
|
||||
id: changelog
|
||||
shell: bash
|
||||
run: |
|
||||
# Get the version from the tag (strip leading 'v')
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
|
||||
# Extract the section for this version from CHANGELOG.md
|
||||
# Matches from "## [X.Y.Z]" until the next "## [" heading
|
||||
NOTES=$(sed -n "/^## \[${VERSION}\]/,/^## \[/{/^## \[${VERSION}\]/d;/^## \[/d;p;}" CHANGELOG.md)
|
||||
|
||||
# Fall back to a placeholder if the version isn't in the changelog
|
||||
if [ -z "$(echo "$NOTES" | tr -d '[:space:]')" ]; then
|
||||
NOTES="See the assets below to download and install this version."
|
||||
fi
|
||||
|
||||
# Use multiline output syntax
|
||||
{
|
||||
echo "notes<<CHANGELOG_EOF"
|
||||
echo "$NOTES"
|
||||
echo "CHANGELOG_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Linux hang watchdog: previous releases silently wedged inside tauri
|
||||
# bundling (possibly linuxdeploy/AppImage download, possibly cargo link).
|
||||
# Cap the step at 30 min so we get logs instead of waiting out the 6hr
|
||||
# job timeout. Other platforms historically complete in ~25 min, so 45
|
||||
# is comfortable.
|
||||
- uses: tauri-apps/[email protected]
|
||||
timeout-minutes: ${{ (contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'namespace')) && 30 || 45 }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_PROVIDER_SHORT_NAME: ${{ secrets.APPLE_PROVIDER_SHORT_NAME }}
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||
# Stream subprocess stdout/stderr so the hang is visible in logs.
|
||||
CARGO_TERM_VERBOSE: "true"
|
||||
RUST_BACKTRACE: "1"
|
||||
with:
|
||||
projectPath: tauri
|
||||
tagName: v__VERSION__
|
||||
releaseName: 'voicebox v__VERSION__'
|
||||
releaseBody: |
|
||||
## What's Changed
|
||||
See the assets below to download and install this version.
|
||||
|
||||
### Installation
|
||||
- **macOS**: Download the `.dmg` file
|
||||
- **Windows**: Download the `.msi` installer
|
||||
- **Linux**: Download the `.AppImage` or `.deb` package
|
||||
|
||||
The app includes automatic updates - future updates will be installed automatically.
|
||||
releaseName: "voicebox v__VERSION__"
|
||||
releaseBody: ${{ steps.changelog.outputs.notes }}
|
||||
releaseDraft: true
|
||||
prerelease: false
|
||||
args: ${{ matrix.args }}
|
||||
includeUpdaterJson: true
|
||||
|
||||
# Tauri's bundler signs the .app and notarizes it, but the .dmg wrapper
|
||||
# ships unnotarized. Gatekeeper rejects that on macOS 15 Sequoia (caught
|
||||
# by Homebrew Cask CI) and causes "app isn't signed" dialogs on older
|
||||
# Intel Macs when Apple's notarization servers are slow (see issue #509).
|
||||
# Submit the .dmg to notarytool, staple the ticket, and overwrite the
|
||||
# release asset uploaded by tauri-action.
|
||||
- name: Notarize and staple DMG (macOS)
|
||||
if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-15-intel'
|
||||
env:
|
||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }}
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
KEY_PATH="$HOME/.appstoreconnect/private_keys/AuthKey_${APPLE_API_KEY_ID}.p8"
|
||||
TARGET=$(echo "${{ matrix.args }}" | sed -n 's/.*--target \([a-z0-9_-]*\).*/\1/p')
|
||||
DMG_DIR="tauri/src-tauri/target/${TARGET}/release/bundle/dmg"
|
||||
# Match the release tag tauri-action resolved from tauri.conf.json's
|
||||
# version field; GITHUB_REF_NAME is a branch name under workflow_dispatch.
|
||||
RELEASE_TAG="v$(jq -r '.version' tauri/src-tauri/tauri.conf.json)"
|
||||
shopt -s nullglob
|
||||
dmgs=("${DMG_DIR}"/*.dmg)
|
||||
if [ ${#dmgs[@]} -eq 0 ]; then
|
||||
echo "::error::No DMGs found in ${DMG_DIR} — tauri bundler output path may have changed"
|
||||
exit 1
|
||||
fi
|
||||
for dmg in "${dmgs[@]}"; do
|
||||
echo "::group::Notarize $(basename "$dmg")"
|
||||
xcrun notarytool submit "$dmg" \
|
||||
--key "$KEY_PATH" \
|
||||
--key-id "$APPLE_API_KEY_ID" \
|
||||
--issuer "$APPLE_API_ISSUER" \
|
||||
--wait --timeout 20m
|
||||
xcrun stapler staple "$dmg"
|
||||
spctl -a -t open --context context:primary-signature -vv "$dmg"
|
||||
gh release upload "${RELEASE_TAG}" "$dmg" --clobber \
|
||||
--repo "${GITHUB_REPOSITORY}"
|
||||
echo "::endgroup::"
|
||||
done
|
||||
|
||||
build-cuda-windows:
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pyinstaller
|
||||
pip install -r backend/requirements.txt
|
||||
pip install --no-deps chatterbox-tts
|
||||
pip install --no-deps hume-tada
|
||||
|
||||
- name: Install PyTorch with CUDA 12.8
|
||||
run: |
|
||||
pip install torch --index-url https://download.pytorch.org/whl/cu128 --force-reinstall --no-deps
|
||||
pip install torchaudio --index-url https://download.pytorch.org/whl/cu128 --force-reinstall --no-deps
|
||||
|
||||
- name: Verify CUDA support in torch
|
||||
run: |
|
||||
python -c "import torch; print(f'CUDA available in build: {torch.cuda.is_available()}'); print(f'CUDA version: {torch.version.cuda}')"
|
||||
|
||||
- name: Build CUDA server binary (onedir)
|
||||
shell: bash
|
||||
working-directory: backend
|
||||
env:
|
||||
# Include Blackwell (sm_120) via PTX forward compatibility.
|
||||
# Pre-built PyTorch cu128 wheels ship native kernels for sm_80/86/89/90
|
||||
# but not sm_120. Setting this env var causes torch.utils.cpp_extension
|
||||
# (and any JIT-compiled kernels) to target Blackwell GPUs as well.
|
||||
TORCH_CUDA_ARCH_LIST: "8.0;8.6;8.9;9.0;12.0+PTX"
|
||||
run: python build_binary.py --cuda
|
||||
|
||||
- name: Package into server core + CUDA libs archives
|
||||
shell: bash
|
||||
run: |
|
||||
python scripts/package_cuda.py \
|
||||
backend/dist/voicebox-server-cuda/ \
|
||||
--output release-assets/ \
|
||||
--cuda-libs-version cu128-v1 \
|
||||
--torch-compat ">=2.7.0,<2.11.0"
|
||||
|
||||
- name: Upload archives to GitHub Release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
release-assets/voicebox-server-cuda.tar.gz
|
||||
release-assets/voicebox-server-cuda.tar.gz.sha256
|
||||
release-assets/cuda-libs-cu128-v1.tar.gz
|
||||
release-assets/cuda-libs-cu128-v1.tar.gz.sha256
|
||||
release-assets/cuda-libs.json
|
||||
draft: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload onedir as workflow artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: voicebox-server-cuda-windows
|
||||
path: backend/dist/voicebox-server-cuda/
|
||||
retention-days: 7
|
||||
|
||||
+15
-4
@@ -35,10 +35,7 @@ target/
|
||||
Thumbs.db
|
||||
|
||||
# Data (user-generated)
|
||||
data/profiles/*
|
||||
data/generations/*
|
||||
data/projects/*
|
||||
data/voicebox.db
|
||||
data/
|
||||
!data/.gitkeep
|
||||
|
||||
# Logs
|
||||
@@ -52,8 +49,22 @@ logs/
|
||||
# Generated files
|
||||
app/openapi.json
|
||||
tauri/src-tauri/binaries/*
|
||||
tauri/src-tauri/gen/Assets.car
|
||||
tauri/src-tauri/gen/voicebox.icns
|
||||
tauri/src-tauri/gen/partial.plist
|
||||
|
||||
# PyInstaller
|
||||
*.spec
|
||||
|
||||
# Windows artifacts
|
||||
nul
|
||||
|
||||
# Temporary
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
|
||||
# E2E test artifacts
|
||||
backend/tests/results/
|
||||
backend/tests/fixtures/reference_voice.wav
|
||||
backend/tests/fixtures/reference_voice.txt
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"voicebox": {
|
||||
"type": "http",
|
||||
"url": "http://127.0.0.1:17493/mcp",
|
||||
"headers": {
|
||||
"X-Voicebox-Client-Id": "claude-code"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+767
@@ -0,0 +1,767 @@
|
||||
<!-- This file is compiled automatically during the release workflow. -->
|
||||
<!-- Do not edit manually — your changes will be overwritten. -->
|
||||
<!-- To update the draft: ask the agent to use the draft-release-notes skill. -->
|
||||
<!-- To finalize a release: ask the agent to use the release-bump skill. -->
|
||||
|
||||
# Changelog
|
||||
|
||||
## [0.5.0] - 2026-04-22
|
||||
|
||||
**The Capture release.** Voicebox stops being just a voice-cloning studio and becomes a full AI voice studio. Hold a key anywhere on your machine, speak, release — the transcript lands in the focused text field. Flip the primitive around and any MCP-aware agent — Claude Code, Cursor, Spacebot — speaks back through an on-screen pill in one of your cloned voices. A local LLM sits between the two, so transcripts come out clean and voice profiles can carry a personality that reshapes what the agent says before it gets spoken.
|
||||
|
||||
### Dictation — speak anywhere, paste anywhere
|
||||
|
||||
- **Global hotkey capture.** Hold a customizable chord anywhere on your machine (defaults: right-Cmd + right-Option on macOS, right-Ctrl + right-Shift on Windows), speak, release. A floating on-screen pill walks through recording → transcribing → refining → done with a live elapsed timer. The transcript lands as clean text.
|
||||
- **Push-to-talk and toggle modes, each with its own chord.** The default toggle chord adds Space to the push-to-talk chord. Holding PTT and tapping Space mid-hold upgrades a hold into a hands-free session without a gap in the recording.
|
||||
- **Auto-paste into the focused app.** Once transcription finishes, Voicebox synthesizes a paste into whatever text field had focus when you started the chord — not wherever focus drifted while you were talking. Works across Dvorak / AZERTY layouts. Your clipboard is saved before and restored after.
|
||||
- **Chord picker UI.** Customize either chord from Settings → Captures by holding the keys you want. Left/right modifier badges show whether a key is the left or right variant.
|
||||
- **Defaults stay out of your way.** macOS defaults avoid left-hand Cmd+Option chords so the system shortcuts they collide with stay yours. Windows defaults route around AltGr collisions on German / French / Spanish layouts.
|
||||
- **Accessibility permission is scoped.** If macOS Accessibility isn't granted, dictation still runs and transcripts still land in the Captures tab — only synthetic paste is disabled. The permission prompt lives inline next to the auto-paste toggle, not as a global banner.
|
||||
|
||||
### Personality — voice profiles that speak for themselves
|
||||
|
||||
Voice profiles now carry an optional **personality** — a free-form description of who this voice is, up to 2000 characters. When set, two new controls appear next to the generate button, each powered by a new Qwen3 LLM running entirely locally:
|
||||
|
||||
- **Compose** — the shuffle button drops a fresh in-character line into the textarea. Click again for variety, edit before speaking.
|
||||
- **Speak in character** — the wand toggle runs your input through the personality LLM before TTS, preserving every idea but delivering it in the character's voice.
|
||||
|
||||
The same LLM doubles as the refinement model, so there's one local LLM in the app, not two.
|
||||
|
||||
**API surface.** `POST /generate`, `POST /speak`, and the MCP `voicebox.speak` tool accept `personality: bool`. `POST /profiles/{id}/compose` powers the shuffle button. MCP client bindings carry a `default_personality: bool` that applies when `personality` isn't passed explicitly.
|
||||
|
||||
### Agents — any MCP-aware agent gets a voice
|
||||
|
||||
Voicebox ships a built-in **Model Context Protocol** server at `http://127.0.0.1:17493/mcp` so Claude Code, Cursor, Windsurf, Cline, VS Code MCP extensions — any MCP-aware agent — can call into your local Voicebox install. Four tools ship with dotted names:
|
||||
|
||||
- **`voicebox.speak`** — speak text in any voice profile, with optional `personality: true` to run through the profile's personality LLM first
|
||||
- **`voicebox.transcribe`** — Whisper transcription of a base64 blob or an absolute local path. Path mode is restricted to loopback callers so a Voicebox bound on `0.0.0.0` doesn't double as an unauthenticated arbitrary-local-file read primitive.
|
||||
- **`voicebox.list_captures`** — recent captures with their transcripts
|
||||
- **`voicebox.list_profiles`** — available voice profiles (cloned + preset)
|
||||
|
||||
- **Streamable HTTP as primary transport.** Cursor / Windsurf / VS Code / Claude Code all support it out of the box — drop a `mcpServers` block with the URL and an `X-Voicebox-Client-Id` header.
|
||||
- **Stdio shim for clients that don't speak HTTP MCP.** A `voicebox-mcp` binary ships inside the app bundle as a Tauri sidecar. The Settings page renders the install snippet with the right absolute path pre-filled.
|
||||
- **Per-client voice binding.** Pin Claude Code to Morgan, Cursor to Scarlett, Cline to its own voice — the `X-Voicebox-Client-Id` header resolves to a bound voice whenever `speak` is called without an explicit `profile`. Managed in **Settings → MCP**.
|
||||
- **Profile resolution precedence.** Explicit `profile` arg (name or id, case-insensitive) → per-client binding → global default from `capture_settings.default_playback_voice_id` → error with a pointer to Settings.
|
||||
- **Speaking pill.** Agent-initiated speech surfaces the same on-screen pill as dictation, in a `speaking` state with the profile name and an elapsed timer. Silent background TTS is a trust hazard — the pill always shows what's coming out of your machine.
|
||||
- **`POST /speak` REST wrapper.** Same code path and voice resolution for shell scripts, ACP, A2A, GitHub Actions, or anything else that isn't MCP-native.
|
||||
|
||||
**Claude Code one-liner:**
|
||||
|
||||
```
|
||||
claude mcp add voicebox --transport http --url http://127.0.0.1:17493/mcp --header "X-Voicebox-Client-Id: claude-code"
|
||||
```
|
||||
|
||||
### Refinement
|
||||
|
||||
A clean transcript needs more than Whisper. Each capture flows through a small Qwen3 LLM that strips fillers, fixes punctuation, and optionally rewrites self-corrections — all on-device.
|
||||
|
||||
- **Loop-stripping before the LLM sees the transcript.** Whisper's "thanks for watching thanks for watching thanks for watching…" hallucination loops are collapsed at a six-identical-tokens threshold (case-insensitive) so a small refinement model can't echo them back. Coverage spans single-word runs, multi-word phrases, CJK character runs, and Japanese emphasis patterns; legitimate repetition ("no, no, no, no, no") doesn't cross the threshold.
|
||||
- **Per-capture flag snapshot.** `smart_cleanup`, `self_correction`, and `preserve_technical` are stored on each capture, so refinement can be re-run later with different flags without losing the raw transcript.
|
||||
- **Model picker** — Qwen3 0.6B (400 MB, very fast), 1.7B (1.1 GB, fast), 4B (2.5 GB, full quality). 0.6B is the default; 1.7B is the sweet spot for transcripts with code identifiers.
|
||||
|
||||
### Captures tab + settings
|
||||
|
||||
Settings → Captures is now the home for the whole dictation flow:
|
||||
|
||||
- **Dictation**: global shortcut toggle, push-to-talk chord picker, toggle chord picker, live pill preview, auto-paste into focused field (with inline accessibility prompt).
|
||||
- **Transcription**: model picker (Whisper Base / Small / Medium / Large / Turbo), language lock.
|
||||
- **Refinement**: auto-refine toggle, model picker, smart cleanup, remove self-corrections, preserve technical terms.
|
||||
- **Playback**: default voice for the Captures tab's "Play as" action — picking a voice from the split-button persists the choice across tab switches and restarts.
|
||||
- **Storage**: captures folder quick-open.
|
||||
|
||||
### Stories — timeline editor
|
||||
|
||||
The Stories tab graduates from a TTS sequencer into a real timeline editor. Same generation-row backing, but clips now compose with imported audio, per-clip levels, and a flexible track stack.
|
||||
|
||||
- **Import external audio.** Drag a music file onto the story content area or pick one from the new "Import audio" entry in the add-clip popover. Accepted formats: wav / mp3 / flac / ogg / m4a / aac / webm, capped at 200 MB. Imported clips show their filename instead of a profile name and skip the regenerate / version-picker controls — there's nothing to regenerate.
|
||||
- **Per-clip volume.** A `Volume2` icon in the clip-edit toolbar opens a 0–200% slider. Adjustments apply live and to exports. Split and duplicate carry the volume forward into the new clips.
|
||||
- **Regenerate** from both the clip's chat-list dropdown and the track-editor toolbar. Re-runs the underlying generation through the same path the History tab uses, with completion tracked in the global pending set.
|
||||
- **Add empty tracks above or below the timeline** via tiny `+` strips at the top of the topmost label cell and the bottom of the bottommost. Sticky in the label column so they follow horizontal scroll.
|
||||
- **Zoom bar tracks the project.** Min scope is 10 seconds visible (zoomed in cap), max is the entire project (zoomed out cap), default lands on 60 s. Both the +/− buttons and the scrollbar edge-drag handles clamp to those dynamic bounds.
|
||||
|
||||
### Interface
|
||||
|
||||
- **Theme selector.** Light / dark / system in **Settings → General**, persisted across sessions. System mode listens for OS-level appearance changes and flips live without a restart.
|
||||
- **Scrubbable waveform player on captures.** The capture detail card now embeds a WaveSurfer waveform with click-to-seek and a current / total timestamp pair, replacing the static duration label.
|
||||
- **Capture pill light mode.** The on-screen pill gets a dedicated light palette so it stays legible against bright windows.
|
||||
- **Readiness checklist in the Captures settings sidebar.** The same six-gate checklist the Captures empty state uses mirrors into Settings → Captures so a red gate can't hide behind a green toggle. Hidden once every gate is green. macOS-only rows (Input Monitoring, Accessibility) hide entirely on Windows and Linux.
|
||||
|
||||
### Windows parity
|
||||
|
||||
Same dictation flow on Windows. Right-hand default chord (Ctrl+Shift) avoids AltGr collisions on layouts where Ctrl+Alt is the compose key. Focus is captured at chord-start so paste lands in the original field even if focus drifts during transcribe/refine.
|
||||
|
||||
## [0.4.5] - 2026-04-22
|
||||
|
||||
Second hotfix for the "offline mode is enabled" crash on model load. 0.4.4 reverted the inference-path offline guards but kept the same trap on the load path, so users who updated to 0.4.4 kept hitting the exact error the release was supposed to fix ([#526](https://github.com/jamiepine/voicebox/issues/526)). This release removes the load-path guards and patches the transformers tokenizer load to be robust to HuggingFace metadata failures at the source, so the class of bug can't recur.
|
||||
|
||||
### Reliability
|
||||
|
||||
- **Load no longer fails with "offline mode is enabled"** ([#530](https://github.com/jamiepine/voicebox/pull/530), fixes [#526](https://github.com/jamiepine/voicebox/issues/526)). transformers 4.57.x added an unconditional `huggingface_hub.model_info()` call inside `AutoTokenizer.from_pretrained` (via `_patch_mistral_regex`) that runs for every non-local repo load, regardless of cache state or whether the target model is actually a Mistral variant. The load-time `HF_HUB_OFFLINE` guard from 0.4.2 turned that into a hard crash for cached online users the moment 0.4.4 removed the inference-path guard that had been masking the problem. Fix wraps `_patch_mistral_regex` so any exception from the HF metadata check is caught and the tokenizer is returned unchanged — matching the success-path behavior for non-Mistral repos. The wrapper installs at `backend.backends` import time so it covers Qwen Base, Qwen CustomVoice, TADA, and every other transformers-backed engine on Windows, Linux, and CUDA alike. The load-time `force_offline_if_cached` guards were removed — with the wrapper in place they provide zero value and only risk re-introducing the same failure mode.
|
||||
- **No more 30s pause when generating without a network.** The HuggingFace metadata timeout called out as a known caveat in 0.4.4 is covered by the same patch; offline users no longer wait for the check to time out before load completes.
|
||||
|
||||
## [0.4.4] - 2026-04-21
|
||||
|
||||
Hotfix for a regression in 0.4.3 where generation and transcription could fail outright with "offline mode is enabled" even when the user was online.
|
||||
|
||||
### Reliability
|
||||
|
||||
- **Inference no longer fails with "offline mode is enabled" while online** ([#524](https://github.com/jamiepine/voicebox/pull/524), reverts the inference-path guards from [#503](https://github.com/jamiepine/voicebox/pull/503)). 0.4.3 wrapped every inference body (`generate`, `transcribe`, `create_voice_clone_prompt`) with a process-wide `HF_HUB_OFFLINE` flip to stop lazy HuggingFace lookups from hanging when the network drops mid-inference ([#462](https://github.com/jamiepine/voicebox/issues/462)). That flag also blocks legitimate metadata calls (e.g. `HfApi().model_info` for revision resolution) so online users started seeing generation fail outright. Inference now runs with the process's default HF state. Load-time offline guards — which weren't the source of the regression — stay in place.
|
||||
|
||||
**Known caveat**: users generating without an internet connection may see brief pauses during inference while HuggingFace metadata lookups time out (typically ~30s, after which the library recovers). A proper offline-mode toggle is planned for 0.4.5.
|
||||
|
||||
## [0.4.3] - 2026-04-20
|
||||
|
||||
A patch focused on two user-impacting reliability fixes: macOS DMG notarization (unblocks `brew install voicebox` on macOS 15 Sequoia and fixes spurious "app isn't signed" Gatekeeper dialogs on older Intel Macs) and Kokoro Japanese voice initialization on fresh installs.
|
||||
|
||||
### macOS
|
||||
|
||||
- **DMGs are now notarized and stapled** ([#523](https://github.com/jamiepine/voicebox/pull/523)). Tauri's bundler notarizes the `.app` inside the DMG but ships the DMG wrapper itself unnotarized. Gatekeeper rejects that on macOS 15 Sequoia (confirmed by Homebrew Cask CI failing on both arm and intel Sequoia runners) and causes the "the app is not signed" dialog on older Intel Macs when Apple's notarization servers are slow or unreachable ([#509](https://github.com/jamiepine/voicebox/issues/509)). The release workflow now submits each DMG to `notarytool`, staples the ticket, verifies with `spctl`, and overwrites the draft-release asset `tauri-action` uploaded. Adds ~5-10 min per macOS job.
|
||||
|
||||
### Backend
|
||||
|
||||
- **Kokoro Japanese voices no longer crash on fresh installs** ([#521](https://github.com/jamiepine/voicebox/pull/521), fixes [#514](https://github.com/jamiepine/voicebox/issues/514)). `misaki[ja]` pulls in `fugashi`, which needs a MeCab dictionary on disk. The `unidic` package that was being installed ships no data and expects a ~526MB runtime download that `just setup` doesn't run (and which wouldn't survive PyInstaller anyway). Swapped to `unidic-lite`, which bundles a MeCab-compatible dict inside the wheel (~50MB). Collected in `build_binary.py` so frozen builds pick up `unidic_lite/dicdir/`.
|
||||
|
||||
## [0.4.2] - 2026-04-20
|
||||
|
||||
This release localizes the entire app. English, Simplified Chinese (zh-CN), Traditional Chinese (zh-TW), and Japanese (ja) are wired up end-to-end across every tab, modal, dialog, and toast — 559 translation keys per locale, parity verified. Plus a batch of reliability fixes: offline-mode now actually stays offline, Chatterbox accepts reference samples it used to reject, MLX Qwen 0.6B points at the right repo, and macOS system audio survives backgrounding.
|
||||
|
||||
### Internationalization ([#508](https://github.com/jamiepine/voicebox/pull/508))
|
||||
- **i18next foundation** with an in-app language switcher that re-renders the tree on change — lazy-loaded components were holding stale strings without an explicit key-bump on the React root.
|
||||
- **Four locales** at full coverage: English, Simplified Chinese, Traditional Chinese, Japanese. No partial/English-fallback surfaces.
|
||||
- **Every user-visible surface translated**: Stories (list, content editor, dialogs, toasts), Effects (list, detail, chain editor, built-in preset names), Voices (table, search, inspector, Create/Edit modal, audio sample panels), Audio Channels (list, dialogs, device picker), history + story dropdown menus, ProfileCard / ProfileList / HistoryTable, and the unsupported-model note.
|
||||
- **Relative dates** localize via `date-fns` locale objects (`3 days ago` → `3 天前` / `3 日前`) — `Intl.RelativeTimeFormat` doesn't produce the phrasing we use in the history table.
|
||||
- **Dev-build version suffix** (`v0.4.2 (dev)` / `(开发版)` / `(開發版)` / `(開発版)`) is now locale-aware.
|
||||
- **559 translation keys** across all four locales.
|
||||
|
||||
### Reliability
|
||||
- **`HF_HUB_OFFLINE` now guards every inference path** ([#503](https://github.com/jamiepine/voicebox/pull/503)) — some engines were still attempting a HuggingFace metadata roundtrip on first load when offline mode was enabled, causing hangs on airgapped or flaky networks.
|
||||
- **Chatterbox reference samples are preprocessed instead of rejected** ([#502](https://github.com/jamiepine/voicebox/pull/502)) — samples outside the expected sample rate or channel layout are resampled to match, rather than failing with an opaque error.
|
||||
- **MLX Qwen 0.6B repo path fixed** ([#501](https://github.com/jamiepine/voicebox/pull/501)) — now points at the published `mlx-community` repo so the model actually downloads on Apple Silicon.
|
||||
- **macOS system audio survives backgrounding** ([#486](https://github.com/jamiepine/voicebox/pull/486), closes [#41](https://github.com/jamiepine/voicebox/issues/41)) — WKWebView was tearing down the audio session when the app lost focus, silently killing system-audio capture.
|
||||
- **MLX backend `miniaudio` dependency pinned** ([#506](https://github.com/jamiepine/voicebox/pull/506)) — `mlx_audio.stt` needs it at runtime and nothing else transitively pulled it in, so `--no-deps` installs were breaking on first use.
|
||||
|
||||
### Landing / Docs
|
||||
- **New `/download` page** ([#487](https://github.com/jamiepine/voicebox/pull/487)) — no more dumping first-time visitors onto the GitHub releases list. The API example snippet on the landing page also got an accuracy pass.
|
||||
- **Download redirects work behind reverse proxies** ([#498](https://github.com/jamiepine/voicebox/pull/498)) — uses the public origin instead of `localhost` when resolving platform-specific installer URLs.
|
||||
- **MDX docs audited against the multi-engine backend** ([#484](https://github.com/jamiepine/voicebox/pull/484)) — stale single-engine assumptions removed.
|
||||
- **Three more tutorials + mobile navbar / hero CTA fixes** ([#483](https://github.com/jamiepine/voicebox/pull/483)).
|
||||
|
||||
### Linux
|
||||
- **Still not shipping.** The re-enable attempt ([#488](https://github.com/jamiepine/voicebox/pull/488)) landed on `main` but CI still hangs in the `tauri-action` bundler step on `ubuntu-22.04` — no output for 25+ minutes after `rpm` bundling, even with `createUpdaterArtifacts: false` and `--bundles deb,rpm`. The matrix entry is disabled again for 0.4.2; the ubuntu-specific setup steps stay in the workflow so re-enabling is a one-line change once we identify the hang. Next release will take another pass.
|
||||
|
||||
### New Contributors
|
||||
- [@shekharyv](https://github.com/shekharyv) — download redirects behind reverse proxies ([#498](https://github.com/jamiepine/voicebox/pull/498))
|
||||
|
||||
## [0.4.1] - 2026-04-18
|
||||
|
||||
A fast follow-up to 0.4.0 focused on making the new engines actually load in the production binary — plus generation cancellation, Linux system-audio capture, and the repo's first PR-time type check. Five first-time contributors shipped in this release.
|
||||
|
||||
0.4.0 introduced three new TTS engines, but the frozen PyInstaller binary tripped over several Python-ecosystem quirks that don't show up in the dev venv: `transformers` opening `.py` sources at runtime, `scipy.stats._distn_infrastructure` hitting a frozen-importer `NameError`, and `chatterbox-multilingual` failing to find its Chinese segmenter dictionary. This release patches all of those in one sweep.
|
||||
|
||||
### Frozen-Binary Reliability ([#438](https://github.com/jamiepine/voicebox/pull/438))
|
||||
- **Kokoro** now bundles `.py` sources alongside `.pyc` via `--collect-all kokoro` so `transformers`' `_can_set_attn_implementation` regex scan can read them — previously `FileNotFoundError: kokoro/modules.py` killed Kokoro loading in production builds
|
||||
- **Chatterbox Multilingual** now bundles `spacy_pkuseg/dicts/default.pkl` and the package's native `.so` extensions via `--collect-all spacy_pkuseg` — previously the Chinese word segmenter crashed with `FileNotFoundError` on first load
|
||||
- **scipy.stats._distn_infrastructure** — new runtime hook source-patches the trailing `del obj` (which raises `NameError` under PyInstaller's frozen importer because the preceding list comprehension evaluates empty) to `globals().pop('obj', None)`, unblocking `librosa` → `scipy.signal` → `scipy.stats` for every TTS engine that depends on librosa
|
||||
- **transformers.masking_utils** — same runtime hook forces `_is_torch_greater_or_equal_than_2_6 = False` so the older `sdpa_mask_older_torch` path is selected; the 2.6+ path uses `TransformGetItemToIndex()`, a real `torch._dynamo` graph transform our permissive stub can't reproduce
|
||||
- **torch._dynamo** — no-op stub replaces the real module before `transformers` imports it, preventing the `torch._numpy._ufuncs` import crash (`NameError: name 'name' is not defined`) that blocked Kokoro and every engine pulling in `flex_attention`
|
||||
- `.spec` paths are now repo-relative instead of absolute, so the generated spec is portable across machines and CI
|
||||
|
||||
### Generation
|
||||
- **Cancel queued or running generations** ([#444](https://github.com/jamiepine/voicebox/pull/444)) — new `/generate/{id}/cancel` endpoint and a Stop button on the history row while generating. The serial queue now tracks per-ID state (queued / running / cancelled) so queued jobs are skipped before the worker picks them up and running jobs are `.cancel()`-ed mid-flight; `run_generation` catches `CancelledError` and marks the row `failed` with a "cancelled" error.
|
||||
- **Legacy `data/` path prefix resolution** ([#440](https://github.com/jamiepine/voicebox/pull/440)) — generations stored with the old `data/` prefix under pre-0.4 installs now resolve correctly after the storage root moved, fixing 404s for historical audio.
|
||||
|
||||
### Model Migration
|
||||
- Migration dialog no longer hangs when the cache is empty ([#439](https://github.com/jamiepine/voicebox/pull/439)) — the backend now emits a completion SSE event even when zero models are moved.
|
||||
- Storage-change flow surfaces a toast when there's nothing to migrate ([#433](https://github.com/jamiepine/voicebox/pull/433)) instead of proceeding with a no-op move and restarting the server.
|
||||
- Deleting all generations from a voice profile now deletes the associated version files and DB rows too ([#447](https://github.com/jamiepine/voicebox/pull/447)) — previously orphaned versions accumulated in storage.
|
||||
|
||||
### Platform
|
||||
- **Linux system audio capture** ([#457](https://github.com/jamiepine/voicebox/pull/457)) — `cpal`'s ALSA backend doesn't expose PulseAudio/PipeWire monitor sources by name, so the previous device-name search never matched and silently fell back to the microphone. Detection now uses `pactl get-default-sink` + `pactl list short sources` and routes via `PULSE_SOURCE`, with the name-based search retained as a fallback when `pactl` is absent.
|
||||
|
||||
### Frontend CI
|
||||
- First PR-time quality gate ([#418](https://github.com/jamiepine/voicebox/pull/418)) — new `.github/workflows/ci.yml` runs `bun run typecheck` + `bun run build:web` on every PR. Fixed pre-existing type issues that were being suppressed with `@ts-expect-error`, cleaned up a dep-array typo (`[platform.metadata.isTauricheckOnMountcheckForUpdates]`) in `useAutoUpdater`, and removed 100+ lines of dead `ModelItem` code from `ModelManagement.tsx`.
|
||||
- Follow-up: widened `apiClient.migrateModels()` return type to include `moved` and `errors` so the storage-change handler typechecks against the real backend response ([#470](https://github.com/jamiepine/voicebox/pull/470)).
|
||||
|
||||
### Docs
|
||||
- Clarified in the Quick Start + README that paralinguistic tags (`[laugh]`, `[sigh]`) only work with Chatterbox Turbo; other engines read them as literal text ([#450](https://github.com/jamiepine/voicebox/pull/450)).
|
||||
|
||||
### New Contributors
|
||||
- [@Bortlesboat](https://github.com/Bortlesboat) — generation cancellation (#444)
|
||||
- [@gaojulong](https://github.com/gaojulong) — migration dialog hang fix (#439)
|
||||
- [@fuleinist](https://github.com/fuleinist) — migration no-op toast (#433)
|
||||
- [@erionjuniordeandrade-a11y](https://github.com/erionjuniordeandrade-a11y) — frontend CI + type hardening (#418)
|
||||
- [@estefrac](https://github.com/estefrac) — Linux pactl system-audio capture (#457)
|
||||
|
||||
## [0.4.0] - 2026-04-16
|
||||
|
||||
The biggest Voicebox release yet. Three new TTS engines bring the lineup to **seven** — HumeAI TADA, Kokoro 82M, and Qwen CustomVoice join Qwen3-TTS, LuxTTS, Chatterbox Multilingual, and Chatterbox Turbo. GPU support broadens to Intel Arc (XPU) and NVIDIA Blackwell (RTX 50-series), with runtime diagnostics that warn when your PyTorch build doesn't match your GPU. The CUDA backend is now split into independently versioned server and library archives, so upgrading no longer redownloads 4 GB of PyTorch/CUDA DLLs.
|
||||
|
||||
This release also marks a big community moment: **13 new contributors** shipped fixes and features in 0.4.0. Thirty-plus bug fixes target the most-reported issues in the tracker — numpy 2.x TTS crashes, Windows background-server reliability, macOS 11 launch failures, audio playback silence, Stories clip-splitting races, history status staleness, and more.
|
||||
|
||||
### New TTS Engines
|
||||
|
||||
#### HumeAI TADA — Expressive English & Multilingual ([#296](https://github.com/jamiepine/voicebox/pull/296))
|
||||
- Added `tada-1b` (English) and `tada-3b-ml` (multilingual) backends
|
||||
- Replaced `descript-audio-codec` with a lightweight DAC shim to cut dependencies
|
||||
- Switched audio decoding to `soundfile` to sidestep `torchcodec` bundling issues
|
||||
- Redirected gated Llama tokenizer lookups to an ungated mirror so model loading works out of the box
|
||||
- Fixed tokenizer patch that was corrupting `AutoTokenizer` for other engines
|
||||
- Fixed TorchScript error in frozen builds
|
||||
|
||||
#### Kokoro 82M — Fast Lightweight TTS ([#325](https://github.com/jamiepine/voicebox/pull/325))
|
||||
- Added Kokoro 82M engine with a new voice profile type system that distinguishes preset voices from cloned profiles
|
||||
- Profile grid now handles engine compatibility directly — removed redundant dropdown filtering
|
||||
- Tightened Kokoro profile handling so preset voices can't be edited like cloned profiles
|
||||
|
||||
#### Qwen CustomVoice ([#328](https://github.com/jamiepine/voicebox/pull/328))
|
||||
- Added `qwen-custom-voice` preset engine backed by Qwen3-TTS
|
||||
- Enforced preset/profile engine compatibility across the generation flow
|
||||
- Floating generator now shows all engines instead of silently filtering
|
||||
|
||||
### Voice Profile UX
|
||||
|
||||
Until 0.4, every engine in Voicebox was a cloning model, so every voice profile was usable with every engine and the profile grid just showed them all. Introducing Kokoro and Qwen CustomVoice — which work from preset voices rather than cloned samples — broke that assumption for the first time. An early cut on `main` filtered the grid by the selected engine, which left users running pre-release builds thinking their cloned voices had vanished whenever they switched to a preset-only engine.
|
||||
|
||||
This release ships the resolution before it ever reaches a tagged version:
|
||||
|
||||
- **Grey-out instead of filter** — all profiles are always visible; unsupported ones render dimmed with a compatibility hint at the bottom of the grid
|
||||
- **Auto-switch on selection** — clicking a greyed-out profile selects it AND switches the engine to a compatible one, instead of silently doing nothing
|
||||
- **Instruct toggle restored for Qwen CustomVoice** — the floating generate box now reveals a delivery-instructions input (tone, emotion, pace) when CustomVoice is selected. Hidden across the board while the new multi-engine lineup was stabilizing because most engines don't honor the kwarg; now conditionally exposed only for the one engine that was actually trained for instruction-based style control
|
||||
- Supported profiles sort first; the grid scrolls the selected profile into view after engine/sort changes
|
||||
- Fixed engine desync on tab navigation — the form now initializes its engine from the store
|
||||
- Fixed the disabled-and-selected card click edge case by bouncing selection to re-trigger the auto-switch
|
||||
- Cleaned up scroll effect timers (requestAnimationFrame + setTimeout) to prevent stale DOM writes on unmount or rapid selection changes
|
||||
|
||||
### GPU & Platform
|
||||
|
||||
#### Intel Arc (XPU) Support ([#320](https://github.com/jamiepine/voicebox/pull/320))
|
||||
- First-class Intel Arc support across all PyTorch-based backends
|
||||
- Device-aware seeding, XPU detection in the GPU status panel, and setup flow detection
|
||||
- Reports correct device name and VRAM in settings
|
||||
|
||||
#### Blackwell / RTX 50-series Support ([#316](https://github.com/jamiepine/voicebox/pull/316), [#401](https://github.com/jamiepine/voicebox/pull/401))
|
||||
- Upgraded the CUDA backend from cu126 → cu128 for RTX 50-series support
|
||||
- Added `sm_120+PTX` to the CUDA build via `TORCH_CUDA_ARCH_LIST` for forward-compatibility with Blackwell architectures (closes 5 open reports: #386, #395, #396, #399, #400)
|
||||
- GPU settings UI fixes around install/uninstall state
|
||||
|
||||
#### GPU Compatibility Diagnostics ([#367](https://github.com/jamiepine/voicebox/pull/367), adapted)
|
||||
- New `check_cuda_compatibility()` compares the current device's compute capability against the bundled PyTorch's architecture list
|
||||
- Health endpoint exposes a `gpu_compatibility_warning` field so the UI can surface mismatches
|
||||
- Startup logs a `WARN` when the installed PyTorch build doesn't support the detected GPU
|
||||
- GPU status label shows `[UNSUPPORTED - see logs]` — no more silent "no kernel image" failures
|
||||
|
||||
#### Split CUDA Backend ([#298](https://github.com/jamiepine/voicebox/pull/298))
|
||||
- CUDA backend now ships as two independently versioned archives: a small server binary and a large libs archive (the ~4 GB of PyTorch/CUDA DLLs)
|
||||
- Upgrading Voicebox no longer redownloads the libs archive when only the server binary changed
|
||||
- Added `asyncio.Lock` around `download_cuda_binary()` so auto-update and manual download can't race on the same temp file ([#428](https://github.com/jamiepine/voicebox/pull/428))
|
||||
- Updated `package_cuda.py` for PyInstaller 6.18 onedir layout
|
||||
- Temp archives are always cleaned up on failure, even when the install aborts mid-extract
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
#### Critical: TTS Generation
|
||||
- **numpy 2.x `torch.from_numpy` crash** ([#361](https://github.com/jamiepine/voicebox/pull/361)) — torch compiled against numpy 1.x ABI fails silently when paired with numpy 2.x, causing `RuntimeError: Numpy is not available` / `Unable to create tensor` on every TTS request in bundled macOS Intel / Rosetta builds. Pinned `numpy<2.0` in requirements and added a PyInstaller runtime hook with a `ctypes.memmove` fallback as belt-and-suspenders. Hardened afterward to raise on unknown dtypes instead of silently reinterpreting bytes as float32.
|
||||
|
||||
#### Platform Reliability
|
||||
- **Windows background server** ([#402](https://github.com/jamiepine/voicebox/pull/402)) — "keep server running after close" now actually keeps the server running. The HTTP `/watchdog/disable` request could lose the race against process exit on Windows; added a `.keep-running` sentinel file as a synchronous fallback, with stale-sentinel cleanup on startup to avoid orphan server processes
|
||||
- **macOS 11 launch crash** ([#424](https://github.com/jamiepine/voicebox/pull/424)) — weak-linked ScreenCaptureKit so the app can launch on macOS < 12.3 instead of crashing at dyld resolution. Gated system audio capture behind a real `sw_vers` version check so unsupported systems cleanly advertise "not available" rather than crashing at runtime
|
||||
- **macOS Intel (x86_64) setup** ([#416](https://github.com/jamiepine/voicebox/pull/416)) — relaxed `torch>=2.7.0` → `torch>=2.2.0`. PyTorch dropped pre-built x86_64 wheels after 2.2.2, so Intel Mac devs could no longer `pip install`. Now resolves to the latest compatible torch per platform
|
||||
- **Offline model loading** ([#318](https://github.com/jamiepine/voicebox/pull/318)) — Qwen TTS and Whisper force offline mode when loading cached models, so startup works without network access
|
||||
- **GUI startup with external server** ([#319](https://github.com/jamiepine/voicebox/pull/319)) — fixed GUI launch when pointed at a remote/external server, and added data refresh on server switch; hardened health validation and error handling
|
||||
- **Qwen3-TTS cache split on Windows** (adapted from [#218](https://github.com/jamiepine/voicebox/pull/218)) — route `Qwen3TTSModel.from_pretrained` through `hf_constants.HF_HUB_CACHE` so the speech tokenizer and `preprocessor_config.json` resolve from a single cache root
|
||||
- **Qwen3-TTS bundling** ([#305](https://github.com/jamiepine/voicebox/pull/305)) — bundle `qwen_tts` source files in the PyInstaller build to fix `inspect.getsource` errors in frozen builds
|
||||
- **Backend import paths** ([#345](https://github.com/jamiepine/voicebox/pull/345)) — moved lazy imports to top-level with absolute paths to resolve the "Failed to Save" preset error caused by `ModuleNotFoundError` in production builds
|
||||
- **Effects service import** ([#384](https://github.com/jamiepine/voicebox/pull/384)) — fixed `ModuleNotFoundError` on preset create/update by switching to relative imports (#349)
|
||||
|
||||
#### Audio & Playback
|
||||
- **cpal stream silent playback** ([#405](https://github.com/jamiepine/voicebox/pull/405)) — `cpal::Stream` was dropped on function return immediately after `play()`, causing every playback to fall silent. Now holds the stream until either the buffer drains or the stop flag fires (#404)
|
||||
|
||||
#### Stories & History
|
||||
- **Clip-splitting race** ([#403](https://github.com/jamiepine/voicebox/pull/403)) — rapid double-clicks on split could race through `split_story_item` with inconsistent state. Added `with_for_update()` row locking on the backend and an `isPending` guard on the frontend (#366)
|
||||
- **History `status` staleness** ([#394](https://github.com/jamiepine/voicebox/pull/394)) — `GET /history/{id}` was hardcoding `status="completed"` regardless of the DB row, breaking any client polling for job completion. Now returns `status`, `error`, `engine`, `model_size`, and `is_favorited` from the actual row
|
||||
- **"Clear failed" bulk button** ([#412](https://github.com/jamiepine/voicebox/pull/412)) — new `DELETE /history/failed` endpoint and a header strip showing `"N failed generations"` with a Clear button, complementing the per-row trash icon added in #321 (#410)
|
||||
- **Delete failed generations** ([#321](https://github.com/jamiepine/voicebox/pull/321)) — added a trash icon next to the retry button so failed entries can be cleaned up without having to retry first
|
||||
|
||||
#### Security & Safety
|
||||
- **Voice prompt cache hardening** ([#429](https://github.com/jamiepine/voicebox/pull/429)) — `torch.load(weights_only=True)` on cached voice prompts per PyTorch 2.6 recommendation; replaced string-based SPA path guard with `Path.is_relative_to()` for more robust path-traversal protection
|
||||
|
||||
#### Infrastructure & Docker
|
||||
- **Docker web build** ([#344](https://github.com/jamiepine/voicebox/pull/344)) — include `CHANGELOG.md` in the Docker web build so the in-app changelog page works in Docker deployments
|
||||
- **Docker numba cache** ([#425](https://github.com/jamiepine/voicebox/pull/425)) — set `NUMBA_CACHE_DIR` in docker-compose so numba can write its JIT cache in container runtime (#308)
|
||||
- **Relative media paths** ([#332](https://github.com/jamiepine/voicebox/pull/332)) — media paths now stored relative to the configured data dir rather than resolved against CWD, so the data directory is portable between installs
|
||||
|
||||
### Developer Tooling
|
||||
|
||||
- New `triage-prs` agent skill — encodes the end-to-end PR-speedrun workflow (classification → triage doc → rebase → squash-merge → follow-ups) so future release cycles can reproduce it
|
||||
- Rewrote the TTS engine guide with the patterns learned from adding TADA and Kokoro
|
||||
- Added the API refactor plan and CUDA libs addon design doc
|
||||
- Fixed broken links in the Get Started section ([#332](https://github.com/jamiepine/voicebox/pull/332))
|
||||
|
||||
### New Contributors
|
||||
|
||||
Huge thank you to everyone who contributed their first PR to Voicebox in this release:
|
||||
|
||||
[@liorshahverdi](https://github.com/liorshahverdi), [@nicoschtein](https://github.com/nicoschtein), [@ArfianID](https://github.com/ArfianID), [@aimaaaimaa](https://github.com/aimaaaimaa), [@maxmcoding](https://github.com/maxmcoding), [@Khalodddd](https://github.com/Khalodddd), [@LuisSambrano](https://github.com/LuisSambrano), [@shaun0927](https://github.com/shaun0927), [@malletfils](https://github.com/malletfils), [@mvanhorn](https://github.com/mvanhorn), [@kuishou68](https://github.com/kuishou68), [@txhno](https://github.com/txhno), [@MukundaKatta](https://github.com/MukundaKatta)
|
||||
|
||||
## [0.3.0] - 2026-03-17
|
||||
|
||||
This release rewrites the backend into a modular architecture, overhauls the settings UI into routed sub-pages, fixes audio player freezing, migrates documentation to Fumadocs, and ships a batch of bug fixes targeting the most-reported issues from the tracker.
|
||||
|
||||
The backend's 3,000-line monolith `main.py` has been decomposed into domain routers, a services layer, and a proper database package. A style guide and ruff configuration now enforce consistency. On the frontend, settings have been split into dedicated routed pages with server logs, a changelog viewer, and an about page. The audio player no longer freezes mid-playback, and model loading status is now visible in the UI. Seven user-reported bugs have been fixed, including server crashes during sample uploads, generation list staleness, cryptic error messages, and CUDA support for RTX 50-series GPUs.
|
||||
|
||||
### Settings Overhaul ([#294](https://github.com/jamiepine/voicebox/pull/294))
|
||||
- Split settings into routed sub-tabs: General, Generation, GPU, Logs, Changelog, About
|
||||
- Added live server log viewer with auto-scroll
|
||||
- Added in-app changelog page that parses `CHANGELOG.md` at build time
|
||||
- Added About page with version info, license, and generation folder quick-open
|
||||
- Extracted reusable `SettingRow` component for consistent setting layouts
|
||||
|
||||
### Audio Player Fix ([#293](https://github.com/jamiepine/voicebox/pull/293))
|
||||
- Fixed audio player freezing during playback
|
||||
- Improved playback UX with better state management and listener cleanup
|
||||
- Fixed restart race condition during regeneration
|
||||
- Added stable keys for audio element re-rendering
|
||||
- Improved accessibility across player controls
|
||||
|
||||
### Backend Refactor ([#285](https://github.com/jamiepine/voicebox/pull/285))
|
||||
- Extracted all routes from `main.py` into 13 domain routers under `backend/routes/` — `main.py` dropped from ~3,100 lines to ~10
|
||||
- Moved CRUD and service modules into `backend/services/`, platform detection into `backend/utils/`
|
||||
- Split monolithic `database.py` into a `database/` package with separate `models`, `session`, `migrations`, and `seed` modules
|
||||
- Added `backend/STYLE_GUIDE.md` and `pyproject.toml` with ruff linting config
|
||||
- Removed dead code: unused `_get_cuda_dll_excludes`, stale `studio.py`, `example_usage.py`, old `Makefile`
|
||||
- Deduplicated shared logic across TTS backends into `backends/base.py`
|
||||
- Improved startup logging with version, platform, data directory, and database stats
|
||||
- Fixed startup database session leak — sessions now rollback and close in `finally` block
|
||||
- Isolated shutdown unload calls so one backend failure doesn't block the others
|
||||
- Handled null duration in `story_items` migration
|
||||
- Reject model migration when target is a subdirectory of source cache
|
||||
|
||||
### Documentation Rewrite ([#288](https://github.com/jamiepine/voicebox/pull/288))
|
||||
- Migrated docs site from Mintlify to Fumadocs (Next.js-based)
|
||||
- Rewrote introduction and root page with content from README
|
||||
- Added "Edit on GitHub" links and last-updated timestamps on all pages
|
||||
- Generated OpenAPI spec and auto-generated API reference pages
|
||||
- Removed stale planning docs (`CUDA_BACKEND_SWAP`, `EXTERNAL_PROVIDERS`, `MLX_AUDIO`, `TTS_PROVIDER_ARCHITECTURE`, etc.)
|
||||
- Sidebar groups now expand by default; root redirects to `/docs`
|
||||
- Added OG image metadata and `/og` preview page
|
||||
|
||||
### UI & Frontend
|
||||
- Added model loading status indicator and effects preset dropdown ([3187344](https://github.com/jamiepine/voicebox/commit/3187344))
|
||||
- Fixed take-label race condition during regeneration
|
||||
- Added accessible focus styling to select component
|
||||
- Softened select focus indicator opacity
|
||||
- Addressed 4 critical and 12 major issues from CodeRabbit review
|
||||
|
||||
### Bug Fixes ([#295](https://github.com/jamiepine/voicebox/pull/295))
|
||||
- Fixed sample uploads crashing the server — audio decoding now runs in a thread pool instead of blocking the async event loop ([#278](https://github.com/jamiepine/voicebox/issues/278))
|
||||
- Fixed generation list not updating when a generation completes — switched to `refetchQueries` for reliable cache busting, added SSE error fallback, and page reset on completion ([#231](https://github.com/jamiepine/voicebox/issues/231))
|
||||
- Fixed error toasts showing `[object Object]` instead of the actual error message ([#290](https://github.com/jamiepine/voicebox/issues/290))
|
||||
- Added Whisper model selection (`base`, `small`, `medium`, `large`, `turbo`) and expanded language support to the `/transcribe` endpoint ([#233](https://github.com/jamiepine/voicebox/issues/233))
|
||||
- Upgraded CUDA backend build from cu121 to cu126 for RTX 50-series (Blackwell) GPU support ([#289](https://github.com/jamiepine/voicebox/issues/289))
|
||||
- Handled client disconnects in SSE and streaming endpoints to suppress `[Errno 32] Broken Pipe` errors ([#248](https://github.com/jamiepine/voicebox/issues/248))
|
||||
- Fixed Docker build failure from pip hash mismatch on Qwen3-TTS dependencies ([#286](https://github.com/jamiepine/voicebox/issues/286))
|
||||
- Added 50 MB upload size limit with chunked reads to prevent unbounded memory allocation on sample uploads
|
||||
- Eliminated redundant double audio decode in sample processing pipeline
|
||||
|
||||
### Platform Fixes
|
||||
- Replaced `netstat` with `TcpStream` + PowerShell for Windows port detection ([#277](https://github.com/jamiepine/voicebox/pull/277))
|
||||
- Fixed Docker frontend build and cleaned up Docker docs
|
||||
- Fixed macOS download links to use `.dmg` instead of `.app.tar.gz`
|
||||
- Added dynamic download redirect routes to landing site
|
||||
|
||||
### Release Tooling
|
||||
- Added `draft-release-notes` and `release-bump` agent skills
|
||||
- Wired CI release workflow to extract notes from `CHANGELOG.md` for GitHub Releases
|
||||
- Backfilled changelog with all historical releases
|
||||
|
||||
## [0.2.3] - 2026-03-15
|
||||
|
||||
The "it works in dev but not in prod" release. This version fixes a series of PyInstaller bundling issues that prevented model downloading, loading, generation, and progress tracking from working in production builds.
|
||||
|
||||
### Model Downloads Now Actually Work
|
||||
|
||||
The v0.2.1/v0.2.2 builds could not download or load models that weren't already cached from a dev install. This release fixes the entire chain:
|
||||
|
||||
- **Chatterbox, Chatterbox Turbo, and LuxTTS** all download, load, and generate correctly in bundled builds
|
||||
- **Real-time download progress** — byte-level progress bars now work in production. The root cause: `huggingface_hub` silently disables tqdm progress bars based on logger level, which prevented our progress tracker from receiving byte updates. We now force-enable the internal counter regardless.
|
||||
- **Fixed Python 3.12.0 `code.replace()` bug** — the macOS build was on Python 3.12.0, which has a [known CPython bug](https://github.com/pyinstaller/pyinstaller/issues/7992) that corrupts bytecode when PyInstaller rewrites code objects. This caused `NameError: name 'obj' is not defined` crashes during scipy/torch imports. Upgraded to Python 3.12.13.
|
||||
|
||||
### PyInstaller Fixes
|
||||
|
||||
- Collect all `inflect` files — `typeguard`'s `@typechecked` decorator calls `inspect.getsource()` at import time, which needs `.py` source files, not just bytecode. Fixes LuxTTS "could not get source code" error.
|
||||
- Collect all `perth` files — bundles the pretrained watermark model (`hparams.yaml`, `.pth.tar`) needed by Chatterbox at runtime
|
||||
- Collect all `piper_phonemize` files — bundles `espeak-ng-data/` (phoneme tables, language dicts) needed by LuxTTS for text-to-phoneme conversion
|
||||
- Set `ESPEAK_DATA_PATH` in frozen builds so the espeak-ng C library finds the bundled data instead of looking at `/usr/share/espeak-ng-data/`
|
||||
- Collect all `linacodec` files — fixes `inspect.getsource` error in Vocos codec
|
||||
- Collect all `zipvoice` files — fixes source code lookup in LuxTTS voice cloning
|
||||
- Copy metadata for `requests`, `transformers`, `huggingface-hub`, `tokenizers`, `safetensors`, `tqdm` — fixes `importlib.metadata` lookups in frozen binary
|
||||
- Add hidden imports for `chatterbox`, `chatterbox_turbo`, `luxtts`, `zipvoice` backends
|
||||
- Add `multiprocessing.freeze_support()` to fix resource_tracker subprocess crash in frozen binary
|
||||
- `--noconsole` now only applied on Windows — macOS/Linux need stdout/stderr for Tauri sidecar log capture
|
||||
- Hardened `sys.stdout`/`sys.stderr` devnull redirect to test writability, not just `None` check
|
||||
|
||||
### Updater
|
||||
|
||||
- Fixed updater artifact generation with `v1Compatible` for `tauri-action` signature files
|
||||
- Updated `tauri-action` to v0.6 to fix updater JSON and `.sig` generation
|
||||
|
||||
### Other Fixes
|
||||
|
||||
- Full traceback logging on all backend model loading errors (was just `str(e)` before)
|
||||
|
||||
## [0.2.2] - 2026-03-15
|
||||
|
||||
- Fix Chatterbox model support in bundled builds
|
||||
- Fix LuxTTS/ZipVoice support in bundled builds
|
||||
- Auto-update CUDA binary when app version changes
|
||||
- CUDA download progress bar
|
||||
- Fix server process staying alive on macOS (SIGHUP handling, watchdog grace period)
|
||||
- Hide console window when running CUDA binary on Windows
|
||||
|
||||
## [0.2.1] - 2026-03-15
|
||||
|
||||
Voicebox v0.1.x was a single-engine voice cloning app built around Qwen3-TTS. v0.2.0 is a ground-up rethink: four TTS engines, 23 languages, paralinguistic emotion controls, a post-processing effects pipeline, unlimited generation length, an async generation queue, and support for every major GPU vendor. Plus Docker.
|
||||
|
||||
### New TTS Engines
|
||||
|
||||
#### Multi-Engine Architecture
|
||||
|
||||
Voicebox now runs **four independent TTS engines** behind a thread-safe per-engine backend registry. Switch engines per-generation from a single dropdown — no restart required.
|
||||
|
||||
| Engine | Languages | Size | Key Strengths |
|
||||
| --------------------------- | --------- | ------- | --------------------------------------------- |
|
||||
| **Qwen3-TTS 1.7B** | 10 | ~3.5 GB | Highest quality, delivery instructions |
|
||||
| **Qwen3-TTS 0.6B** | 10 | ~1.2 GB | Lighter, faster variant |
|
||||
| **LuxTTS** | English | ~300 MB | CPU-friendly, 48 kHz output, 150x realtime |
|
||||
| **Chatterbox Multilingual** | 23 | ~3.2 GB | Broadest language coverage, zero-shot cloning |
|
||||
| **Chatterbox Turbo** | English | ~1.5 GB | 350M params, low latency, paralinguistic tags |
|
||||
|
||||
#### Chatterbox Multilingual — 23 Languages ([#257](https://github.com/jamiepine/voicebox/pull/257))
|
||||
|
||||
Zero-shot voice cloning in Arabic, Chinese, Danish, Dutch, English, Finnish, French, German, Greek, Hebrew, Hindi, Italian, Japanese, Korean, Malay, Norwegian, Polish, Portuguese, Russian, Spanish, Swahili, Swedish, and Turkish.
|
||||
|
||||
#### LuxTTS — Lightweight English TTS ([#254](https://github.com/jamiepine/voicebox/pull/254))
|
||||
|
||||
A fast, CPU-friendly English engine. ~300 MB download, 48 kHz output, runs at 150x realtime on CPU.
|
||||
|
||||
#### Chatterbox Turbo — Expressive English ([#258](https://github.com/jamiepine/voicebox/pull/258))
|
||||
|
||||
A fast 350M-parameter English model with inline paralinguistic tags.
|
||||
|
||||
#### Paralinguistic Tags Autocomplete ([#265](https://github.com/jamiepine/voicebox/pull/265))
|
||||
|
||||
Type `/` in the text input with Chatterbox Turbo selected to open an autocomplete for **9 expressive tags**: `[laugh]` `[chuckle]` `[gasp]` `[cough]` `[sigh]` `[groan]` `[sniff]` `[shush]` `[clear throat]`
|
||||
|
||||
### Generation
|
||||
|
||||
#### Unlimited Generation Length — Auto-Chunking ([#266](https://github.com/jamiepine/voicebox/pull/266))
|
||||
|
||||
Long text is now automatically split at sentence boundaries, generated per-chunk, and crossfaded back together. Engine-agnostic.
|
||||
|
||||
- Auto-chunking limit slider — 100–5,000 chars (default 800)
|
||||
- Crossfade slider — 0–200ms (default 50ms)
|
||||
- Max text length raised to 50,000 characters
|
||||
- Smart splitting respects abbreviations, CJK punctuation, and `[tags]`
|
||||
|
||||
#### Asynchronous Generation Queue ([#269](https://github.com/jamiepine/voicebox/pull/269))
|
||||
|
||||
Generation is now fully non-blocking. Serial execution queue prevents GPU contention. Real-time SSE status streaming.
|
||||
|
||||
#### Generation Versions
|
||||
|
||||
Every generation now supports multiple versions with provenance tracking — original, effects versions, takes, source tracking, version pinning in stories, and favorites.
|
||||
|
||||
### Post-Processing Effects ([#271](https://github.com/jamiepine/voicebox/pull/271))
|
||||
|
||||
A full audio effects system powered by Spotify's `pedalboard` library: Pitch Shift, Reverb, Delay, Chorus/Flanger, Compressor, Gain, High-Pass Filter, Low-Pass Filter. 4 built-in presets, custom presets, per-profile default effects, and live preview.
|
||||
|
||||
### Platform Support
|
||||
|
||||
- **Windows Support** ([#272](https://github.com/jamiepine/voicebox/pull/272)) — Full Windows support with CUDA GPU detection
|
||||
- **Linux** ([#262](https://github.com/jamiepine/voicebox/pull/262)) — AMD ROCm, NVIDIA GBM fix, WebKitGTK mic access (build from source)
|
||||
- **NVIDIA CUDA Backend Swap** ([#252](https://github.com/jamiepine/voicebox/pull/252)) — Download and swap in CUDA backend from within the app
|
||||
- **Intel Arc (XPU) and DirectML** — PyTorch backend supports Intel Arc and DirectML
|
||||
- **Docker + Web Deployment** ([#161](https://github.com/jamiepine/voicebox/pull/161)) — 3-stage build, non-root runtime, health checks
|
||||
- **Whisper Turbo** — Added `openai/whisper-large-v3-turbo` as a transcription model option
|
||||
|
||||
### Model Management ([#268](https://github.com/jamiepine/voicebox/pull/268))
|
||||
|
||||
Per-model unload, custom models directory, model folder migration, download cancel/clear UI ([#238](https://github.com/jamiepine/voicebox/pull/238)), restructured settings UI.
|
||||
|
||||
### Security & Reliability
|
||||
|
||||
- CORS hardening ([#88](https://github.com/jamiepine/voicebox/pull/88))
|
||||
- Network access toggle ([#133](https://github.com/jamiepine/voicebox/pull/133))
|
||||
- Offline crash fix ([#152](https://github.com/jamiepine/voicebox/pull/152))
|
||||
- Atomic audio saves ([#263](https://github.com/jamiepine/voicebox/pull/263))
|
||||
- Filesystem health endpoint
|
||||
- Chatterbox float64 dtype fix ([#264](https://github.com/jamiepine/voicebox/pull/264))
|
||||
|
||||
### Accessibility ([#243](https://github.com/jamiepine/voicebox/pull/243))
|
||||
|
||||
Screen reader support, keyboard navigation, state-aware `aria-label` attributes on all interactive controls.
|
||||
|
||||
### UI Polish
|
||||
|
||||
- Redesigned landing page ([#274](https://github.com/jamiepine/voicebox/pull/274))
|
||||
- Voices tab overhaul with inline inspector
|
||||
- Responsive layout improvements
|
||||
- Duplicate profile name validation ([#175](https://github.com/jamiepine/voicebox/pull/175))
|
||||
|
||||
### Community Contributors
|
||||
|
||||
[@haosenwang1018](https://github.com/haosenwang1018), [@Balneario-de-Cofrentes](https://github.com/Balneario-de-Cofrentes), [@ageofalgo](https://github.com/ageofalgo), [@mikeswann](https://github.com/mikeswann), [@rayl15](https://github.com/rayl15), [@mpecanha](https://github.com/mpecanha), [@ways2read](https://github.com/ways2read), [@ieguiguren](https://github.com/ieguiguren), [@Vaibhavee89](https://github.com/Vaibhavee89), [@pandego](https://github.com/pandego), [@luminest-llc](https://github.com/luminest-llc)
|
||||
|
||||
## [0.1.13] - 2026-02-23
|
||||
|
||||
### Stability and reliability
|
||||
|
||||
- [#95](https://github.com/jamiepine/voicebox/pull/95) Fix: selecting 0.6B model still downloads and uses 1.7B
|
||||
- [#93](https://github.com/jamiepine/voicebox/pull/93) fix(mlx): bundle native libs and broaden error handling for Apple Silicon
|
||||
- [#79](https://github.com/jamiepine/voicebox/pull/79) fix: handle non-ASCII filenames in Content-Disposition headers
|
||||
- [#78](https://github.com/jamiepine/voicebox/pull/78) fix: guard getUserMedia call against undefined mediaDevices in non-secure contexts
|
||||
- [#77](https://github.com/jamiepine/voicebox/pull/77) fix: await for confirmation before deleting voices and channels
|
||||
- [#128](https://github.com/jamiepine/voicebox/pull/128) fix: resolve multiple issues (#96, #119, #111, #108, #121, #125, #127)
|
||||
- [#40](https://github.com/jamiepine/voicebox/pull/40) Fix: audio export path resolution
|
||||
|
||||
### Build and packaging
|
||||
|
||||
- [#122](https://github.com/jamiepine/voicebox/pull/122) fix(web): add @tailwindcss/vite plugin to web config
|
||||
- [#126](https://github.com/jamiepine/voicebox/pull/126) Create requirements.txt
|
||||
|
||||
### UX and docs
|
||||
|
||||
- [#44](https://github.com/jamiepine/voicebox/pull/44) Enhances floating generate box UX
|
||||
- [#57](https://github.com/jamiepine/voicebox/pull/57) chore: updates repo URL in README
|
||||
- [#146](https://github.com/jamiepine/voicebox/pull/146) Add Spacebot banner to landing page
|
||||
- [#1](https://github.com/jamiepine/voicebox/pull/1) Improvements
|
||||
|
||||
## [0.1.12] - 2026-01-31
|
||||
|
||||
### Model Download UX Overhaul
|
||||
|
||||
- Real-time download progress tracking with accurate percentage and speed info
|
||||
- No more downloading notifications during generation even when its not downloading
|
||||
- Better error handling and status reporting throughout the download process
|
||||
|
||||
### Other Improvements
|
||||
|
||||
- Enhanced health check endpoint with GPU type information
|
||||
- Improved model caching verification
|
||||
- More reliable SSE progress updates
|
||||
- Actual update notifications — no need to manually check in settings anymore
|
||||
|
||||
## [0.1.11] - 2026-01-30
|
||||
|
||||
- Fixed transcriptions on MLX
|
||||
- Fixed model download progress (finally)
|
||||
|
||||
## [0.1.10] - 2026-01-30
|
||||
|
||||
### Faster generation on Apple Silicon
|
||||
|
||||
Massive speed gains, from around 20s per generation to 2-3s. Added native MLX backend support for Apple Silicon, providing significantly faster TTS and STT generation on M-series macOS machines.
|
||||
|
||||
- **MLX Backend** — New backend implementation optimized for Apple Silicon using MLX framework
|
||||
- **Dynamic Backend Selection** — Automatically detects platform and selects between MLX (macOS) and PyTorch (other platforms)
|
||||
- Refactored TTS and STT logic into modular backend implementations
|
||||
- Updated build process to include MLX-specific dependencies for macOS builds
|
||||
|
||||
## [0.1.9] - 2026-01-30
|
||||
|
||||
### Improved voice profile creation flow
|
||||
|
||||
- Voice create drafts: No longer lose work if you close the modal
|
||||
- Fixed whisper only transcribing English or Chinese, now has support for all languages
|
||||
|
||||
### Improved Stories editor
|
||||
|
||||
- Added spacebar for play/pause
|
||||
- Timeline now auto-scrolls to follow playhead during playback
|
||||
- Fixed misalignment of the items with mouse when picking up
|
||||
- Fixed hitbox for selecting an item
|
||||
- Fixed playhead jumping forward when pressing play
|
||||
|
||||
### Generation box improvements
|
||||
|
||||
- Instruct mode no longer wipes prompt text
|
||||
- Improved UI cleanliness
|
||||
|
||||
### Misc
|
||||
|
||||
- Fixed "Model downloading" toast during generation when model is already downloaded
|
||||
|
||||
## [0.1.8] - 2026-01-29
|
||||
|
||||
### Model Download Timeout Issues
|
||||
|
||||
Fixed critical issue where model downloads would fail with "Failed to fetch" errors on Windows. Refactored download endpoints to return immediately and continue downloads in background.
|
||||
|
||||
### Cross-Platform Cache Path Issues
|
||||
|
||||
Fixed hardcoded `~/.cache/huggingface/hub` paths that don't work on Windows. All cache paths now use `hf_constants.HF_HUB_CACHE` for proper cross-platform support.
|
||||
|
||||
### Windows Process Management
|
||||
|
||||
- Added `/shutdown` endpoint for graceful server shutdown on Windows
|
||||
- Added `gpu_type` field to health check response
|
||||
|
||||
## [0.1.7] - 2026-01-29
|
||||
|
||||
- Trim and split audio clips in Story Editor
|
||||
- Auto-activation of stories in Story Editor with visible playhead
|
||||
- Conditional auto-play support in AudioPlayer for better user control
|
||||
- Refactored audio loading across HistoryTable, SampleList, and generation forms
|
||||
- Audio now only auto-plays when explicitly intended, preventing unexpected playback
|
||||
|
||||
## [0.1.6] - 2026-01-29
|
||||
|
||||
### Introducing Stories
|
||||
|
||||
A full voice editor for composing podcasts and generated conversations.
|
||||
|
||||
- **Stories Editor** — Create multi-voice narratives, podcasts, or conversations with a timeline-based editor
|
||||
- Compose tracks with different voices
|
||||
- Edit and arrange audio segments inline
|
||||
- Build generated conversations with multiple participants
|
||||
- **Improved Voice Generation UI** — Auto-resizing input, default voice selection, better layout
|
||||
- **Track Editor Integration** — Inline track editing within story items
|
||||
|
||||
## [0.1.5] - 2026-01-28
|
||||
|
||||
Fixed recording length limit at 0:29 to auto stop instead of passing the limit and getting an error, which would cause users to lose their recording.
|
||||
|
||||
## [0.1.4] - 2026-01-28
|
||||
|
||||
- Audio channel management system
|
||||
- Native audio playback handling in AudioPlayer component
|
||||
- Refactored ConnectionForm and Checkbox components
|
||||
- Improved layout consistency and responsiveness
|
||||
- Added safe area constants for better responsive design
|
||||
|
||||
## [0.1.3] - 2026-01-27
|
||||
|
||||
- Improved the generate textbox
|
||||
- Maybe fixed Windows autoupdate restarting entire computer
|
||||
|
||||
## [0.1.2] - 2026-01-27
|
||||
|
||||
### Audio Capture & Format Conversion
|
||||
|
||||
- Added audio format conversion util
|
||||
- Enhanced system audio capture on macOS and Windows
|
||||
- Improved audio recording hooks
|
||||
- Added audio input entitlement for macOS
|
||||
- Added audio capture tests
|
||||
|
||||
### Update System
|
||||
|
||||
- Enhanced auto-updater functionality and update status display
|
||||
|
||||
## [0.1.1] - 2026-01-27
|
||||
|
||||
### Platform Support
|
||||
|
||||
- **macOS Audio Capture** — Native audio capture support for sample creation
|
||||
- **Windows Audio Capture** — WASAPI implementation with improved thread safety
|
||||
- **Linux Support** — Temporarily removed builds due to runner disk space constraints
|
||||
|
||||
### Audio Features
|
||||
|
||||
- Play/pause for audio samples across all components
|
||||
- Three new sample components: Recording, System capture, Upload with drag-and-drop
|
||||
- Audio validation, error handling, and consistent cleanup
|
||||
|
||||
### Voice Profile Management
|
||||
|
||||
- Profile import with file size validation (100MB limit)
|
||||
- Enhanced profile form with new audio sample components
|
||||
- Drag-and-drop support for audio file uploads
|
||||
|
||||
### Server Management
|
||||
|
||||
- Changed default URL from `localhost:8000` to `127.0.0.1:17493`
|
||||
- Server reuse logic, "keep server running" preference, orphaned process handling
|
||||
|
||||
### Build & Release
|
||||
|
||||
- Added `.bumpversion.cfg` for automated version management
|
||||
- Enhanced icon generation script for multi-size Windows icons
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed date formatting for timezone-less date strings
|
||||
- Fixed getLatestRelease file filtering
|
||||
- Improved audio duration metadata on Windows
|
||||
|
||||
## [0.1.0] - 2026-01-27
|
||||
|
||||
The first public release of Voicebox — an open-source voice synthesis studio powered by Qwen3-TTS.
|
||||
|
||||
### Voice Cloning with Qwen3-TTS
|
||||
|
||||
- Automatic model download from HuggingFace
|
||||
- Multiple model sizes (1.7B and 0.6B)
|
||||
- Voice prompt caching for instant regeneration
|
||||
- English and Chinese support
|
||||
|
||||
### Voice Profile Management
|
||||
|
||||
- Create profiles from audio files or record directly in the app
|
||||
- Multiple samples per profile for higher quality cloning
|
||||
- Import/Export profiles
|
||||
- Automatic transcription via Whisper
|
||||
|
||||
### Speech Generation
|
||||
|
||||
- Simple text-to-speech with profile selection
|
||||
- Seed control for reproducible generations
|
||||
- Long-form support up to 5,000 characters
|
||||
|
||||
### Generation History
|
||||
|
||||
- Full history with metadata
|
||||
- Search by text content
|
||||
- Inline playback and download
|
||||
|
||||
### Flexible Deployment
|
||||
|
||||
- Local mode with bundled backend
|
||||
- Remote mode for GPU servers on your network
|
||||
- One-click server setup
|
||||
|
||||
### Desktop Experience
|
||||
|
||||
- Built with Tauri v2 (Rust) — native performance, not Electron
|
||||
- Cross-platform: macOS and Windows
|
||||
- No Python installation required
|
||||
|
||||
### Tech Stack
|
||||
|
||||
Tauri v2, React, TypeScript, Tailwind CSS, FastAPI, Qwen3-TTS, Whisper, SQLite
|
||||
|
||||
[0.5.0]: https://github.com/jamiepine/voicebox/compare/v0.4.5...v0.5.0
|
||||
[0.4.5]: https://github.com/jamiepine/voicebox/compare/v0.4.4...v0.4.5
|
||||
[0.4.4]: https://github.com/jamiepine/voicebox/compare/v0.4.3...v0.4.4
|
||||
[0.4.3]: https://github.com/jamiepine/voicebox/compare/v0.4.2...v0.4.3
|
||||
[0.4.2]: https://github.com/jamiepine/voicebox/compare/v0.4.1...v0.4.2
|
||||
[0.4.1]: https://github.com/jamiepine/voicebox/compare/v0.4.0...v0.4.1
|
||||
[0.4.0]: https://github.com/jamiepine/voicebox/compare/v0.3.0...v0.4.0
|
||||
[0.3.0]: https://github.com/jamiepine/voicebox/compare/v0.2.3...v0.3.0
|
||||
[0.2.3]: https://github.com/jamiepine/voicebox/compare/v0.2.2...v0.2.3
|
||||
[0.2.2]: https://github.com/jamiepine/voicebox/compare/v0.2.1...v0.2.2
|
||||
[0.2.1]: https://github.com/jamiepine/voicebox/compare/v0.1.13...v0.2.1
|
||||
[0.1.13]: https://github.com/jamiepine/voicebox/compare/v0.1.12...v0.1.13
|
||||
[0.1.12]: https://github.com/jamiepine/voicebox/compare/v0.1.11...v0.1.12
|
||||
[0.1.11]: https://github.com/jamiepine/voicebox/compare/v0.1.10...v0.1.11
|
||||
[0.1.10]: https://github.com/jamiepine/voicebox/compare/v0.1.9...v0.1.10
|
||||
[0.1.9]: https://github.com/jamiepine/voicebox/compare/v0.1.8...v0.1.9
|
||||
[0.1.8]: https://github.com/jamiepine/voicebox/compare/v0.1.7...v0.1.8
|
||||
[0.1.7]: https://github.com/jamiepine/voicebox/compare/v0.1.6...v0.1.7
|
||||
[0.1.6]: https://github.com/jamiepine/voicebox/compare/v0.1.5...v0.1.6
|
||||
[0.1.5]: https://github.com/jamiepine/voicebox/compare/v0.1.4...v0.1.5
|
||||
[0.1.4]: https://github.com/jamiepine/voicebox/compare/v0.1.3...v0.1.4
|
||||
[0.1.3]: https://github.com/jamiepine/voicebox/compare/v0.1.2...v0.1.3
|
||||
[0.1.2]: https://github.com/jamiepine/voicebox/compare/v0.1.1...v0.1.2
|
||||
[0.1.1]: https://github.com/jamiepine/voicebox/compare/v0.1.0...v0.1.1
|
||||
[0.1.0]: https://github.com/jamiepine/voicebox/releases/tag/v0.1.0
|
||||
+392
@@ -0,0 +1,392 @@
|
||||
# Contributing to Voicebox
|
||||
|
||||
Thank you for your interest in contributing to Voicebox! This document provides guidelines and instructions for contributing.
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
- Be respectful and inclusive
|
||||
- Welcome newcomers and help them learn
|
||||
- Focus on constructive feedback
|
||||
- Respect different viewpoints and experiences
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **[Bun](https://bun.sh)** - Fast JavaScript runtime and package manager
|
||||
```bash
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
```
|
||||
|
||||
- **[Python 3.11+](https://python.org)** - For backend development
|
||||
```bash
|
||||
python --version # Should be 3.11 or higher
|
||||
```
|
||||
|
||||
- **[Rust](https://rustup.rs)** - For Tauri desktop app (installed automatically by Tauri CLI)
|
||||
```bash
|
||||
rustc --version # Check if installed
|
||||
```
|
||||
- **[Tauri Prerequisites](https://v2.tauri.app/start/prerequisites)** - Tauri-specific system dependencies (varies by OS).
|
||||
|
||||
- **Git** - Version control
|
||||
|
||||
### Development Setup
|
||||
|
||||
Install [just](https://github.com/casey/just) (`brew install just`, `cargo install just`, or `winget install Casey.Just`), then:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/voicebox.git
|
||||
cd voicebox
|
||||
|
||||
just setup # creates venv, installs Python + JS deps
|
||||
just dev # starts backend + desktop app
|
||||
```
|
||||
|
||||
`just setup` handles everything automatically, including:
|
||||
- Creating a Python virtual environment
|
||||
- Installing Python dependencies (with CUDA PyTorch on Windows if an NVIDIA GPU is detected)
|
||||
- Installing MLX dependencies on Apple Silicon
|
||||
- Installing JavaScript dependencies
|
||||
|
||||
`just dev` starts the backend and desktop app together. If a backend is already running (e.g. from `just dev-backend` in another terminal), it detects it and only starts the frontend.
|
||||
|
||||
Other useful commands:
|
||||
|
||||
```bash
|
||||
just dev-web # backend + web app (no Tauri/Rust build)
|
||||
just dev-backend # backend only
|
||||
just dev-frontend # Tauri app only (backend must be running)
|
||||
just kill # stop all dev processes
|
||||
just clean-all # nuke everything and start fresh
|
||||
just --list # see all available commands
|
||||
```
|
||||
|
||||
> **Note:** In dev mode, the app connects to a manually-started Python server.
|
||||
> The bundled server binary is only used in production builds.
|
||||
|
||||
#### Windows Notes
|
||||
|
||||
The justfile works natively on Windows via PowerShell. No WSL or Git Bash required. On Windows with an NVIDIA GPU, `just setup` automatically installs CUDA-enabled PyTorch for GPU acceleration.
|
||||
|
||||
### Model Downloads
|
||||
|
||||
Models are automatically downloaded from HuggingFace Hub on first use:
|
||||
- **Whisper** (transcription): Auto-downloads on first transcription
|
||||
- **Qwen3-TTS** (voice cloning): Auto-downloads on first generation (~2-4GB)
|
||||
|
||||
First-time usage will be slower due to model downloads, but subsequent runs will use cached models.
|
||||
|
||||
### Building
|
||||
|
||||
**Build production app:**
|
||||
|
||||
```bash
|
||||
just build # Build CPU server binary + Tauri installer
|
||||
```
|
||||
|
||||
On Windows, to build with CUDA support for local testing:
|
||||
|
||||
```bash
|
||||
just build-local # Build CPU + CUDA server binaries + Tauri installer
|
||||
```
|
||||
|
||||
This builds the CPU sidecar (bundled with the app), the CUDA binary (placed in `%APPDATA%/com.voicebox.app/backends/` for runtime GPU switching), and the installable Tauri app.
|
||||
|
||||
Creates platform-specific installers (`.dmg`, `.msi`, `.AppImage`) in `tauri/src-tauri/target/release/bundle/`.
|
||||
|
||||
**Individual build targets:**
|
||||
|
||||
```bash
|
||||
just build-server # CPU server binary only
|
||||
just build-server-cuda # CUDA server binary only (Windows)
|
||||
just build-tauri # Tauri desktop app only
|
||||
just build-web # Web app only
|
||||
```
|
||||
|
||||
**Building with local Qwen3-TTS development version:**
|
||||
|
||||
If you're actively developing or modifying the Qwen3-TTS library, set the `QWEN_TTS_PATH` environment variable to point to your local clone:
|
||||
|
||||
```bash
|
||||
export QWEN_TTS_PATH=~/path/to/your/Qwen3-TTS
|
||||
just build-server
|
||||
```
|
||||
|
||||
This makes PyInstaller use your local qwen-tts version instead of the pip-installed package.
|
||||
|
||||
### Generate OpenAPI Client
|
||||
|
||||
After starting the backend server:
|
||||
```bash
|
||||
./scripts/generate-api.sh
|
||||
```
|
||||
This downloads the OpenAPI schema and generates the TypeScript client in `app/src/lib/api/`
|
||||
|
||||
### Convert Assets to Web Formats
|
||||
|
||||
To optimize images and videos for the web, run:
|
||||
```bash
|
||||
bun run convert:assets
|
||||
```
|
||||
|
||||
This script:
|
||||
- Converts PNG → WebP (better compression, same quality)
|
||||
- Converts MOV → WebM (VP9 codec, smaller file size)
|
||||
- Processes files in `landing/public/` and `docs/public/`
|
||||
- **Deletes original files** after successful conversion
|
||||
|
||||
**Requirements:** Install `webp` and `ffmpeg`:
|
||||
```bash
|
||||
brew install webp ffmpeg
|
||||
```
|
||||
|
||||
> **Note:** Run this before committing new images or videos to keep the repository size small.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### 1. Create a Branch
|
||||
|
||||
```bash
|
||||
git checkout -b feature/your-feature-name
|
||||
# or
|
||||
git checkout -b fix/your-bug-fix
|
||||
```
|
||||
|
||||
### 2. Make Your Changes
|
||||
|
||||
- Write clean, readable code
|
||||
- Follow existing code style
|
||||
- Add comments for complex logic
|
||||
- Update documentation as needed
|
||||
|
||||
### 3. Test Your Changes
|
||||
|
||||
- Test manually in the app
|
||||
- Ensure backend API endpoints work
|
||||
- Check for TypeScript/Python errors
|
||||
- Verify UI components render correctly
|
||||
|
||||
### 4. Commit Your Changes
|
||||
|
||||
Write clear, descriptive commit messages:
|
||||
|
||||
```bash
|
||||
git commit -m "Add feature: voice profile export"
|
||||
git commit -m "Fix: audio playback stops after 30 seconds"
|
||||
```
|
||||
|
||||
### 5. Push and Create Pull Request
|
||||
|
||||
```bash
|
||||
git push origin feature/your-feature-name
|
||||
```
|
||||
|
||||
Then create a pull request on GitHub with:
|
||||
- Clear description of changes
|
||||
- Screenshots (for UI changes)
|
||||
- Reference to related issues
|
||||
|
||||
## Code Style
|
||||
|
||||
### TypeScript/React
|
||||
|
||||
- Use TypeScript strict mode
|
||||
- Follow React best practices
|
||||
- Use functional components with hooks
|
||||
- Prefer named exports
|
||||
- Format with Biome (runs automatically)
|
||||
|
||||
```typescript
|
||||
// Good
|
||||
export function ProfileCard({ profile }: { profile: Profile }) {
|
||||
return <div>{profile.name}</div>;
|
||||
}
|
||||
|
||||
// Avoid
|
||||
export const ProfileCard = (props) => { ... }
|
||||
```
|
||||
|
||||
### Python
|
||||
|
||||
- Follow PEP 8 style guide
|
||||
- Use type hints
|
||||
- Use async/await for I/O operations
|
||||
- Format with Black (if configured)
|
||||
|
||||
```python
|
||||
# Good
|
||||
async def create_profile(name: str, language: str) -> Profile:
|
||||
"""Create a new voice profile."""
|
||||
...
|
||||
|
||||
# Avoid
|
||||
def create_profile(name, language):
|
||||
...
|
||||
```
|
||||
|
||||
### Rust
|
||||
|
||||
- Follow Rust conventions
|
||||
- Use meaningful variable names
|
||||
- Handle errors explicitly
|
||||
- Format with `rustfmt`
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
voicebox/
|
||||
├── app/ # Shared React frontend
|
||||
│ └── src/
|
||||
│ ├── components/ # UI components
|
||||
│ ├── lib/ # Utilities and API client
|
||||
│ └── hooks/ # React hooks
|
||||
├── backend/ # Python FastAPI server
|
||||
│ ├── main.py # API routes
|
||||
│ ├── tts.py # Voice synthesis
|
||||
│ └── ...
|
||||
├── tauri/ # Desktop app wrapper
|
||||
│ └── src-tauri/ # Rust backend
|
||||
└── scripts/ # Build scripts
|
||||
```
|
||||
|
||||
## Areas for Contribution
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Check existing issues for bugs to fix
|
||||
- Test your fix thoroughly
|
||||
- Add tests if possible
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- Check the roadmap in README.md and the engineering status in [`docs/PROJECT_STATUS.md`](docs/PROJECT_STATUS.md) before proposing work — it lists prioritized tasks (Tier 1 → 3), known architectural bottlenecks, and candidate TTS engines already under evaluation (including why some have been backlogged)
|
||||
- Discuss major features in an issue first
|
||||
- Keep features focused and well-scoped
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
- Improve README clarity
|
||||
- Add code comments
|
||||
- Write API documentation
|
||||
- Create tutorials or guides
|
||||
|
||||
### 🎨 UI/UX Improvements
|
||||
|
||||
- Improve accessibility
|
||||
- Enhance visual design
|
||||
- Optimize performance
|
||||
- Add animations/transitions
|
||||
|
||||
### 🔧 Infrastructure
|
||||
|
||||
- Improve build process
|
||||
- Add CI/CD improvements
|
||||
- Optimize bundle size
|
||||
- Add testing infrastructure
|
||||
|
||||
## API Development
|
||||
|
||||
When adding new API endpoints:
|
||||
|
||||
1. **Add route in `backend/main.py`**
|
||||
2. **Create Pydantic models in `backend/models.py`**
|
||||
3. **Implement business logic in appropriate module**
|
||||
4. **Update OpenAPI schema** (automatic with FastAPI)
|
||||
5. **Regenerate TypeScript client:**
|
||||
```bash
|
||||
bun run generate:api
|
||||
```
|
||||
6. **Update `backend/README.md`** with endpoint documentation
|
||||
|
||||
## Testing
|
||||
|
||||
Currently, testing is primarily manual. When adding tests:
|
||||
|
||||
- **Backend**: Use pytest for Python tests
|
||||
- **Frontend**: Use Vitest for React component tests
|
||||
- **E2E**: Use Playwright for end-to-end tests (future)
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. **Update documentation** if needed
|
||||
2. **Ensure code follows style guidelines**
|
||||
3. **Test your changes thoroughly**
|
||||
4. **Update CHANGELOG.md** with your changes
|
||||
5. **Request review** from maintainers
|
||||
|
||||
### PR Checklist
|
||||
|
||||
- [ ] Code follows style guidelines
|
||||
- [ ] Documentation updated
|
||||
- [ ] Changes tested
|
||||
- [ ] No breaking changes (or documented)
|
||||
- [ ] CHANGELOG.md updated
|
||||
|
||||
## Release Process
|
||||
|
||||
Releases are managed by maintainers:
|
||||
|
||||
1. **Bump version using bumpversion:**
|
||||
```bash
|
||||
# Install bumpversion (if not already installed)
|
||||
pip install bumpversion
|
||||
|
||||
# Bump patch version (0.1.0 -> 0.1.1)
|
||||
bumpversion patch
|
||||
|
||||
# Or bump minor version (0.1.0 -> 0.2.0)
|
||||
bumpversion minor
|
||||
|
||||
# Or bump major version (0.1.0 -> 1.0.0)
|
||||
bumpversion major
|
||||
```
|
||||
|
||||
This automatically:
|
||||
- Updates version numbers in all files (`tauri.conf.json`, `Cargo.toml`, all `package.json` files, `backend/main.py`)
|
||||
- Creates a git commit with the version bump
|
||||
- Creates a git tag (e.g., `v0.1.1`, `v0.2.0`)
|
||||
|
||||
2. **Update CHANGELOG.md** with release notes
|
||||
|
||||
3. **Push commits and tags:**
|
||||
```bash
|
||||
git push
|
||||
git push --tags
|
||||
```
|
||||
|
||||
4. **GitHub Actions builds and releases** automatically when tags are pushed
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
See [docs/content/docs/overview/troubleshooting.mdx](docs/content/docs/overview/troubleshooting.mdx) for common issues and solutions.
|
||||
|
||||
**Quick fixes:**
|
||||
|
||||
- **Backend won't start:** Check Python version (3.11+), ensure venv is activated, install dependencies
|
||||
- **Tauri build fails:** Ensure Rust is installed, clean build with `cd tauri/src-tauri && cargo clean`
|
||||
- **OpenAPI client generation fails:** Ensure backend is running, check `curl http://localhost:17493/openapi.json`
|
||||
|
||||
## Questions?
|
||||
|
||||
- Open an issue for bugs or feature requests
|
||||
- Check existing issues and discussions
|
||||
- Review the codebase to understand patterns
|
||||
- See [docs/content/docs/overview/troubleshooting.mdx](docs/content/docs/overview/troubleshooting.mdx) for common issues
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [README.md](README.md) - Project overview
|
||||
- [backend/README.md](backend/README.md) - API documentation
|
||||
- [docs/PROJECT_STATUS.md](docs/PROJECT_STATUS.md) - Living engineering roadmap: architecture, shipped vs in-flight work, prioritized open issues, candidate TTS engines under evaluation, architectural bottlenecks. Keep this updated when you ship significant features, close or backlog a model integration, or identify new bottlenecks.
|
||||
- [docs/AUTOUPDATER_QUICKSTART.md](docs/AUTOUPDATER_QUICKSTART.md) - Auto-updater setup
|
||||
- [SECURITY.md](SECURITY.md) - Security policy
|
||||
- [CHANGELOG.md](CHANGELOG.md) - Version history
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the MIT License.
|
||||
|
||||
---
|
||||
|
||||
Thank you for contributing to Voicebox! 🎉
|
||||
@@ -1,447 +0,0 @@
|
||||
# voicebox - Current State Overview
|
||||
|
||||
**Last Updated:** January 25, 2026
|
||||
**Status:** ✅ MVP Core Features Working - Voice generation from Tauri app successful!
|
||||
|
||||
---
|
||||
|
||||
## 🎯 What We Have
|
||||
|
||||
### ✅ **Fully Implemented & Working**
|
||||
|
||||
#### **Backend (Python FastAPI)**
|
||||
- **Voice Profile Management**
|
||||
- Create, read, update, delete profiles
|
||||
- Add multiple audio samples per profile
|
||||
- Multi-reference voice combination (combines multiple samples)
|
||||
- Profile storage in SQLite + file system (`data/profiles/`)
|
||||
|
||||
- **Voice Generation**
|
||||
- Qwen3-TTS model integration (1.7B and 0.6B support)
|
||||
- Automatic model downloading from HuggingFace Hub
|
||||
- Voice prompt caching for instant re-generation
|
||||
- Support for English and Chinese
|
||||
- Seed-based reproducibility
|
||||
- GPU/CPU/MPS device detection
|
||||
|
||||
- **Generation History**
|
||||
- Full CRUD operations
|
||||
- Search by text content
|
||||
- Filter by profile
|
||||
- Pagination support
|
||||
- Statistics endpoint
|
||||
- Audio file storage (`data/generations/`)
|
||||
|
||||
- **Audio Transcription**
|
||||
- Whisper integration for speech-to-text
|
||||
- Language detection/selection
|
||||
- Used for reference text extraction from samples
|
||||
|
||||
- **Database**
|
||||
- SQLite with SQLAlchemy ORM
|
||||
- Tables: `profiles`, `profile_samples`, `generations`, `projects` (ready for future)
|
||||
- Automatic schema initialization
|
||||
|
||||
- **API Endpoints**
|
||||
- RESTful API with FastAPI
|
||||
- OpenAPI schema generation
|
||||
- CORS enabled
|
||||
- Health check endpoint
|
||||
- File serving for audio files
|
||||
|
||||
#### **Frontend (React + TypeScript + Tauri)**
|
||||
- **Voice Profile UI**
|
||||
- Profile list with cards
|
||||
- Create/edit profile dialog
|
||||
- Upload audio samples with transcription
|
||||
- Sample management (view/delete)
|
||||
- Profile detail view
|
||||
|
||||
- **Generation UI**
|
||||
- Form with profile selection
|
||||
- Text input (up to 5000 chars)
|
||||
- Language selection (en/zh)
|
||||
- Optional seed input
|
||||
- Loading states and error handling
|
||||
|
||||
- **History UI**
|
||||
- Table view with pagination
|
||||
- Search functionality
|
||||
- Play audio inline
|
||||
- Download audio files
|
||||
- Delete generations
|
||||
|
||||
- **Server Settings**
|
||||
- Connection form (local/remote mode)
|
||||
- Server status display
|
||||
- Health check integration
|
||||
|
||||
- **State Management**
|
||||
- React Query for server state
|
||||
- Zustand for client state (server URL, connection status)
|
||||
- Type-safe API client
|
||||
|
||||
- **UI Components**
|
||||
- shadcn/ui component library
|
||||
- Tailwind CSS styling
|
||||
- Responsive design
|
||||
- Toast notifications
|
||||
- Form validation with Zod
|
||||
|
||||
#### **Tauri Desktop App**
|
||||
- **Rust Backend**
|
||||
- Sidecar management for Python server
|
||||
- Start/stop server commands
|
||||
- Remote mode support (0.0.0.0 binding)
|
||||
- Process lifecycle management
|
||||
|
||||
- **Build System**
|
||||
- Tauri v2 configuration
|
||||
- Platform-specific builds
|
||||
- Dev tools in debug mode
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
### **Project Structure**
|
||||
```
|
||||
voicebox/
|
||||
├── app/ # Shared React frontend
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # React components
|
||||
│ │ │ ├── VoiceProfiles/ ✅ Complete
|
||||
│ │ │ ├── Generation/ ✅ Complete
|
||||
│ │ │ ├── History/ ✅ Complete
|
||||
│ │ │ ├── ServerSettings/ ✅ Complete
|
||||
│ │ │ └── AudioStudio/ 📦 Placeholder (future)
|
||||
│ │ ├── lib/
|
||||
│ │ │ ├── api/ # Type-safe API client ✅
|
||||
│ │ │ ├── hooks/ # React Query hooks ✅
|
||||
│ │ │ └── utils/ # Utilities ✅
|
||||
│ │ └── stores/ # Zustand stores ✅
|
||||
│
|
||||
├── backend/ # Python FastAPI server
|
||||
│ ├── main.py # FastAPI app + routes ✅
|
||||
│ ├── models.py # Pydantic models ✅
|
||||
│ ├── database.py # SQLAlchemy ORM ✅
|
||||
│ ├── profiles.py # Profile management ✅
|
||||
│ ├── history.py # History management ✅
|
||||
│ ├── tts.py # Qwen3-TTS integration ✅
|
||||
│ ├── transcribe.py # Whisper integration ✅
|
||||
│ ├── studio.py # Audio studio (future)
|
||||
│ └── utils/
|
||||
│ ├── audio.py # Audio processing ✅
|
||||
│ ├── cache.py # Voice prompt caching ✅
|
||||
│ └── validation.py # Validation helpers ✅
|
||||
│
|
||||
├── tauri/ # Tauri desktop wrapper
|
||||
│ ├── src/ # React entry point ✅
|
||||
│ └── src-tauri/ # Rust backend ✅
|
||||
│ └── src/main.rs # Sidecar management ✅
|
||||
│
|
||||
├── data/ # User data directory
|
||||
│ ├── profiles/ # Profile audio samples
|
||||
│ ├── generations/ # Generated audio files
|
||||
│ ├── cache/ # Cached voice prompts
|
||||
│ └── voicebox.db # SQLite database
|
||||
│
|
||||
└── scripts/ # Build & generation scripts
|
||||
├── generate-api.sh # OpenAPI client generation
|
||||
└── build-server.sh # Python binary build
|
||||
```
|
||||
|
||||
### **Data Flow**
|
||||
|
||||
```
|
||||
User Action (Tauri App)
|
||||
↓
|
||||
React Component (Form Submit)
|
||||
↓
|
||||
React Query Hook (useGeneration)
|
||||
↓
|
||||
API Client (apiClient.generateSpeech)
|
||||
↓
|
||||
HTTP Request → FastAPI Backend
|
||||
↓
|
||||
Backend Route Handler (/generate)
|
||||
↓
|
||||
Business Logic:
|
||||
1. Get profile from DB
|
||||
2. Create voice prompt (with caching)
|
||||
3. Generate audio with Qwen3-TTS
|
||||
4. Save audio file
|
||||
5. Create history entry
|
||||
↓
|
||||
Response (GenerationResponse)
|
||||
↓
|
||||
React Query Cache Update
|
||||
↓
|
||||
UI Refresh (History table updates)
|
||||
```
|
||||
|
||||
### **Key Technologies**
|
||||
|
||||
| Layer | Technology | Purpose |
|
||||
|-------|-----------|---------|
|
||||
| **Desktop Framework** | Tauri v2 | Native desktop app wrapper |
|
||||
| **Frontend Framework** | React 18 | UI components |
|
||||
| **Language** | TypeScript | Type safety |
|
||||
| **Styling** | Tailwind CSS | Utility-first CSS |
|
||||
| **UI Components** | shadcn/ui | Component library |
|
||||
| **State Management** | React Query + Zustand | Server & client state |
|
||||
| **Form Handling** | React Hook Form + Zod | Form validation |
|
||||
| **Backend Framework** | FastAPI | Async REST API |
|
||||
| **Database** | SQLite + SQLAlchemy | Data persistence |
|
||||
| **ML Models** | Qwen3-TTS + Whisper | Voice cloning + transcription |
|
||||
| **Audio Processing** | librosa + soundfile | Audio I/O and processing |
|
||||
| **Package Manager** | Bun | Fast JS/TS package management |
|
||||
| **Build Tool** | Vite | Frontend bundling |
|
||||
|
||||
---
|
||||
|
||||
## 🔑 Key Features & Capabilities
|
||||
|
||||
### **1. Voice Profile System**
|
||||
- **Multi-sample support**: Add multiple audio samples per profile
|
||||
- **Automatic combination**: Multiple samples are combined for better quality
|
||||
- **Voice prompt caching**: Re-use voice prompts for instant re-generation
|
||||
- **Audio validation**: Ensures samples meet quality requirements
|
||||
|
||||
### **2. Generation Pipeline**
|
||||
- **Lazy model loading**: Model loads on first use
|
||||
- **Device detection**: Automatically uses GPU if available
|
||||
- **Caching layer**: Voice prompts cached by audio hash + text
|
||||
- **Error handling**: Graceful degradation and clear error messages
|
||||
|
||||
### **3. History & Search**
|
||||
- **Full-text search**: Search generations by text content
|
||||
- **Pagination**: Efficient loading of large histories
|
||||
- **Audio playback**: Inline audio player
|
||||
- **File management**: Download and delete operations
|
||||
|
||||
### **4. Server/Client Architecture**
|
||||
- **Local mode**: Backend runs alongside Tauri app
|
||||
- **Remote mode**: Connect to remote GPU machine
|
||||
- **One-click server**: Start server from UI
|
||||
- **Connection management**: Persistent server URL storage
|
||||
|
||||
---
|
||||
|
||||
## 📊 Database Schema
|
||||
|
||||
### **Tables**
|
||||
|
||||
```sql
|
||||
-- Voice Profiles
|
||||
profiles
|
||||
- id (PK, UUID)
|
||||
- name (unique)
|
||||
- description
|
||||
- language (en/zh)
|
||||
- created_at
|
||||
- updated_at
|
||||
|
||||
-- Profile Samples
|
||||
profile_samples
|
||||
- id (PK, UUID)
|
||||
- profile_id (FK → profiles.id)
|
||||
- audio_path
|
||||
- reference_text
|
||||
|
||||
-- Generations
|
||||
generations
|
||||
- id (PK, UUID)
|
||||
- profile_id (FK → profiles.id)
|
||||
- text
|
||||
- language
|
||||
- audio_path
|
||||
- duration (seconds)
|
||||
- seed (optional)
|
||||
- created_at
|
||||
|
||||
-- Projects (ready for future)
|
||||
projects
|
||||
- id (PK, UUID)
|
||||
- name
|
||||
- data (JSON)
|
||||
- created_at
|
||||
- updated_at
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 UI Components Status
|
||||
|
||||
| Component | Status | Features |
|
||||
|-----------|--------|----------|
|
||||
| **ProfileList** | ✅ Complete | List, create, empty state |
|
||||
| **ProfileCard** | ✅ Complete | Display profile info |
|
||||
| **ProfileForm** | ✅ Complete | Create/edit dialog |
|
||||
| **ProfileDetail** | ✅ Complete | View samples, add samples |
|
||||
| **SampleUpload** | ✅ Complete | File upload + transcription |
|
||||
| **GenerationForm** | ✅ Complete | Full generation form |
|
||||
| **HistoryTable** | ✅ Complete | Table, search, pagination, play/download |
|
||||
| **ConnectionForm** | ✅ Complete | Server URL input |
|
||||
| **ServerStatus** | ✅ Complete | Health check display |
|
||||
| **AudioStudio** | 📦 Placeholder | Timeline editor (future) |
|
||||
|
||||
---
|
||||
|
||||
## 🔌 API Endpoints
|
||||
|
||||
### **Profiles**
|
||||
- `POST /profiles` - Create profile
|
||||
- `GET /profiles` - List all profiles
|
||||
- `GET /profiles/{id}` - Get profile
|
||||
- `PUT /profiles/{id}` - Update profile
|
||||
- `DELETE /profiles/{id}` - Delete profile
|
||||
- `POST /profiles/{id}/samples` - Add sample
|
||||
- `GET /profiles/{id}/samples` - List samples
|
||||
- `DELETE /profiles/samples/{id}` - Delete sample
|
||||
|
||||
### **Generation**
|
||||
- `POST /generate` - Generate speech
|
||||
|
||||
### **History**
|
||||
- `GET /history` - List generations (with filters)
|
||||
- `GET /history/{id}` - Get generation
|
||||
- `DELETE /history/{id}` - Delete generation
|
||||
- `GET /history/stats` - Get statistics
|
||||
|
||||
### **Transcription**
|
||||
- `POST /transcribe` - Transcribe audio
|
||||
|
||||
### **Audio**
|
||||
- `GET /audio/{id}` - Serve audio file
|
||||
|
||||
### **Health**
|
||||
- `GET /health` - Health check with model status
|
||||
|
||||
### **Model Management**
|
||||
- `POST /models/load` - Load TTS model
|
||||
- `POST /models/unload` - Unload TTS model
|
||||
|
||||
---
|
||||
|
||||
## 🚀 What's Next (Planned Features)
|
||||
|
||||
### **Phase 2: Advanced Features**
|
||||
- [ ] Multi-reference voice combination UI
|
||||
- [ ] Batch generation (multiple variations)
|
||||
- [ ] Advanced audio normalization
|
||||
- [ ] Export options (MP3, OGG, etc.)
|
||||
- [ ] M3GAN voice effect
|
||||
|
||||
### **Phase 3: Audio Studio**
|
||||
- [ ] Timeline-based audio editor
|
||||
- [ ] Word-level timestamps
|
||||
- [ ] Project system (save/load sessions)
|
||||
- [ ] Audio effects and filters
|
||||
- [ ] Multi-track editing
|
||||
|
||||
### **Phase 4: Voice Design**
|
||||
- [ ] Text-to-voice (no reference needed)
|
||||
- [ ] Preset voices with style control
|
||||
- [ ] Conversation mode (multi-speaker)
|
||||
- [ ] Custom audio effects library
|
||||
|
||||
---
|
||||
|
||||
## 📝 Code Quality Standards
|
||||
|
||||
- ✅ **Type safety**: TypeScript strict mode, Pydantic models
|
||||
- ✅ **Modular architecture**: No files over 500 lines
|
||||
- ✅ **Error handling**: Comprehensive error messages
|
||||
- ✅ **Caching**: Voice prompt caching for performance
|
||||
- ✅ **Database**: SQLAlchemy ORM with proper relationships
|
||||
- ✅ **API design**: RESTful with OpenAPI schema
|
||||
- ✅ **UI/UX**: Responsive, accessible, loading states
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing Status
|
||||
|
||||
- ✅ **Manual testing**: Voice generation working end-to-end
|
||||
- 📦 **Unit tests**: Not yet implemented
|
||||
- 📦 **Integration tests**: Not yet implemented
|
||||
- 📦 **E2E tests**: Not yet implemented
|
||||
|
||||
---
|
||||
|
||||
## 📦 Dependencies
|
||||
|
||||
### **Backend**
|
||||
- FastAPI - Web framework
|
||||
- SQLAlchemy - ORM
|
||||
- Pydantic - Validation
|
||||
- Qwen3-TTS - Voice cloning model
|
||||
- Whisper - Speech recognition
|
||||
- librosa - Audio processing
|
||||
- soundfile - Audio I/O
|
||||
- PyTorch - ML framework
|
||||
|
||||
### **Frontend**
|
||||
- React 18 - UI framework
|
||||
- TypeScript - Type safety
|
||||
- React Query - Server state
|
||||
- Zustand - Client state
|
||||
- React Hook Form - Forms
|
||||
- Zod - Schema validation
|
||||
- Tailwind CSS - Styling
|
||||
- shadcn/ui - Components
|
||||
- Lucide React - Icons
|
||||
|
||||
### **Desktop**
|
||||
- Tauri v2 - Desktop framework
|
||||
- Rust - System backend
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Current Capabilities Summary
|
||||
|
||||
✅ **Working End-to-End:**
|
||||
1. Create voice profiles with audio samples
|
||||
2. Generate speech from text using cloned voices
|
||||
3. View and manage generation history
|
||||
4. Play and download generated audio
|
||||
5. Search and filter history
|
||||
6. Connect to local or remote backend
|
||||
7. Automatic model downloading
|
||||
8. Voice prompt caching for speed
|
||||
|
||||
🎉 **You just successfully generated voice from the Tauri app!**
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Key Files Reference
|
||||
|
||||
### **Backend Core**
|
||||
- `backend/main.py` - FastAPI app and routes
|
||||
- `backend/tts.py` - Qwen3-TTS model wrapper
|
||||
- `backend/profiles.py` - Profile business logic
|
||||
- `backend/history.py` - History business logic
|
||||
- `backend/database.py` - Database models
|
||||
|
||||
### **Frontend Core**
|
||||
- `app/src/App.tsx` - Main app component
|
||||
- `app/src/lib/api/client.ts` - API client
|
||||
- `app/src/lib/hooks/` - React Query hooks
|
||||
- `app/src/stores/` - Zustand stores
|
||||
|
||||
### **Tauri**
|
||||
- `tauri/src-tauri/src/main.rs` - Rust backend
|
||||
- `tauri/src/main.tsx` - React entry point
|
||||
|
||||
---
|
||||
|
||||
## 💡 Development Workflow
|
||||
|
||||
1. **Start backend**: `bun run dev:server` (or via Tauri)
|
||||
2. **Start frontend**: `bun run dev` (Tauri) or `bun run dev:web` (web)
|
||||
3. **Generate API client**: `bun run generate:api` (after backend changes)
|
||||
4. **Build server binary**: `bun run build:server` (for Tauri bundling)
|
||||
|
||||
---
|
||||
|
||||
**Ready to build more features! 🚀**
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
# ============================================================
|
||||
# Voicebox — Local TTS Server with Web UI (CPU)
|
||||
# 3-stage build: Frontend → Python deps → Runtime
|
||||
# ============================================================
|
||||
|
||||
# === Stage 1: Build frontend ===
|
||||
FROM oven/bun:1 AS frontend
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Copy workspace config and frontend source
|
||||
COPY package.json bun.lock CHANGELOG.md ./
|
||||
COPY app/ ./app/
|
||||
COPY web/ ./web/
|
||||
|
||||
# Strip workspaces not needed for web build, and fix trailing comma
|
||||
RUN sed -i '/"tauri"/d; /"landing"/d' package.json && \
|
||||
sed -i -z 's/,\n ]/\n ]/' package.json
|
||||
RUN bun install --no-save
|
||||
# Build frontend (skip tsc — upstream has pre-existing type errors)
|
||||
RUN cd web && bunx --bun vite build
|
||||
|
||||
|
||||
# === Stage 2: Build Python dependencies ===
|
||||
FROM python:3.11-slim AS backend-builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pip install --no-cache-dir --upgrade pip
|
||||
|
||||
COPY backend/requirements.txt .
|
||||
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
|
||||
RUN pip install --no-cache-dir --prefix=/install --no-deps chatterbox-tts
|
||||
RUN pip install --no-cache-dir --prefix=/install --no-deps hume-tada
|
||||
RUN pip install --no-cache-dir --prefix=/install \
|
||||
git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||
|
||||
|
||||
# === Stage 3: Runtime ===
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Create non-root user for security
|
||||
RUN groupadd -r voicebox && \
|
||||
useradd -r -g voicebox -m -s /bin/bash voicebox
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install only runtime system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy installed Python packages from builder stage
|
||||
COPY --from=backend-builder /install /usr/local
|
||||
|
||||
# Copy backend application code
|
||||
COPY --chown=voicebox:voicebox backend/ /app/backend/
|
||||
|
||||
# Copy built frontend from frontend stage
|
||||
COPY --from=frontend --chown=voicebox:voicebox /build/web/dist /app/frontend/
|
||||
|
||||
# Create data directories owned by non-root user
|
||||
RUN mkdir -p /app/data/generations /app/data/profiles /app/data/cache \
|
||||
&& chown -R voicebox:voicebox /app/data
|
||||
|
||||
# Switch to non-root user
|
||||
USER voicebox
|
||||
|
||||
# Expose the API port
|
||||
EXPOSE 17493
|
||||
|
||||
# Health check — auto-restart if the server hangs
|
||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=60s \
|
||||
CMD curl -f http://localhost:17493/health || exit 1
|
||||
|
||||
# Start the FastAPI server
|
||||
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "17493"]
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Voicebox Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,409 +1,469 @@
|
||||
# voicebox
|
||||
<p align="center">
|
||||
<img src=".github/assets/icon-dark.webp" alt="Voicebox" width="120" height="120" />
|
||||
</p>
|
||||
|
||||
A production-quality desktop app for Qwen3-TTS voice cloning and generation.
|
||||
<h1 align="center">Voicebox</h1>
|
||||
|
||||
**Domain:** voicebox.sh
|
||||
<p align="center">
|
||||
<strong>The open-source AI voice studio.</strong><br/>
|
||||
Clone any voice. Generate speech. Dictate into any app. Talk to agents in voices you own.<br/>
|
||||
The full voice I/O stack, running locally on your machine.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/jamiepine/voicebox/releases">
|
||||
<img src="https://img.shields.io/github/downloads/jamiepine/voicebox/total?style=flat&color=blue" alt="Downloads" />
|
||||
</a>
|
||||
<a href="https://github.com/jamiepine/voicebox/releases/latest">
|
||||
<img src="https://img.shields.io/github/v/release/jamiepine/voicebox?style=flat" alt="Release" />
|
||||
</a>
|
||||
<a href="https://github.com/jamiepine/voicebox/stargazers">
|
||||
<img src="https://img.shields.io/github/stars/jamiepine/voicebox?style=flat" alt="Stars" />
|
||||
</a>
|
||||
<a href="https://github.com/jamiepine/voicebox/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/jamiepine/voicebox?style=flat" alt="License" />
|
||||
</a>
|
||||
<a href="https://deepwiki.com/jamiepine/voicebox">
|
||||
<img src="https://img.shields.io/static/v1?label=Ask&message=DeepWiki&color=5B6EF7" alt="Ask DeepWiki" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://voicebox.sh">voicebox.sh</a> •
|
||||
<a href="https://docs.voicebox.sh">Docs</a> •
|
||||
<a href="#download">Download</a> •
|
||||
<a href="#features">Features</a> •
|
||||
<a href="#api">API</a> •
|
||||
<a href="docs/content/docs/overview/troubleshooting.mdx">Troubleshooting</a>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://voicebox.sh">
|
||||
<img src="landing/public/assets/app-screenshot-1.webp" alt="Voicebox App Screenshot" width="800" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<em>Click the image above to watch the demo video on <a href="https://voicebox.sh">voicebox.sh</a></em>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
<p align="center">
|
||||
<img src="landing/public/assets/app-screenshot-2.webp" alt="Voicebox Screenshot 2" width="800" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="landing/public/assets/app-screenshot-3.webp" alt="Voicebox Screenshot 3" width="800" />
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
## What is Voicebox?
|
||||
|
||||
Voicebox is a **local-first AI voice studio** — a free and open-source alternative to **ElevenLabs** and **WisprFlow** in one app. Clone voices from a few seconds of audio, generate speech in 23 languages across 7 TTS engines, dictate into any text field with a global hotkey, and give any MCP-aware AI agent a voice of your choosing.
|
||||
|
||||
The two cloud incumbents sit on opposite halves of the voice I/O loop — ElevenLabs on output, WisprFlow on input. Voicebox does both, bridges them with a bundled local LLM for refinement and per-profile personas, and runs the whole thing on your machine.
|
||||
|
||||
- **Complete privacy** — models, voice data, and captures never leave your machine
|
||||
- **7 TTS engines** — Qwen3-TTS, Qwen CustomVoice, LuxTTS, Chatterbox Multilingual, Chatterbox Turbo, HumeAI TADA, and Kokoro
|
||||
- **Voice cloning and preset voices** — zero-shot cloning from a reference sample, or 50+ curated preset voices via Kokoro and Qwen CustomVoice
|
||||
- **23 languages** — from English to Arabic, Japanese, Hindi, Swahili, and more
|
||||
- **Post-processing effects** — pitch shift, reverb, delay, chorus, compression, and filters
|
||||
- **Expressive speech** — paralinguistic tags like `[laugh]`, `[sigh]`, `[gasp]` via Chatterbox Turbo; natural-language delivery control via Qwen CustomVoice
|
||||
- **Unlimited length** — auto-chunking with crossfade for scripts, articles, and chapters
|
||||
- **Stories editor** — multi-track timeline for conversations, podcasts, and narratives
|
||||
- **Voice input** — global dictation hotkey with push-to-talk and toggle modes, accessibility-verified auto-paste on macOS, in-app mic on every text field, Whisper-based STT
|
||||
- **Agent voice output** — one tool call (`voicebox.speak`) and any MCP-aware agent (Claude Code, Cursor, Cline) speaks to you in a voice you've cloned
|
||||
- **Voice personalities** — attach a free-form persona to any voice profile, then Compose, Rewrite, or Respond via a bundled local LLM — agents can invoke the same modes over MCP
|
||||
- **API-first** — REST API plus a built-in MCP server for integrating voice I/O into your own apps and agents
|
||||
- **Native performance** — built with Tauri (Rust), not Electron
|
||||
- **Runs everywhere** — macOS (MLX/Metal), Windows (CUDA), Linux, AMD ROCm, Intel Arc, Docker
|
||||
|
||||
---
|
||||
|
||||
## Vision
|
||||
## Download
|
||||
|
||||
Qwen3-TTS is a breakthrough model from Alibaba that achieves near-perfect voice cloning. The existing implementations (Voice-Clone-Studio, mimic, etc.) are either feature-rich but architecturally messy, or well-structured but limited in scope.
|
||||
| Platform | Download |
|
||||
| --------------------- | ------------------------------------------------------ |
|
||||
| macOS (Apple Silicon) | [Download DMG](https://voicebox.sh/download/mac-arm) |
|
||||
| macOS (Intel) | [Download DMG](https://voicebox.sh/download/mac-intel) |
|
||||
| Windows | [Download MSI](https://voicebox.sh/download/windows) |
|
||||
| Docker | `docker compose up` |
|
||||
|
||||
voicebox aims to build the definitive Qwen3-TTS application by combining the best patterns from existing projects while avoiding their architectural mistakes.
|
||||
> **[View all binaries →](https://github.com/jamiepine/voicebox/releases/latest)**
|
||||
|
||||
## Design Principles
|
||||
> **Linux** — Pre-built binaries are not yet available. See [voicebox.sh/linux-install](https://voicebox.sh/linux-install) for build-from-source instructions.
|
||||
|
||||
1. **Clean architecture from day one** - No monolithic files, proper separation of concerns
|
||||
2. **Desktop-first experience** - Native feel via Tauri, not a web app in disguise
|
||||
3. **Production code quality** - Type safety, modularity, maintainability
|
||||
4. **Performance and UX** - Smart caching, async operations, responsive UI
|
||||
5. **Extensible design** - Easy to add new models, effects, and features
|
||||
6. **Flexible deployment** - Run backend locally or connect to remote GPU machine with one click
|
||||
> **Having trouble?** See the [Troubleshooting Guide](docs/content/docs/overview/troubleshooting.mdx) for common install, generation, model-download, and GPU issues.
|
||||
|
||||
## Technology Stack
|
||||
---
|
||||
|
||||
### Backend (Python)
|
||||
- **FastAPI** - Async REST API
|
||||
- **SQLAlchemy** - Database ORM with migrations
|
||||
- **Pydantic** - Request/response validation
|
||||
- **Qwen3-TTS** - Voice cloning model
|
||||
- **Whisper** - Speech-to-text transcription
|
||||
- **librosa + soundfile** - Audio processing
|
||||
## Features
|
||||
|
||||
### Frontend (Tauri + TypeScript)
|
||||
- **Tauri** - Native desktop framework
|
||||
- **React** - UI framework
|
||||
- **TypeScript** - Type safety throughout
|
||||
- **Bun** - Fast package manager and JavaScript runtime
|
||||
- **React Query** - Server state management and API calls
|
||||
- **OpenAPI (generated)** - Type-safe API client from FastAPI schema
|
||||
- **Tailwind CSS** - Styling
|
||||
- **Zustand** - Client-side state management
|
||||
- **WaveSurfer.js** - Audio visualization
|
||||
### Multi-Engine Voice Cloning
|
||||
|
||||
### Database
|
||||
- **SQLite** - Local storage
|
||||
- **Alembic** - Schema migrations
|
||||
Seven TTS engines with different strengths, switchable per-generation:
|
||||
|
||||
## Server/Client Mode
|
||||
| Engine | Languages | Strengths |
|
||||
| --------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Qwen3-TTS** (0.6B / 1.7B) | 10 | High-quality multilingual cloning, delivery instructions ("speak slowly", "whisper") |
|
||||
| **Qwen CustomVoice** | 10 | 9 curated preset voices with natural-language delivery control — no reference audio required |
|
||||
| **LuxTTS** | English | Lightweight (~1GB VRAM), 48kHz output, 150x realtime on CPU |
|
||||
| **Chatterbox Multilingual** | 23 | Broadest language coverage — Arabic, Danish, Finnish, Greek, Hebrew, Hindi, Malay, Norwegian, Polish, Swahili, Swedish, Turkish and more |
|
||||
| **Chatterbox Turbo** | English | Fast 350M model with paralinguistic emotion/sound tags |
|
||||
| **TADA** (1B / 3B) | 10 | HumeAI speech-language model — 700s+ coherent audio, text-acoustic dual alignment |
|
||||
| **Kokoro** | 8 | 50 curated preset voices, tiny 82M model, fast CPU inference |
|
||||
|
||||
voicebox supports flexible deployment for users with multiple machines:
|
||||
### Emotions & Paralinguistic Tags
|
||||
|
||||
### Local Mode (Default)
|
||||
- Backend runs locally alongside the Tauri app
|
||||
- Best for users with GPU on their primary machine
|
||||
Only **Chatterbox Turbo** interprets paralinguistic tags like `[laugh]` and
|
||||
`[sigh]`. Qwen3-TTS, LuxTTS, Chatterbox Multilingual, and HumeAI TADA read them
|
||||
literally as text.
|
||||
|
||||
### Remote Mode (One-Click Setup)
|
||||
- **Use case:** Your laptop doesn't have a GPU, but your desktop does
|
||||
- **Server:** Run voicebox on GPU machine, click "Start Server"
|
||||
- Starts FastAPI backend on local network
|
||||
- Shows connection URL (e.g., `http://192.168.1.100:8000`)
|
||||
- **Client:** Run voicebox on laptop, enter server URL
|
||||
- Connects to remote backend
|
||||
- Full UI functionality, inference happens on GPU machine
|
||||
- **Security:** Local network only for now (no internet exposure)
|
||||
With **Chatterbox Turbo** selected, type `/` in the text input to open the tag
|
||||
inserter and add expressive tags inline with speech:
|
||||
|
||||
### How It Works
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ Laptop │ │ Desktop │
|
||||
│ (Client) │ │ (Server) │
|
||||
│ │ │ │
|
||||
│ Tauri App ────────────────▶ FastAPI │
|
||||
│ React UI │ HTTP │ Qwen3-TTS │
|
||||
│ │ │ SQLite │
|
||||
│ │ │ CUDA/GPU │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
```
|
||||
`[laugh]` `[chuckle]` `[gasp]` `[cough]` `[sigh]` `[groan]` `[sniff]` `[shush]` `[clear throat]`
|
||||
|
||||
**Benefits:**
|
||||
- Use powerful GPU machine from lightweight laptop
|
||||
- No complex setup - just click "Start Server"
|
||||
- All data (history, profiles) lives on server
|
||||
- Client is just a UI - no local storage needed in remote mode
|
||||
### Post-Processing Effects
|
||||
|
||||
## Core Features
|
||||
8 audio effects powered by Spotify's `pedalboard` library. Apply after generation, preview in real time, build reusable presets.
|
||||
|
||||
### Phase 1 (MVP)
|
||||
- Voice profile management
|
||||
- Single-reference voice cloning
|
||||
- Generation history with search
|
||||
- Basic audio playback and preview
|
||||
- Server/client mode (local network)
|
||||
- One-click server startup
|
||||
| Effect | Description |
|
||||
| ---------------- | --------------------------------------------- |
|
||||
| Pitch Shift | Up or down by up to 12 semitones |
|
||||
| Reverb | Configurable room size, damping, wet/dry mix |
|
||||
| Delay | Echo with adjustable time, feedback, and mix |
|
||||
| Chorus / Flanger | Modulated delay for metallic or lush textures |
|
||||
| Compressor | Dynamic range compression |
|
||||
| Gain | Volume adjustment (-40 to +40 dB) |
|
||||
| High-Pass Filter | Remove low frequencies |
|
||||
| Low-Pass Filter | Remove high frequencies |
|
||||
|
||||
### Phase 2
|
||||
- Multi-reference voice combination
|
||||
- Batch variation generation
|
||||
- Advanced audio normalization
|
||||
- Export options and formats
|
||||
Ships with 4 built-in presets (Robotic, Radio, Echo Chamber, Deep Voice) and supports custom presets. Effects can be assigned per-profile as defaults.
|
||||
|
||||
### Phase 3
|
||||
- Audio studio with timeline editing
|
||||
- Word-level timestamps
|
||||
- Project system (save/load sessions)
|
||||
- Export options
|
||||
### Unlimited Generation Length
|
||||
|
||||
### Phase 4
|
||||
- Voice design (text-to-voice)
|
||||
- Preset voices with style control
|
||||
- Conversation mode (multi-speaker)
|
||||
- Custom audio effects
|
||||
Text is automatically split at sentence boundaries and each chunk is generated independently, then crossfaded together. Works with all engines.
|
||||
|
||||
## Key Differentiators
|
||||
- Configurable auto-chunking limit (100–5,000 chars)
|
||||
- Crossfade slider (0–200ms) for smooth transitions
|
||||
- Max text length: 50,000 characters
|
||||
- Smart splitting respects abbreviations, CJK punctuation, and `[tags]`
|
||||
|
||||
What makes voicebox better than existing implementations:
|
||||
### Generation Versions
|
||||
|
||||
1. **Clean codebase** - Modular architecture, no 2,000+ line files
|
||||
2. **Type safety end-to-end** - OpenAPI-generated TypeScript client, Pydantic backend, React Query
|
||||
3. **Smart caching** - Voice prompt caching for instant re-generation
|
||||
4. **Desktop UX** - Native performance, keyboard shortcuts, native dialogs
|
||||
5. **Server/client mode** - One-click remote GPU access from any device
|
||||
6. **Multi-reference** - Combine voice samples for higher quality
|
||||
7. **Audio studio** - Timeline-based editing with word-level precision
|
||||
8. **Production patterns** - Cross-platform, graceful degradation, error recovery
|
||||
9. **Database-backed** - Searchable history, project persistence
|
||||
10. **Extensible** - Clean plugin system for models and features
|
||||
Every generation supports multiple versions with provenance tracking:
|
||||
|
||||
## Architecture Overview
|
||||
- **Original** — clean TTS output, always preserved
|
||||
- **Effects versions** — apply different effects chains from any source version
|
||||
- **Takes** — regenerate with a new seed for variation
|
||||
- **Source tracking** — each version records its lineage
|
||||
- **Favorites** — star generations for quick access
|
||||
|
||||
```
|
||||
voicebox/
|
||||
├── app/ # Shared React frontend (used by web & desktop)
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # React components
|
||||
│ │ │ ├── VoiceProfiles/
|
||||
│ │ │ ├── Generation/
|
||||
│ │ │ ├── AudioStudio/
|
||||
│ │ │ ├── History/
|
||||
│ │ │ └── ServerSettings/
|
||||
│ │ ├── lib/
|
||||
│ │ │ ├── api/ # Generated OpenAPI client
|
||||
│ │ │ ├── hooks/ # React Query hooks
|
||||
│ │ │ └── utils/
|
||||
│ │ ├── types/
|
||||
│ │ └── App.tsx
|
||||
│ ├── package.json
|
||||
│ └── vite.config.ts
|
||||
│
|
||||
├── tauri/ # Tauri desktop app (thin wrapper)
|
||||
│ ├── src/
|
||||
│ │ └── main.tsx # Entry point, imports from ../app
|
||||
│ ├── src-tauri/ # Rust backend
|
||||
│ │ ├── src/
|
||||
│ │ │ └── main.rs # Sidecar management, IPC
|
||||
│ │ ├── binaries/ # Bundled Python server
|
||||
│ │ │ └── voicebox-server-{platform}
|
||||
│ │ ├── Cargo.toml
|
||||
│ │ └── tauri.conf.json
|
||||
│ └── package.json
|
||||
│
|
||||
├── web/ # Web deployment (thin wrapper)
|
||||
│ ├── src/
|
||||
│ │ └── main.tsx # Entry point, imports from ../app
|
||||
│ ├── package.json
|
||||
│ └── vite.config.ts
|
||||
│
|
||||
├── backend/ # Python FastAPI server
|
||||
│ ├── main.py # FastAPI app + server mode
|
||||
│ ├── models.py # Pydantic models
|
||||
│ ├── tts.py # TTS inference
|
||||
│ ├── transcribe.py # Whisper ASR
|
||||
│ ├── profiles.py # Voice profiles
|
||||
│ ├── history.py # Generation history
|
||||
│ ├── studio.py # Audio editing
|
||||
│ ├── database.py # SQLite ORM
|
||||
│ ├── utils/
|
||||
│ │ ├── audio.py # Audio processing
|
||||
│ │ ├── cache.py # Prompt caching
|
||||
│ │ └── validation.py
|
||||
│ ├── requirements.txt
|
||||
│ └── build_binary.py # PyInstaller build script
|
||||
│
|
||||
├── scripts/
|
||||
│ ├── build-server.sh # Build Python binary for all platforms
|
||||
│ └── generate-api.sh # Generate OpenAPI client
|
||||
│
|
||||
├── data/ # User data
|
||||
│ ├── profiles/
|
||||
│ ├── generations/
|
||||
│ ├── projects/
|
||||
│ └── voicebox.db
|
||||
│
|
||||
├── package.json # Root workspace config
|
||||
└── docs/
|
||||
├── ANALYSIS.md # Analysis of existing projects
|
||||
├── TAURI_PLAN.md # Tauri app structure and bundling strategy
|
||||
└── ARCHITECTURE.md # Detailed architecture docs
|
||||
```
|
||||
### Async Generation Queue
|
||||
|
||||
**Key architectural decisions:**
|
||||
- **Shared frontend** - `app/` contains all React code, used by both desktop and web
|
||||
- **Thin wrappers** - `tauri/` and `web/` just configure build tools and entry points
|
||||
- **Bundled backend** - Python server packaged as sidecar binary with PyInstaller
|
||||
- **Type-safe API** - OpenAPI schema generated from FastAPI, TypeScript client auto-generated
|
||||
Generation is non-blocking. Submit and immediately start typing the next one.
|
||||
|
||||
See [TAURI_PLAN.md](./docs/TAURI_PLAN.md) for detailed bundling strategy.
|
||||
- Serial execution queue prevents GPU contention
|
||||
- Real-time SSE status streaming
|
||||
- Failed generations can be retried
|
||||
- Stale generations from crashes auto-recover on startup
|
||||
|
||||
## Lessons from Existing Projects
|
||||
### Voice Profile Management
|
||||
|
||||
voicebox learns from five existing Qwen3-TTS implementations:
|
||||
- Create profiles from audio files or record directly in-app
|
||||
- Import/export profiles to share or back up
|
||||
- Multi-sample support for higher quality cloning
|
||||
- Per-profile default effects chains
|
||||
- Organize with descriptions and language tags
|
||||
|
||||
### voice (Rust CLI)
|
||||
- ✅ Clean Rust/Python IPC pattern
|
||||
- ✅ M3GAN voice effect
|
||||
- ✅ Voice profile abstraction
|
||||
- ❌ No concurrent requests
|
||||
- ❌ No generation history
|
||||
### Stories Editor
|
||||
|
||||
### Voice-Clone-Studio
|
||||
- ✅ Brilliant voice prompt caching
|
||||
- ✅ Feature-rich (voice design, presets, conversations)
|
||||
- ✅ VRAM-efficient model management
|
||||
- ❌ 2,815-line single file
|
||||
- ❌ Global state everywhere
|
||||
Multi-voice timeline editor for conversations, podcasts, and narratives.
|
||||
|
||||
### Qwen3-TTS_server
|
||||
- ✅ Clean modular structure
|
||||
- ✅ FastAPI REST API design
|
||||
- ✅ Health endpoint for monitoring
|
||||
- ❌ No authentication or rate limiting
|
||||
- ❌ No caching or streaming
|
||||
- ❌ No OpenAPI client generation
|
||||
- Multi-track composition with drag-and-drop
|
||||
- Inline audio trimming and splitting
|
||||
- Auto-playback with synchronized playhead
|
||||
- Version pinning per track clip
|
||||
|
||||
### mimic
|
||||
- ✅ Excellent backend architecture (async, modular)
|
||||
- ✅ Audio studio with timeline
|
||||
- ✅ Database-backed history
|
||||
- ✅ Multi-sample voice profiles
|
||||
- ❌ 2,794-line app.js frontend
|
||||
- ❌ Global state in UI
|
||||
### Global Dictation & Voice Input
|
||||
|
||||
### qwen3-tts-enhanced
|
||||
- ✅ Multi-reference combination
|
||||
- ✅ Cross-platform graceful degradation
|
||||
- ✅ Audio validation
|
||||
- ✅ Production error handling
|
||||
- ❌ Still monolithic (1,892 lines)
|
||||
- ❌ No API layer
|
||||
The other half of the voice I/O loop. Hold a hotkey anywhere on your system, speak, release — on macOS the transcript pastes straight into the focused text field. Or hit the mic on any Voicebox text input and dictate directly into the app.
|
||||
|
||||
See [ANALYSIS.md](./docs/ANALYSIS.md) for detailed breakdown of each project.
|
||||
- **Configurable chord bindings** — hold-to-speak and tap-to-toggle chords, each rebindable in the in-app chord picker. Holding push-to-talk and tapping `Space` mid-hold upgrades into a toggle session without a gap in audio
|
||||
- **Target-aware paste (macOS)** — accessibility-verified injection into the focused text field, with atomic clipboard save/restore so your clipboard isn't clobbered
|
||||
- **First-run permissions UX** — in-app gates walk you through the macOS Accessibility and Input Monitoring grants with deep-links to System Settings
|
||||
- **In-app mic button** on every Voicebox text field — generation form, profile descriptions, story titles, anywhere you'd type
|
||||
- **LLM refinement** — optional cleanup of ums, stutters, and false starts before paste
|
||||
- **On-screen pill** — floating overlay surfacing `recording`, `transcribing`, `refining`, and `speaking` states. Same pill agents use when they speak to you, so there's one mental model for both directions of the loop
|
||||
|
||||
## Development Roadmap
|
||||
### Speech-to-Text
|
||||
|
||||
### Week 1: Foundation
|
||||
- Project structure setup
|
||||
- Backend skeleton (FastAPI + SQLite)
|
||||
- OpenAPI schema generation
|
||||
- Frontend skeleton (Tauri + React)
|
||||
- TypeScript client generation from OpenAPI
|
||||
- React Query setup
|
||||
- Basic voice profile CRUD
|
||||
- Server mode implementation
|
||||
- Client connection UI
|
||||
Voicebox runs OpenAI Whisper for transcription — the same model that backs dictation, the Captures tab, and the `/transcribe` API. Running on MLX (Apple Silicon) or PyTorch (CUDA / ROCm / DirectML / CPU) depending on your platform.
|
||||
|
||||
### Week 2: Core Features
|
||||
- TTS integration
|
||||
- Voice cloning pipeline
|
||||
- Voice prompt caching
|
||||
- Generation history
|
||||
| Size | Notes |
|
||||
| ----------------------------- | -------------------------------------------------- |
|
||||
| Base / Small / Medium / Large | Standard Whisper quality ladder |
|
||||
| Turbo | ~8x faster than Whisper Large, minimal quality loss |
|
||||
|
||||
### Week 3: UX Polish
|
||||
- Audio playback and preview
|
||||
- Profile management UI
|
||||
- History search and filters
|
||||
- Error handling and validation
|
||||
More engines (Parakeet v3, Qwen3-ASR) are planned — see [Roadmap](#roadmap).
|
||||
|
||||
### Week 4: Advanced Features
|
||||
- Multi-reference combination
|
||||
- Batch generation
|
||||
- Audio normalization
|
||||
- M3GAN effect
|
||||
### Captures
|
||||
|
||||
### Week 5+: Studio Features
|
||||
- Timeline editor
|
||||
- Word-level timestamps
|
||||
- Project system
|
||||
- Export pipeline
|
||||
Every dictation, in-app recording, and uploaded audio file lands in the Captures tab — original audio paired with transcript, always preserved.
|
||||
|
||||
## Technical Decisions
|
||||
- **Replay, re-transcribe, refine** — rerun STT with any Whisper size, or re-run the raw transcript through the local LLM with different flags (filler cleanup, self-correction removal, technical-term preservation)
|
||||
- **Edit inline** — tweak the transcript and save on blur
|
||||
- **Play as voice profile** — turn any capture into speech with a cloned voice, one click
|
||||
- **Promote to voice sample** — use a capture's audio + transcript as a reference sample on any voice profile
|
||||
- **Local capture storage** — original audio and transcript stay in your Voicebox data directory, with a folder shortcut in Settings
|
||||
|
||||
### Why Tauri over Electron?
|
||||
- Smaller bundle size (Rust vs. Node.js)
|
||||
- Better performance (native vs. V8)
|
||||
- Lower memory usage
|
||||
- Rust for system-level operations
|
||||
### Agent Voice Output
|
||||
|
||||
### Why FastAPI over Flask?
|
||||
- Native async/await support
|
||||
- Automatic OpenAPI schema generation
|
||||
- Pydantic validation built-in
|
||||
- Better performance
|
||||
Every agent gets a voice. One tool call and any MCP-aware agent can speak to you in a voice you've cloned — task completions, questions, notifications. The same pill that surfaces during dictation surfaces during agent speech, so you always see what's coming out of your machine.
|
||||
|
||||
### Why OpenAPI + React Query?
|
||||
- **Type safety end-to-end** - FastAPI generates OpenAPI schema, we generate TypeScript client
|
||||
- **No manual API code** - Client generated from `openapi.json` using openapi-typescript-codegen
|
||||
- **Automatic caching** - React Query handles request deduplication and background refetching
|
||||
- **Optimistic updates** - Update UI immediately, rollback on error
|
||||
- **DevX** - Full autocomplete and type checking for all API calls
|
||||
|
||||
**Example workflow:**
|
||||
```bash
|
||||
# Backend generates OpenAPI schema
|
||||
python backend/main.py --openapi > openapi.json
|
||||
|
||||
# Frontend generates TypeScript client
|
||||
bun run generate-client
|
||||
|
||||
# Use type-safe hooks in React
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ProfilesService } from '@/lib/api';
|
||||
|
||||
const { data: profiles } = useQuery({
|
||||
queryKey: ['profiles'],
|
||||
queryFn: () => ProfilesService.listProfiles()
|
||||
```ts
|
||||
// In any MCP-aware agent:
|
||||
await voicebox.speak({
|
||||
text: "Deploy complete.",
|
||||
profile: "Morgan",
|
||||
});
|
||||
```
|
||||
|
||||
### Why Bun over npm/yarn/pnpm?
|
||||
- **Speed** - 20-30x faster than npm for install operations
|
||||
- **Drop-in replacement** - Compatible with npm ecosystem, no migration needed
|
||||
- **Built-in tooling** - Bundler, test runner, and package manager in one
|
||||
- **Performance** - Faster script execution than Node.js
|
||||
- **Developer experience** - Better error messages, workspaces support
|
||||
Also exposed as `POST /speak` for anything that doesn't speak MCP — ACP, A2A, shell scripts, custom harnesses.
|
||||
|
||||
### Why SQLite over file-based storage?
|
||||
- Full-text search
|
||||
- Transactions and integrity
|
||||
- Migrations via Alembic
|
||||
- Easy to backup/restore
|
||||
- **Bidirectional pill** — `recording`, `transcribing`, `refining`, and `speaking` are all states of the same OS-level overlay, so dictation and agent speech share one surface
|
||||
- **Per-agent voice binding** — in **Settings → MCP**, pin Claude Code to Morgan and Cursor to Scarlett so you can tell which agent is talking without looking. Each client's `last_seen_at` timestamp confirms the install actually took
|
||||
- **Always visible** — no silent background TTS; every agent-initiated speak surfaces the pill with the voice profile name for the full duration
|
||||
- **HTTP + stdio transports** — install as a URL in Claude Code / Cursor / Windsurf / VS Code MCP, or point stdio-only clients at the bundled `voicebox-mcp` binary
|
||||
|
||||
### Why React over Vue/Svelte?
|
||||
- Larger ecosystem
|
||||
- Better TypeScript support
|
||||
- Familiar to most developers
|
||||
- Mature tooling
|
||||
### Voice Personalities
|
||||
|
||||
### Why bundle Python server with PyInstaller?
|
||||
- **No Python installation required** - Users don't need Python on their system
|
||||
- **Consistent environment** - Exact dependencies bundled, no version conflicts
|
||||
- **Single-click install** - One installer includes everything
|
||||
- **Tauri sidecar pattern** - Rust spawns/manages Python process lifecycle
|
||||
- **Platform-specific binaries** - PyInstaller creates native executables for each platform
|
||||
Attach a free-form personality to any voice profile — who this voice is, how they speak, what they care about. Two actions appear on the generate box when a personality is set, powered by a bundled Qwen3 LLM running entirely locally.
|
||||
|
||||
**Tradeoffs:**
|
||||
- Larger bundle size (~500MB with models vs ~50MB without backend)
|
||||
- Need separate build for each platform (macOS Intel/ARM, Windows, Linux)
|
||||
- First launch slower (model loading time)
|
||||
- **Compose** — a shuffle button that drops a fresh in-character line into the textarea; edit and speak, or click again for a different take
|
||||
- **Speak in character** — a toggle that routes your input text through the personality LLM to be rewritten in their voice before TTS
|
||||
|
||||
**Alternative considered:** Require users to install Python and run `pip install` - rejected for poor UX
|
||||
Agents can reach the same rewrite path over MCP by passing `personality: true` to `voicebox.speak`, turning the tool into a text-in → personality-LLM → TTS pipeline. The same LLM backs dictation's refinement step — one LLM in the app, one model cache, one GPU-memory footprint.
|
||||
|
||||
### Why no Docker initially?
|
||||
- Desktop app, not server deployment
|
||||
- Users install locally
|
||||
- Can add later for server mode
|
||||
**Local LLM options:** Qwen3 0.6B / 1.7B / 4B, sharing the TTS runtime (MLX on Apple Silicon, PyTorch elsewhere).
|
||||
|
||||
## Performance Targets
|
||||
Use cases: agent dev loops (dictate a question, hear the answer in a cloned voice), interactive characters for games and narrative tools, speech assistance for people who can't speak in their original voice.
|
||||
|
||||
- **First generation:** < 10 seconds (cold start)
|
||||
- **Cached generation:** < 2 seconds (warm start)
|
||||
- **UI responsiveness:** 60 FPS at all times
|
||||
- **Memory usage:** < 4GB VRAM for small models
|
||||
- **Startup time:** < 3 seconds to UI
|
||||
- **Database queries:** < 100ms for history search
|
||||
### Model Management
|
||||
|
||||
## Quality Standards
|
||||
- Per-model unload to free GPU memory without deleting downloads
|
||||
- Custom models directory via `VOICEBOX_MODELS_DIR`
|
||||
- Model folder migration with progress tracking
|
||||
- Download cancel/clear UI
|
||||
|
||||
- **No files over 500 lines** (except auto-generated)
|
||||
- **Type hints on all Python functions**
|
||||
- **TypeScript strict mode enabled**
|
||||
- **OpenAPI client auto-generated from schema**
|
||||
- **ESLint + Prettier for frontend**
|
||||
- **Black + isort for backend**
|
||||
- **All user-facing errors have context**
|
||||
- **No global mutable state**
|
||||
- **React Query for all server state**
|
||||
### GPU Support
|
||||
|
||||
## Project Status
|
||||
| Platform | Backend | Notes |
|
||||
| ------------------------ | -------------- | ---------------------------------------------- |
|
||||
| macOS (Apple Silicon) | MLX (Metal) | 4-5x faster via Neural Engine |
|
||||
| Windows / Linux (NVIDIA) | PyTorch (CUDA) | Auto-downloads CUDA binary from within the app |
|
||||
| Linux (AMD) | PyTorch (ROCm) | Auto-configures HSA_OVERRIDE_GFX_VERSION |
|
||||
| Windows (any GPU) | DirectML | Universal Windows GPU support |
|
||||
| Intel Arc | IPEX/XPU | Intel discrete GPU acceleration |
|
||||
| Any | CPU | Works everywhere, just slower |
|
||||
|
||||
**Current phase:** Planning and analysis
|
||||
---
|
||||
|
||||
**Documentation:**
|
||||
- [ANALYSIS.md](./docs/ANALYSIS.md) - Comprehensive analysis of existing implementations
|
||||
- [TAURI_PLAN.md](./docs/TAURI_PLAN.md) - Tauri app architecture and Python server bundling strategy
|
||||
## API
|
||||
|
||||
Voicebox exposes a REST API for integrating voice I/O into your own apps and agents.
|
||||
|
||||
```bash
|
||||
# Generate speech
|
||||
curl -X POST http://127.0.0.1:17493/generate \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"text": "Hello world", "profile_id": "abc123", "language": "en"}'
|
||||
|
||||
# Agent voice output — any app or script can speak in a cloned voice
|
||||
curl -X POST http://127.0.0.1:17493/speak \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Voicebox-Client-Id: my-script" \
|
||||
-d '{"text": "Deploy complete.", "profile": "Morgan"}'
|
||||
|
||||
# Transcribe an audio file
|
||||
curl -X POST http://127.0.0.1:17493/transcribe \
|
||||
-F "[email protected]" \
|
||||
-F "model=whisper-turbo"
|
||||
|
||||
# List voice profiles
|
||||
curl http://127.0.0.1:17493/profiles
|
||||
```
|
||||
|
||||
`POST /speak` accepts `profile` as a name (case-insensitive) or id, and resolves via the same precedence as the MCP tool: explicit arg → per-client binding → `capture_settings.default_playback_voice_id`.
|
||||
|
||||
### MCP server
|
||||
|
||||
Voicebox ships a built-in **Model Context Protocol** server so any MCP-aware agent (Claude Code, Cursor, Windsurf, Cline, VS Code MCP extensions) can speak, transcribe, and browse captures and profiles.
|
||||
|
||||
**Claude Code one-liner:**
|
||||
|
||||
```
|
||||
claude mcp add voicebox \
|
||||
--transport http \
|
||||
--url http://127.0.0.1:17493/mcp \
|
||||
--header "X-Voicebox-Client-Id: claude-code"
|
||||
```
|
||||
|
||||
**Any HTTP MCP client** (Cursor, Windsurf, VS Code, etc.):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"voicebox": {
|
||||
"url": "http://127.0.0.1:17493/mcp",
|
||||
"headers": { "X-Voicebox-Client-Id": "cursor" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Stdio fallback** for clients that don't speak HTTP MCP — point at the bundled `voicebox-mcp` binary inside the app:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"voicebox": {
|
||||
"command": "/Applications/Voicebox.app/Contents/MacOS/voicebox-mcp",
|
||||
"env": { "VOICEBOX_CLIENT_ID": "claude-desktop" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Four tools ship: `voicebox.speak`, `voicebox.transcribe`, `voicebox.list_captures`, `voicebox.list_profiles`. Per-client voice bindings are managed in **Voicebox → Settings → MCP**. See the [full MCP guide](docs/content/docs/overview/mcp-server.mdx) for tool signatures, resolution precedence, the speaking-pill contract, and security notes.
|
||||
|
||||
```ts
|
||||
// In any MCP-aware agent:
|
||||
await voicebox.speak({
|
||||
text: "Tests passing. Ready to merge.",
|
||||
profile: "Morgan", // optional — falls back to the per-client binding
|
||||
personality: true, // optional — rewrites text through the profile's personality LLM first
|
||||
});
|
||||
```
|
||||
|
||||
**Use cases:** agent dev loops (voice in, voice out), game dialogue, podcast production, accessibility tools, voice assistants, content automation.
|
||||
|
||||
Full API documentation available at `http://127.0.0.1:17493/docs`.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
| ------------- | ------------------------------------------------------------------------------- |
|
||||
| Desktop App | Tauri (Rust) |
|
||||
| Frontend | React, TypeScript, Tailwind CSS |
|
||||
| State | Zustand, React Query |
|
||||
| Backend | FastAPI (Python) |
|
||||
| TTS Engines | Qwen3-TTS, Qwen CustomVoice, LuxTTS, Chatterbox, Chatterbox Turbo, TADA, Kokoro |
|
||||
| STT | Whisper / Whisper Turbo (PyTorch or MLX) |
|
||||
| Local LLM | Qwen3 (0.6B / 1.7B / 4B), shared runtime with TTS / STT |
|
||||
| MCP Server | FastMCP mounted at `/mcp` (Streamable HTTP) + bundled stdio shim binary |
|
||||
| Native Shim | Rust (inside Tauri) for global hotkey, paste injection, focus introspection |
|
||||
| Effects | Pedalboard (Spotify) |
|
||||
| Inference | MLX (Apple Silicon) / PyTorch (CUDA/ROCm/XPU/CPU) |
|
||||
| Database | SQLite |
|
||||
| Audio | WaveSurfer.js, librosa |
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
| Feature | Description |
|
||||
| ---------------------------------- | ------------------------------------------------------------------------ |
|
||||
| **Windows / Linux auto-paste** | Dictation paste parity — `SendInput` on Windows, `uinput` / AT-SPI on Linux |
|
||||
| **STT engine expansion** | Parakeet v3 and Qwen3-ASR joining Whisper — 50+ languages, better non-English quality |
|
||||
| **Pipeline routing** | Configurable source → transform → sink chains with webhook + MCP sinks and a preset editor |
|
||||
| **Streaming transcription** | WebSocket `/transcribe/stream` for partial transcripts as you speak |
|
||||
| **End-to-end speech LLMs** | Moshi, GLM-4-Voice, Qwen2.5 Omni — real voice-to-voice, no text between |
|
||||
| **Voice Design** | Create new voices from text descriptions |
|
||||
| **Long-form capture** | Dual-stream recorder (mic + system audio) with summary LLM transform |
|
||||
| **Platform sinks** | Apple Notes, Obsidian, and other opt-in integrations |
|
||||
| **Plugin architecture** | Extend with custom models, transforms, and sinks |
|
||||
| **Mobile companion** | Control Voicebox from your phone |
|
||||
|
||||
For the **full engineering status, open-issue triage, and prioritized work queue**, see [`docs/PROJECT_STATUS.md`](docs/PROJECT_STATUS.md) — a living document that tracks what's shipped, what's in-flight, candidate TTS engines under evaluation, and why we've accepted or backlogged specific integrations.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed setup and contribution guidelines.
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/jamiepine/voicebox.git
|
||||
cd voicebox
|
||||
|
||||
just setup # creates Python venv, installs all deps
|
||||
just dev # starts backend + desktop app
|
||||
```
|
||||
|
||||
Install [just](https://github.com/casey/just): `brew install just` or `cargo install just`. Run `just --list` to see all commands.
|
||||
|
||||
**Prerequisites:** [Bun](https://bun.sh), [Rust](https://rustup.rs), [Python 3.11+](https://python.org), [Tauri Prerequisites](https://v2.tauri.app/start/prerequisites/), and [Xcode](https://developer.apple.com/xcode/) on macOS.
|
||||
|
||||
The repo ships a pre-wired `.mcp.json` at the root — running Claude Code inside this checkout picks up the Voicebox MCP tools automatically once the dev app is running.
|
||||
|
||||
### Building Locally
|
||||
|
||||
```bash
|
||||
just build # Build CPU server binary + Tauri app
|
||||
just build-local # (Windows) Build CPU + CUDA server binaries + Tauri app
|
||||
```
|
||||
|
||||
### Adding New Voice Models
|
||||
|
||||
The multi-engine architecture makes adding new TTS engines straightforward. A [step-by-step guide](docs/content/docs/developer/tts-engines.mdx) covers the full process: dependency research, backend protocol implementation, frontend wiring, and PyInstaller bundling.
|
||||
|
||||
The guide is optimized for AI coding agents. An [agent skill](.agents/skills/add-tts-engine/SKILL.md) can pick up a model name and handle the entire integration autonomously — you just test the build locally.
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
voicebox/
|
||||
├── app/ # Shared React frontend
|
||||
├── tauri/ # Desktop app (Tauri + Rust)
|
||||
├── web/ # Web deployment
|
||||
├── backend/ # Python FastAPI server
|
||||
├── landing/ # Marketing website
|
||||
└── scripts/ # Build & release scripts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
1. Fork the repo
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Submit a PR
|
||||
|
||||
## Security
|
||||
|
||||
Found a security vulnerability? Please report it responsibly. See [SECURITY.md](SECURITY.md) for details.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
TBD
|
||||
MIT License — see [LICENSE](LICENSE) for details.
|
||||
|
||||
## Credits
|
||||
---
|
||||
|
||||
Built by analyzing and learning from:
|
||||
- voice (Rust CLI)
|
||||
- Voice-Clone-Studio
|
||||
- Qwen3-TTS_server
|
||||
- mimic
|
||||
- qwen3-tts-enhanced
|
||||
|
||||
Powered by Alibaba's Qwen3-TTS model.
|
||||
<p align="center">
|
||||
<a href="https://voicebox.sh">voicebox.sh</a>
|
||||
</p>
|
||||
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
We release patches for security vulnerabilities. Which versions are eligible for receiving such patches depends on the CVSS v3.0 Rating:
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 0.3.x | :white_check_mark: |
|
||||
| < 0.3 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you discover a security vulnerability, please report it responsibly:
|
||||
|
||||
1. **Do not** open a public GitHub issue
|
||||
2. Email security details to: [[email protected]](mailto:[email protected])
|
||||
3. Include:
|
||||
- Description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Potential impact
|
||||
- Suggested fix (if any)
|
||||
|
||||
We will:
|
||||
- Acknowledge receipt within 48 hours
|
||||
- Provide a timeline for addressing the issue
|
||||
- Keep you informed of progress
|
||||
- Credit you in the security advisory (if desired)
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### For Users
|
||||
|
||||
- **Keep Voicebox updated** - Updates include security patches
|
||||
- **Verify downloads** - Only download from official releases
|
||||
- **Local processing** - Voice data stays on your machine
|
||||
- **Network security** - Use HTTPS when connecting to remote servers
|
||||
|
||||
### For Developers
|
||||
|
||||
- **Dependencies** - Keep all dependencies up to date
|
||||
- **Code review** - All PRs require review before merging
|
||||
- **Secrets** - Never commit API keys or signing keys
|
||||
- **Signing** - All releases are cryptographically signed
|
||||
|
||||
## Known Security Considerations
|
||||
|
||||
### Local Processing
|
||||
|
||||
Voicebox processes all audio locally by default. Your voice data never leaves your machine unless you explicitly enable remote server mode.
|
||||
|
||||
### Remote Server Mode
|
||||
|
||||
When connecting to a remote server:
|
||||
- Ensure the server is on a trusted network
|
||||
- Use HTTPS for remote connections
|
||||
- Verify server identity before connecting
|
||||
|
||||
### Auto-Updates
|
||||
|
||||
- Updates are cryptographically signed
|
||||
- Signature verification happens before installation
|
||||
- Only HTTPS endpoints are allowed
|
||||
|
||||
### Python Server
|
||||
|
||||
The embedded Python server:
|
||||
- Runs locally by default (localhost only)
|
||||
- Can be configured for remote access
|
||||
- Uses standard FastAPI security practices
|
||||
|
||||
## Disclosure Timeline
|
||||
|
||||
- **Day 0**: Vulnerability reported
|
||||
- **Day 1-2**: Initial assessment and acknowledgment
|
||||
- **Day 3-7**: Investigation and fix development
|
||||
- **Day 8-14**: Testing and release preparation
|
||||
- **Day 15+**: Public disclosure (if applicable)
|
||||
|
||||
Timeline may vary based on severity and complexity.
|
||||
|
||||
## Security Updates
|
||||
|
||||
Security updates will be:
|
||||
- Released as patch versions (e.g., 0.3.2)
|
||||
- Documented in CHANGELOG.md
|
||||
- Announced via GitHub releases
|
||||
- Automatically delivered via auto-updater
|
||||
|
||||
---
|
||||
|
||||
Thank you for helping keep Voicebox secure! 🔒
|
||||
@@ -1,207 +0,0 @@
|
||||
# voicebox Setup Guide
|
||||
|
||||
Quick start guide for setting up the voicebox development environment.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Bun** - Fast JavaScript runtime and package manager
|
||||
```bash
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
```
|
||||
|
||||
- **Python 3.11+** - For backend development
|
||||
```bash
|
||||
python --version # Should be 3.11 or higher
|
||||
```
|
||||
|
||||
- **Rust** - For Tauri desktop app (installed automatically by Tauri CLI)
|
||||
```bash
|
||||
rustc --version # Check if installed
|
||||
```
|
||||
|
||||
- **Node.js 18+** (optional) - Fallback if Bun is not available
|
||||
|
||||
## Initial Setup
|
||||
|
||||
### 1. Install Dependencies
|
||||
|
||||
```bash
|
||||
# Install all workspace dependencies
|
||||
bun install
|
||||
```
|
||||
|
||||
This will install dependencies for:
|
||||
- `app/` - Shared React frontend
|
||||
- `tauri/` - Tauri desktop wrapper
|
||||
- `web/` - Web deployment wrapper
|
||||
|
||||
### 2. Setup Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Create virtual environment
|
||||
python -m venv venv
|
||||
|
||||
# Activate virtual environment
|
||||
source venv/bin/activate # On macOS/Linux
|
||||
# or
|
||||
venv\Scripts\activate # On Windows
|
||||
|
||||
# Install Python dependencies
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 3. Initialize Database
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python -c "from database import init_db; init_db()"
|
||||
```
|
||||
|
||||
This creates the SQLite database at `data/voicebox.db`.
|
||||
|
||||
### 4. Install Qwen3-TTS (Optional)
|
||||
|
||||
The Qwen3-TTS models are automatically downloaded from HuggingFace Hub on first use. However, you need to install the `qwen_tts` package:
|
||||
|
||||
```bash
|
||||
pip install git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||
```
|
||||
|
||||
**Note:** Models (~2-4GB) will be automatically downloaded on first generation. This may take a few minutes depending on your internet connection.
|
||||
|
||||
## Development
|
||||
|
||||
### Start Backend Server
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
source venv/bin/activate # Activate venv if not already active
|
||||
uvicorn main:app --reload --port 8000
|
||||
```
|
||||
|
||||
Backend will be available at `http://localhost:8000`
|
||||
|
||||
### Start Tauri Desktop App
|
||||
|
||||
```bash
|
||||
# From project root
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Or manually:
|
||||
```bash
|
||||
cd tauri
|
||||
bun run tauri dev
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Start Vite dev server on port 5173
|
||||
2. Launch Tauri window pointing to localhost:5173
|
||||
3. Enable hot reload
|
||||
|
||||
### Start Web App
|
||||
|
||||
```bash
|
||||
# From project root
|
||||
bun run dev:web
|
||||
```
|
||||
|
||||
Or manually:
|
||||
```bash
|
||||
cd web
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Web app will be available at `http://localhost:5174` (or next available port)
|
||||
|
||||
## Building
|
||||
|
||||
### Build Python Server Binary
|
||||
|
||||
```bash
|
||||
./scripts/build-server.sh
|
||||
```
|
||||
|
||||
This creates a platform-specific binary in `tauri/src-tauri/binaries/`
|
||||
|
||||
### Build Tauri Desktop App
|
||||
|
||||
```bash
|
||||
cd tauri
|
||||
bun run tauri build
|
||||
```
|
||||
|
||||
Creates platform-specific installers:
|
||||
- macOS: `.app`, `.dmg`
|
||||
- Windows: `.exe`, `.msi`
|
||||
- Linux: `.deb`, `.AppImage`
|
||||
|
||||
### Build Web App
|
||||
|
||||
```bash
|
||||
cd web
|
||||
bun run build
|
||||
```
|
||||
|
||||
Output in `web/dist/`
|
||||
|
||||
## Generate OpenAPI Client
|
||||
|
||||
After starting the backend server:
|
||||
|
||||
```bash
|
||||
./scripts/generate-api.sh
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Download OpenAPI schema from backend
|
||||
2. Generate TypeScript client in `app/src/lib/api/`
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
voicebox/
|
||||
├── app/ # Shared React frontend
|
||||
├── tauri/ # Tauri desktop wrapper
|
||||
├── web/ # Web deployment wrapper
|
||||
├── backend/ # Python FastAPI server
|
||||
├── scripts/ # Build and utility scripts
|
||||
├── data/ # User data (gitignored)
|
||||
└── docs/ # Documentation
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Backend won't start
|
||||
- Check Python version: `python --version` (needs 3.11+)
|
||||
- Ensure virtual environment is activated
|
||||
- Install dependencies: `pip install -r requirements.txt`
|
||||
|
||||
### Tauri build fails
|
||||
- Ensure Rust is installed: `rustc --version`
|
||||
- Install Tauri CLI: `bunx @tauri-apps/cli install`
|
||||
- Check `tauri/src-tauri/Cargo.toml` for correct dependencies
|
||||
|
||||
### OpenAPI client generation fails
|
||||
- Ensure backend is running on port 8000
|
||||
- Check `curl http://localhost:8000/openapi.json` returns valid JSON
|
||||
- Install openapi-typescript-codegen: `bun add -d openapi-typescript-codegen`
|
||||
|
||||
## Model Downloads
|
||||
|
||||
Models are automatically downloaded from HuggingFace Hub on first use:
|
||||
- **Whisper** (transcription): Auto-downloads on first transcription
|
||||
- **Qwen3-TTS** (voice cloning): Auto-downloads on first generation
|
||||
|
||||
First-time usage will be slower due to model downloads, but subsequent runs will use cached models.
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. ✅ TTS model loading implemented in `backend/tts.py`
|
||||
2. ✅ API routes implemented in `backend/main.py`
|
||||
3. Build React components in `app/src/components/`
|
||||
4. Connect frontend to backend via generated API client
|
||||
|
||||
See [README.md](./README.md) for architecture details and [docs/](./docs/) for detailed documentation.
|
||||
+17
-1
@@ -1,10 +1,26 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>voicebox</title>
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var theme = 'system';
|
||||
var raw = localStorage.getItem('voicebox-ui');
|
||||
if (raw) {
|
||||
var parsed = JSON.parse(raw);
|
||||
if (parsed && parsed.state && parsed.state.theme) theme = parsed.state.theme;
|
||||
}
|
||||
var resolved = theme === 'system'
|
||||
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
||||
: theme;
|
||||
if (resolved === 'dark') document.documentElement.classList.add('dark');
|
||||
} catch (_) {}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+13
-1
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "@voicebox/app",
|
||||
"version": "0.1.0",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"preview": "vite preview",
|
||||
"lint": "biome lint src",
|
||||
"lint:fix": "biome lint --write src",
|
||||
@@ -13,6 +14,9 @@
|
||||
"check": "biome check --write src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||
"@radix-ui/react-avatar": "^1.1.0",
|
||||
@@ -30,17 +34,25 @@
|
||||
"@radix-ui/react-toast": "^1.2.1",
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"@tanstack/react-query-devtools": "^5.0.0",
|
||||
"@tanstack/react-router": "^1.157.16",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||
"@tauri-apps/plugin-fs": "^2.0.0",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.9.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"framer-motion": "^12.29.0",
|
||||
"i18next": "^26.0.6",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^0.454.0",
|
||||
"motion": "^12.29.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"react-hook-form": "^7.53.0",
|
||||
"react-i18next": "^17.0.4",
|
||||
"react-sound-visualizer": "^1.4.0",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"wavesurfer.js": "^7.0.0",
|
||||
"zod": "^3.23.8",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { Plugin } from 'vite';
|
||||
|
||||
/** Vite plugin that exposes CHANGELOG.md as `virtual:changelog`. */
|
||||
export function changelogPlugin(repoRoot: string): Plugin {
|
||||
const virtualId = 'virtual:changelog';
|
||||
const resolvedId = '\0' + virtualId;
|
||||
const changelogPath = path.resolve(repoRoot, 'CHANGELOG.md');
|
||||
|
||||
return {
|
||||
name: 'changelog',
|
||||
resolveId(id) {
|
||||
if (id === virtualId) return resolvedId;
|
||||
},
|
||||
load(id) {
|
||||
if (id === resolvedId) {
|
||||
const raw = readFileSync(changelogPath, 'utf-8');
|
||||
return `export default ${JSON.stringify(raw)};`;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
+253
-85
@@ -1,33 +1,151 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { GenerationForm } from '@/components/Generation/GenerationForm';
|
||||
import { HistoryTable } from '@/components/History/HistoryTable';
|
||||
import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm';
|
||||
import { ServerStatus } from '@/components/ServerSettings/ServerStatus';
|
||||
import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus';
|
||||
import { ModelManagement } from '@/components/ServerSettings/ModelManagement';
|
||||
import { Toaster } from '@/components/ui/toaster';
|
||||
import { ProfileList } from '@/components/VoiceProfiles/ProfileList';
|
||||
import { Sidebar } from '@/components/Sidebar';
|
||||
import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer';
|
||||
import { UpdateNotification } from '@/components/UpdateNotification';
|
||||
import { isTauri, startServer, setupWindowCloseHandler } from '@/lib/tauri';
|
||||
import { RouterProvider } from '@tanstack/react-router';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
||||
import { DictateWindow } from '@/components/DictateWindow/DictateWindow';
|
||||
import ShinyText from '@/components/ShinyText';
|
||||
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
|
||||
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
|
||||
import { useThemeSync } from '@/hooks/useThemeSync';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { HealthResponse } from '@/lib/api/types';
|
||||
import { useChordSync } from '@/lib/hooks/useChordSync';
|
||||
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { router } from '@/router';
|
||||
import { useLogStore } from '@/stores/logStore';
|
||||
import {
|
||||
getDefaultServerUrl,
|
||||
isLoopbackVoiceboxServerUrl,
|
||||
useServerStore,
|
||||
} from '@/stores/serverStore';
|
||||
|
||||
// Track if server is starting to prevent duplicate starts
|
||||
let serverStarting = false;
|
||||
function isDictateView(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return new URLSearchParams(window.location.search).get('view') === 'dictate';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a health response has the expected Voicebox-specific shape.
|
||||
* Prevents misidentifying an unrelated service on the same port.
|
||||
*/
|
||||
function isVoiceboxHealthResponse(health: HealthResponse): boolean {
|
||||
return (
|
||||
health?.status === 'healthy' &&
|
||||
typeof health.model_loaded === 'boolean' &&
|
||||
typeof health.gpu_available === 'boolean'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a startup error indicates the port is occupied by an external
|
||||
* server (which we should try to reuse via health-check polling) vs. a real
|
||||
* failure (missing sidecar, signing issue, etc.) that should surface immediately.
|
||||
*/
|
||||
function isPortInUseError(error: unknown): boolean {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
return (
|
||||
msg.includes('already in use') ||
|
||||
msg.includes('port') ||
|
||||
msg.includes('EADDRINUSE') ||
|
||||
msg.includes('address already in use')
|
||||
);
|
||||
}
|
||||
|
||||
const LOADING_MESSAGES = [
|
||||
'Warming up tensors...',
|
||||
'Calibrating synthesizer engine...',
|
||||
'Initializing voice models...',
|
||||
'Loading neural networks...',
|
||||
'Preparing audio pipelines...',
|
||||
'Optimizing waveform generators...',
|
||||
'Tuning frequency analyzers...',
|
||||
'Building voice embeddings...',
|
||||
'Configuring text-to-speech cores...',
|
||||
'Syncing audio buffers...',
|
||||
'Establishing model connections...',
|
||||
'Preprocessing training data...',
|
||||
'Validating voice samples...',
|
||||
'Compiling inference engines...',
|
||||
'Mapping phoneme sequences...',
|
||||
'Aligning prosody parameters...',
|
||||
'Activating speech synthesis...',
|
||||
'Fine-tuning acoustic models...',
|
||||
'Preparing voice cloning matrices...',
|
||||
'Initializing Qwen TTS framework...',
|
||||
];
|
||||
|
||||
function App() {
|
||||
const [activeTab, setActiveTab] = useState('main');
|
||||
useThemeSync();
|
||||
|
||||
// The dictate window runs in a separate Tauri webview that must skip
|
||||
// server bootstrap (the main window owns that lifecycle) and render only
|
||||
// the floating recording surface. Split into a sibling component so the
|
||||
// main app's hooks are not called on the dictate path.
|
||||
if (isDictateView()) {
|
||||
return <DictateWindow />;
|
||||
}
|
||||
return <MainApp />;
|
||||
}
|
||||
|
||||
function MainApp() {
|
||||
const platform = usePlatform();
|
||||
const [serverReady, setServerReady] = useState(false);
|
||||
const [startupError, setStartupError] = useState<string | null>(null);
|
||||
const [loadingMessageIndex, setLoadingMessageIndex] = useState(0);
|
||||
const serverStartingRef = useRef(false);
|
||||
|
||||
// Automatically check for app updates on startup and show toast notifications
|
||||
useAutoUpdater({ checkOnMount: true, showToast: true });
|
||||
|
||||
// Replay the saved chord into the Rust hotkey listener every time
|
||||
// capture_settings resolves or the user edits the chord.
|
||||
useChordSync();
|
||||
|
||||
// Sync stored setting to Rust on startup
|
||||
useEffect(() => {
|
||||
if (platform.metadata.isTauri) {
|
||||
const keepRunning = useServerStore.getState().keepServerRunningOnClose;
|
||||
platform.lifecycle.setKeepServerRunning(keepRunning).catch((error) => {
|
||||
console.error('Failed to sync initial setting to Rust:', error);
|
||||
});
|
||||
}
|
||||
// Empty dependency array - platform is stable from context, only run once
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.metadata.isTauri, platform.lifecycle]);
|
||||
|
||||
// Setup lifecycle callbacks
|
||||
useEffect(() => {
|
||||
platform.lifecycle.onServerReady = () => {
|
||||
setServerReady(true);
|
||||
};
|
||||
// Empty dependency array - platform is stable from context, only run once
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.lifecycle]);
|
||||
|
||||
// Subscribe to server logs
|
||||
useEffect(() => {
|
||||
const unsubscribe = platform.lifecycle.subscribeToServerLogs((entry) => {
|
||||
useLogStore.getState().addEntry(entry);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [platform.lifecycle]);
|
||||
|
||||
// Setup window close handler and auto-start server when running in Tauri (production only)
|
||||
useEffect(() => {
|
||||
if (!isTauri()) {
|
||||
if (!platform.metadata.isTauri) {
|
||||
const serverUrl = getDefaultServerUrl();
|
||||
const currentServerUrl = useServerStore.getState().serverUrl;
|
||||
if (currentServerUrl !== serverUrl && isLoopbackVoiceboxServerUrl(currentServerUrl)) {
|
||||
useServerStore.getState().setServerUrl(serverUrl);
|
||||
}
|
||||
setServerReady(true); // Web assumes server is running
|
||||
return;
|
||||
}
|
||||
|
||||
// Setup window close handler to check setting and stop server if needed
|
||||
// This works in both dev and prod, but will only stop server if it was started by the app
|
||||
setupWindowCloseHandler().catch((error) => {
|
||||
platform.lifecycle.setupWindowCloseHandler().catch((error) => {
|
||||
console.error('Failed to setup window close handler:', error);
|
||||
});
|
||||
|
||||
@@ -37,104 +155,154 @@ function App() {
|
||||
console.log('Dev mode: Skipping auto-start of server (run it separately)');
|
||||
setServerReady(true); // Mark as ready so UI doesn't show loading screen
|
||||
// Mark that server was not started by app (so we don't try to stop it on close)
|
||||
// @ts-expect-error - adding property to window
|
||||
window.__voiceboxServerStartedByApp = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-start server in production
|
||||
if (serverStarting) {
|
||||
if (serverStartingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
serverStarting = true;
|
||||
console.log('Production mode: Starting bundled server...');
|
||||
serverStartingRef.current = true;
|
||||
const isRemote = useServerStore.getState().mode === 'remote';
|
||||
const customModelsDir = useServerStore.getState().customModelsDir;
|
||||
console.log(`Production mode: Starting bundled server... (remote: ${isRemote})`);
|
||||
|
||||
startServer(false)
|
||||
.then(() => {
|
||||
console.log('Server is ready');
|
||||
platform.lifecycle
|
||||
.startServer(isRemote, customModelsDir)
|
||||
.then((serverUrl) => {
|
||||
console.log('Server is ready at:', serverUrl);
|
||||
// Update the server URL in the store with the dynamically assigned port
|
||||
useServerStore.getState().setServerUrl(serverUrl);
|
||||
setServerReady(true);
|
||||
// Mark that we started the server (so we know to stop it on close)
|
||||
// @ts-expect-error - adding property to window
|
||||
window.__voiceboxServerStartedByApp = true;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to auto-start server:', error);
|
||||
serverStarting = false;
|
||||
// @ts-expect-error - adding property to window
|
||||
serverStartingRef.current = false;
|
||||
window.__voiceboxServerStartedByApp = false;
|
||||
|
||||
// Only fall back to health-check polling when the error indicates the
|
||||
// port is occupied (likely an external server). For real failures
|
||||
// (missing sidecar, signing issues, etc.) surface the error immediately.
|
||||
if (!isPortInUseError(error)) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.error('Real startup failure — not polling:', msg);
|
||||
setStartupError(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to polling: the server may already be running externally
|
||||
// (e.g. started via python/uvicorn/Docker). Poll the health endpoint
|
||||
// until it responds with a valid Voicebox payload, then transition to
|
||||
// the main UI.
|
||||
console.log('Falling back to health-check polling...');
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const health = await apiClient.getHealth();
|
||||
if (!isVoiceboxHealthResponse(health)) {
|
||||
console.log('Health response is not from a Voicebox server, keep polling...');
|
||||
return;
|
||||
}
|
||||
console.log('External Voicebox server detected via health check');
|
||||
clearInterval(pollInterval);
|
||||
setServerReady(true);
|
||||
} catch {
|
||||
// Server not ready yet, keep polling
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
// Stop polling after 2 minutes and surface the failure
|
||||
setTimeout(() => {
|
||||
clearInterval(pollInterval);
|
||||
serverStartingRef.current = false;
|
||||
setStartupError(
|
||||
'Could not connect to a Voicebox server within 2 minutes. ' +
|
||||
'Please check that the server is running and try again.',
|
||||
);
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
// Cleanup: stop server on actual unmount (not StrictMode remount)
|
||||
// Note: Window close is handled separately in Tauri Rust code
|
||||
return () => {
|
||||
// Window close event handles server shutdown based on setting
|
||||
serverStarting = false;
|
||||
serverStartingRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
// Empty dependency array - platform is stable from context, only run once
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platform.metadata.isTauri, platform.lifecycle]);
|
||||
|
||||
// Cycle through loading messages every 3 seconds
|
||||
useEffect(() => {
|
||||
if (!platform.metadata.isTauri || serverReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setLoadingMessageIndex((prev) => (prev + 1) % LOADING_MESSAGES.length);
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [serverReady, platform.metadata.isTauri]);
|
||||
|
||||
// Show loading screen while server is starting in Tauri
|
||||
if (isTauri() && !serverReady) {
|
||||
if (platform.metadata.isTauri && !serverReady) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
|
||||
<p className="text-muted-foreground">Starting server...</p>
|
||||
<div
|
||||
className={cn(
|
||||
'min-h-screen bg-background flex items-center justify-center',
|
||||
TOP_SAFE_AREA_PADDING,
|
||||
)}
|
||||
>
|
||||
<TitleBarDragRegion />
|
||||
<div className="text-center space-y-6">
|
||||
<div className="flex justify-center relative">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-48 h-48 rounded-full bg-accent/20 blur-3xl" />
|
||||
</div>
|
||||
<img
|
||||
src={voiceboxLogo}
|
||||
alt="Voicebox"
|
||||
className="w-48 h-48 object-contain animate-fade-in-scale relative z-10"
|
||||
/>
|
||||
</div>
|
||||
{startupError ? (
|
||||
<div className="animate-fade-in-delayed max-w-md mx-auto space-y-3">
|
||||
<p className="text-lg font-medium text-destructive">Server startup failed</p>
|
||||
<p className="text-sm text-muted-foreground">{startupError}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
onClick={() => {
|
||||
setStartupError(null);
|
||||
serverStartingRef.current = false;
|
||||
// Trigger a re-mount of the effect by toggling state
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="animate-fade-in-delayed">
|
||||
<ShinyText
|
||||
text={LOADING_MESSAGES[loadingMessageIndex]}
|
||||
className="text-lg font-medium text-muted-foreground"
|
||||
speed={2}
|
||||
color="hsl(var(--muted-foreground))"
|
||||
shineColor="hsl(var(--foreground))"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-background flex flex-col overflow-hidden">
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
<Sidebar activeTab={activeTab} onTabChange={setActiveTab} />
|
||||
|
||||
<main className="flex-1 ml-20 overflow-hidden flex flex-col">
|
||||
<div className="container mx-auto px-8 py-8 max-w-[1800px] h-full overflow-hidden flex flex-col">
|
||||
<UpdateNotification />
|
||||
|
||||
{activeTab === 'settings' ? (
|
||||
<div className="space-y-4 overflow-y-auto">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<ConnectionForm />
|
||||
<ServerStatus />
|
||||
</div>
|
||||
{isTauri() && <UpdateStatus />}
|
||||
<ModelManagement />
|
||||
</div>
|
||||
) : (
|
||||
// Main view: Profiles top left, Generator bottom left, History right
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-full min-h-0 overflow-hidden">
|
||||
{/* Left Column */}
|
||||
<div className="flex flex-col gap-6 min-h-0 overflow-y-auto pb-32">
|
||||
{/* Profiles - Top Left */}
|
||||
<div className="shrink-0 flex flex-col">
|
||||
<ProfileList />
|
||||
</div>
|
||||
|
||||
{/* Generator - Bottom Left */}
|
||||
<div className="shrink-0">
|
||||
<GenerationForm />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column - History */}
|
||||
<div className="flex flex-col min-h-0 overflow-hidden">
|
||||
<HistoryTable />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Audio Player - always visible except on settings */}
|
||||
{activeTab !== 'settings' && <AudioPlayer />}
|
||||
|
||||
<Toaster />
|
||||
</div>
|
||||
);
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 1180 320" xmlns="http://www.w3.org/2000/svg"><path d="m367.44 153.84c0 52.32 33.6 88.8 80.16 88.8s80.16-36.48 80.16-88.8-33.6-88.8-80.16-88.8-80.16 36.48-80.16 88.8zm129.6 0c0 37.44-20.4 61.68-49.44 61.68s-49.44-24.24-49.44-61.68 20.4-61.68 49.44-61.68 49.44 24.24 49.44 61.68z"/><path d="m614.27 242.64c35.28 0 55.44-29.76 55.44-65.52s-20.16-65.52-55.44-65.52c-16.32 0-28.32 6.48-36.24 15.84v-13.44h-28.8v169.2h28.8v-56.4c7.92 9.36 19.92 15.84 36.24 15.84zm-36.96-69.12c0-23.76 13.44-36.72 31.2-36.72 20.88 0 32.16 16.32 32.16 40.32s-11.28 40.32-32.16 40.32c-17.76 0-31.2-13.2-31.2-36.48z"/><path d="m747.65 242.64c25.2 0 45.12-13.2 54-35.28l-24.72-9.36c-3.84 12.96-15.12 20.16-29.28 20.16-18.48 0-31.44-13.2-33.6-34.8h88.32v-9.6c0-34.56-19.44-62.16-55.92-62.16s-60 28.56-60 65.52c0 38.88 25.2 65.52 61.2 65.52zm-1.44-106.8c18.24 0 26.88 12 27.12 25.92h-57.84c4.32-17.04 15.84-25.92 30.72-25.92z"/><path d="m823.98 240h28.8v-73.92c0-18 13.2-27.6 26.16-27.6 15.84 0 22.08 11.28 22.08 26.88v74.64h28.8v-83.04c0-27.12-15.84-45.36-42.24-45.36-16.32 0-27.6 7.44-34.8 15.84v-13.44h-28.8z"/><path d="m1014.17 67.68-65.28 172.32h30.48l14.64-39.36h74.4l14.88 39.36h30.96l-65.28-172.32zm16.8 34.08 27.36 72h-54.24z"/><path d="m1163.69 68.18h-30.72v172.32h30.72z"/><path d="m297.06 130.97c7.26-21.79 4.76-45.66-6.85-65.48-17.46-30.4-52.56-46.04-86.84-38.68-15.25-17.18-37.16-26.95-60.13-26.81-35.04-.08-66.13 22.48-76.91 55.82-22.51 4.61-41.94 18.7-53.31 38.67-17.59 30.32-13.58 68.54 9.92 94.54-7.26 21.79-4.76 45.66 6.85 65.48 17.46 30.4 52.56 46.04 86.84 38.68 15.24 17.18 37.16 26.95 60.13 26.8 35.06.09 66.16-22.49 76.94-55.86 22.51-4.61 41.94-18.7 53.31-38.67 17.57-30.32 13.55-68.51-9.94-94.51zm-120.28 168.11c-14.03.02-27.62-4.89-38.39-13.88.49-.26 1.34-.73 1.89-1.07l63.72-36.8c3.26-1.85 5.26-5.32 5.24-9.07v-89.83l26.93 15.55c.29.14.48.42.52.74v74.39c-.04 33.08-26.83 59.9-59.91 59.97zm-128.84-55.03c-7.03-12.14-9.56-26.37-7.15-40.18.47.28 1.3.79 1.89 1.13l63.72 36.8c3.23 1.89 7.23 1.89 10.47 0l77.79-44.92v31.1c.02.32-.13.63-.38.83l-64.41 37.19c-28.69 16.52-65.33 6.7-81.92-21.95zm-16.77-139.09c7-12.16 18.05-21.46 31.21-26.29 0 .55-.03 1.52-.03 2.2v73.61c-.02 3.74 1.98 7.21 5.23 9.06l77.79 44.91-26.93 15.55c-.27.18-.61.21-.91.08l-64.42-37.22c-28.63-16.58-38.45-53.21-21.95-81.89zm221.26 51.49-77.79-44.92 26.93-15.54c.27-.18.61-.21.91-.08l64.42 37.19c28.68 16.57 38.51 53.26 21.94 81.94-7.01 12.14-18.05 21.44-31.2 26.28v-75.81c.03-3.74-1.96-7.2-5.2-9.06zm26.8-40.34c-.47-.29-1.3-.79-1.89-1.13l-63.72-36.8c-3.23-1.89-7.23-1.89-10.47 0l-77.79 44.92v-31.1c-.02-.32.13-.63.38-.83l64.41-37.16c28.69-16.55 65.37-6.7 81.91 22 6.99 12.12 9.52 26.31 7.15 40.1zm-168.51 55.43-26.94-15.55c-.29-.14-.48-.42-.52-.74v-74.39c.02-33.12 26.89-59.96 60.01-59.94 14.01 0 27.57 4.92 38.34 13.88-.49.26-1.33.73-1.89 1.07l-63.72 36.8c-3.26 1.85-5.26 5.31-5.24 9.06l-.04 89.79zm14.63-31.54 34.65-20.01 34.65 20v40.01l-34.65 20-34.65-20z"/></svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,126 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
import { AlertTriangle, ExternalLink } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
/**
|
||||
* Tracks macOS Accessibility permission state. Without this permission the
|
||||
* global chord can still record, but the synthetic-⌘V paste silently drops —
|
||||
* so callers can surface an inline prompt instead of relying on the
|
||||
* system-level permission dialog (which only fires once, the first time the
|
||||
* app tries to post a keystroke).
|
||||
*
|
||||
* Triggered on three signals:
|
||||
* - app mount in Tauri
|
||||
* - `system:accessibility-missing` event from the dictate window's paste
|
||||
* failure handler
|
||||
* - window focus (cheap way to re-check after the user flips the toggle in
|
||||
* System Settings and alt-tabs back)
|
||||
*/
|
||||
export function useAccessibilityPermission() {
|
||||
const platform = usePlatform();
|
||||
const [needsPermission, setNeedsPermission] = useState(false);
|
||||
const [checking, setChecking] = useState(false);
|
||||
|
||||
const recheck = useCallback(async (): Promise<boolean> => {
|
||||
if (!platform.metadata.isTauri) return true;
|
||||
setChecking(true);
|
||||
try {
|
||||
const trusted = await invoke<boolean>('check_accessibility_permission');
|
||||
setNeedsPermission(!trusted);
|
||||
return trusted;
|
||||
} catch (err) {
|
||||
console.warn('[accessibility] check failed:', err);
|
||||
return false;
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}, [platform.metadata.isTauri]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!platform.metadata.isTauri) return;
|
||||
recheck();
|
||||
const onFocus = () => {
|
||||
recheck();
|
||||
};
|
||||
window.addEventListener('focus', onFocus);
|
||||
return () => window.removeEventListener('focus', onFocus);
|
||||
}, [platform.metadata.isTauri, recheck]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!platform.metadata.isTauri) return;
|
||||
let unlisten: UnlistenFn | null = null;
|
||||
listen('system:accessibility-missing', () => {
|
||||
setNeedsPermission(true);
|
||||
})
|
||||
.then((fn) => {
|
||||
unlisten = fn;
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
if (unlisten) unlisten();
|
||||
};
|
||||
}, [platform.metadata.isTauri]);
|
||||
|
||||
const openSettings = useCallback(async () => {
|
||||
try {
|
||||
await invoke('open_accessibility_settings');
|
||||
} catch (err) {
|
||||
console.warn('[accessibility] open settings failed:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { needsPermission, checking, recheck, openSettings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline notice rendered next to the auto-paste setting when macOS
|
||||
* Accessibility permission is missing. Returns null when the permission is
|
||||
* already granted.
|
||||
*/
|
||||
export function AccessibilityNotice() {
|
||||
const { t } = useTranslation();
|
||||
const { needsPermission, checking, recheck, openSettings } = useAccessibilityPermission();
|
||||
const [stillMissing, setStillMissing] = useState(false);
|
||||
|
||||
const handleRecheck = useCallback(async () => {
|
||||
setStillMissing(false);
|
||||
const trusted = await recheck();
|
||||
if (!trusted) setStillMissing(true);
|
||||
}, [recheck]);
|
||||
|
||||
if (!needsPermission) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3.5 py-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5 text-amber-500" />
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t('captures.permissions.accessibility.title')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
<Trans i18nKey="captures.permissions.accessibility.body" components={{ path: <span /> }} />
|
||||
</p>
|
||||
<div className="flex items-center gap-2 pt-1.5">
|
||||
<Button size="sm" onClick={openSettings} className="gap-1.5">
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
{t('captures.permissions.accessibility.openSettings')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleRecheck} disabled={checking}>
|
||||
{checking ? t('captures.permissions.accessibility.rechecking') : t('captures.permissions.accessibility.recheck')}
|
||||
</Button>
|
||||
</div>
|
||||
{stillMissing && !checking && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400 pt-1">
|
||||
{t('captures.permissions.accessibility.stillMissing')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useRouterState } from '@tanstack/react-router';
|
||||
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
|
||||
import { AudioKeepAlive } from '@/components/AudioPlayer/AudioKeepAlive';
|
||||
import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer';
|
||||
import { StoryTrackEditor } from '@/components/StoriesTab/StoryTrackEditor';
|
||||
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { useStoryStore } from '@/stores/storyStore';
|
||||
import { useStory } from '@/lib/hooks/useStories';
|
||||
|
||||
interface AppFrameProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AppFrame({ children }: AppFrameProps) {
|
||||
const routerState = useRouterState();
|
||||
const isStoriesRoute = routerState.location.pathname === '/stories';
|
||||
|
||||
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||
const { data: story } = useStory(selectedStoryId);
|
||||
|
||||
// Show track editor when on stories route with a selected story that has items
|
||||
const showTrackEditor = isStoriesRoute && selectedStoryId && story && story.items.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('h-screen bg-background flex flex-col overflow-hidden', TOP_SAFE_AREA_PADDING)}
|
||||
>
|
||||
<TitleBarDragRegion />
|
||||
<AudioKeepAlive />
|
||||
{children}
|
||||
{showTrackEditor ? (
|
||||
<StoryTrackEditor storyId={story.id} items={story.items} />
|
||||
) : (
|
||||
<AudioPlayer />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
export type AudioBarsMode = 'idle' | 'generating' | 'playing';
|
||||
|
||||
interface AudioBarsProps {
|
||||
mode: AudioBarsMode;
|
||||
className?: string;
|
||||
barClassName?: string;
|
||||
}
|
||||
|
||||
export function AudioBars({ mode, className, barClassName }: AudioBarsProps) {
|
||||
const activeColor = mode !== 'idle' ? 'bg-accent' : 'bg-muted-foreground/40';
|
||||
return (
|
||||
<div className={cn('flex items-center gap-[2px] h-5', className)}>
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<motion.div
|
||||
key={`${mode}-${i}`}
|
||||
className={cn('w-[3px] rounded-full', activeColor, barClassName)}
|
||||
animate={
|
||||
mode === 'generating'
|
||||
? { height: ['6px', '16px', '6px'] }
|
||||
: mode === 'playing'
|
||||
? { height: ['8px', '14px', '4px', '12px', '8px'] }
|
||||
: { height: '8px' }
|
||||
}
|
||||
transition={
|
||||
mode === 'generating'
|
||||
? { duration: 0.6, repeat: Infinity, delay: i * 0.08, ease: 'easeInOut' }
|
||||
: mode === 'playing'
|
||||
? { duration: 1.2, repeat: Infinity, delay: i * 0.15, ease: 'easeInOut' }
|
||||
: { duration: 0.4, ease: 'easeOut' }
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { debug } from '@/lib/utils/debug';
|
||||
|
||||
// WKWebView tears down the app's CoreAudio output when idle for long enough,
|
||||
// and a JS-level reload (cmd+R) does NOT restore it — only relaunching the
|
||||
// Tauri app does. Keeping a silent <audio> element looping forever prevents
|
||||
// the OS audio session from ever going dormant.
|
||||
//
|
||||
// Real silence (zero PCM samples) at full volume is preferred over a muted
|
||||
// element: browsers/WebKit can optimize muted media away, which defeats the
|
||||
// purpose of holding the session open.
|
||||
|
||||
function buildSilentWavUrl(seconds = 1, sampleRate = 8000): string {
|
||||
const numSamples = seconds * sampleRate;
|
||||
const bytes = 44 + numSamples * 2;
|
||||
const buffer = new ArrayBuffer(bytes);
|
||||
const view = new DataView(buffer);
|
||||
const write = (offset: number, str: string) => {
|
||||
for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i));
|
||||
};
|
||||
write(0, 'RIFF');
|
||||
view.setUint32(4, bytes - 8, true);
|
||||
write(8, 'WAVE');
|
||||
write(12, 'fmt ');
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true);
|
||||
view.setUint16(22, 1, true);
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * 2, true);
|
||||
view.setUint16(32, 2, true);
|
||||
view.setUint16(34, 16, true);
|
||||
write(36, 'data');
|
||||
view.setUint32(40, numSamples * 2, true);
|
||||
return URL.createObjectURL(new Blob([buffer], { type: 'audio/wav' }));
|
||||
}
|
||||
|
||||
export function AudioKeepAlive() {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const url = buildSilentWavUrl(1, 8000);
|
||||
const el = new Audio(url);
|
||||
el.loop = true;
|
||||
el.volume = 1;
|
||||
el.preload = 'auto';
|
||||
audioRef.current = el;
|
||||
|
||||
const tryPlay = () => {
|
||||
if (!audioRef.current) return;
|
||||
if (!audioRef.current.paused) return;
|
||||
audioRef.current.play().catch((err) => {
|
||||
debug.log('[AudioKeepAlive] play blocked (will retry on next gesture):', err);
|
||||
});
|
||||
};
|
||||
|
||||
tryPlay();
|
||||
|
||||
// Autoplay may be blocked until first user interaction — re-attempt then.
|
||||
const onGesture = () => tryPlay();
|
||||
window.addEventListener('pointerdown', onGesture, { once: false });
|
||||
window.addEventListener('keydown', onGesture, { once: false });
|
||||
|
||||
// If the webview ever pauses the element on background, resume on return.
|
||||
const onWake = () => {
|
||||
if (!document.hidden) tryPlay();
|
||||
};
|
||||
document.addEventListener('visibilitychange', onWake);
|
||||
window.addEventListener('focus', onWake);
|
||||
window.addEventListener('pageshow', onWake);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointerdown', onGesture);
|
||||
window.removeEventListener('keydown', onGesture);
|
||||
document.removeEventListener('visibilitychange', onWake);
|
||||
window.removeEventListener('focus', onWake);
|
||||
window.removeEventListener('pageshow', onWake);
|
||||
el.pause();
|
||||
el.src = '';
|
||||
URL.revokeObjectURL(url);
|
||||
audioRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,57 +1,92 @@
|
||||
import { Pause, Play, Repeat, Volume2, VolumeX } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Pause, Play, Repeat, Volume2, VolumeX, X } from 'lucide-react';
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import WaveSurfer from 'wavesurfer.js';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||
import { debug } from '@/lib/utils/debug';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
|
||||
export function AudioPlayer() {
|
||||
const platform = usePlatform();
|
||||
const volumeLabelId = useId();
|
||||
const {
|
||||
audioUrl,
|
||||
title,
|
||||
audioId,
|
||||
profileId,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
volume,
|
||||
isLooping,
|
||||
shouldRestart,
|
||||
setIsPlaying,
|
||||
setCurrentTime,
|
||||
setDuration,
|
||||
setVolume,
|
||||
toggleLoop,
|
||||
clearRestartFlag,
|
||||
reset,
|
||||
} = usePlayerStore();
|
||||
|
||||
// Check if profile has assigned channels (for native audio routing)
|
||||
const { data: profileChannels } = useQuery({
|
||||
queryKey: ['profile-channels', profileId],
|
||||
queryFn: () => {
|
||||
if (!profileId) return { channel_ids: [] };
|
||||
return apiClient.getProfileChannels(profileId);
|
||||
},
|
||||
enabled: !!profileId && platform.metadata.isTauri,
|
||||
});
|
||||
|
||||
const { data: channels } = useQuery({
|
||||
queryKey: ['channels'],
|
||||
queryFn: () => apiClient.listChannels(),
|
||||
enabled: !!profileChannels && profileChannels.channel_ids.length > 0,
|
||||
});
|
||||
|
||||
// Determine if we should use native playback
|
||||
const useNativePlayback = useMemo(() => {
|
||||
if (!platform.metadata.isTauri || !profileChannels || !channels) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const assignedChannels = channels.filter((ch) => profileChannels.channel_ids.includes(ch.id));
|
||||
|
||||
// Use native playback if any assigned channel has non-default devices
|
||||
const shouldUseNative = assignedChannels.some(
|
||||
(ch) => ch.device_ids.length > 0 && !ch.is_default,
|
||||
);
|
||||
|
||||
return shouldUseNative;
|
||||
}, [profileChannels, channels, platform.metadata.isTauri]);
|
||||
|
||||
const waveformRef = useRef<HTMLDivElement>(null);
|
||||
const wavesurferRef = useRef<WaveSurfer | null>(null);
|
||||
const loadingRef = useRef(false);
|
||||
const previousAudioIdRef = useRef<string | null>(null);
|
||||
const hasInitializedRef = useRef(false);
|
||||
const isUsingNativePlaybackRef = useRef(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [wsReady, setWsReady] = useState(false);
|
||||
|
||||
// Initialize WaveSurfer (only when audioUrl exists and container is ready)
|
||||
// Create WaveSurfer once when the player becomes visible (audioUrl is set).
|
||||
// This instance is reused for all subsequent audio loads - never destroyed until unmount.
|
||||
useEffect(() => {
|
||||
// Don't initialize if no audioUrl or already initialized
|
||||
if (!audioUrl) {
|
||||
return;
|
||||
}
|
||||
if (!audioUrl) return;
|
||||
if (wavesurferRef.current) return; // already created
|
||||
|
||||
if (wavesurferRef.current) {
|
||||
console.log('WaveSurfer already initialized, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Creating NEW WaveSurfer instance');
|
||||
|
||||
// Wait for container to be properly rendered
|
||||
const initWaveSurfer = () => {
|
||||
const container = waveformRef.current;
|
||||
if (!container) {
|
||||
// Container not ready yet, retry
|
||||
setTimeout(initWaveSurfer, 50);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if container has dimensions and is visible
|
||||
const rect = container.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(container);
|
||||
const isVisible =
|
||||
@@ -61,267 +96,221 @@ export function AudioPlayer() {
|
||||
style.visibility !== 'hidden';
|
||||
|
||||
if (!isVisible) {
|
||||
// Retry after a short delay
|
||||
setTimeout(initWaveSurfer, 50);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Initializing WaveSurfer...', {
|
||||
container,
|
||||
debug.log('Creating WaveSurfer instance', {
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
});
|
||||
|
||||
try {
|
||||
// Get computed CSS variable values
|
||||
const root = document.documentElement;
|
||||
const getCSSVar = (varName: string) => {
|
||||
const value = getComputedStyle(root).getPropertyValue(varName).trim();
|
||||
return value ? `hsl(${value})` : '';
|
||||
};
|
||||
|
||||
const waveColor = getCSSVar('--muted');
|
||||
const progressColor = getCSSVar('--accent');
|
||||
const cursorColor = getCSSVar('--accent');
|
||||
|
||||
const wavesurfer = WaveSurfer.create({
|
||||
container: container,
|
||||
waveColor: waveColor,
|
||||
progressColor: progressColor,
|
||||
cursorColor: cursorColor,
|
||||
container,
|
||||
waveColor: getCSSVar('--muted'),
|
||||
progressColor: getCSSVar('--accent'),
|
||||
cursorColor: getCSSVar('--accent'),
|
||||
cursorWidth: 3,
|
||||
barWidth: 2,
|
||||
barRadius: 2,
|
||||
height: 80,
|
||||
normalize: true,
|
||||
interact: true,
|
||||
dragToSeek: { debounceTime: 0 },
|
||||
mediaControls: false,
|
||||
backend: 'WebAudio',
|
||||
interact: true, // Enable interaction (click to seek)
|
||||
mediaControls: false, // Don't show native controls
|
||||
});
|
||||
|
||||
// Wire up event handlers (these persist for the lifetime of the instance)
|
||||
wavesurfer.on('timeupdate', (time) => {
|
||||
const dur = usePlayerStore.getState().duration;
|
||||
if (dur > 0 && time >= dur) {
|
||||
setCurrentTime(dur);
|
||||
const loop = usePlayerStore.getState().isLooping;
|
||||
if (loop) {
|
||||
wavesurfer.seekTo(0);
|
||||
wavesurfer.play().catch((err) => debug.error('Loop play failed:', err));
|
||||
} else {
|
||||
wavesurfer.pause();
|
||||
setIsPlaying(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setCurrentTime(time);
|
||||
});
|
||||
|
||||
wavesurfer.on('ready', () => {
|
||||
const dur = wavesurfer.getDuration();
|
||||
setDuration(dur);
|
||||
loadingRef.current = false;
|
||||
setIsLoading(false);
|
||||
setError(null);
|
||||
debug.log('Audio ready, duration:', dur);
|
||||
|
||||
wavesurfer.setVolume(usePlayerStore.getState().volume);
|
||||
wavesurfer.setMuted(false);
|
||||
|
||||
// Auto-play if the flag is set (story mode advance or explicit play)
|
||||
const shouldAutoPlayNow = usePlayerStore.getState().shouldAutoPlay;
|
||||
if (shouldAutoPlayNow) {
|
||||
usePlayerStore.getState().clearAutoPlayFlag();
|
||||
wavesurfer.play().catch((err) => {
|
||||
debug.error('Failed to autoplay:', err);
|
||||
});
|
||||
} else {
|
||||
debug.log('Skipping auto-play - shouldAutoPlay is false');
|
||||
}
|
||||
});
|
||||
|
||||
wavesurfer.on('play', () => setIsPlaying(true));
|
||||
wavesurfer.on('pause', () => {
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(wavesurfer.getCurrentTime());
|
||||
});
|
||||
|
||||
wavesurfer.on('seeking', (time) => setCurrentTime(time));
|
||||
|
||||
// Mute audio during drag-to-seek to prevent popping from the WebAudio
|
||||
// backend's hard stop/start cycle on each seek. Unmute with a short
|
||||
// fade-in when the drag ends.
|
||||
const seekMedia = wavesurfer.getMediaElement() as any;
|
||||
const seekGain: GainNode | null = seekMedia?.getGainNode?.() ?? null;
|
||||
if (seekGain) {
|
||||
const ctx = seekGain.context as AudioContext;
|
||||
wavesurfer.on('dragstart', () => {
|
||||
seekGain.gain.cancelScheduledValues(ctx.currentTime);
|
||||
seekGain.gain.setTargetAtTime(0, ctx.currentTime, 0.002);
|
||||
});
|
||||
wavesurfer.on('dragend', () => {
|
||||
seekGain.gain.cancelScheduledValues(ctx.currentTime);
|
||||
seekGain.gain.setTargetAtTime(1, ctx.currentTime, 0.01);
|
||||
});
|
||||
}
|
||||
wavesurfer.on('finish', () => {
|
||||
const loop = usePlayerStore.getState().isLooping;
|
||||
if (loop) {
|
||||
wavesurfer.seekTo(0);
|
||||
wavesurfer.play().catch((err) => debug.error('Loop play failed:', err));
|
||||
} else {
|
||||
setIsPlaying(false);
|
||||
const onFinish = usePlayerStore.getState().onFinish;
|
||||
if (onFinish) onFinish();
|
||||
}
|
||||
});
|
||||
|
||||
wavesurfer.on('error', (err) => {
|
||||
debug.error('WaveSurfer error:', err);
|
||||
setIsLoading(false);
|
||||
setError(`Audio error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
});
|
||||
|
||||
wavesurfer.on('loading', (percent) => {
|
||||
setIsLoading(true);
|
||||
if (percent === 100) setIsLoading(false);
|
||||
});
|
||||
|
||||
wavesurferRef.current = wavesurfer;
|
||||
console.log('WaveSurfer created successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to create WaveSurfer:', error);
|
||||
setWsReady(true);
|
||||
debug.log('WaveSurfer created successfully');
|
||||
} catch (err) {
|
||||
debug.error('Failed to create WaveSurfer:', err);
|
||||
setError(
|
||||
`Failed to initialize waveform: ${error instanceof Error ? error.message : String(error)}`,
|
||||
`Failed to initialize waveform: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const wavesurfer = wavesurferRef.current;
|
||||
if (!wavesurfer) return;
|
||||
|
||||
// Update store when time changes
|
||||
wavesurfer.on('timeupdate', (time) => {
|
||||
setCurrentTime(time);
|
||||
});
|
||||
|
||||
// Update store when duration is loaded
|
||||
wavesurfer.on('ready', () => {
|
||||
const dur = wavesurfer.getDuration();
|
||||
setDuration(dur);
|
||||
loadingRef.current = false;
|
||||
setIsLoading(false);
|
||||
setError(null);
|
||||
console.log('Audio ready, duration:', dur);
|
||||
console.log('Waveform should be visible now');
|
||||
|
||||
// Ensure volume is set
|
||||
const currentVolume = usePlayerStore.getState().volume;
|
||||
wavesurfer.setVolume(currentVolume);
|
||||
|
||||
// Get the underlying audio element and ensure it's not muted
|
||||
const mediaElement = wavesurfer.getMediaElement();
|
||||
if (mediaElement) {
|
||||
mediaElement.volume = currentVolume;
|
||||
mediaElement.muted = false;
|
||||
console.log('Audio element volume:', mediaElement.volume, 'muted:', mediaElement.muted);
|
||||
}
|
||||
|
||||
// Auto-play when ready
|
||||
// Use a small delay to ensure audio element is fully ready
|
||||
setTimeout(() => {
|
||||
wavesurfer.play().catch((error) => {
|
||||
console.error('Failed to autoplay:', error);
|
||||
// Don't show error for autoplay failures (browser restrictions)
|
||||
});
|
||||
}, 100);
|
||||
});
|
||||
|
||||
// Handle play/pause
|
||||
wavesurfer.on('play', () => {
|
||||
setIsPlaying(true);
|
||||
// Ensure audio element is not muted when playing
|
||||
const mediaElement = wavesurfer.getMediaElement();
|
||||
if (mediaElement) {
|
||||
mediaElement.muted = false;
|
||||
const currentVolume = usePlayerStore.getState().volume;
|
||||
mediaElement.volume = currentVolume;
|
||||
console.log('Playing - volume:', mediaElement.volume, 'muted:', mediaElement.muted);
|
||||
}
|
||||
});
|
||||
wavesurfer.on('pause', () => setIsPlaying(false));
|
||||
wavesurfer.on('finish', () => {
|
||||
// Check loop state from store
|
||||
const loop = usePlayerStore.getState().isLooping;
|
||||
if (loop) {
|
||||
wavesurfer.seekTo(0);
|
||||
wavesurfer.play();
|
||||
} else {
|
||||
setIsPlaying(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle errors
|
||||
wavesurfer.on('error', (error) => {
|
||||
console.error('WaveSurfer error:', error);
|
||||
setIsLoading(false);
|
||||
setError(`Audio error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
|
||||
// Handle loading
|
||||
wavesurfer.on('loading', (percent) => {
|
||||
setIsLoading(true);
|
||||
if (percent === 100) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Load audio immediately if audioUrl is already set
|
||||
if (audioUrl) {
|
||||
console.log('WaveSurfer ready, loading audio:', audioUrl);
|
||||
loadingRef.current = true;
|
||||
setIsLoading(true);
|
||||
// Stop any current playback before loading new audio
|
||||
if (wavesurfer.isPlaying()) {
|
||||
wavesurfer.pause();
|
||||
}
|
||||
wavesurfer
|
||||
.load(audioUrl)
|
||||
.then(() => {
|
||||
console.log('Audio loaded into WaveSurfer');
|
||||
loadingRef.current = false;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load audio into WaveSurfer:', error);
|
||||
loadingRef.current = false;
|
||||
setIsLoading(false);
|
||||
setError(
|
||||
`Failed to load audio: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Use double requestAnimationFrame to ensure DOM is fully rendered
|
||||
let rafId1: number;
|
||||
let rafId2: number;
|
||||
let timeoutId: number | null = null;
|
||||
|
||||
rafId1 = requestAnimationFrame(() => {
|
||||
rafId2 = requestAnimationFrame(() => {
|
||||
// Add a small delay to ensure container is fully laid out
|
||||
timeoutId = setTimeout(() => {
|
||||
initWaveSurfer();
|
||||
}, 10);
|
||||
});
|
||||
let rafId: number;
|
||||
rafId = requestAnimationFrame(() => {
|
||||
initWaveSurfer();
|
||||
});
|
||||
|
||||
return () => {
|
||||
console.log('Cleaning up WaveSurfer initialization effect');
|
||||
if (rafId1) cancelAnimationFrame(rafId1);
|
||||
if (rafId2) cancelAnimationFrame(rafId2);
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
cancelAnimationFrame(rafId);
|
||||
};
|
||||
// Only run on mount-like conditions. audioUrl is here so we create the instance
|
||||
// when the player first appears, but we guard against re-creation above.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [audioUrl, setIsPlaying, setDuration, setCurrentTime]);
|
||||
|
||||
// Destroy WaveSurfer only on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (wavesurferRef.current) {
|
||||
console.log('Destroying WaveSurfer instance');
|
||||
debug.log('Destroying WaveSurfer instance (unmount)');
|
||||
try {
|
||||
const mediaElement = wavesurferRef.current.getMediaElement();
|
||||
if (mediaElement) {
|
||||
mediaElement.pause();
|
||||
mediaElement.src = '';
|
||||
}
|
||||
wavesurferRef.current.destroy();
|
||||
} catch (error) {
|
||||
console.error('Error destroying WaveSurfer:', error);
|
||||
} catch (err) {
|
||||
debug.error('Error destroying WaveSurfer:', err);
|
||||
}
|
||||
wavesurferRef.current = null;
|
||||
setWsReady(false);
|
||||
}
|
||||
};
|
||||
}, [audioUrl, setIsPlaying, setCurrentTime, setDuration]);
|
||||
}, []);
|
||||
|
||||
// Load audio when URL changes (only if WaveSurfer is already initialized)
|
||||
// Load audio when URL changes (reuses the existing WaveSurfer instance)
|
||||
useEffect(() => {
|
||||
const wavesurfer = wavesurferRef.current;
|
||||
if (!wavesurfer || !wsReady) return;
|
||||
|
||||
if (!audioUrl || !wavesurfer) {
|
||||
// Reset state when no audio or WaveSurfer not ready
|
||||
if (!audioUrl && wavesurfer) {
|
||||
wavesurfer.pause();
|
||||
wavesurfer.seekTo(0);
|
||||
loadingRef.current = false;
|
||||
setIsLoading(false);
|
||||
setDuration(0);
|
||||
setCurrentTime(0);
|
||||
setError(null);
|
||||
}
|
||||
if (!audioUrl) {
|
||||
// No audio - pause and reset
|
||||
wavesurfer.pause();
|
||||
wavesurfer.seekTo(0);
|
||||
loadingRef.current = false;
|
||||
setIsLoading(false);
|
||||
setDuration(0);
|
||||
setCurrentTime(0);
|
||||
setError(null);
|
||||
isUsingNativePlaybackRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// CRITICAL: Force stop any current playback and cancel any pending loads
|
||||
// This must happen BEFORE any early returns
|
||||
console.log('Audio URL changed to:', audioUrl);
|
||||
// Reset native playback state
|
||||
isUsingNativePlaybackRef.current = false;
|
||||
wavesurfer.setMuted(false);
|
||||
wavesurfer.setVolume(usePlayerStore.getState().volume);
|
||||
|
||||
// COMPLETELY stop and destroy the current audio
|
||||
// Stop current playback and reset position before loading new audio.
|
||||
// With the WebAudio backend, pause() accumulates playedDuration internally.
|
||||
// seekTo(0) resets it so the new track starts from the beginning.
|
||||
debug.log('Loading new audio URL:', audioUrl);
|
||||
try {
|
||||
// First pause if playing
|
||||
if (wavesurfer.isPlaying()) {
|
||||
console.log('Pausing current playback');
|
||||
wavesurfer.pause();
|
||||
}
|
||||
|
||||
// Stop the media element explicitly
|
||||
const mediaElement = wavesurfer.getMediaElement();
|
||||
if (mediaElement) {
|
||||
console.log('Stopping media element');
|
||||
mediaElement.pause();
|
||||
mediaElement.currentTime = 0;
|
||||
mediaElement.src = '';
|
||||
}
|
||||
|
||||
// Use empty() to completely destroy the waveform and media element
|
||||
console.log('Calling wavesurfer.empty() to destroy audio');
|
||||
wavesurfer.empty();
|
||||
} catch (error) {
|
||||
console.error('Error stopping previous audio:', error);
|
||||
// Continue anyway to load new audio
|
||||
wavesurfer.seekTo(0);
|
||||
} catch (err) {
|
||||
debug.error('Error resetting before load:', err);
|
||||
}
|
||||
|
||||
// Reset loading state to allow new load (cancel any pending loads)
|
||||
loadingRef.current = false;
|
||||
|
||||
// Now start the new load
|
||||
loadingRef.current = true;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
|
||||
// Load new audio
|
||||
console.log('Starting new audio load for:', audioUrl);
|
||||
wavesurfer
|
||||
.load(audioUrl)
|
||||
.then(() => {
|
||||
console.log('Audio load promise resolved');
|
||||
// Don't set loading to false here - wait for 'ready' event
|
||||
debug.log('Audio loaded into WaveSurfer');
|
||||
loadingRef.current = false;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load audio:', error);
|
||||
console.error('Audio URL:', audioUrl);
|
||||
.catch((err) => {
|
||||
debug.error('Failed to load audio:', err);
|
||||
loadingRef.current = false;
|
||||
setIsLoading(false);
|
||||
setError(`Failed to load audio: ${error instanceof Error ? error.message : String(error)}`);
|
||||
setError(`Failed to load audio: ${err instanceof Error ? err.message : String(err)}`);
|
||||
});
|
||||
}, [audioUrl, setCurrentTime, setDuration]);
|
||||
}, [audioUrl, wsReady, setCurrentTime, setDuration]);
|
||||
|
||||
// Sync play/pause state (only when user clicks play/pause button, not auto-sync)
|
||||
// This effect is kept for external state changes but should be minimal
|
||||
@@ -329,9 +318,8 @@ export function AudioPlayer() {
|
||||
if (!wavesurferRef.current || duration === 0) return;
|
||||
|
||||
if (isPlaying && wavesurferRef.current.isPlaying() === false) {
|
||||
// Only auto-play if audio is ready
|
||||
wavesurferRef.current.play().catch((error) => {
|
||||
console.error('Failed to play:', error);
|
||||
debug.error('Failed to play:', error);
|
||||
setIsPlaying(false);
|
||||
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
@@ -344,36 +332,156 @@ export function AudioPlayer() {
|
||||
useEffect(() => {
|
||||
if (wavesurferRef.current) {
|
||||
wavesurferRef.current.setVolume(volume);
|
||||
// Also ensure the underlying audio element volume is set
|
||||
const mediaElement = wavesurferRef.current.getMediaElement();
|
||||
if (mediaElement) {
|
||||
mediaElement.volume = volume;
|
||||
mediaElement.muted = volume === 0;
|
||||
console.log('Volume synced:', volume, 'muted:', mediaElement.muted);
|
||||
}
|
||||
}
|
||||
}, [volume]);
|
||||
|
||||
// Handle loop - WaveSurfer handles this via the 'finish' event
|
||||
// Mark as initialized when audio is ready, reset when audioId changes
|
||||
useEffect(() => {
|
||||
if (duration > 0 && audioId) {
|
||||
hasInitializedRef.current = true;
|
||||
}
|
||||
// Reset initialization flag when audioId changes to a new audio
|
||||
if (audioId !== previousAudioIdRef.current && previousAudioIdRef.current !== null) {
|
||||
hasInitializedRef.current = false;
|
||||
}
|
||||
if (audioId !== null) {
|
||||
previousAudioIdRef.current = audioId;
|
||||
}
|
||||
}, [duration, audioId]);
|
||||
|
||||
const handlePlayPause = () => {
|
||||
// Handle restart flag - when history item is clicked again, restart from beginning
|
||||
useEffect(() => {
|
||||
const wavesurfer = wavesurferRef.current;
|
||||
if (!wavesurfer || !shouldRestart || duration === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
debug.log('Restarting current audio from beginning');
|
||||
wavesurfer.seekTo(0);
|
||||
wavesurfer.play().catch((error) => {
|
||||
debug.error('Failed to play after restart:', error);
|
||||
setIsPlaying(false);
|
||||
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
|
||||
clearRestartFlag();
|
||||
}, [shouldRestart, duration, setIsPlaying, clearRestartFlag]);
|
||||
|
||||
// Auto-play is handled exclusively in the WaveSurfer 'ready' event handler.
|
||||
// A separate effect here would race with the ready event since the WebAudio
|
||||
// backend needs to fully decode the audio before play() works correctly.
|
||||
|
||||
// Spacebar to play/pause (capture phase so it fires before focused elements)
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code !== 'Space') return;
|
||||
// Ignore if user is typing in an input/textarea
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
if (audioUrl && duration > 0 && wavesurferRef.current) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (wavesurferRef.current.isPlaying()) {
|
||||
wavesurferRef.current.pause();
|
||||
} else {
|
||||
wavesurferRef.current.play().catch((err) => debug.error('Spacebar play failed:', err));
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown, true);
|
||||
return () => document.removeEventListener('keydown', onKeyDown, true);
|
||||
}, [audioUrl, duration]);
|
||||
|
||||
const handlePlayPause = async () => {
|
||||
// Standard WaveSurfer playback (works for both normal and native playback modes)
|
||||
// When using native playback, WaveSurfer is muted but still controls visualization
|
||||
if (!wavesurferRef.current) {
|
||||
console.error('WaveSurfer not initialized');
|
||||
debug.error('WaveSurfer not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if audio is loaded
|
||||
if (duration === 0 && !isLoading) {
|
||||
console.error('Audio not loaded yet');
|
||||
debug.error('Audio not loaded yet');
|
||||
setError('Audio not loaded. Please wait...');
|
||||
return;
|
||||
}
|
||||
|
||||
// If using native playback
|
||||
if (useNativePlayback && audioUrl && profileChannels && channels) {
|
||||
if (isPlaying) {
|
||||
// Pause: stop native playback and pause WaveSurfer visualization
|
||||
try {
|
||||
platform.audio.stopPlayback();
|
||||
debug.log('Stopped native audio playback');
|
||||
} catch (error) {
|
||||
debug.error('Failed to stop native playback:', error);
|
||||
}
|
||||
wavesurferRef.current.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
// Play: trigger native playback
|
||||
try {
|
||||
// Stop any existing native playback first
|
||||
try {
|
||||
platform.audio.stopPlayback();
|
||||
} catch (_error) {
|
||||
// Ignore errors when stopping (might not be playing)
|
||||
debug.log('No existing playback to stop');
|
||||
}
|
||||
|
||||
// Collect all device IDs from assigned channels
|
||||
const assignedChannels = channels.filter((ch) =>
|
||||
profileChannels.channel_ids.includes(ch.id),
|
||||
);
|
||||
const deviceIds = assignedChannels.flatMap((ch) => ch.device_ids);
|
||||
|
||||
if (deviceIds.length > 0) {
|
||||
// Fetch audio data
|
||||
const response = await fetch(audioUrl);
|
||||
const audioData = new Uint8Array(await response.arrayBuffer());
|
||||
|
||||
// Play via native audio
|
||||
await platform.audio.playToDevices(audioData, deviceIds);
|
||||
|
||||
// Mark that we're using native playback
|
||||
isUsingNativePlaybackRef.current = true;
|
||||
|
||||
// Mute WaveSurfer and start it for visualization
|
||||
wavesurferRef.current.setVolume(0);
|
||||
wavesurferRef.current.setMuted(true);
|
||||
|
||||
// Start WaveSurfer for visualization (muted)
|
||||
wavesurferRef.current.play().catch((error) => {
|
||||
debug.error('Failed to start WaveSurfer visualization:', error);
|
||||
setIsPlaying(false);
|
||||
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
debug.error('Native playback failed, falling back to WaveSurfer:', error);
|
||||
// Fall through to WaveSurfer playback
|
||||
isUsingNativePlaybackRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Standard WaveSurfer playback (or fallback from native playback failure)
|
||||
if (wavesurferRef.current.isPlaying()) {
|
||||
wavesurferRef.current.pause();
|
||||
} else {
|
||||
// Ensure WaveSurfer is not muted if not using native playback
|
||||
if (!isUsingNativePlaybackRef.current) {
|
||||
wavesurferRef.current.setMuted(false);
|
||||
wavesurferRef.current.setVolume(volume);
|
||||
}
|
||||
|
||||
wavesurferRef.current.play().catch((error) => {
|
||||
console.error('Failed to play:', error);
|
||||
debug.error('Failed to play:', error);
|
||||
setIsPlaying(false);
|
||||
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
@@ -390,6 +498,24 @@ export function AudioPlayer() {
|
||||
setVolume(value[0] / 100);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
// Stop any native playback
|
||||
if (isUsingNativePlaybackRef.current && platform.metadata.isTauri) {
|
||||
try {
|
||||
platform.audio.stopPlayback();
|
||||
} catch (error) {
|
||||
debug.error('Failed to stop native playback:', error);
|
||||
}
|
||||
}
|
||||
// Stop WaveSurfer
|
||||
if (wavesurferRef.current) {
|
||||
wavesurferRef.current.pause();
|
||||
wavesurferRef.current.seekTo(0);
|
||||
}
|
||||
// Reset player state
|
||||
reset();
|
||||
};
|
||||
|
||||
// Don't render if no audio
|
||||
if (!audioUrl) {
|
||||
return null;
|
||||
@@ -405,27 +531,32 @@ export function AudioPlayer() {
|
||||
size="icon"
|
||||
onClick={handlePlayPause}
|
||||
disabled={isLoading || duration === 0}
|
||||
className="shrink-0"
|
||||
className={`shrink-0 -mt-2 ${isPlaying ? 'bg-accent text-accent-foreground' : ''}`}
|
||||
title={duration === 0 && !isLoading ? 'Audio not loaded' : ''}
|
||||
aria-label={
|
||||
duration === 0 && !isLoading ? 'Audio not loaded' : isPlaying ? 'Pause' : 'Play'
|
||||
}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
|
||||
{isPlaying ? (
|
||||
<Pause className="h-5 w-5 fill-current" />
|
||||
) : (
|
||||
<Play className="h-5 w-5 fill-current" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Waveform */}
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-1">
|
||||
<div ref={waveformRef} className="w-full min-h-[80px]" />
|
||||
{duration > 0 && (
|
||||
<Slider
|
||||
value={duration > 0 ? [(currentTime / duration) * 100] : [0]}
|
||||
onValueChange={handleSeek}
|
||||
max={100}
|
||||
step={0.1}
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
{isLoading && (
|
||||
<div className="text-xs text-muted-foreground text-center py-2">Loading audio...</div>
|
||||
)}
|
||||
<div ref={waveformRef} className="w-full min-h-[80px] select-none" />
|
||||
<Slider
|
||||
value={duration > 0 ? [(currentTime / duration) * 100] : [0]}
|
||||
onValueChange={handleSeek}
|
||||
max={100}
|
||||
step={0.1}
|
||||
className="w-full"
|
||||
aria-label="Playback position"
|
||||
aria-valuetext={`${formatAudioDuration(currentTime)} of ${formatAudioDuration(duration)}`}
|
||||
/>
|
||||
|
||||
{error && <div className="text-xs text-destructive text-center py-2">{error}</div>}
|
||||
</div>
|
||||
|
||||
@@ -436,40 +567,58 @@ export function AudioPlayer() {
|
||||
<span className="font-mono">{formatAudioDuration(duration)}</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
{title && (
|
||||
<div className="text-sm font-medium truncate max-w-[200px] shrink-0">{title}</div>
|
||||
)}
|
||||
|
||||
{/* Loop Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleLoop}
|
||||
className={isLooping ? 'text-primary' : ''}
|
||||
className={isLooping ? 'bg-accent text-accent-foreground' : ''}
|
||||
title="Toggle loop"
|
||||
aria-label={isLooping ? 'Stop looping' : 'Loop'}
|
||||
>
|
||||
<Repeat className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Volume Control */}
|
||||
<div className="flex items-center gap-2 shrink-0 w-[120px]">
|
||||
<div
|
||||
className="flex items-center gap-2 shrink-0 w-[120px]"
|
||||
role="group"
|
||||
aria-label="Volume"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setVolume(volume > 0 ? 0 : 1)}
|
||||
className="h-8 w-8"
|
||||
aria-label={volume > 0 ? 'Mute' : 'Unmute'}
|
||||
>
|
||||
{volume > 0 ? <Volume2 className="h-4 w-4" /> : <VolumeX className="h-4 w-4" />}
|
||||
</Button>
|
||||
<span id={volumeLabelId} className="sr-only">
|
||||
Volume level, {Math.round(volume * 100)}%
|
||||
</span>
|
||||
<Slider
|
||||
value={[volume * 100]}
|
||||
onValueChange={handleVolumeChange}
|
||||
max={100}
|
||||
step={1}
|
||||
className="flex-1"
|
||||
aria-labelledby={volumeLabelId}
|
||||
aria-valuetext={`${Math.round(volume * 100)}%`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Close Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleClose}
|
||||
className="shrink-0"
|
||||
title="Close player"
|
||||
aria-label="Close player"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,675 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Check, CheckCircle2, Edit, Plus, Speaker, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
|
||||
interface AudioDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
export function AudioTab() {
|
||||
const { t } = useTranslation();
|
||||
const platform = usePlatform();
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [editingChannel, setEditingChannel] = useState<string | null>(null);
|
||||
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||
const isPlayerVisible = !!audioUrl;
|
||||
|
||||
const { data: channels, isLoading: channelsLoading } = useQuery({
|
||||
queryKey: ['channels'],
|
||||
queryFn: () => apiClient.listChannels(),
|
||||
});
|
||||
|
||||
const { data: devices, isLoading: devicesLoading } = useQuery({
|
||||
queryKey: ['audio-devices'],
|
||||
queryFn: async () => {
|
||||
if (!platform.metadata.isTauri) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
return await platform.audio.listOutputDevices();
|
||||
} catch (error) {
|
||||
console.error('Failed to list audio devices:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
enabled: platform.metadata.isTauri,
|
||||
});
|
||||
|
||||
const { data: profiles } = useQuery({
|
||||
queryKey: ['profiles'],
|
||||
queryFn: () => apiClient.listProfiles(),
|
||||
});
|
||||
|
||||
const createChannel = useMutation({
|
||||
mutationFn: (data: { name: string; device_ids: string[] }) => apiClient.createChannel(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['channels'] });
|
||||
setCreateDialogOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const updateChannel = useMutation({
|
||||
mutationFn: ({
|
||||
channelId,
|
||||
data,
|
||||
}: {
|
||||
channelId: string;
|
||||
data: { name?: string; device_ids?: string[] };
|
||||
}) => apiClient.updateChannel(channelId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['channels'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['profile-channels'] });
|
||||
setEditingChannel(null);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteChannel = useMutation({
|
||||
mutationFn: (channelId: string) => apiClient.deleteChannel(channelId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['channels'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['profile-channels'] });
|
||||
},
|
||||
});
|
||||
|
||||
const { data: channelVoices } = useQuery({
|
||||
queryKey: ['channel-voices', editingChannel],
|
||||
queryFn: async () => {
|
||||
if (!editingChannel) return { profile_ids: [] };
|
||||
return apiClient.getChannelVoices(editingChannel);
|
||||
},
|
||||
enabled: !!editingChannel,
|
||||
});
|
||||
|
||||
const setChannelVoices = useMutation({
|
||||
mutationFn: ({ channelId, profileIds }: { channelId: string; profileIds: string[] }) =>
|
||||
apiClient.setChannelVoices(channelId, profileIds),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['channel-voices'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['profile-channels'] });
|
||||
},
|
||||
});
|
||||
|
||||
if (channelsLoading || devicesLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-muted-foreground">{t('audioChannels.loading')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleChannelDelete = async (e: React.MouseEvent, channelId: string) => {
|
||||
e.stopPropagation();
|
||||
if (await confirm(t('audioChannels.confirmDelete'))) {
|
||||
deleteChannel.mutate(channelId);
|
||||
}
|
||||
};
|
||||
|
||||
const allChannels = channels || [];
|
||||
const allDevices = devices || [];
|
||||
const selectedChannel = selectedChannelId
|
||||
? allChannels.find((c) => c.id === selectedChannelId)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-6 shrink-0">
|
||||
<h2 className="text-2xl font-bold">{t('audioChannels.title')}</h2>
|
||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t('audioChannels.newChannel')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-full min-h-0">
|
||||
{/* Left Column - Channels */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col min-h-0 overflow-y-auto',
|
||||
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
|
||||
)}
|
||||
>
|
||||
{allChannels.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
|
||||
<Speaker className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground mb-4">{t('audioChannels.empty.message')}</p>
|
||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t('audioChannels.empty.action')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{allChannels.map((channel) => {
|
||||
const isSelected = selectedChannelId === channel.id;
|
||||
return (
|
||||
<button
|
||||
key={channel.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'group border rounded-lg p-4 transition-colors cursor-pointer text-left w-full',
|
||||
isSelected && 'ring-2 ring-primary bg-primary/5 border-primary',
|
||||
)}
|
||||
onClick={() => setSelectedChannelId(isSelected ? null : channel.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
||||
<Speaker className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<h3 className="font-semibold text-base truncate">{channel.name}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5 ml-10">
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||
{t('audioChannels.labels.outputDevices')}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{channel.device_ids.length > 0
|
||||
? channel.device_ids.map((deviceId) => {
|
||||
const device = allDevices.find((d) => d.id === deviceId);
|
||||
return (
|
||||
<Badge
|
||||
key={deviceId}
|
||||
variant="outline"
|
||||
className="text-xs font-normal"
|
||||
>
|
||||
{device?.name || deviceId}
|
||||
</Badge>
|
||||
);
|
||||
})
|
||||
: (() => {
|
||||
const defaultDevice = allDevices.find((d) => d.is_default);
|
||||
return defaultDevice ? (
|
||||
<Badge variant="outline" className="text-xs font-normal">
|
||||
{defaultDevice.name}
|
||||
</Badge>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||
{t('audioChannels.labels.assignedVoices')}
|
||||
</div>
|
||||
<ChannelVoicesList channelId={channel.id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!channel.is_default && (
|
||||
<div className="flex gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingChannel(channel.id);
|
||||
}}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={(e) => handleChannelDelete(e, channel.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Column - Available Devices */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col min-h-0 overflow-y-auto',
|
||||
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
|
||||
)}
|
||||
>
|
||||
<div className="shrink-0 mb-4">
|
||||
<h3 className="text-lg font-semibold">{t('audioChannels.devices.title')}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{selectedChannelId
|
||||
? selectedChannel?.is_default
|
||||
? t('audioChannels.devices.defaultNote')
|
||||
: t('audioChannels.devices.toggleHint')
|
||||
: t('audioChannels.devices.selectHint')}
|
||||
</p>
|
||||
</div>
|
||||
{allDevices.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{allDevices.map((device) => {
|
||||
const isConnected =
|
||||
selectedChannelId &&
|
||||
selectedChannel &&
|
||||
(selectedChannel.device_ids.length === 0
|
||||
? device.is_default
|
||||
: selectedChannel.device_ids.includes(device.id));
|
||||
const canToggle =
|
||||
selectedChannelId && selectedChannel && !selectedChannel.is_default;
|
||||
|
||||
const handleDeviceClick = () => {
|
||||
if (!canToggle || !selectedChannel) return;
|
||||
|
||||
const currentDeviceIds = selectedChannel.device_ids;
|
||||
const newDeviceIds = isConnected
|
||||
? currentDeviceIds.filter((id) => id !== device.id)
|
||||
: [...currentDeviceIds, device.id];
|
||||
|
||||
updateChannel.mutate({
|
||||
channelId: selectedChannelId,
|
||||
data: { device_ids: newDeviceIds },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
key={device.id}
|
||||
type="button"
|
||||
onClick={handleDeviceClick}
|
||||
disabled={!canToggle}
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-sm p-3 rounded-lg border transition-colors text-left w-full',
|
||||
isConnected
|
||||
? 'bg-primary/10 border-primary ring-1 ring-primary/20'
|
||||
: 'hover:bg-muted/50',
|
||||
!canToggle && 'cursor-default opacity-60',
|
||||
canToggle && 'cursor-pointer',
|
||||
)}
|
||||
>
|
||||
{canToggle ? (
|
||||
<div
|
||||
className={cn(
|
||||
'h-4 w-4 rounded border-2 flex items-center justify-center shrink-0',
|
||||
isConnected ? 'bg-accent border-accent' : 'border-muted-foreground/30',
|
||||
)}
|
||||
>
|
||||
{isConnected && <Check className="h-3 w-3 text-accent-foreground" />}
|
||||
</div>
|
||||
) : device.is_default ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-primary shrink-0" />
|
||||
) : null}
|
||||
<span className={cn('truncate flex-1', device.is_default && 'font-medium')}>
|
||||
{device.name}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
|
||||
<CheckCircle2 className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground text-center">
|
||||
{platform.metadata.isTauri
|
||||
? t('audioChannels.devices.empty')
|
||||
: t('audioChannels.devices.requiresTauri')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Channel Dialog */}
|
||||
<CreateChannelDialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
devices={devices || []}
|
||||
onCreate={(name, deviceIds) => {
|
||||
createChannel.mutate({ name, device_ids: deviceIds });
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Edit Channel Dialog */}
|
||||
{editingChannel &&
|
||||
(() => {
|
||||
const channel = channels?.find((c) => c.id === editingChannel);
|
||||
return channel ? (
|
||||
<EditChannelDialog
|
||||
open={!!editingChannel}
|
||||
onOpenChange={(open) => !open && setEditingChannel(null)}
|
||||
channel={channel}
|
||||
devices={devices || []}
|
||||
profiles={profiles || []}
|
||||
channelVoices={channelVoices?.profile_ids || []}
|
||||
onUpdate={(name, deviceIds) => {
|
||||
updateChannel.mutate({
|
||||
channelId: editingChannel,
|
||||
data: { name, device_ids: deviceIds },
|
||||
});
|
||||
}}
|
||||
onSetVoices={(profileIds) => {
|
||||
setChannelVoices.mutate({
|
||||
channelId: editingChannel,
|
||||
profileIds,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelVoicesList({ channelId }: { channelId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { data: voices } = useQuery({
|
||||
queryKey: ['channel-voices', channelId],
|
||||
queryFn: () => apiClient.getChannelVoices(channelId),
|
||||
});
|
||||
|
||||
const { data: profiles } = useQuery({
|
||||
queryKey: ['profiles'],
|
||||
queryFn: () => apiClient.listProfiles(),
|
||||
});
|
||||
|
||||
const voiceNames =
|
||||
voices?.profile_ids.map((id) => profiles?.find((p) => p.id === id)?.name).filter(Boolean) || [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{voiceNames.length > 0 ? (
|
||||
voiceNames.map((name) => (
|
||||
<Badge key={name} variant="outline" className="text-xs font-normal">
|
||||
{name}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">{t('audioChannels.noVoicesAssigned')}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CreateChannelDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
devices: AudioDevice[];
|
||||
onCreate: (name: string, deviceIds: string[]) => void;
|
||||
}
|
||||
|
||||
function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateChannelDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState('');
|
||||
const [selectedDevices, setSelectedDevices] = useState<string[]>([]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (name.trim()) {
|
||||
onCreate(name.trim(), selectedDevices);
|
||||
setName('');
|
||||
setSelectedDevices([]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('audioChannels.createDialog.title')}</DialogTitle>
|
||||
<DialogDescription>{t('audioChannels.createDialog.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="channel-name">{t('audioChannels.fields.name')}</Label>
|
||||
<Input
|
||||
id="channel-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t('audioChannels.fields.namePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('audioChannels.labels.outputDevices')}</Label>
|
||||
<Select
|
||||
value={selectedDevices[0] || ''}
|
||||
onValueChange={(value) => {
|
||||
if (value && !selectedDevices.includes(value)) {
|
||||
setSelectedDevices([...selectedDevices, value]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('audioChannels.selectDevice')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{devices.map((device) => (
|
||||
<SelectItem key={device.id} value={device.id}>
|
||||
{device.name} {device.is_default && `(${t('audioChannels.defaultSuffix')})`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedDevices.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{selectedDevices.map((deviceId) => {
|
||||
const device = devices.find((d) => d.id === deviceId);
|
||||
return (
|
||||
<div
|
||||
key={deviceId}
|
||||
className="flex items-center justify-between text-sm bg-muted p-2 rounded"
|
||||
>
|
||||
<span>{device?.name || deviceId}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setSelectedDevices(selectedDevices.filter((id) => id !== deviceId))
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!name.trim()}>
|
||||
{t('audioChannels.createDialog.action')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface EditChannelDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
channel: {
|
||||
id: string;
|
||||
name: string;
|
||||
device_ids: string[];
|
||||
};
|
||||
devices: AudioDevice[];
|
||||
profiles: Array<{ id: string; name: string }>;
|
||||
channelVoices: string[];
|
||||
onUpdate: (name: string, deviceIds: string[]) => void;
|
||||
onSetVoices: (profileIds: string[]) => void;
|
||||
}
|
||||
|
||||
function EditChannelDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
channel,
|
||||
devices,
|
||||
profiles,
|
||||
channelVoices,
|
||||
onUpdate,
|
||||
onSetVoices,
|
||||
}: EditChannelDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState(channel.name);
|
||||
const [selectedDevices, setSelectedDevices] = useState<string[]>(channel.device_ids);
|
||||
const [selectedVoices, setSelectedVoices] = useState<string[]>(channelVoices);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (name.trim()) {
|
||||
onUpdate(name.trim(), selectedDevices);
|
||||
onSetVoices(selectedVoices);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('audioChannels.editDialog.title')}</DialogTitle>
|
||||
<DialogDescription>{t('audioChannels.editDialog.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="edit-channel-name">{t('audioChannels.fields.name')}</Label>
|
||||
<Input id="edit-channel-name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('audioChannels.labels.outputDevices')}</Label>
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(value) => {
|
||||
if (value && !selectedDevices.includes(value)) {
|
||||
setSelectedDevices([...selectedDevices, value]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('audioChannels.addDevice')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{devices.map((device) => (
|
||||
<SelectItem key={device.id} value={device.id}>
|
||||
{device.name} {device.is_default && `(${t('audioChannels.defaultSuffix')})`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedDevices.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{selectedDevices.map((deviceId) => {
|
||||
const device = devices.find((d) => d.id === deviceId);
|
||||
return (
|
||||
<div
|
||||
key={deviceId}
|
||||
className="flex items-center justify-between text-sm bg-muted p-2 rounded"
|
||||
>
|
||||
<span>{device?.name || deviceId}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setSelectedDevices(selectedDevices.filter((id) => id !== deviceId))
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('audioChannels.labels.assignedVoices')}</Label>
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(value) => {
|
||||
if (value && !selectedVoices.includes(value)) {
|
||||
setSelectedVoices([...selectedVoices, value]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('audioChannels.addVoice')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{profiles.map((profile) => (
|
||||
<SelectItem key={profile.id} value={profile.id}>
|
||||
{profile.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedVoices.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{selectedVoices.map((profileId) => {
|
||||
const profile = profiles.find((p) => p.id === profileId);
|
||||
return (
|
||||
<div
|
||||
key={profileId}
|
||||
className="flex items-center justify-between text-sm bg-muted p-2 rounded"
|
||||
>
|
||||
<span>{profile?.name || profileId}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setSelectedVoices(selectedVoices.filter((id) => id !== profileId))
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!name.trim()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
/**
|
||||
* Pill state machine shared between the settings preview and the live
|
||||
* recording pill in the Captures tab.
|
||||
*/
|
||||
export type PillState =
|
||||
| 'recording'
|
||||
| 'transcribing'
|
||||
| 'refining'
|
||||
| 'speaking'
|
||||
| 'completed'
|
||||
| 'rest'
|
||||
| 'error';
|
||||
|
||||
const PILL_LABEL_KEYS: Record<Exclude<PillState, 'rest' | 'error'>, string> = {
|
||||
recording: 'captures.pill.recording',
|
||||
transcribing: 'captures.pill.transcribing',
|
||||
refining: 'captures.pill.refining',
|
||||
speaking: 'captures.pill.speaking',
|
||||
completed: 'captures.pill.completed',
|
||||
};
|
||||
|
||||
function barModeFor(
|
||||
state: Exclude<PillState, 'error'>,
|
||||
): 'generating' | 'playing' | 'idle' {
|
||||
if (state === 'recording' || state === 'speaking') return 'playing';
|
||||
if (state === 'completed' || state === 'rest') return 'idle';
|
||||
return 'generating';
|
||||
}
|
||||
|
||||
export function PillAudioBars({ mode }: { mode: 'generating' | 'playing' | 'idle' }) {
|
||||
return (
|
||||
<div className="flex items-center gap-[2px] h-5 shrink-0">
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<motion.div
|
||||
key={`${mode}-${i}`}
|
||||
className={cn('w-[3px] rounded-full', mode === 'idle' ? 'bg-accent/30' : 'bg-accent')}
|
||||
animate={
|
||||
mode === 'generating'
|
||||
? { height: ['6px', '16px', '6px'] }
|
||||
: mode === 'playing'
|
||||
? { height: ['8px', '14px', '4px', '12px', '8px'] }
|
||||
: { height: '8px' }
|
||||
}
|
||||
transition={
|
||||
mode === 'generating'
|
||||
? { duration: 0.6, repeat: Infinity, delay: i * 0.08, ease: 'easeInOut' }
|
||||
: mode === 'playing'
|
||||
? { duration: 1.2, repeat: Infinity, delay: i * 0.15, ease: 'easeInOut' }
|
||||
: { duration: 0.4, ease: 'easeOut' }
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatElapsed(ms: number): string {
|
||||
const total = Math.max(0, Math.floor(ms / 1000));
|
||||
const m = Math.floor(total / 60);
|
||||
const s = total % 60;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Floating pill shown during capture. `state` drives the label, dot animation,
|
||||
* and bar motion; `elapsedMs` freezes at whatever the caller last passed in
|
||||
* (recording advances the timer, transcribing/refining hold the final value).
|
||||
* The ``error`` state renders a destructive variant — a clickable pill that
|
||||
* copies its message to the clipboard on press and calls ``onDismiss``.
|
||||
*/
|
||||
export function CapturePill({
|
||||
state,
|
||||
elapsedMs,
|
||||
onStop,
|
||||
errorMessage,
|
||||
onDismiss,
|
||||
className,
|
||||
}: {
|
||||
state: PillState;
|
||||
elapsedMs: number;
|
||||
onStop?: () => void;
|
||||
errorMessage?: string | null;
|
||||
onDismiss?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (state === 'error') {
|
||||
return (
|
||||
<ErrorPill
|
||||
message={errorMessage ?? t('captures.pill.errorFallback')}
|
||||
onDismiss={onDismiss}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const visible = state !== 'rest';
|
||||
const labelText = t(state === 'rest' ? PILL_LABEL_KEYS.recording : PILL_LABEL_KEYS[state]);
|
||||
const barMode = barModeFor(state);
|
||||
|
||||
const dot = (
|
||||
<span className="relative flex h-2 w-2 shrink-0">
|
||||
{state === 'recording' && (
|
||||
<span className="absolute inset-0 rounded-full bg-accent animate-ping opacity-70" />
|
||||
)}
|
||||
<span className="relative rounded-full h-2 w-2 bg-accent" />
|
||||
</span>
|
||||
);
|
||||
|
||||
const stopButton = onStop && state === 'recording' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
aria-label={t('captures.pill.stopAria')}
|
||||
className="relative flex h-2 w-2 shrink-0 items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-accent/50"
|
||||
>
|
||||
{dot}
|
||||
</button>
|
||||
) : dot;
|
||||
|
||||
// Completed gets an inset accent stroke (via box-shadow, not Tailwind's
|
||||
// ring — ring utility doesn't compose with arbitrary shadow-[…]) to mark
|
||||
// the success moment without changing the pill's dimensions.
|
||||
const completedStroke =
|
||||
state === 'completed'
|
||||
? 'shadow-[inset_0_0_0_2px_hsl(var(--accent)/0.6)]'
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-3 px-4 h-10 rounded-full text-accent',
|
||||
'bg-white/80 ring-1 ring-black/5 shadow-lg backdrop-blur-xl',
|
||||
'dark:bg-black/55 dark:ring-0 dark:shadow-none dark:backdrop-blur-md',
|
||||
completedStroke,
|
||||
'transition-opacity duration-300 ease-out',
|
||||
visible ? 'opacity-100' : 'opacity-0 pointer-events-none',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{stopButton}
|
||||
<span className="text-sm font-medium shrink-0" style={{ minWidth: '104px' }}>
|
||||
{labelText}
|
||||
</span>
|
||||
<PillAudioBars mode={barMode} />
|
||||
<span className="text-xs tabular-nums text-accent/70 font-medium shrink-0 -ml-1">
|
||||
{formatElapsed(elapsedMs)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorPill({
|
||||
message,
|
||||
onDismiss,
|
||||
className,
|
||||
}: {
|
||||
message: string;
|
||||
onDismiss?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const handleClick = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(message);
|
||||
} catch {
|
||||
// Clipboard access can be denied in rare webview configs — ignore,
|
||||
// we still want the dismiss to land.
|
||||
}
|
||||
onDismiss?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
title={t('captures.pill.errorCopyTooltip')}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2.5 px-4 h-10 rounded-full',
|
||||
'bg-white/85 ring-1 ring-destructive/25 shadow-lg backdrop-blur-xl text-red-600 hover:bg-white',
|
||||
'dark:bg-black/65 dark:ring-0 dark:shadow-none dark:backdrop-blur-md dark:text-red-300 dark:hover:bg-black/80',
|
||||
'max-w-[380px] transition-colors',
|
||||
'focus:outline-none focus:ring-2 focus:ring-red-400/50',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-sm font-medium truncate">{message}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Loader2, Pause, Play } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import WaveSurfer from 'wavesurfer.js';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { debug } from '@/lib/utils/debug';
|
||||
|
||||
function formatDuration(ms?: number | null): string {
|
||||
if (!ms || ms < 0) return '0:00';
|
||||
const total = Math.round(ms / 1000);
|
||||
const m = Math.floor(total / 60);
|
||||
const s = total % 60;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function CaptureInlinePlayer({
|
||||
audioUrl,
|
||||
fallbackDurationMs,
|
||||
className,
|
||||
}: {
|
||||
audioUrl: string;
|
||||
fallbackDurationMs?: number | null;
|
||||
className?: string;
|
||||
}) {
|
||||
const waveformRef = useRef<HTMLDivElement>(null);
|
||||
const wavesurferRef = useRef<WaveSurfer | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = waveformRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const root = document.documentElement;
|
||||
const cssHsla = (varName: string, alpha: number) => {
|
||||
const value = getComputedStyle(root).getPropertyValue(varName).trim();
|
||||
if (!value) return '';
|
||||
const [h, s, l] = value.split(/\s+/);
|
||||
if (!h || !s || !l) return '';
|
||||
return `hsla(${h}, ${s}, ${l}, ${alpha})`;
|
||||
};
|
||||
|
||||
const ws = WaveSurfer.create({
|
||||
container,
|
||||
waveColor: cssHsla('--muted-foreground', 1),
|
||||
progressColor: cssHsla('--accent', 1),
|
||||
cursorColor: 'transparent',
|
||||
barWidth: 2,
|
||||
barRadius: 2,
|
||||
barGap: 2,
|
||||
height: 40,
|
||||
normalize: true,
|
||||
interact: true,
|
||||
dragToSeek: { debounceTime: 0 },
|
||||
mediaControls: false,
|
||||
backend: 'WebAudio',
|
||||
});
|
||||
|
||||
ws.on('ready', () => {
|
||||
setDuration(ws.getDuration());
|
||||
setIsLoading(false);
|
||||
setError(null);
|
||||
});
|
||||
ws.on('play', () => setIsPlaying(true));
|
||||
ws.on('pause', () => setIsPlaying(false));
|
||||
ws.on('finish', () => {
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(ws.getDuration());
|
||||
});
|
||||
ws.on('timeupdate', (t) => setCurrentTime(t));
|
||||
ws.on('seeking', (t) => setCurrentTime(t));
|
||||
ws.on('error', (err) => {
|
||||
debug.error('Inline waveform error', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
wavesurferRef.current = ws;
|
||||
|
||||
return () => {
|
||||
try {
|
||||
ws.destroy();
|
||||
} catch (err) {
|
||||
debug.error('Failed to destroy inline waveform', err);
|
||||
}
|
||||
wavesurferRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const ws = wavesurferRef.current;
|
||||
if (!ws) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
setIsPlaying(false);
|
||||
try {
|
||||
if (ws.isPlaying()) ws.pause();
|
||||
ws.seekTo(0);
|
||||
} catch (err) {
|
||||
debug.error('Failed to reset inline waveform before load', err);
|
||||
}
|
||||
ws.load(audioUrl).catch((err) => {
|
||||
debug.error('Inline waveform load failed', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [audioUrl]);
|
||||
|
||||
const handlePlayPause = () => {
|
||||
const ws = wavesurferRef.current;
|
||||
if (!ws || isLoading) return;
|
||||
if (ws.isPlaying()) {
|
||||
ws.pause();
|
||||
} else {
|
||||
ws.play().catch((err) => {
|
||||
debug.error('Inline play failed', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const displayMs =
|
||||
duration > 0
|
||||
? Math.round((isPlaying || currentTime > 0 ? currentTime : duration) * 1000)
|
||||
: (fallbackDurationMs ?? 0);
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-4', className)}>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-10 w-10 rounded-full shrink-0"
|
||||
onClick={handlePlayPause}
|
||||
disabled={isLoading || !!error}
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : isPlaying ? (
|
||||
<Pause className="h-4 w-4 fill-current" />
|
||||
) : (
|
||||
<Play className="h-4 w-4 ml-0.5 fill-current" />
|
||||
)}
|
||||
</Button>
|
||||
<div ref={waveformRef} className="flex-1 min-w-0 h-10 select-none" />
|
||||
<span className="text-xs tabular-nums text-muted-foreground font-medium shrink-0">
|
||||
{error ? '—' : formatDuration(displayMs)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,909 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
import { save } from '@tauri-apps/plugin-dialog';
|
||||
import { writeFile, writeTextFile } from '@tauri-apps/plugin-fs';
|
||||
import {
|
||||
Captions,
|
||||
Check,
|
||||
ChevronDown,
|
||||
CircleDot,
|
||||
Copy,
|
||||
Download,
|
||||
FileAudio,
|
||||
FileText,
|
||||
Loader2,
|
||||
Mic,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
Square,
|
||||
Trash2,
|
||||
Upload,
|
||||
Volume2,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AudioBars } from '@/components/AudioBars';
|
||||
import { CapturePill } from '@/components/CapturePill/CapturePill';
|
||||
import { CaptureInlinePlayer } from '@/components/CapturesTab/CaptureInlinePlayer';
|
||||
import { DictationReadinessChecklist } from '@/components/CapturesTab/DictationReadinessChecklist';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
ListPane,
|
||||
ListPaneHeader,
|
||||
ListPaneScroll,
|
||||
ListPaneSearch,
|
||||
ListPaneTitle,
|
||||
ListPaneTitleRow,
|
||||
} from '@/components/ListPane';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type {
|
||||
CaptureListResponse,
|
||||
CaptureResponse,
|
||||
CaptureSource,
|
||||
VoiceProfileResponse,
|
||||
} from '@/lib/api/types';
|
||||
import type { LanguageCode } from '@/lib/constants/languages';
|
||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||
import { useCaptureRecordingSession } from '@/lib/hooks/useCaptureRecordingSession';
|
||||
import { useDictationReadiness } from '@/lib/hooks/useDictationReadiness';
|
||||
import { useCaptureSettings } from '@/lib/hooks/useSettings';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { formatAbsoluteDate, formatDate } from '@/lib/utils/format';
|
||||
import { displayLabelForKey, modifierSideHint } from '@/lib/utils/keyCodes';
|
||||
import { useGenerationStore } from '@/stores/generationStore';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
|
||||
const CAPTURE_AUDIO_MIME = 'audio/*,.wav,.mp3,.m4a,.flac,.ogg,.webm';
|
||||
|
||||
function formatDuration(ms?: number | null): string {
|
||||
if (!ms || ms < 0) return '0:00';
|
||||
const total = Math.round(ms / 1000);
|
||||
const m = Math.floor(total / 60);
|
||||
const s = total % 60;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function ChordKeys({ keys }: { keys: string[] }) {
|
||||
if (keys.length === 0) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{keys.map((k) => {
|
||||
const side = modifierSideHint(k);
|
||||
return (
|
||||
<span
|
||||
key={k}
|
||||
className="relative inline-flex items-center justify-center h-6 min-w-[1.5rem] px-1.5 rounded-md border border-border bg-muted/60 font-mono text-[11px] font-medium shadow-sm text-foreground"
|
||||
>
|
||||
{displayLabelForKey(k)}
|
||||
{side ? (
|
||||
<span className="absolute -top-1 -right-1 h-3 min-w-[0.75rem] px-0.5 rounded-sm bg-accent text-[7px] font-bold leading-none flex items-center justify-center text-accent-foreground">
|
||||
{side}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceBadge({ source }: { source: CaptureSource }) {
|
||||
const { t } = useTranslation();
|
||||
const Icon = source === 'dictation' ? Mic : source === 'recording' ? CircleDot : FileAudio;
|
||||
const label =
|
||||
source === 'dictation'
|
||||
? t('captures.source.dictation')
|
||||
: source === 'recording'
|
||||
? t('captures.source.recording')
|
||||
: t('captures.source.file');
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 px-1.5 text-[10px] gap-1 font-medium bg-muted/60 text-muted-foreground"
|
||||
>
|
||||
<Icon className="h-2.5 w-2.5" />
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
type PlaybackState = 'idle' | 'generating' | 'playing';
|
||||
|
||||
export function CapturesTab() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const snippetOf = (capture: CaptureResponse): string => {
|
||||
const source = capture.transcript_refined || capture.transcript_raw || '';
|
||||
return source.trim() || t('captures.snippetEmpty');
|
||||
};
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [showRefined, setShowRefined] = useState(true);
|
||||
const [launchedPlayAsId, setLaunchedPlayAsId] = useState<string | null>(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
||||
const audioUrl = usePlayerStore((s) => s.audioUrl);
|
||||
const playerAudioId = usePlayerStore((s) => s.audioId);
|
||||
const playerIsPlaying = usePlayerStore((s) => s.isPlaying);
|
||||
const isPlayerVisible = !!audioUrl;
|
||||
|
||||
const setIsPlaying = usePlayerStore((s) => s.setIsPlaying);
|
||||
|
||||
const addPendingGeneration = useGenerationStore((s) => s.addPendingGeneration);
|
||||
const pendingGenerationIds = useGenerationStore((s) => s.pendingGenerationIds);
|
||||
|
||||
const { settings: captureSettings, update: updateCaptureSettings } = useCaptureSettings();
|
||||
const sttModel = captureSettings?.stt_model ?? 'turbo';
|
||||
const llmModel = captureSettings?.llm_model ?? '0.6B';
|
||||
const hotkeyEnabled = captureSettings?.hotkey_enabled ?? false;
|
||||
const pushToTalkKeys = captureSettings?.chord_push_to_talk_keys ?? [];
|
||||
const toggleToTalkKeys = captureSettings?.chord_toggle_to_talk_keys ?? [];
|
||||
const readiness = useDictationReadiness();
|
||||
|
||||
const session = useCaptureRecordingSession({
|
||||
onCaptureCreated: (capture) => setSelectedId(capture.id),
|
||||
});
|
||||
|
||||
const { data: capturesData, isLoading: capturesLoading } = useQuery({
|
||||
queryKey: ['captures'],
|
||||
queryFn: () => apiClient.listCaptures(200, 0),
|
||||
});
|
||||
|
||||
const { data: profiles } = useQuery({
|
||||
queryKey: ['profiles'],
|
||||
queryFn: () => apiClient.listProfiles(),
|
||||
});
|
||||
|
||||
const captures = capturesData?.items ?? [];
|
||||
|
||||
// Keep a selection. If the current selection disappears (e.g. deletion),
|
||||
// fall through to the first capture, then to null.
|
||||
useEffect(() => {
|
||||
if (!captures.length) {
|
||||
if (selectedId !== null) setSelectedId(null);
|
||||
return;
|
||||
}
|
||||
if (!selectedId || !captures.find((c) => c.id === selectedId)) {
|
||||
setSelectedId(captures[0].id);
|
||||
}
|
||||
}, [captures, selectedId]);
|
||||
|
||||
// Live sync from sibling Tauri webviews (the floating dictate window).
|
||||
// ``capture:created`` carries the full row so we can seed the cache before
|
||||
// the refetch lands and focus the new capture in one shot — without the
|
||||
// seed, the selection-guard effect would snap back to ``captures[0]`` in
|
||||
// the race window between ``setSelectedId(new)`` and the refetched list
|
||||
// actually containing the new row.
|
||||
useEffect(() => {
|
||||
const unlistens: Promise<UnlistenFn>[] = [];
|
||||
unlistens.push(
|
||||
listen<{ capture: CaptureResponse }>('capture:created', (event) => {
|
||||
const capture = event.payload?.capture;
|
||||
if (capture) {
|
||||
queryClient.setQueryData<CaptureListResponse>(['captures'], (prev) => {
|
||||
if (!prev) return prev;
|
||||
if (prev.items.some((c) => c.id === capture.id)) return prev;
|
||||
return { ...prev, items: [capture, ...prev.items], total: prev.total + 1 };
|
||||
});
|
||||
setSelectedId(capture.id);
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['captures'] });
|
||||
}),
|
||||
);
|
||||
unlistens.push(
|
||||
listen('capture:updated', () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['captures'] });
|
||||
}),
|
||||
);
|
||||
return () => {
|
||||
for (const p of unlistens) p.then((fn) => fn()).catch(() => {});
|
||||
};
|
||||
}, [queryClient]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return captures;
|
||||
return captures.filter((c) => {
|
||||
const raw = (c.transcript_raw || '').toLowerCase();
|
||||
const refined = (c.transcript_refined || '').toLowerCase();
|
||||
return raw.includes(q) || refined.includes(q);
|
||||
});
|
||||
}, [search, captures]);
|
||||
|
||||
const selected = captures.find((c) => c.id === selectedId) ?? null;
|
||||
// Source of truth is capture_settings.default_playback_voice_id, shared
|
||||
// with Settings → Captures and the MCP global default. Stale ids (e.g.
|
||||
// referenced profile was deleted) fall through to the first profile.
|
||||
const storedVoiceId = captureSettings?.default_playback_voice_id ?? null;
|
||||
const playAsVoice =
|
||||
(storedVoiceId && profiles?.find((p) => p.id === storedVoiceId)) ||
|
||||
profiles?.[0] ||
|
||||
null;
|
||||
const playAsVoiceId = playAsVoice?.id ?? null;
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (captureId: string) => apiClient.deleteCapture(captureId),
|
||||
onSuccess: () => {
|
||||
setDeleteDialogOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['captures'] });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast({ title: t('captures.toast.deleteFailed'), description: err.message, variant: 'destructive' });
|
||||
},
|
||||
});
|
||||
|
||||
const playAsMutation = useMutation({
|
||||
mutationFn: async ({ capture, voice }: { capture: CaptureResponse; voice: VoiceProfileResponse }) => {
|
||||
const text = capture.transcript_refined || capture.transcript_raw;
|
||||
if (!text.trim()) throw new Error(t('captures.noTranscriptError'));
|
||||
const language = (capture.language || voice.language) as LanguageCode;
|
||||
// Preset profiles (Kokoro etc.) reject the qwen default — honor the
|
||||
// profile's stored engine preference. Cloned profiles without an
|
||||
// override fall through to whatever the backend picks.
|
||||
const engine = voice.default_engine as
|
||||
| 'qwen' | 'qwen_custom_voice' | 'luxtts' | 'chatterbox'
|
||||
| 'chatterbox_turbo' | 'tada' | 'kokoro'
|
||||
| undefined;
|
||||
return apiClient.generateSpeech({
|
||||
profile_id: voice.id,
|
||||
text,
|
||||
language,
|
||||
engine,
|
||||
});
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
// /generate is queue-based — it returns a generating row with an empty
|
||||
// audio_path. Hand the id to the global SSE handler which polls
|
||||
// /generation/{id}/status and triggers autoplay on completion.
|
||||
setLaunchedPlayAsId(result.id);
|
||||
addPendingGeneration(result.id);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast({ title: t('captures.toast.playAsFailed'), description: err.message, variant: 'destructive' });
|
||||
},
|
||||
});
|
||||
|
||||
const playbackState: PlaybackState = playAsMutation.isPending
|
||||
? 'generating'
|
||||
: launchedPlayAsId && pendingGenerationIds.has(launchedPlayAsId)
|
||||
? 'generating'
|
||||
: launchedPlayAsId && playerAudioId === launchedPlayAsId && playerIsPlaying
|
||||
? 'playing'
|
||||
: 'idle';
|
||||
|
||||
const handleUploadClick = () => uploadInputRef.current?.click();
|
||||
|
||||
const handleUploadFile = (e: React.ChangeEvent<HTMLInputElement>, source: CaptureSource) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
session.uploadFile(file, source);
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!selected) return;
|
||||
const text = showRefined
|
||||
? selected.transcript_refined || selected.transcript_raw
|
||||
: selected.transcript_raw;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text || '');
|
||||
toast({ title: t('captures.toast.transcriptCopied') });
|
||||
} catch {
|
||||
toast({ title: t('captures.toast.copyFailed'), variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
const exportToastSuccess = (path: string) => {
|
||||
const name = path.split(/[\\/]/).pop() ?? path;
|
||||
toast({ title: t('captures.toast.exportSuccess', { path: name }) });
|
||||
};
|
||||
|
||||
const exportToastError = (err: unknown) => {
|
||||
toast({
|
||||
title: t('captures.toast.exportFailed'),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
variant: 'destructive',
|
||||
});
|
||||
};
|
||||
|
||||
const handleExportAudio = async () => {
|
||||
if (!selected) return;
|
||||
try {
|
||||
const dest = await save({
|
||||
defaultPath: `capture_${selected.id.slice(0, 8)}.wav`,
|
||||
filters: [{ name: 'Audio', extensions: ['wav'] }],
|
||||
});
|
||||
if (!dest) return;
|
||||
const res = await fetch(apiClient.getCaptureAudioUrl(selected.id));
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const buf = new Uint8Array(await res.arrayBuffer());
|
||||
await writeFile(dest, buf);
|
||||
exportToastSuccess(dest);
|
||||
} catch (err) {
|
||||
exportToastError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportTranscript = async () => {
|
||||
if (!selected) return;
|
||||
const text = (selected.transcript_refined || selected.transcript_raw || '').trim();
|
||||
if (!text) {
|
||||
toast({ title: t('captures.toast.exportEmpty'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const dest = await save({
|
||||
defaultPath: `capture_${selected.id.slice(0, 8)}.txt`,
|
||||
filters: [{ name: 'Text', extensions: ['txt'] }],
|
||||
});
|
||||
if (!dest) return;
|
||||
await writeTextFile(dest, text);
|
||||
exportToastSuccess(dest);
|
||||
} catch (err) {
|
||||
exportToastError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const buildCaptureMarkdown = (capture: CaptureResponse): string => {
|
||||
const lines: string[] = [];
|
||||
lines.push(`# Capture ${capture.id}`, '');
|
||||
lines.push(`- **Source:** ${capture.source}`);
|
||||
lines.push(`- **Created:** ${capture.created_at}`);
|
||||
if (capture.duration_ms != null) lines.push(`- **Duration:** ${formatDuration(capture.duration_ms)}`);
|
||||
if (capture.language) lines.push(`- **Language:** ${capture.language}`);
|
||||
if (capture.stt_model) lines.push(`- **STT model:** ${capture.stt_model}`);
|
||||
if (capture.llm_model) lines.push(`- **LLM model:** ${capture.llm_model}`);
|
||||
lines.push('');
|
||||
if (capture.transcript_refined?.trim()) {
|
||||
lines.push('## Refined transcript', '', capture.transcript_refined.trim(), '');
|
||||
}
|
||||
if (capture.transcript_raw?.trim()) {
|
||||
lines.push('## Raw transcript', '', capture.transcript_raw.trim(), '');
|
||||
}
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
||||
const handleExportMarkdown = async () => {
|
||||
if (!selected) return;
|
||||
const hasContent = (selected.transcript_refined || selected.transcript_raw || '').trim();
|
||||
if (!hasContent) {
|
||||
toast({ title: t('captures.toast.exportEmpty'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const dest = await save({
|
||||
defaultPath: `capture_${selected.id.slice(0, 8)}.md`,
|
||||
filters: [{ name: 'Markdown', extensions: ['md'] }],
|
||||
});
|
||||
if (!dest) return;
|
||||
await writeTextFile(dest, buildCaptureMarkdown(selected));
|
||||
exportToastSuccess(dest);
|
||||
} catch (err) {
|
||||
exportToastError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlayAs = (voice?: VoiceProfileResponse) => {
|
||||
if (!selected) return;
|
||||
// Stop the current playback when the button is in its 'playing' state
|
||||
// and the user clicked the main button without picking a new voice.
|
||||
if (!voice && playbackState === 'playing') {
|
||||
setIsPlaying(false);
|
||||
return;
|
||||
}
|
||||
const target = voice ?? playAsVoice;
|
||||
if (!target) {
|
||||
toast({
|
||||
title: t('captures.toast.noVoice'),
|
||||
description: t('captures.toast.noVoiceDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (voice && voice.id !== playAsVoiceId) {
|
||||
updateCaptureSettings({ default_playback_voice_id: voice.id });
|
||||
}
|
||||
playAsMutation.mutate({ capture: selected, voice: target });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex gap-0 overflow-hidden -mx-8">
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
accept={CAPTURE_AUDIO_MIME}
|
||||
onChange={(e) => handleUploadFile(e, 'file')}
|
||||
className="hidden"
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={CAPTURE_AUDIO_MIME}
|
||||
onChange={(e) => handleUploadFile(e, 'file')}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Left: capture list */}
|
||||
<div className="w-[340px] shrink-0">
|
||||
<ListPane>
|
||||
<ListPaneHeader>
|
||||
<ListPaneTitleRow>
|
||||
<ListPaneTitle>{t('captures.title')}</ListPaneTitle>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 px-1.5 -ml-2 text-[10px] font-medium text-accent bg-accent/10 border border-accent/20"
|
||||
>
|
||||
{t('captures.beta')}
|
||||
</Badge>
|
||||
</ListPaneTitleRow>
|
||||
<ListPaneSearch
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder={t('captures.searchPlaceholder')}
|
||||
/>
|
||||
</ListPaneHeader>
|
||||
|
||||
<ListPaneScroll className={cn(isPlayerVisible && BOTTOM_SAFE_AREA_PADDING)}>
|
||||
<div className="px-4 pb-6 space-y-1">
|
||||
{capturesLoading ? (
|
||||
<div className="px-4 py-12 flex items-center justify-center text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="px-4 py-12 text-center text-sm text-muted-foreground">
|
||||
{search ? (
|
||||
<p>{t('captures.empty.noMatches', { query: search })}</p>
|
||||
) : (
|
||||
<p>{t('captures.empty.none')}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((capture) => {
|
||||
const isActive = selectedId === capture.id;
|
||||
const refined = !!capture.transcript_refined;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={capture.id}
|
||||
onClick={() => setSelectedId(capture.id)}
|
||||
className={cn(
|
||||
'w-full text-left p-3 rounded-lg transition-colors block',
|
||||
isActive
|
||||
? 'bg-muted/70 border border-border'
|
||||
: 'border border-transparent hover:bg-muted/30',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="text-[11px] text-muted-foreground font-medium">
|
||||
{formatDate(capture.created_at)}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<span className="text-[10px] text-muted-foreground/70 tabular-nums">
|
||||
{formatDuration(capture.duration_ms)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[13px] text-foreground/90 line-clamp-2 leading-snug mb-2">
|
||||
{snippetOf(capture)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<SourceBadge source={capture.source} />
|
||||
{refined && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 px-1.5 text-[10px] gap-1 font-medium bg-accent/10 text-accent border border-accent/20"
|
||||
>
|
||||
<Sparkles className="h-2.5 w-2.5" />
|
||||
{t('captures.transcript.refined')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ListPaneScroll>
|
||||
</ListPane>
|
||||
</div>
|
||||
|
||||
{/* Right: capture detail */}
|
||||
<div className="flex-1 flex flex-col relative overflow-hidden min-w-0">
|
||||
<div className="absolute top-0 left-0 right-0 h-20 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
|
||||
|
||||
{/* Top action bar */}
|
||||
<div className="absolute top-0 left-0 right-0 z-20 px-8">
|
||||
<div className="flex items-center gap-3 py-4">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
<span>
|
||||
{t('captures.header.modelSummary', {
|
||||
stt: sttModel.charAt(0).toUpperCase() + sttModel.slice(1),
|
||||
llm: llmModel,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
{session.pillState !== 'hidden' && (
|
||||
<CapturePill
|
||||
state={session.pillState}
|
||||
elapsedMs={session.pillElapsedMs}
|
||||
errorMessage={session.errorMessage}
|
||||
onDismiss={session.dismissError}
|
||||
onStop={session.isRecording ? session.stopRecording : undefined}
|
||||
/>
|
||||
)}
|
||||
{session.pillState === 'hidden' && (
|
||||
<>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/settings/captures">
|
||||
<Settings2 className="mr-2 h-4 w-4" />
|
||||
{t('captures.actions.configure')}
|
||||
</Link>
|
||||
</Button>
|
||||
{readiness.canRecord && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleUploadClick}
|
||||
disabled={session.isUploading}
|
||||
>
|
||||
{session.isUploading ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{session.isUploading ? t('captures.actions.importing') : t('captures.actions.import')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Hide Dictate when recording readiness fails so the user can't kick off
|
||||
a capture that has nowhere to land. Stop stays visible if a
|
||||
recording is somehow already in flight (e.g. a model was
|
||||
uninstalled mid-record) so the user can always cancel. */}
|
||||
{(readiness.canRecord || session.isRecording) && (
|
||||
<Button
|
||||
onClick={session.toggleRecording}
|
||||
disabled={session.isUploading && !session.isRecording}
|
||||
className="relative overflow-hidden transition-all bg-accent text-accent-foreground hover:bg-accent/90"
|
||||
>
|
||||
{session.isRecording ? (
|
||||
<>
|
||||
<Square className="h-4 w-4 mr-2 fill-current" />
|
||||
{t('captures.actions.stop')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mic className="h-4 w-4 mr-2" />
|
||||
{t('captures.actions.dictate')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selected ? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 overflow-y-auto pt-20 px-8 pb-8',
|
||||
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
|
||||
)}
|
||||
>
|
||||
{/* Meta row */}
|
||||
<div className="flex items-center gap-3 mb-4 text-xs text-muted-foreground">
|
||||
<span>{formatAbsoluteDate(selected.created_at)}</span>
|
||||
{selected.language && (
|
||||
<>
|
||||
<span className="text-muted-foreground/40">·</span>
|
||||
<span>{selected.language.toUpperCase()}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-muted-foreground/40">·</span>
|
||||
<SourceBadge source={selected.source} />
|
||||
</div>
|
||||
|
||||
{/* Audio player card */}
|
||||
<div className="rounded-xl border border-border bg-muted/20 p-4 mb-6">
|
||||
<CaptureInlinePlayer
|
||||
audioUrl={apiClient.getCaptureAudioUrl(selected.id)}
|
||||
fallbackDurationMs={selected.duration_ms}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Transcript header */}
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="inline-flex rounded-md bg-muted/40 p-0.5 border border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRefined(true)}
|
||||
disabled={!selected.transcript_refined}
|
||||
className={cn(
|
||||
'px-3 py-1 text-xs font-medium rounded transition-colors',
|
||||
showRefined && selected.transcript_refined
|
||||
? 'bg-background shadow-sm text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground disabled:opacity-40',
|
||||
)}
|
||||
>
|
||||
<Sparkles className="h-3 w-3 inline-block mr-1 -translate-y-px" />
|
||||
{t('captures.transcript.refined')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRefined(false)}
|
||||
className={cn(
|
||||
'px-3 py-1 text-xs font-medium rounded transition-colors',
|
||||
!showRefined || !selected.transcript_refined
|
||||
? 'bg-background shadow-sm text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Captions className="h-3 w-3 inline-block mr-1 -translate-y-px" />
|
||||
{t('captures.transcript.raw')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{showRefined && selected.transcript_refined
|
||||
? t('captures.transcript.refinedHint', { model: selected.llm_model ?? llmModel })
|
||||
: selected.stt_model
|
||||
? t('captures.transcript.rawHint', { model: selected.stt_model })
|
||||
: null}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Transcript body */}
|
||||
<div className="rounded-xl border border-border bg-muted/10">
|
||||
<Textarea
|
||||
key={`${selected.id}-${showRefined}`}
|
||||
defaultValue={
|
||||
showRefined && selected.transcript_refined
|
||||
? selected.transcript_refined
|
||||
: selected.transcript_raw
|
||||
}
|
||||
readOnly
|
||||
className="text-[15px] leading-relaxed min-h-[260px] border-0 bg-transparent resize-none focus-visible:ring-0 focus-visible:ring-offset-0 p-6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bottom actions */}
|
||||
<div className="flex items-center gap-2 mt-4 flex-wrap">
|
||||
<div className="inline-flex">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePlayAs()}
|
||||
disabled={!playAsVoice || playAsMutation.isPending}
|
||||
className={cn(
|
||||
'gap-2 rounded-r-none border-r-0 pr-3 pl-2 transition-colors',
|
||||
playbackState !== 'idle' &&
|
||||
'border-accent/50 text-foreground bg-accent/10 hover:bg-accent/15 hover:text-foreground hover:border-accent/50',
|
||||
)}
|
||||
>
|
||||
{playbackState === 'generating' ? (
|
||||
<>
|
||||
<AudioBars mode="generating" className="h-3.5" />
|
||||
{t('captures.actions.playAsGenerating')}
|
||||
</>
|
||||
) : playbackState === 'playing' ? (
|
||||
<>
|
||||
<Square className="h-3 w-3 fill-current" />
|
||||
{playAsVoice
|
||||
? t('captures.actions.playAsStop', { name: playAsVoice.name })
|
||||
: t('captures.actions.playAsStopFallback')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Volume2 className="h-3.5 w-3.5" />
|
||||
{playAsVoice
|
||||
? t('captures.actions.playAs', { name: playAsVoice.name })
|
||||
: t('captures.actions.playAsFallback')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'rounded-l-none px-2 transition-colors',
|
||||
playbackState !== 'idle' &&
|
||||
'border-accent/50 bg-accent/10 hover:bg-accent/15 hover:text-foreground hover:border-accent/50',
|
||||
)}
|
||||
disabled={!profiles || !profiles.length}
|
||||
>
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
<DropdownMenuLabel className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
|
||||
{t('captures.actions.playAsDropdownLabel')}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{profiles?.map((v) => (
|
||||
<DropdownMenuItem
|
||||
key={v.id}
|
||||
onClick={() => handlePlayAs(v)}
|
||||
className="py-2"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{v.name}</div>
|
||||
<div className="text-[11px] text-muted-foreground truncate">
|
||||
{v.description || v.language.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
{v.id === playAsVoiceId && (
|
||||
<Check className="h-3.5 w-3.5 text-accent shrink-0" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleCopy}>
|
||||
<Copy className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('captures.actions.copy')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => session.refine(selected.id)}
|
||||
disabled={session.isRefining}
|
||||
>
|
||||
{session.isRefining ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-3.5 w-3.5 mr-1.5" />
|
||||
)}
|
||||
{selected.transcript_refined
|
||||
? t('captures.actions.reRefine')
|
||||
: t('captures.actions.refine')}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('captures.actions.export')}
|
||||
<ChevronDown className="h-3.5 w-3.5 ml-1 opacity-70" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
<DropdownMenuLabel className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
|
||||
{t('captures.actions.exportDropdownLabel')}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleExportAudio}>
|
||||
<FileAudio className="h-3.5 w-3.5 mr-2 text-muted-foreground" />
|
||||
{t('captures.actions.exportAudio')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleExportTranscript}>
|
||||
<Captions className="h-3.5 w-3.5 mr-2 text-muted-foreground" />
|
||||
{t('captures.actions.exportTranscript')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleExportMarkdown}>
|
||||
<FileText className="h-3.5 w-3.5 mr-2 text-muted-foreground" />
|
||||
{t('captures.actions.exportMarkdown')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="text-muted-foreground "
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
|
||||
)}
|
||||
{t('captures.actions.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground pt-20">
|
||||
{capturesLoading ? (
|
||||
<div className="text-center space-y-3">
|
||||
<Captions className="h-10 w-10 mx-auto opacity-40" />
|
||||
<p className="text-sm">{t('captures.empty.loading')}</p>
|
||||
</div>
|
||||
) : captures.length ? (
|
||||
<div className="text-center space-y-3">
|
||||
<Captions className="h-10 w-10 mx-auto opacity-40" />
|
||||
<p className="text-sm">{t('captures.empty.pickOne')}</p>
|
||||
</div>
|
||||
) : hotkeyEnabled && !readiness.canRecord ? (
|
||||
<DictationReadinessChecklist readiness={readiness} />
|
||||
) : hotkeyEnabled && (pushToTalkKeys.length || toggleToTalkKeys.length) ? (
|
||||
<div className="max-w-sm mx-auto text-center space-y-5">
|
||||
<div className="space-y-2">
|
||||
{pushToTalkKeys.length ? (
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<ChordKeys keys={pushToTalkKeys} />
|
||||
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||
{t('captures.empty.holdToRecord')}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{toggleToTalkKeys.length ? (
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<ChordKeys keys={toggleToTalkKeys} />
|
||||
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||
{t('captures.empty.toggleHandsFree')}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
{t('captures.empty.pressShortcut')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-sm mx-auto text-center space-y-3">
|
||||
<Captions className="h-10 w-10 mx-auto opacity-40" />
|
||||
<p className="text-sm">{t('captures.empty.none')}</p>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t('captures.empty.turnOnShortcut')}
|
||||
</p>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to="/settings/captures">{t('captures.empty.openSettings')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('captures.deleteDialog.title')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t('captures.deleteDialog.description')}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<Button
|
||||
onClick={() => selected && deleteMutation.mutate(selected.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleteMutation.isPending ? t('captures.deleteDialog.deleting') : t('common.delete')}
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Accessibility,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Cpu,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Keyboard,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { ActiveDownloadTask } from '@/lib/api/types';
|
||||
import type { DictationReadiness, ReadinessGate } from '@/lib/hooks/useDictationReadiness';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
interface RowProps {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
ready: boolean;
|
||||
action?: React.ReactNode;
|
||||
}
|
||||
|
||||
function ChecklistRow({ icon, title, description, ready, action }: RowProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-lg border p-3.5 transition-colors',
|
||||
ready ? 'border-accent/20 bg-accent/5' : 'border-border bg-muted/20',
|
||||
)}
|
||||
>
|
||||
<div className="mt-0.5 shrink-0">
|
||||
{ready ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-accent" />
|
||||
) : (
|
||||
<Circle className="h-5 w-5 text-muted-foreground/50" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">{icon}</span>
|
||||
<p className="text-sm font-medium text-foreground">{title}</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">{description}</p>
|
||||
{!ready && action ? <div className="pt-1.5">{action}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function progressPercent(task: ActiveDownloadTask | undefined): number | null {
|
||||
if (!task) return null;
|
||||
if (typeof task.progress === 'number')
|
||||
return Math.round(Math.max(0, Math.min(100, task.progress)));
|
||||
if (task.current && task.total) return Math.round((task.current / task.total) * 100);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one row per dictation-readiness gate. Each unmet gate gets an
|
||||
* inline action — Download for missing models, Open Settings for missing
|
||||
* TCC permissions — so the user can resolve everything without leaving
|
||||
* Captures.
|
||||
*
|
||||
* Download-in-progress state is sourced from ``/tasks/active`` (same query
|
||||
* the Models page uses) so it survives unmount: navigating away and back
|
||||
* still shows "Downloading…" instead of resetting to "Download".
|
||||
*
|
||||
* The chord stays disarmed until every row is green; this is what stops the
|
||||
* "stuck pill" failure mode of pressing the chord with a missing model.
|
||||
*
|
||||
* ``compact`` drops the centered title/subheading block and the
|
||||
* empty-state max-width so the checklist can be embedded in a narrow
|
||||
* sidebar alongside other settings. Callers own their own heading in
|
||||
* that mode (typically an ``<h3>`` that matches the surrounding sidebar
|
||||
* section style).
|
||||
*/
|
||||
export function DictationReadinessChecklist({
|
||||
readiness,
|
||||
compact = false,
|
||||
}: {
|
||||
readiness: DictationReadiness;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: activeTasks } = useQuery({
|
||||
queryKey: ['activeTasks'],
|
||||
queryFn: () => apiClient.getActiveTasks(),
|
||||
// Mirror ModelManagement's cadence: 1s while a download is in flight,
|
||||
// 5s otherwise. Keeps progress feeling live without hammering when idle.
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data;
|
||||
const hasActive = data?.downloads.some((d) => d.status === 'downloading');
|
||||
return hasActive ? 1000 : 5000;
|
||||
},
|
||||
});
|
||||
|
||||
// Memo so the Map identity is stable across renders that don't change
|
||||
// activeTasks — otherwise the cleanup effect below saw a fresh Map every
|
||||
// render and re-fired on every 1 s poll tick.
|
||||
const downloadByModel = useMemo(() => {
|
||||
const m = new Map<string, ActiveDownloadTask>();
|
||||
for (const dl of activeTasks?.downloads ?? []) {
|
||||
if (dl.status === 'downloading') m.set(dl.model_name, dl);
|
||||
}
|
||||
return m;
|
||||
}, [activeTasks]);
|
||||
|
||||
// When a download disappears from activeTasks, it just finished — refetch
|
||||
// readiness immediately so the row flips to ✓ instead of waiting up to 5s
|
||||
// for the next readiness poll.
|
||||
const prevActive = useRef<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
const current = new Set(downloadByModel.keys());
|
||||
for (const name of prevActive.current) {
|
||||
if (!current.has(name)) {
|
||||
queryClient.invalidateQueries({ queryKey: ['capture-readiness'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
|
||||
break;
|
||||
}
|
||||
}
|
||||
prevActive.current = current;
|
||||
}, [downloadByModel, queryClient]);
|
||||
|
||||
const downloadMutation = useMutation({
|
||||
mutationFn: async ({ modelName }: { gate: ReadinessGate; modelName: string }) =>
|
||||
apiClient.triggerModelDownload(modelName),
|
||||
onSuccess: (_data, vars) => {
|
||||
// Bump activeTasks so the row immediately shows "Downloading…" without
|
||||
// waiting for the next 5s poll. modelStatus + readiness invalidations
|
||||
// keep adjacent UI in sync.
|
||||
queryClient.invalidateQueries({ queryKey: ['activeTasks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['capture-readiness'] });
|
||||
const displayName =
|
||||
vars.gate === 'stt' ? readiness.stt?.display_name : readiness.llm?.display_name;
|
||||
toast({
|
||||
title: t('captures.readiness.downloadStarted'),
|
||||
description: t('captures.readiness.downloadStartedDescription', { name: displayName }),
|
||||
});
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast({
|
||||
title: t('captures.readiness.downloadFailed'),
|
||||
description: err.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const sttSize =
|
||||
readiness.stt?.size_mb != null ? `${(readiness.stt.size_mb / 1000).toFixed(1)} GB` : null;
|
||||
const llmSize =
|
||||
readiness.llm?.size_mb != null ? `${(readiness.llm.size_mb / 1000).toFixed(1)} GB` : null;
|
||||
|
||||
function modelDownloadButton(
|
||||
gate: 'stt' | 'llm',
|
||||
modelName: string,
|
||||
ready: boolean,
|
||||
): React.ReactNode {
|
||||
const task = downloadByModel.get(modelName);
|
||||
const downloading = !ready && !!task;
|
||||
const pct = progressPercent(task);
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => downloadMutation.mutate({ gate, modelName })}
|
||||
disabled={downloading || downloadMutation.isPending}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{downloading ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
{pct != null
|
||||
? t('captures.readiness.downloadingPercent', { pct })
|
||||
: t('captures.readiness.downloading')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{t('captures.readiness.downloadButton')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('w-full space-y-2.5', !compact && 'max-w-md mx-auto')}>
|
||||
{!compact && (
|
||||
<div className="text-center mb-5 space-y-1">
|
||||
<h2 className="text-base font-semibold text-foreground">
|
||||
{t('captures.readiness.title')}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('captures.readiness.subheading')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{readiness.stt && (
|
||||
<ChecklistRow
|
||||
icon={<Cpu className="h-3.5 w-3.5" />}
|
||||
title={t('captures.readiness.stt.label', { name: readiness.stt.display_name })}
|
||||
description={
|
||||
readiness.stt.ready
|
||||
? t('captures.readiness.stt.ready')
|
||||
: sttSize
|
||||
? t('captures.readiness.stt.missingWithSize', { size: sttSize })
|
||||
: t('captures.readiness.stt.missing')
|
||||
}
|
||||
ready={readiness.stt.ready}
|
||||
action={modelDownloadButton('stt', readiness.stt.model_name, readiness.stt.ready)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{readiness.llm && (
|
||||
<ChecklistRow
|
||||
icon={<Cpu className="h-3.5 w-3.5" />}
|
||||
title={t('captures.readiness.llm.label', { name: readiness.llm.display_name })}
|
||||
description={
|
||||
readiness.llm.ready
|
||||
? t('captures.readiness.llm.ready')
|
||||
: llmSize
|
||||
? t('captures.readiness.llm.missingWithSize', { size: llmSize })
|
||||
: t('captures.readiness.llm.missing')
|
||||
}
|
||||
ready={readiness.llm.ready}
|
||||
action={modelDownloadButton('llm', readiness.llm.model_name, readiness.llm.ready)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Input Monitoring + Accessibility are macOS-only TCC permissions.
|
||||
The Rust stubs return true on Windows/Linux, so rendering these
|
||||
rows there would show permanent green checkmarks with copy
|
||||
that talks about macOS — noise. Hide on non-mac. */}
|
||||
{isMacOS && (
|
||||
<ChecklistRow
|
||||
icon={<Keyboard className="h-3.5 w-3.5" />}
|
||||
title={t('captures.readiness.inputMonitoring.label')}
|
||||
description={
|
||||
readiness.inputMonitoring
|
||||
? t('captures.readiness.inputMonitoring.ready')
|
||||
: t('captures.readiness.inputMonitoring.missing')
|
||||
}
|
||||
ready={readiness.inputMonitoring}
|
||||
action={
|
||||
<Button size="sm" onClick={readiness.openInputMonitoringSettings} className="gap-1.5">
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
{t('captures.readiness.inputMonitoring.openSettings')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isMacOS && (
|
||||
<ChecklistRow
|
||||
icon={<Accessibility className="h-3.5 w-3.5" />}
|
||||
title={t('captures.readiness.accessibility.label')}
|
||||
description={
|
||||
readiness.accessibility
|
||||
? t('captures.readiness.accessibility.ready')
|
||||
: t('captures.readiness.accessibility.missing')
|
||||
}
|
||||
ready={readiness.accessibility}
|
||||
action={
|
||||
<Button size="sm" onClick={readiness.openAccessibilitySettings} className="gap-1.5">
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
{t('captures.readiness.accessibility.openSettings')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isMacOS =
|
||||
typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent);
|
||||
@@ -0,0 +1,209 @@
|
||||
import { Keyboard } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
canonicalKeyFromEvent,
|
||||
displayLabelForKey,
|
||||
modifierSideHint,
|
||||
sortChordKeys,
|
||||
} from '@/lib/utils/keyCodes';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
interface ChordPickerProps {
|
||||
open: boolean;
|
||||
/** Title shown in the modal — caller picks "push-to-talk" vs "toggle". */
|
||||
title: string;
|
||||
description?: string;
|
||||
/** The chord currently saved, shown as the starting state. */
|
||||
initialKeys: string[];
|
||||
onSave: (keys: string[]) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal that captures a key chord from the browser keyboard. Tracks the
|
||||
* peak set of keys held during the session so the user can release
|
||||
* before clicking Save (otherwise they'd be saving while still holding
|
||||
* the shortcut, which is awkward).
|
||||
*
|
||||
* Browser limitation: we can only capture keys while Voicebox has key
|
||||
* focus, so the picker pulls focus to a hidden capture surface inside
|
||||
* the dialog. The actual chord runs through the Rust global hook —
|
||||
* this picker only writes the configuration the hook reads.
|
||||
*/
|
||||
export function ChordPicker({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
initialKeys,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: ChordPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
// Currently held set, peak set captured this session, and "is the user
|
||||
// mid-chord?". We freeze the peak when they release everything so the
|
||||
// Save button can read a stable value.
|
||||
const [pressed, setPressed] = useState<Set<string>>(new Set());
|
||||
const [captured, setCaptured] = useState<string[]>(initialKeys);
|
||||
const [unsupportedAttempt, setUnsupportedAttempt] = useState<string | null>(null);
|
||||
const captureRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Reset every time the modal re-opens — otherwise the previous picker
|
||||
// session's peak set leaks into the next open and confuses the user.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setPressed(new Set());
|
||||
setCaptured(initialKeys);
|
||||
setUnsupportedAttempt(null);
|
||||
// Defer focus to the next paint so the dialog is mounted.
|
||||
const timeoutId = window.setTimeout(() => captureRef.current?.focus(), 50);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}
|
||||
return;
|
||||
}, [open, initialKeys]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
// Esc reaches the dialog's onOpenChange and closes the modal — let
|
||||
// it pass through unmodified.
|
||||
if (event.key === 'Escape') return;
|
||||
// Tab cycles focus inside the dialog; capturing it would trap the
|
||||
// user. Same for the dialog's own keyboard interactions.
|
||||
if (event.key === 'Tab') return;
|
||||
|
||||
const canonical = canonicalKeyFromEvent(event);
|
||||
if (!canonical) {
|
||||
setUnsupportedAttempt(event.code || event.key || 'unknown');
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setUnsupportedAttempt(null);
|
||||
|
||||
setPressed((prev) => {
|
||||
if (prev.has(canonical)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(canonical);
|
||||
setCaptured((prevCaptured) => {
|
||||
const candidate = sortChordKeys(Array.from(next));
|
||||
// First key in a fresh sequence replaces the peak — otherwise a
|
||||
// user trying to swap a longer saved chord for a shorter one is
|
||||
// stuck because their candidate never beats the seed length.
|
||||
if (prev.size === 0) return candidate;
|
||||
return candidate.length >= prevCaptured.length ? candidate : prevCaptured;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleKeyUp = useCallback((event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' || event.key === 'Tab') return;
|
||||
const canonical = canonicalKeyFromEvent(event);
|
||||
if (!canonical) return;
|
||||
event.preventDefault();
|
||||
setPressed((prev) => {
|
||||
if (!prev.has(canonical)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(canonical);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Wire global listeners only while open. Capture phase so Voicebox's
|
||||
// own command palette / global shortcuts don't swallow the chord first.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
window.addEventListener('keydown', handleKeyDown, true);
|
||||
window.addEventListener('keyup', handleKeyUp, true);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown, true);
|
||||
window.removeEventListener('keyup', handleKeyUp, true);
|
||||
};
|
||||
}, [open, handleKeyDown, handleKeyUp]);
|
||||
|
||||
const displayKeys = pressed.size > 0
|
||||
? sortChordKeys(Array.from(pressed))
|
||||
: captured;
|
||||
|
||||
const canSave = captured.length > 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => { if (!next) onCancel(); }}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description ? <DialogDescription>{description}</DialogDescription> : null}
|
||||
</DialogHeader>
|
||||
|
||||
<div
|
||||
ref={captureRef}
|
||||
tabIndex={-1}
|
||||
className="rounded-lg border border-border bg-muted/30 p-6 outline-none focus:ring-2 focus:ring-accent"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Keyboard className="h-3.5 w-3.5" />
|
||||
{pressed.size > 0 ? t('captures.chord.capturing') : t('captures.chord.pressShortcut')}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-center gap-1.5 min-h-[2.5rem]">
|
||||
{displayKeys.length === 0 ? (
|
||||
<span className="text-sm text-muted-foreground italic">
|
||||
{t('captures.chord.noKeys')}
|
||||
</span>
|
||||
) : (
|
||||
displayKeys.map((k) => <ChordKey key={k} name={k} />)
|
||||
)}
|
||||
</div>
|
||||
{unsupportedAttempt ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{t('captures.chord.unsupported', { key: unsupportedAttempt })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={() => onSave(captured)} disabled={!canSave}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ChordKey({ name }: { name: string }) {
|
||||
const side = modifierSideHint(name);
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'relative inline-flex items-center justify-center h-8 min-w-[2rem] px-2',
|
||||
'rounded-md border border-border bg-background font-mono text-sm font-medium',
|
||||
'shadow-sm text-foreground',
|
||||
)}
|
||||
>
|
||||
{displayLabelForKey(name)}
|
||||
{side ? (
|
||||
<span className="absolute -top-1 -right-1 h-3.5 min-w-[0.875rem] px-0.5 rounded-sm bg-accent text-[8px] font-bold leading-none flex items-center justify-center text-accent-foreground">
|
||||
{side}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { CapturePill } from '@/components/CapturePill/CapturePill';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { FocusSnapshot } from '@/lib/api/types';
|
||||
import { useCaptureRecordingSession } from '@/lib/hooks/useCaptureRecordingSession';
|
||||
|
||||
/**
|
||||
* Floating dictate surface shown in a separate transparent Tauri window.
|
||||
* Mounted when the URL contains ``?view=dictate``. The main window bypasses
|
||||
* this branch and renders the full app shell.
|
||||
*
|
||||
* The pill surfaces for two independent cycles:
|
||||
* 1. User dictation — driven by ``dictate:start`` / ``dictate:stop``
|
||||
* from the Rust hotkey monitor.
|
||||
* 2. Agent speech — driven by ``dictate:speak-start`` / ``dictate:speak-end``
|
||||
* from the Rust ``speak_monitor`` (which owns the backend SSE stream).
|
||||
* On speak-start we subscribe to this single generation's status SSE,
|
||||
* then play ``/audio/{id}`` via a plain ``HTMLAudioElement`` when it
|
||||
* lands. When the audio element's ``ended`` fires, we emit
|
||||
* ``dictate:hide`` so Rust tucks the window away.
|
||||
*/
|
||||
export function DictateWindow() {
|
||||
// Force the host document chrome to be transparent so the Tauri window
|
||||
// takes on the pill's own shape.
|
||||
useEffect(() => {
|
||||
const prevHtml = document.documentElement.style.background;
|
||||
const prevBody = document.body.style.background;
|
||||
document.documentElement.style.background = 'transparent';
|
||||
document.body.style.background = 'transparent';
|
||||
return () => {
|
||||
document.documentElement.style.background = prevHtml;
|
||||
document.body.style.background = prevBody;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Snapshot of the focused UI element at chord-start, shipped over from
|
||||
// Rust on the ``dictate:start`` payload. Held in a ref so it survives
|
||||
// the 1–2 s transcribe + refine window — the paste only fires once the
|
||||
// final text comes back.
|
||||
const focusRef = useRef<FocusSnapshot | null>(null);
|
||||
|
||||
const session = useCaptureRecordingSession({
|
||||
onFinalText: async (text, _capture, allowAutoPaste) => {
|
||||
const focus = focusRef.current;
|
||||
// Consume-once: a second chord before this fires would overwrite
|
||||
// focusRef, but nulling it here guards against the late-arriving
|
||||
// refine-result firing a paste after the user has moved on.
|
||||
focusRef.current = null;
|
||||
if (!allowAutoPaste) return;
|
||||
if (!focus || !text.trim()) return;
|
||||
try {
|
||||
await invoke('paste_final_text', { text, focus });
|
||||
} catch (err) {
|
||||
// Surface accessibility failures to the main window so it can prompt
|
||||
// the user to grant permission. Other errors stay swallowed —
|
||||
// the transcription still landed in the captures list.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (/accessibility/i.test(msg)) {
|
||||
emit('system:accessibility-missing').catch(() => {});
|
||||
}
|
||||
console.warn('[dictate] paste_final_text failed:', err);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Route the chord events emitted from Rust into the session hook. Using a
|
||||
// ref so the `listen` effect only subscribes once — rebinding every render
|
||||
// would thrash the Tauri event bridge.
|
||||
const sessionRef = useRef(session);
|
||||
sessionRef.current = session;
|
||||
|
||||
useEffect(() => {
|
||||
const unlistens: Promise<UnlistenFn>[] = [];
|
||||
unlistens.push(
|
||||
listen<{ focus: FocusSnapshot | null }>('dictate:start', (event) => {
|
||||
focusRef.current = event.payload?.focus ?? null;
|
||||
sessionRef.current.startRecording();
|
||||
}),
|
||||
);
|
||||
unlistens.push(
|
||||
listen('dictate:stop', () => {
|
||||
if (sessionRef.current.isRecording) sessionRef.current.stopRecording();
|
||||
}),
|
||||
);
|
||||
return () => {
|
||||
for (const p of unlistens) p.then((fn) => fn()).catch(() => {});
|
||||
};
|
||||
}, []);
|
||||
|
||||
// --- Agent-speak cycle ---------------------------------------------------
|
||||
|
||||
const [speaking, setSpeaking] = useState<{
|
||||
generationId: string;
|
||||
// Null while the backend is still generating audio; set to the
|
||||
// wall-clock timestamp when audio playback actually begins, so the
|
||||
// pill's elapsed counter only ticks while sound is coming out.
|
||||
startedAt: number | null;
|
||||
} | null>(null);
|
||||
const [speakElapsed, setSpeakElapsed] = useState(0);
|
||||
|
||||
// Refs so handlers inside long-lived `listen()` callbacks can read the
|
||||
// latest state without re-subscribing on every render.
|
||||
const speakingRef = useRef<typeof speaking>(null);
|
||||
speakingRef.current = speaking;
|
||||
const statusSourceRef = useRef<EventSource | null>(null);
|
||||
const statusTimeoutRef = useRef<number | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
const clearStatusTimeout = () => {
|
||||
if (statusTimeoutRef.current !== null) {
|
||||
window.clearTimeout(statusTimeoutRef.current);
|
||||
statusTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const dismissSpeak = (id?: string) => {
|
||||
// Guard against a late dismiss targeting a stale cycle (a new speak
|
||||
// already started by the time audio.ended from the previous one fired).
|
||||
if (id && speakingRef.current && speakingRef.current.generationId !== id) return;
|
||||
statusSourceRef.current?.close();
|
||||
statusSourceRef.current = null;
|
||||
clearStatusTimeout();
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.src = '';
|
||||
audioRef.current = null;
|
||||
}
|
||||
setSpeaking(null);
|
||||
};
|
||||
|
||||
const startSpeakPlayback = (generationId: string) => {
|
||||
const audio = new Audio(apiClient.getAudioUrl(generationId));
|
||||
audio.onended = () => dismissSpeak(generationId);
|
||||
audio.onerror = () => dismissSpeak(generationId);
|
||||
// The pill window stays hidden through the ~1 s generation wait so the
|
||||
// user doesn't see a silent pill. We surface it the moment audio
|
||||
// actually starts playing, and that's also when the elapsed counter
|
||||
// arms.
|
||||
audio.onplaying = () => {
|
||||
emit('dictate:show').catch(() => {});
|
||||
setSpeaking((prev) =>
|
||||
prev && prev.generationId === generationId
|
||||
? { ...prev, startedAt: Date.now() }
|
||||
: prev,
|
||||
);
|
||||
setSpeakElapsed(0);
|
||||
};
|
||||
audioRef.current = audio;
|
||||
audio.play().catch((err) => {
|
||||
console.warn('[dictate] audio.play failed:', err);
|
||||
dismissSpeak(generationId);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const unlistens: Promise<UnlistenFn>[] = [];
|
||||
|
||||
// Rust emits the SSE payload as a JSON *string* (not a parsed object);
|
||||
// the payload shape for speak-start is
|
||||
// {generation_id, profile_name, source, client_id}.
|
||||
unlistens.push(
|
||||
listen<string>('dictate:speak-start', (event) => {
|
||||
let parsed: { generation_id?: string } = {};
|
||||
try {
|
||||
parsed = typeof event.payload === 'string' ? JSON.parse(event.payload) : {};
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const id = parsed.generation_id;
|
||||
if (!id) return;
|
||||
|
||||
// Tear down any previous cycle — last speak wins.
|
||||
dismissSpeak();
|
||||
|
||||
setSpeaking({ generationId: id, startedAt: null });
|
||||
setSpeakElapsed(0);
|
||||
|
||||
// Subscribe to this one generation's status. When it completes, the
|
||||
// `/audio/{id}` endpoint will serve the WAV we need to play.
|
||||
const source = new EventSource(apiClient.getGenerationStatusUrl(id));
|
||||
statusSourceRef.current = source;
|
||||
// Hard cap on how long the pill can sit in the 'speaking' state
|
||||
// without ever hearing back from the backend. Covers the case where
|
||||
// the gen row is deleted mid-flight (SSE 404s and EventSource silently
|
||||
// retries) or the backend goes away while a request is in flight.
|
||||
// Clears as soon as a real status event lands.
|
||||
clearStatusTimeout();
|
||||
statusTimeoutRef.current = window.setTimeout(() => {
|
||||
statusTimeoutRef.current = null;
|
||||
if (speakingRef.current?.generationId === id && !audioRef.current) {
|
||||
dismissSpeak(id);
|
||||
}
|
||||
}, 60_000);
|
||||
source.onmessage = (msg) => {
|
||||
try {
|
||||
const data = JSON.parse(msg.data) as { status?: string };
|
||||
if (data.status === 'completed') {
|
||||
clearStatusTimeout();
|
||||
source.close();
|
||||
if (statusSourceRef.current === source) statusSourceRef.current = null;
|
||||
startSpeakPlayback(id);
|
||||
} else if (data.status === 'failed' || data.status === 'not_found') {
|
||||
clearStatusTimeout();
|
||||
source.close();
|
||||
dismissSpeak(id);
|
||||
}
|
||||
} catch {
|
||||
// heartbeats / junk — ignore.
|
||||
}
|
||||
};
|
||||
source.onerror = () => {
|
||||
// EventSource auto-reconnects on transient drops; the timeout above
|
||||
// is the backstop for the case where it never recovers.
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// Speak-end from the backend is advisory: the authoritative dismiss is
|
||||
// `audio.ended`. But if generation failed or nothing ever triggered
|
||||
// playback, a short grace window followed by forced dismiss avoids a
|
||||
// stuck-visible pill.
|
||||
unlistens.push(
|
||||
listen<string>('dictate:speak-end', (event) => {
|
||||
let parsed: { generation_id?: string; status?: string } = {};
|
||||
try {
|
||||
parsed = typeof event.payload === 'string' ? JSON.parse(event.payload) : {};
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (parsed.status && parsed.status !== 'completed') {
|
||||
// Failed / cancelled — dismiss immediately.
|
||||
if (parsed.generation_id) dismissSpeak(parsed.generation_id);
|
||||
return;
|
||||
}
|
||||
// Completed: if audio never started (shouldn't happen, but guard),
|
||||
// auto-dismiss after 15 s so the pill never stays forever.
|
||||
const id = parsed.generation_id;
|
||||
window.setTimeout(() => {
|
||||
if (speakingRef.current?.generationId === id && !audioRef.current) {
|
||||
dismissSpeak(id);
|
||||
}
|
||||
}, 15_000);
|
||||
}),
|
||||
);
|
||||
|
||||
return () => {
|
||||
for (const p of unlistens) p.then((fn) => fn()).catch(() => {});
|
||||
dismissSpeak();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Advance the pill's elapsed-time label while audio is playing. Paused
|
||||
// during the pre-playback generation window (startedAt is null) so the
|
||||
// counter stays at 0:00 until sound actually starts.
|
||||
useEffect(() => {
|
||||
if (!speaking?.startedAt) return;
|
||||
const anchor = speaking.startedAt;
|
||||
const iv = window.setInterval(() => {
|
||||
setSpeakElapsed(Date.now() - anchor);
|
||||
}, 250);
|
||||
return () => window.clearInterval(iv);
|
||||
}, [speaking?.generationId, speaking?.startedAt]);
|
||||
|
||||
// --- Effective pill state -----------------------------------------------
|
||||
|
||||
const isSpeaking = Boolean(speaking);
|
||||
const effectiveState = isSpeaking ? 'speaking' : session.pillState;
|
||||
const effectiveElapsed = isSpeaking ? speakElapsed : session.pillElapsedMs;
|
||||
|
||||
// When the pill cycle ends (no capture AND no speak), tell Rust to tuck
|
||||
// the window away. Rust owns the hide + park-off-screen + click-through
|
||||
// combo because calling hide() directly from JS has been unreliable for
|
||||
// transparent always-on-top windows on macOS.
|
||||
useEffect(() => {
|
||||
if (effectiveState === 'hidden') {
|
||||
emit('dictate:hide').catch(() => {});
|
||||
}
|
||||
}, [effectiveState]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-screen w-screen flex items-center justify-center px-3"
|
||||
style={{ background: 'transparent' }}
|
||||
>
|
||||
{effectiveState !== 'hidden' ? (
|
||||
<CapturePill
|
||||
state={effectiveState}
|
||||
elapsedMs={effectiveElapsed}
|
||||
errorMessage={session.errorMessage}
|
||||
onDismiss={session.dismissError}
|
||||
onStop={session.isRecording ? session.stopRecording : undefined}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ChevronDown, ChevronRight, GripVertical, Plus, Power, Trash2 } from 'lucide-react';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { AvailableEffect, EffectConfig, EffectPresetResponse } from '@/lib/api/types';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
// Each effect in the chain gets a stable ID for dnd-kit
|
||||
interface EffectWithId extends EffectConfig {
|
||||
_id: string;
|
||||
}
|
||||
|
||||
let nextId = 0;
|
||||
function makeId() {
|
||||
return `fx-${++nextId}`;
|
||||
}
|
||||
|
||||
interface EffectsChainEditorProps {
|
||||
value: EffectConfig[];
|
||||
onChange: (chain: EffectConfig[]) => void;
|
||||
compact?: boolean;
|
||||
showPresets?: boolean;
|
||||
}
|
||||
|
||||
export function EffectsChainEditor({
|
||||
value,
|
||||
onChange,
|
||||
compact = false,
|
||||
showPresets = true,
|
||||
}: EffectsChainEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
// Maintain stable IDs for each effect across renders.
|
||||
// We use a ref to map value items to IDs, rebuilding when length changes.
|
||||
const idsRef = useRef<string[]>([]);
|
||||
const items: EffectWithId[] = useMemo(() => {
|
||||
// Grow ID array if effects were added
|
||||
while (idsRef.current.length < value.length) {
|
||||
idsRef.current.push(makeId());
|
||||
}
|
||||
// Shrink if effects were removed
|
||||
if (idsRef.current.length > value.length) {
|
||||
idsRef.current = idsRef.current.slice(0, value.length);
|
||||
}
|
||||
return value.map((e, i) => ({ ...e, _id: idsRef.current[i] }));
|
||||
}, [value]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
|
||||
const { data: availableEffects } = useQuery({
|
||||
queryKey: ['available-effects'],
|
||||
queryFn: () => apiClient.getAvailableEffects(),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const { data: presets } = useQuery({
|
||||
queryKey: ['effect-presets'],
|
||||
queryFn: () => apiClient.listEffectPresets(),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const effectsMap = useMemo(() => {
|
||||
const m = new Map<string, AvailableEffect>();
|
||||
if (availableEffects) {
|
||||
for (const e of availableEffects.effects) {
|
||||
m.set(e.type, e);
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}, [availableEffects]);
|
||||
|
||||
function addEffect(type: string) {
|
||||
const def = effectsMap.get(type);
|
||||
if (!def) return;
|
||||
const params: Record<string, number> = {};
|
||||
for (const [key, p] of Object.entries(def.params)) {
|
||||
params[key] = p.default;
|
||||
}
|
||||
const newEffect: EffectConfig = { type, enabled: true, params };
|
||||
const newId = makeId();
|
||||
idsRef.current = [...idsRef.current, newId];
|
||||
onChange([...value, newEffect]);
|
||||
setExpandedId(newId);
|
||||
}
|
||||
|
||||
const removeEffect = useCallback(
|
||||
(index: number) => {
|
||||
const removedId = idsRef.current[index];
|
||||
idsRef.current = idsRef.current.filter((_, i) => i !== index);
|
||||
onChange(value.filter((_, i) => i !== index));
|
||||
if (expandedId === removedId) setExpandedId(null);
|
||||
},
|
||||
[value, onChange, expandedId],
|
||||
);
|
||||
|
||||
const toggleEnabled = useCallback(
|
||||
(index: number) => {
|
||||
onChange(value.map((e, i) => (i === index ? { ...e, enabled: !e.enabled } : e)));
|
||||
},
|
||||
[value, onChange],
|
||||
);
|
||||
|
||||
const updateParam = useCallback(
|
||||
(index: number, paramName: string, paramValue: number) => {
|
||||
onChange(
|
||||
value.map((e, i) =>
|
||||
i === index ? { ...e, params: { ...e.params, [paramName]: paramValue } } : e,
|
||||
),
|
||||
);
|
||||
},
|
||||
[value, onChange],
|
||||
);
|
||||
|
||||
function loadPreset(preset: EffectPresetResponse) {
|
||||
idsRef.current = preset.effects_chain.map(() => makeId());
|
||||
onChange(preset.effects_chain);
|
||||
setExpandedId(null);
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
idsRef.current = [];
|
||||
onChange([]);
|
||||
setExpandedId(null);
|
||||
}
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const oldIndex = idsRef.current.indexOf(active.id as string);
|
||||
const newIndex = idsRef.current.indexOf(over.id as string);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
|
||||
idsRef.current = arrayMove(idsRef.current, oldIndex, newIndex);
|
||||
onChange(arrayMove([...value], oldIndex, newIndex));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', compact && 'text-sm')}>
|
||||
{/* Preset selector row */}
|
||||
{showPresets && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
onValueChange={(id) => {
|
||||
const preset = presets?.find((p) => p.id === id);
|
||||
if (preset) loadPreset(preset);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 flex-1 text-xs focus:ring-0 focus:ring-offset-0">
|
||||
<SelectValue placeholder={t('effects.chain.loadPreset')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{presets?.map((p) => {
|
||||
const name = p.is_builtin
|
||||
? t(`effects.builtinPresets.${p.name}.name`, { defaultValue: p.name })
|
||||
: p.name;
|
||||
const description = p.is_builtin
|
||||
? t(`effects.builtinPresets.${p.name}.description`, {
|
||||
defaultValue: p.description ?? '',
|
||||
})
|
||||
: p.description;
|
||||
return (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{name}
|
||||
{description && (
|
||||
<span className="ml-1 text-muted-foreground">- {description}</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{value.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs text-muted-foreground"
|
||||
onClick={clearAll}
|
||||
>
|
||||
{t('effects.chain.clear')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sortable effects chain */}
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={items.map((i) => i._id)} strategy={verticalListSortingStrategy}>
|
||||
{items.map((effect, index) => (
|
||||
<SortableEffectItem
|
||||
key={effect._id}
|
||||
id={effect._id}
|
||||
effect={effect}
|
||||
index={index}
|
||||
effectDef={effectsMap.get(effect.type)}
|
||||
isExpanded={expandedId === effect._id}
|
||||
onToggleExpand={() => setExpandedId(expandedId === effect._id ? null : effect._id)}
|
||||
onRemove={() => removeEffect(index)}
|
||||
onToggleEnabled={() => toggleEnabled(index)}
|
||||
onUpdateParam={(paramName, paramValue) => updateParam(index, paramName, paramValue)}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
{/* Add effect */}
|
||||
{availableEffects && (
|
||||
<Select onValueChange={addEffect}>
|
||||
<SelectTrigger className="h-8 border-dashed text-xs text-muted-foreground focus:ring-0 focus:ring-offset-0">
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
<SelectValue placeholder={t('effects.chain.addEffect')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableEffects.effects.map((e) => (
|
||||
<SelectItem key={e.type} value={e.type}>
|
||||
{t(`effects.types.${e.type}.label`, { defaultValue: e.label })}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sortable effect item
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SortableEffectItemProps {
|
||||
id: string;
|
||||
effect: EffectConfig;
|
||||
index: number;
|
||||
effectDef?: AvailableEffect;
|
||||
isExpanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
onRemove: () => void;
|
||||
onToggleEnabled: () => void;
|
||||
onUpdateParam: (paramName: string, paramValue: number) => void;
|
||||
}
|
||||
|
||||
function SortableEffectItem({
|
||||
id,
|
||||
effect,
|
||||
effectDef,
|
||||
isExpanded,
|
||||
onToggleExpand,
|
||||
onRemove,
|
||||
onToggleEnabled,
|
||||
onUpdateParam,
|
||||
}: SortableEffectItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 10 : undefined,
|
||||
};
|
||||
|
||||
const label = t(`effects.types.${effect.type}.label`, {
|
||||
defaultValue: effectDef?.label ?? effect.type,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
'rounded-md border',
|
||||
effect.enabled ? 'border-border bg-card' : 'border-border/50 bg-muted/30',
|
||||
isDragging && 'opacity-80 shadow-lg',
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-1 px-2 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground"
|
||||
onClick={onToggleExpand}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="p-0.5 text-muted-foreground/50 hover:text-muted-foreground cursor-grab active:cursor-grabbing touch-none"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
<span
|
||||
className={cn('flex-1 text-xs font-medium', !effect.enabled && 'text-muted-foreground')}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'p-0.5 transition-colors',
|
||||
effect.enabled ? 'text-primary' : 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
onClick={onToggleEnabled}
|
||||
title={effect.enabled ? t('effects.chain.disable') : t('effects.chain.enable')}
|
||||
>
|
||||
<Power className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="p-0.5 text-muted-foreground "
|
||||
onClick={onRemove}
|
||||
title={t('effects.chain.remove')}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Params */}
|
||||
{isExpanded && effectDef && (
|
||||
<div className="space-y-3 border-t px-3 py-2.5">
|
||||
{Object.entries(effectDef.params).map(([paramName, paramDef]) => {
|
||||
const currentValue = effect.params[paramName] ?? paramDef.default;
|
||||
return (
|
||||
<div key={paramName} className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-[11px] text-muted-foreground">
|
||||
{t(`effects.types.${effect.type}.params.${paramName}`, {
|
||||
defaultValue: paramDef.description,
|
||||
})}
|
||||
</Label>
|
||||
<span className="text-[11px] font-mono tabular-nums text-foreground">
|
||||
{currentValue.toFixed(
|
||||
paramDef.step < 1 ? Math.max(1, -Math.floor(Math.log10(paramDef.step))) : 0,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={paramDef.min}
|
||||
max={paramDef.max}
|
||||
step={paramDef.step}
|
||||
value={[currentValue]}
|
||||
onValueChange={([v]) => onUpdateParam(paramName, v)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { ChevronDown, Search } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import type { HistoryResponse } from '@/lib/api/types';
|
||||
import { useHistory } from '@/lib/hooks/useHistory';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
interface GenerationPickerProps {
|
||||
selectedId: string | null;
|
||||
onSelect: (generation: HistoryResponse) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function GenerationPicker({ selectedId, onSelect, className }: GenerationPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const { data: historyData } = useHistory({ limit: 50 });
|
||||
|
||||
const completedGenerations = useMemo(() => {
|
||||
if (!historyData?.items) return [];
|
||||
return historyData.items.filter((gen) => gen.status === 'completed');
|
||||
}, [historyData]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!searchQuery) return completedGenerations;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return completedGenerations.filter(
|
||||
(gen) => gen.text.toLowerCase().includes(q) || gen.profile_name.toLowerCase().includes(q),
|
||||
);
|
||||
}, [completedGenerations, searchQuery]);
|
||||
|
||||
const selectedGeneration = completedGenerations.find((g) => g.id === selectedId);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn('h-8 justify-between gap-2 text-xs font-normal', className)}
|
||||
>
|
||||
{selectedGeneration ? (
|
||||
<span className="truncate">
|
||||
<span className="font-medium">{selectedGeneration.profile_name}</span>
|
||||
<span className="text-muted-foreground ml-1.5">
|
||||
{selectedGeneration.text.length > 30
|
||||
? `${selectedGeneration.text.substring(0, 30)}...`
|
||||
: selectedGeneration.text}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Select a generation...</span>
|
||||
)}
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-0" align="start">
|
||||
<div className="p-2 border-b">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by voice or text..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8 pl-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="p-4 text-center text-xs text-muted-foreground">
|
||||
No generations found
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((gen) => (
|
||||
<button
|
||||
key={gen.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 hover:bg-muted/50 transition-colors border-b border-border/30 last:border-0',
|
||||
gen.id === selectedId && 'bg-accent/10',
|
||||
)}
|
||||
onClick={() => {
|
||||
onSelect(gen);
|
||||
setOpen(false);
|
||||
setSearchQuery('');
|
||||
}}
|
||||
>
|
||||
<div className="font-medium text-sm">{gen.profile_name}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{gen.text.length > 60 ? `${gen.text.substring(0, 60)}...` : gen.text}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2, Play, Save, Trash2, Wand2 } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
|
||||
import { GenerationPicker } from '@/components/Effects/GenerationPicker';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { HistoryResponse } from '@/lib/api/types';
|
||||
import { useHistory } from '@/lib/hooks/useHistory';
|
||||
import { useEffectsStore } from '@/stores/effectsStore';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
|
||||
export function EffectsDetail() {
|
||||
const { t } = useTranslation();
|
||||
const selectedPresetId = useEffectsStore((s) => s.selectedPresetId);
|
||||
const isCreatingNew = useEffectsStore((s) => s.isCreatingNew);
|
||||
const workingChain = useEffectsStore((s) => s.workingChain);
|
||||
const setWorkingChain = useEffectsStore((s) => s.setWorkingChain);
|
||||
const setSelectedPresetId = useEffectsStore((s) => s.setSelectedPresetId);
|
||||
const setIsCreatingNew = useEffectsStore((s) => s.setIsCreatingNew);
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
// "Save as Custom" dialog state
|
||||
const [saveAsDialogOpen, setSaveAsDialogOpen] = useState(false);
|
||||
const [saveAsName, setSaveAsName] = useState('');
|
||||
const [saveAsDescription, setSaveAsDescription] = useState('');
|
||||
|
||||
// Preview state
|
||||
const [previewGenId, setPreviewGenId] = useState<string | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const blobUrlRef = useRef<string | null>(null);
|
||||
const setAudioWithAutoPlay = usePlayerStore((s) => s.setAudioWithAutoPlay);
|
||||
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Auto-select the most recent generation as preview source
|
||||
const { data: historyData } = useHistory({ limit: 1 });
|
||||
useEffect(() => {
|
||||
if (!previewGenId && historyData?.items?.length) {
|
||||
const first = historyData.items.find((g) => g.status === 'completed');
|
||||
if (first) setPreviewGenId(first.id);
|
||||
}
|
||||
}, [historyData, previewGenId]);
|
||||
|
||||
const { data: preset } = useQuery({
|
||||
queryKey: ['effect-preset', selectedPresetId],
|
||||
queryFn: () =>
|
||||
selectedPresetId
|
||||
? apiClient
|
||||
.listEffectPresets()
|
||||
.then((all) => all.find((p) => p.id === selectedPresetId) ?? null)
|
||||
: null,
|
||||
enabled: !!selectedPresetId,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
// Sync name/description when selecting a preset
|
||||
useEffect(() => {
|
||||
if (preset) {
|
||||
setName(preset.name);
|
||||
setDescription(preset.description ?? '');
|
||||
} else if (isCreatingNew) {
|
||||
setName('');
|
||||
setDescription('');
|
||||
}
|
||||
}, [preset, isCreatingNew]);
|
||||
|
||||
// Cleanup blob URL on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current);
|
||||
blobUrlRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isEditing = !!selectedPresetId || isCreatingNew;
|
||||
const isBuiltIn = preset?.is_builtin ?? false;
|
||||
const presetName = preset
|
||||
? preset.is_builtin
|
||||
? t(`effects.builtinPresets.${preset.name}.name`, { defaultValue: preset.name })
|
||||
: preset.name
|
||||
: '';
|
||||
const presetDescription = preset
|
||||
? preset.is_builtin
|
||||
? t(`effects.builtinPresets.${preset.name}.description`, {
|
||||
defaultValue: preset.description ?? '',
|
||||
})
|
||||
: preset.description
|
||||
: '';
|
||||
|
||||
async function handlePreview() {
|
||||
if (!previewGenId || workingChain.length === 0) return;
|
||||
|
||||
setPreviewLoading(true);
|
||||
try {
|
||||
const blob = await apiClient.previewEffects(previewGenId, workingChain);
|
||||
|
||||
// Revoke old blob URL
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current);
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
blobUrlRef.current = url;
|
||||
|
||||
// Play through the main audio player
|
||||
setAudioWithAutoPlay(url, `preview-${Date.now()}`, null, 'Effects Preview');
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('effects.toast.previewFailed'),
|
||||
description: error instanceof Error ? error.message : t('common.unknownError'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectGeneration(gen: HistoryResponse) {
|
||||
setPreviewGenId(gen.id);
|
||||
}
|
||||
|
||||
async function handleSaveNew() {
|
||||
if (!name.trim()) {
|
||||
toast({ title: t('effects.toast.nameRequired'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const created = await apiClient.createEffectPreset({
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
effects_chain: workingChain,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
|
||||
setIsCreatingNew(false);
|
||||
setSelectedPresetId(created.id);
|
||||
toast({
|
||||
title: t('effects.toast.saved'),
|
||||
description: t('effects.toast.createdDescription', { name: created.name }),
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('effects.toast.saveFailed'),
|
||||
description: error instanceof Error ? error.message : t('common.unknownError'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveExisting() {
|
||||
if (!selectedPresetId || !name.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiClient.updateEffectPreset(selectedPresetId, {
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
effects_chain: workingChain,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['effect-preset', selectedPresetId] });
|
||||
toast({ title: t('effects.toast.updated') });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('effects.toast.saveFailed'),
|
||||
description: error instanceof Error ? error.message : t('common.unknownError'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSaveAsNew() {
|
||||
const sourceName = isBuiltIn ? presetName : name;
|
||||
setSaveAsName(t('effects.saveAs.suggestedName', { name: sourceName }));
|
||||
setSaveAsDescription(description);
|
||||
setSaveAsDialogOpen(true);
|
||||
}
|
||||
|
||||
async function handleSaveAsConfirm() {
|
||||
if (!saveAsName.trim()) {
|
||||
toast({ title: t('effects.toast.nameRequired'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const created = await apiClient.createEffectPreset({
|
||||
name: saveAsName.trim(),
|
||||
description: saveAsDescription.trim() || undefined,
|
||||
effects_chain: workingChain,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
|
||||
setSaveAsDialogOpen(false);
|
||||
setSelectedPresetId(created.id);
|
||||
toast({
|
||||
title: t('effects.toast.saved'),
|
||||
description: t('effects.toast.createdDescription', { name: created.name }),
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('effects.toast.saveFailed'),
|
||||
description: error instanceof Error ? error.message : t('common.unknownError'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!selectedPresetId) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await apiClient.deleteEffectPreset(selectedPresetId);
|
||||
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
|
||||
setSelectedPresetId(null);
|
||||
setWorkingChain([]);
|
||||
toast({ title: t('effects.toast.deleted') });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('effects.toast.deleteFailed'),
|
||||
description: error instanceof Error ? error.message : t('common.unknownError'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isEditing) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center space-y-2">
|
||||
<Wand2 className="h-10 w-10 mx-auto opacity-30" />
|
||||
<p className="text-sm">{t('effects.placeholder')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{isCreatingNew
|
||||
? t('effects.detail.newTitle')
|
||||
: isBuiltIn
|
||||
? presetName
|
||||
: t('effects.detail.editTitle')}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isBuiltIn && !isCreatingNew && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-destructive gap-1.5"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{deleting ? t('effects.detail.deleting') : t('common.delete')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 gap-1.5"
|
||||
onClick={handleSaveExisting}
|
||||
disabled={saving || workingChain.length === 0}
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
{saving ? t('effects.detail.saving') : t('common.save')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isCreatingNew && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 gap-1.5"
|
||||
onClick={handleSaveNew}
|
||||
disabled={saving || workingChain.length === 0}
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
{saving ? t('effects.detail.saving') : t('effects.detail.savePreset')}
|
||||
</Button>
|
||||
)}
|
||||
{isBuiltIn && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 gap-1.5"
|
||||
onClick={handleSaveAsNew}
|
||||
disabled={saving}
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
{saving ? t('effects.detail.saving') : t('effects.detail.saveAsCustom')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto space-y-5 pr-1">
|
||||
{(isCreatingNew || !isBuiltIn) && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">{t('effects.fields.name')}</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t('effects.fields.namePlaceholder')}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">{t('effects.fields.description')}</Label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t('effects.fields.descriptionPlaceholder')}
|
||||
className="min-h-[60px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isBuiltIn && presetDescription && (
|
||||
<p className="text-sm text-muted-foreground">{presetDescription}</p>
|
||||
)}
|
||||
|
||||
<EffectsChainEditor value={workingChain} onChange={setWorkingChain} showPresets={false} />
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs">{t('effects.preview.label')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<GenerationPicker
|
||||
selectedId={previewGenId}
|
||||
onSelect={handleSelectGeneration}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 shrink-0"
|
||||
onClick={handlePreview}
|
||||
disabled={!previewGenId || workingChain.length === 0 || previewLoading}
|
||||
>
|
||||
{previewLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
{t('effects.preview.processing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
{t('effects.preview.button')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">{t('effects.preview.hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={saveAsDialogOpen} onOpenChange={setSaveAsDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('effects.saveAs.title')}</DialogTitle>
|
||||
<DialogDescription>{t('effects.saveAs.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">{t('effects.fields.name')}</Label>
|
||||
<Input
|
||||
value={saveAsName}
|
||||
onChange={(e) => setSaveAsName(e.target.value)}
|
||||
placeholder={t('effects.fields.namePlaceholder')}
|
||||
className="h-9"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && saveAsName.trim()) {
|
||||
handleSaveAsConfirm();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">{t('effects.fields.description')}</Label>
|
||||
<Textarea
|
||||
value={saveAsDescription}
|
||||
onChange={(e) => setSaveAsDescription(e.target.value)}
|
||||
placeholder={t('effects.fields.descriptionPlaceholder')}
|
||||
className="min-h-[60px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSaveAsDialogOpen(false)} disabled={saving}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSaveAsConfirm} disabled={saving || !saveAsName.trim()}>
|
||||
<Save className="h-3.5 w-3.5 mr-1.5" />
|
||||
{saving ? t('effects.detail.saving') : t('common.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Loader2, Plus, Sparkles, Wand2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ListPane,
|
||||
ListPaneActions,
|
||||
ListPaneHeader,
|
||||
ListPaneScroll,
|
||||
ListPaneTitle,
|
||||
ListPaneTitleRow,
|
||||
} from '@/components/ListPane';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { EffectPresetResponse } from '@/lib/api/types';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { useEffectsStore } from '@/stores/effectsStore';
|
||||
|
||||
export function EffectsList() {
|
||||
const { t } = useTranslation();
|
||||
const selectedPresetId = useEffectsStore((s) => s.selectedPresetId);
|
||||
const setSelectedPresetId = useEffectsStore((s) => s.setSelectedPresetId);
|
||||
const setWorkingChain = useEffectsStore((s) => s.setWorkingChain);
|
||||
const setIsCreatingNew = useEffectsStore((s) => s.setIsCreatingNew);
|
||||
const isCreatingNew = useEffectsStore((s) => s.isCreatingNew);
|
||||
|
||||
const { data: presets, isLoading } = useQuery({
|
||||
queryKey: ['effect-presets'],
|
||||
queryFn: () => apiClient.listEffectPresets(),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const builtIn = presets?.filter((p) => p.is_builtin) ?? [];
|
||||
const userPresets = presets?.filter((p) => !p.is_builtin) ?? [];
|
||||
|
||||
function handleSelect(preset: EffectPresetResponse) {
|
||||
setSelectedPresetId(preset.id);
|
||||
setWorkingChain(preset.effects_chain);
|
||||
}
|
||||
|
||||
function handleCreateNew() {
|
||||
setIsCreatingNew(true);
|
||||
setWorkingChain([]);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ListPane>
|
||||
<ListPaneHeader>
|
||||
<ListPaneTitleRow>
|
||||
<ListPaneTitle>{t('effects.title')}</ListPaneTitle>
|
||||
<ListPaneActions>
|
||||
<Button onClick={handleCreateNew} size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('effects.newPreset')}
|
||||
</Button>
|
||||
</ListPaneActions>
|
||||
</ListPaneTitleRow>
|
||||
</ListPaneHeader>
|
||||
|
||||
<ListPaneScroll className="pt-16">
|
||||
<div className="px-4 pb-6 space-y-4">
|
||||
{builtIn.length > 0 && (
|
||||
<div>
|
||||
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
|
||||
{t('effects.sections.builtin')}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{builtIn.map((preset) => (
|
||||
<PresetCard
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
isSelected={selectedPresetId === preset.id && !isCreatingNew}
|
||||
onSelect={() => handleSelect(preset)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{userPresets.length > 0 && (
|
||||
<div>
|
||||
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
|
||||
{t('effects.sections.custom')}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{userPresets.map((preset) => (
|
||||
<PresetCard
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
isSelected={selectedPresetId === preset.id && !isCreatingNew}
|
||||
onSelect={() => handleSelect(preset)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCreatingNew && (
|
||||
<div>
|
||||
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
|
||||
{t('effects.sections.new')}
|
||||
</div>
|
||||
<div className="rounded-xl border-2 border-accent/40 bg-accent/5 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-accent" />
|
||||
<span className="text-sm font-medium">{t('effects.unsaved.title')}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t('effects.unsaved.hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ListPaneScroll>
|
||||
</ListPane>
|
||||
);
|
||||
}
|
||||
|
||||
function PresetCard({
|
||||
preset,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
preset: EffectPresetResponse;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const effectCount = preset.effects_chain.length;
|
||||
const name = preset.is_builtin
|
||||
? t(`effects.builtinPresets.${preset.name}.name`, { defaultValue: preset.name })
|
||||
: preset.name;
|
||||
const description = preset.is_builtin
|
||||
? t(`effects.builtinPresets.${preset.name}.description`, {
|
||||
defaultValue: preset.description ?? '',
|
||||
})
|
||||
: preset.description;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'w-full text-left rounded-xl border p-3 h-[88px] transition-all duration-150',
|
||||
isSelected
|
||||
? 'border-accent/50 bg-accent/10'
|
||||
: 'border-border bg-card hover:bg-muted/50 hover:border-border',
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Wand2
|
||||
className={cn('h-4 w-4 shrink-0', isSelected ? 'text-accent' : 'text-muted-foreground')}
|
||||
/>
|
||||
<span className="text-sm font-medium truncate">{name}</span>
|
||||
{preset.is_builtin && (
|
||||
<span className="text-[10px] bg-muted text-muted-foreground px-1.5 py-0.5 rounded-full shrink-0">
|
||||
{t('effects.badge.builtin')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-1 pl-6">
|
||||
{description || t('effects.noDescription')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1.5 pl-6">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t('effects.effectCount', { count: effectCount })}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/50">
|
||||
{preset.effects_chain
|
||||
.filter((e) => e.enabled)
|
||||
.map((e) => e.type)
|
||||
.join(' → ')}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { EffectsDetail } from './EffectsDetail';
|
||||
import { EffectsList } from './EffectsList';
|
||||
|
||||
export function EffectsTab() {
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 overflow-hidden -mx-8">
|
||||
<div className="flex-1 min-h-0 flex gap-6 overflow-hidden">
|
||||
{/* Left - Presets list */}
|
||||
<div className="w-full max-w-[360px] shrink-0 flex flex-col min-h-0">
|
||||
<EffectsList />
|
||||
</div>
|
||||
|
||||
{/* Right - Detail / editor */}
|
||||
<div className="flex-1 min-h-0 flex flex-col pr-8">
|
||||
<EffectsDetail />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
# Voice generation components
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { UseFormReturn } from 'react-hook-form';
|
||||
import { FormControl } from '@/components/ui/form';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { VoiceProfileResponse } from '@/lib/api/types';
|
||||
import { getLanguageOptionsForEngine } from '@/lib/constants/languages';
|
||||
import type { GenerationFormValues } from '@/lib/hooks/useGenerationForm';
|
||||
|
||||
/**
|
||||
* Engine/model options and their display metadata.
|
||||
* Adding a new engine means adding one entry here.
|
||||
*/
|
||||
const ENGINE_OPTIONS = [
|
||||
{ value: 'qwen:1.7B', label: 'Qwen3-TTS 1.7B', engine: 'qwen' },
|
||||
{ value: 'qwen:0.6B', label: 'Qwen3-TTS 0.6B', engine: 'qwen' },
|
||||
{ value: 'qwen_custom_voice:1.7B', label: 'Qwen CustomVoice 1.7B', engine: 'qwen_custom_voice' },
|
||||
{ value: 'qwen_custom_voice:0.6B', label: 'Qwen CustomVoice 0.6B', engine: 'qwen_custom_voice' },
|
||||
{ value: 'luxtts', label: 'LuxTTS', engine: 'luxtts' },
|
||||
{ value: 'chatterbox', label: 'Chatterbox', engine: 'chatterbox' },
|
||||
{ value: 'chatterbox_turbo', label: 'Chatterbox Turbo', engine: 'chatterbox_turbo' },
|
||||
{ value: 'tada:1B', label: 'TADA 1B', engine: 'tada' },
|
||||
{ value: 'tada:3B', label: 'TADA 3B Multilingual', engine: 'tada' },
|
||||
{ value: 'kokoro', label: 'Kokoro 82M', engine: 'kokoro' },
|
||||
] as const;
|
||||
|
||||
const ENGINE_DESCRIPTIONS: Record<string, string> = {
|
||||
qwen: 'Multi-language, two sizes',
|
||||
qwen_custom_voice: '9 preset voices, instruct control',
|
||||
luxtts: 'Fast, English-focused',
|
||||
chatterbox: '23 languages, incl. Hebrew',
|
||||
chatterbox_turbo: 'English, [laugh] [cough] tags',
|
||||
tada: 'HumeAI, 700s+ coherent audio',
|
||||
kokoro: '82M params, CPU realtime, 8 langs',
|
||||
};
|
||||
|
||||
/** Engines that only support English and should force language to 'en' on select. */
|
||||
const ENGLISH_ONLY_ENGINES = new Set(['luxtts', 'chatterbox_turbo']);
|
||||
|
||||
/** Engines that support cloned (reference audio) profiles. */
|
||||
const CLONING_ENGINES = new Set(['qwen', 'luxtts', 'chatterbox', 'chatterbox_turbo', 'tada']);
|
||||
|
||||
function getAvailableOptions(selectedProfile?: VoiceProfileResponse | null) {
|
||||
if (!selectedProfile) return ENGINE_OPTIONS;
|
||||
return ENGINE_OPTIONS.filter((opt) => isProfileCompatibleWithEngine(selectedProfile, opt.engine));
|
||||
}
|
||||
|
||||
function getSelectValue(engine: string, modelSize?: string): string {
|
||||
if (engine === 'qwen') return `qwen:${modelSize || '1.7B'}`;
|
||||
if (engine === 'qwen_custom_voice') return `qwen_custom_voice:${modelSize || '1.7B'}`;
|
||||
if (engine === 'tada') return `tada:${modelSize || '1B'}`;
|
||||
return engine;
|
||||
}
|
||||
|
||||
export function applyEngineSelection(form: UseFormReturn<GenerationFormValues>, value: string) {
|
||||
if (value.startsWith('qwen_custom_voice:')) {
|
||||
const [, modelSize] = value.split(':');
|
||||
form.setValue('engine', 'qwen_custom_voice');
|
||||
form.setValue('modelSize', modelSize as '1.7B' | '0.6B');
|
||||
const currentLang = form.getValues('language');
|
||||
const available = getLanguageOptionsForEngine('qwen_custom_voice');
|
||||
if (!available.some((l) => l.value === currentLang)) {
|
||||
form.setValue('language', available[0]?.value ?? 'en');
|
||||
}
|
||||
} else if (value.startsWith('qwen:')) {
|
||||
const [, modelSize] = value.split(':');
|
||||
form.setValue('engine', 'qwen');
|
||||
form.setValue('modelSize', modelSize as '1.7B' | '0.6B');
|
||||
// Validate language is supported by Qwen
|
||||
const currentLang = form.getValues('language');
|
||||
const available = getLanguageOptionsForEngine('qwen');
|
||||
if (!available.some((l) => l.value === currentLang)) {
|
||||
form.setValue('language', available[0]?.value ?? 'en');
|
||||
}
|
||||
} else if (value.startsWith('tada:')) {
|
||||
const [, modelSize] = value.split(':');
|
||||
form.setValue('engine', 'tada');
|
||||
form.setValue('modelSize', modelSize as '1B' | '3B');
|
||||
// TADA 1B is English-only; 3B is multilingual
|
||||
if (modelSize === '1B') {
|
||||
form.setValue('language', 'en');
|
||||
} else {
|
||||
const currentLang = form.getValues('language');
|
||||
const available = getLanguageOptionsForEngine('tada');
|
||||
if (!available.some((l) => l.value === currentLang)) {
|
||||
form.setValue('language', available[0]?.value ?? 'en');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
form.setValue('engine', value as GenerationFormValues['engine']);
|
||||
form.setValue('modelSize', undefined as unknown as '1.7B' | '0.6B');
|
||||
if (ENGLISH_ONLY_ENGINES.has(value)) {
|
||||
form.setValue('language', 'en');
|
||||
} else {
|
||||
// If current language isn't supported by the new engine, reset to first available
|
||||
const currentLang = form.getValues('language');
|
||||
const available = getLanguageOptionsForEngine(value);
|
||||
if (!available.some((l) => l.value === currentLang)) {
|
||||
form.setValue('language', available[0]?.value ?? 'en');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface EngineModelSelectorProps {
|
||||
form: UseFormReturn<GenerationFormValues>;
|
||||
compact?: boolean;
|
||||
selectedProfile?: VoiceProfileResponse | null;
|
||||
}
|
||||
|
||||
export function EngineModelSelector({ form, compact, selectedProfile }: EngineModelSelectorProps) {
|
||||
const engine = form.watch('engine') || 'qwen';
|
||||
const modelSize = form.watch('modelSize');
|
||||
const selectValue = getSelectValue(engine, modelSize);
|
||||
const availableOptions = getAvailableOptions(selectedProfile);
|
||||
|
||||
const currentEngineAvailable = availableOptions.some((opt) => opt.value === selectValue);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentEngineAvailable && availableOptions.length > 0) {
|
||||
applyEngineSelection(form, availableOptions[0].value);
|
||||
}
|
||||
}, [availableOptions, currentEngineAvailable, form]);
|
||||
|
||||
const itemClass = compact ? 'text-xs text-muted-foreground' : undefined;
|
||||
const triggerClass = compact
|
||||
? 'h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all'
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Select value={selectValue} onValueChange={(v) => applyEngineSelection(form, v)}>
|
||||
<FormControl>
|
||||
<SelectTrigger className={triggerClass}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{availableOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value} className={itemClass}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns a human-readable description for the currently selected engine. */
|
||||
export function getEngineDescription(engine: string): string {
|
||||
return ENGINE_DESCRIPTIONS[engine] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a profile is compatible with the currently selected engine.
|
||||
* Useful for UI hints.
|
||||
*/
|
||||
export function isProfileCompatibleWithEngine(
|
||||
profile: VoiceProfileResponse,
|
||||
engine: string,
|
||||
): boolean {
|
||||
const voiceType = profile.voice_type || 'cloned';
|
||||
if (voiceType === 'preset') return profile.preset_engine === engine;
|
||||
if (voiceType === 'cloned') return CLONING_ENGINES.has(engine);
|
||||
return true; // designed — future
|
||||
}
|
||||
@@ -0,0 +1,639 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useMatchRoute } from '@tanstack/react-router';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Dices, Loader2, SlidersHorizontal, Sparkles, Wand2 } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { getLanguageOptionsForEngine, type LanguageCode } from '@/lib/constants/languages';
|
||||
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
|
||||
import { useProfile, useProfiles } from '@/lib/hooks/useProfiles';
|
||||
import { useStory } from '@/lib/hooks/useStories';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { useGenerationStore } from '@/stores/generationStore';
|
||||
import { useStoryStore } from '@/stores/storyStore';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
import { EngineModelSelector } from './EngineModelSelector';
|
||||
import { ParalinguisticInput } from './ParalinguisticInput';
|
||||
|
||||
interface FloatingGenerateBoxProps {
|
||||
isPlayerOpen?: boolean;
|
||||
showVoiceSelector?: boolean;
|
||||
}
|
||||
|
||||
export function FloatingGenerateBox({
|
||||
isPlayerOpen = false,
|
||||
showVoiceSelector = false,
|
||||
}: FloatingGenerateBoxProps) {
|
||||
const { t } = useTranslation();
|
||||
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
||||
const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId);
|
||||
const setSelectedEngine = useUIStore((state) => state.setSelectedEngine);
|
||||
const { data: selectedProfile } = useProfile(selectedProfileId || '');
|
||||
const { data: profiles } = useProfiles();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isInstructExpanded, setIsInstructExpanded] = useState(false);
|
||||
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const matchRoute = useMatchRoute();
|
||||
const isStoriesRoute = matchRoute({ to: '/stories' });
|
||||
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
|
||||
const { data: currentStory } = useStory(selectedStoryId);
|
||||
const addPendingStoryAdd = useGenerationStore((s) => s.addPendingStoryAdd);
|
||||
const { toast } = useToast();
|
||||
|
||||
const composeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!selectedProfileId) throw new Error('No profile selected');
|
||||
return apiClient.composeWithPersonality(selectedProfileId);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast({
|
||||
title: t('generation.compose.failedTitle'),
|
||||
description: err.message || t('generation.compose.failedDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch effect presets for the dropdown
|
||||
const { data: effectPresets } = useQuery({
|
||||
queryKey: ['effectPresets'],
|
||||
queryFn: () => apiClient.listEffectPresets(),
|
||||
});
|
||||
|
||||
// Calculate if track editor is visible (on stories route with items)
|
||||
const hasTrackEditor = isStoriesRoute && currentStory && currentStory.items.length > 0;
|
||||
|
||||
const { form, handleSubmit, isPending } = useGenerationForm({
|
||||
onSuccess: async (generationId) => {
|
||||
setIsExpanded(false);
|
||||
// Defer the story add until TTS completes -- useGenerationProgress handles it
|
||||
if (isStoriesRoute && selectedStoryId && generationId) {
|
||||
addPendingStoryAdd(generationId, selectedStoryId);
|
||||
}
|
||||
},
|
||||
getEffectsChain: () => {
|
||||
if (!selectedPresetId) return undefined;
|
||||
// Profile's own effects chain (no matching preset)
|
||||
if (selectedPresetId === '_profile') {
|
||||
return selectedProfile?.effects_chain ?? undefined;
|
||||
}
|
||||
if (!effectPresets) return undefined;
|
||||
const preset = effectPresets.find((p) => p.id === selectedPresetId);
|
||||
return preset?.effects_chain;
|
||||
},
|
||||
});
|
||||
|
||||
// Click away handler to collapse the box
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// Don't collapse if clicking inside the container
|
||||
if (containerRef.current?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't collapse if clicking on a Select dropdown (which renders in a portal)
|
||||
if (
|
||||
target.closest('[role="listbox"]') ||
|
||||
target.closest('[data-radix-popper-content-wrapper]')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExpanded(false);
|
||||
}
|
||||
|
||||
if (isExpanded) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isExpanded]);
|
||||
|
||||
// Set first voice as default if none selected
|
||||
useEffect(() => {
|
||||
if (!selectedProfileId && profiles && profiles.length > 0) {
|
||||
setSelectedProfileId(profiles[0].id);
|
||||
}
|
||||
}, [selectedProfileId, profiles, setSelectedProfileId]);
|
||||
|
||||
// Sync engine selection to global store so ProfileList can filter
|
||||
const watchedEngine = form.watch('engine');
|
||||
useEffect(() => {
|
||||
if (watchedEngine) {
|
||||
setSelectedEngine(watchedEngine);
|
||||
}
|
||||
}, [watchedEngine, setSelectedEngine]);
|
||||
|
||||
// Sync generation form language, engine, and effects with selected profile
|
||||
type EngineValue =
|
||||
| 'qwen'
|
||||
| 'luxtts'
|
||||
| 'chatterbox'
|
||||
| 'chatterbox_turbo'
|
||||
| 'tada'
|
||||
| 'kokoro'
|
||||
| 'qwen_custom_voice';
|
||||
useEffect(() => {
|
||||
if (selectedProfile?.language) {
|
||||
form.setValue('language', selectedProfile.language as LanguageCode);
|
||||
}
|
||||
// Auto-switch engine to match the profile
|
||||
const engine = selectedProfile?.default_engine ?? selectedProfile?.preset_engine;
|
||||
if (engine) {
|
||||
form.setValue('engine', engine as EngineValue);
|
||||
} else if (selectedProfile && selectedProfile.voice_type !== 'preset') {
|
||||
// Cloned/designed profile with no default — ensure a compatible (non-preset) engine
|
||||
const currentEngine = form.getValues('engine');
|
||||
const presetEngines = new Set(['kokoro', 'qwen_custom_voice']);
|
||||
if (currentEngine && presetEngines.has(currentEngine)) {
|
||||
form.setValue('engine', 'qwen');
|
||||
}
|
||||
}
|
||||
// Pre-fill effects from profile defaults
|
||||
if (
|
||||
selectedProfile?.effects_chain &&
|
||||
selectedProfile.effects_chain.length > 0 &&
|
||||
effectPresets
|
||||
) {
|
||||
// Try to match against a known preset
|
||||
const profileChainJson = JSON.stringify(selectedProfile.effects_chain);
|
||||
const matchingPreset = effectPresets.find(
|
||||
(p) => JSON.stringify(p.effects_chain) === profileChainJson,
|
||||
);
|
||||
if (matchingPreset) {
|
||||
setSelectedPresetId(matchingPreset.id);
|
||||
} else {
|
||||
// No matching preset — use special value to pass profile chain directly
|
||||
setSelectedPresetId('_profile');
|
||||
}
|
||||
} else if (
|
||||
selectedProfile &&
|
||||
(!selectedProfile.effects_chain || selectedProfile.effects_chain.length === 0)
|
||||
) {
|
||||
setSelectedPresetId(null);
|
||||
}
|
||||
// Persona toggle only applies when the profile has a personality prompt.
|
||||
if (selectedProfile && !selectedProfile.personality?.trim()) {
|
||||
form.setValue('personality', false);
|
||||
}
|
||||
}, [selectedProfile, effectPresets, form]);
|
||||
|
||||
// Auto-resize textarea based on content (only when expanded)
|
||||
useEffect(() => {
|
||||
if (!isExpanded) {
|
||||
// Reset textarea height after collapse animation completes
|
||||
const timeoutId = setTimeout(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea) {
|
||||
textarea.style.height = '32px';
|
||||
textarea.style.overflowY = 'hidden';
|
||||
}
|
||||
}, 200); // Wait for animation to complete
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
const adjustHeight = () => {
|
||||
textarea.style.height = 'auto';
|
||||
const scrollHeight = textarea.scrollHeight;
|
||||
const minHeight = 100; // Expanded minimum
|
||||
const maxHeight = 300; // Max height in pixels
|
||||
const targetHeight = Math.max(minHeight, Math.min(scrollHeight, maxHeight));
|
||||
textarea.style.height = `${targetHeight}px`;
|
||||
|
||||
// Show scrollbar if content exceeds max height
|
||||
if (scrollHeight > maxHeight) {
|
||||
textarea.style.overflowY = 'auto';
|
||||
} else {
|
||||
textarea.style.overflowY = 'hidden';
|
||||
}
|
||||
};
|
||||
|
||||
// Small delay to let framer animation complete
|
||||
const timeoutId = setTimeout(() => {
|
||||
adjustHeight();
|
||||
}, 200);
|
||||
|
||||
// Adjust on mount and when value changes
|
||||
adjustHeight();
|
||||
|
||||
// Watch for input changes
|
||||
textarea.addEventListener('input', adjustHeight);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
textarea.removeEventListener('input', adjustHeight);
|
||||
};
|
||||
}, [isExpanded]);
|
||||
|
||||
async function onSubmit(data: Parameters<typeof handleSubmit>[0]) {
|
||||
await handleSubmit(data, selectedProfileId);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'fixed',
|
||||
isStoriesRoute
|
||||
? // Aligned with StoryContent: sidebar + list width + gap (tab bleeds with -mx-8)
|
||||
'left-[calc(5rem+360px+1.5rem)] right-8'
|
||||
: 'left-[calc(5rem+2rem)] right-8 lg:right-auto lg:w-[calc((100%-5rem-4rem)/2-1rem)]',
|
||||
)}
|
||||
style={{
|
||||
// On stories route: offset by track editor height when visible
|
||||
// On other routes: offset by audio player height when visible
|
||||
bottom: hasTrackEditor
|
||||
? `${trackEditorHeight + 24}px`
|
||||
: isPlayerOpen
|
||||
? 'calc(7rem + 1.5rem)'
|
||||
: '1.5rem',
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
className="bg-background/30 backdrop-blur-2xl border border-accent/20 rounded-[2rem] shadow-2xl hover:bg-background/40 hover:border-accent/20 transition-all duration-300 p-3"
|
||||
transition={{ duration: 0.6, ease: 'easeInOut' }}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="flex gap-2">
|
||||
<motion.div className="flex-1" transition={{ duration: 0.3, ease: 'easeOut' }}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="text"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<motion.div
|
||||
animate={{
|
||||
height: isExpanded ? 'auto' : '32px',
|
||||
}}
|
||||
transition={{ duration: 0.15, ease: 'easeOut' }}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
{form.watch('engine') === 'chatterbox_turbo' ? (
|
||||
<ParalinguisticInput
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder={
|
||||
isStoriesRoute && currentStory
|
||||
? t('generation.placeholder.storyWithEffects', {
|
||||
name: currentStory.name,
|
||||
})
|
||||
: selectedProfile
|
||||
? t('generation.placeholder.effectsHint')
|
||||
: t('generation.placeholder.selectVoice')
|
||||
}
|
||||
className="px-3 py-2 resize-none bg-transparent border-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0 outline-none ring-0 rounded-2xl text-sm w-full"
|
||||
style={{
|
||||
minHeight: isExpanded ? '100px' : '32px',
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
disabled={!selectedProfileId}
|
||||
onClick={() => setIsExpanded(true)}
|
||||
onFocus={() => setIsExpanded(true)}
|
||||
/>
|
||||
) : (
|
||||
<Textarea
|
||||
{...field}
|
||||
ref={(node: HTMLTextAreaElement | null) => {
|
||||
textareaRef.current = node;
|
||||
if (typeof field.ref === 'function') {
|
||||
field.ref(node);
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
isStoriesRoute && currentStory
|
||||
? t('generation.placeholder.story', { name: currentStory.name })
|
||||
: selectedProfile
|
||||
? t('generation.placeholder.profile', {
|
||||
name: selectedProfile.name,
|
||||
})
|
||||
: t('generation.placeholder.selectVoice')
|
||||
}
|
||||
className="resize-none bg-transparent border-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0 outline-none ring-0 rounded-2xl text-sm placeholder:text-muted-foreground/60 w-full"
|
||||
style={{
|
||||
minHeight: isExpanded ? '100px' : '32px',
|
||||
maxHeight: '300px',
|
||||
}}
|
||||
disabled={!selectedProfileId}
|
||||
onClick={() => setIsExpanded(true)}
|
||||
onFocus={() => setIsExpanded(true)}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</FormControl>
|
||||
<FormMessage className="text-xs" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<div className="flex items-start gap-2 shrink-0">
|
||||
{/* Compose — fills the textarea with a fresh in-character line. */}
|
||||
<AnimatePresence>
|
||||
{selectedProfile?.personality?.trim() && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="group relative">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={composeMutation.isPending || !selectedProfileId}
|
||||
onClick={async () => {
|
||||
const result = await composeMutation.mutateAsync();
|
||||
form.setValue('text', result.text, { shouldDirty: true });
|
||||
setIsExpanded(true);
|
||||
}}
|
||||
className="h-10 w-10 rounded-full bg-card border border-border hover:bg-background/50 transition-all duration-200"
|
||||
aria-label={t('generation.compose.ariaLabel')}
|
||||
>
|
||||
{composeMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Dices className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]">
|
||||
{t('generation.compose.tooltip')}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Persona — rewrite input through the profile's personality LLM before TTS. */}
|
||||
<AnimatePresence>
|
||||
{selectedProfile?.personality?.trim() && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="personality"
|
||||
render={({ field }) => {
|
||||
const active = !!field.value;
|
||||
return (
|
||||
<FormItem className="space-y-0">
|
||||
<FormControl>
|
||||
<div className="group relative">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => field.onChange(!active)}
|
||||
className={cn(
|
||||
'h-10 w-10 rounded-full transition-all duration-200',
|
||||
active
|
||||
? 'bg-accent text-accent-foreground border border-accent hover:bg-accent/90'
|
||||
: 'bg-card border border-border hover:bg-background/50',
|
||||
)}
|
||||
aria-label={active ? t('generation.persona.ariaLabelActive') : t('generation.persona.ariaLabelInactive')}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<Wand2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]">
|
||||
{active ? t('generation.persona.tooltipActive') : t('generation.persona.tooltipInactive')}
|
||||
</span>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Instruct toggle — only for Qwen CustomVoice, which actually honors the kwarg */}
|
||||
<AnimatePresence>
|
||||
{isExpanded && form.watch('engine') === 'qwen_custom_voice' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="group relative">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsInstructExpanded((prev) => !prev)}
|
||||
className={cn(
|
||||
'h-10 w-10 rounded-full transition-all duration-200',
|
||||
isInstructExpanded
|
||||
? 'bg-accent text-accent-foreground border border-accent hover:bg-accent/90'
|
||||
: 'bg-card border border-border hover:bg-background/50',
|
||||
)}
|
||||
aria-label={
|
||||
isInstructExpanded
|
||||
? t('generation.instruct.hide')
|
||||
: t('generation.instruct.show')
|
||||
}
|
||||
aria-pressed={isInstructExpanded}
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]">
|
||||
{t('generation.instruct.tooltip')}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="group relative">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending || !selectedProfileId}
|
||||
className="h-10 w-10 rounded-full bg-accent hover:bg-accent/90 hover:scale-105 text-accent-foreground shadow-lg hover:shadow-accent/50 transition-all duration-200"
|
||||
size="icon"
|
||||
aria-label={
|
||||
isPending
|
||||
? t('generation.button.generating')
|
||||
: !selectedProfileId
|
||||
? t('generation.button.selectFirst')
|
||||
: t('generation.button.generate')
|
||||
}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]">
|
||||
{isPending
|
||||
? t('generation.button.generating')
|
||||
: !selectedProfileId
|
||||
? t('generation.button.selectFirst')
|
||||
: t('generation.button.generate')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additive instruct textarea — shown below main text when toggle is on and engine supports it */}
|
||||
<AnimatePresence>
|
||||
{isInstructExpanded && form.watch('engine') === 'qwen_custom_voice' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="instruct"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-2">
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
placeholder={t('generation.instruct.placeholder')}
|
||||
className="resize-none bg-transparent border border-accent/20 focus-visible:ring-1 focus-visible:ring-accent/40 rounded-2xl text-sm placeholder:text-muted-foreground/60 w-full px-3 py-2"
|
||||
style={{ minHeight: '60px', maxHeight: '160px' }}
|
||||
maxLength={500}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className="text-xs" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: 'easeOut' }}
|
||||
className=" mt-3"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{showVoiceSelector && (
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
value={selectedProfileId || ''}
|
||||
onValueChange={(value) => setSelectedProfileId(value || null)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all w-full">
|
||||
<SelectValue placeholder={t('generation.voiceSelector.placeholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{profiles?.map((profile) => (
|
||||
<SelectItem key={profile.id} value={profile.id} className="text-xs">
|
||||
{profile.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="language"
|
||||
render={({ field }) => {
|
||||
const engineLangs = getLanguageOptionsForEngine(
|
||||
form.watch('engine') || 'qwen',
|
||||
);
|
||||
return (
|
||||
<FormItem className="flex-1 space-y-0">
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{engineLangs.map((lang) => (
|
||||
<SelectItem key={lang.value} value={lang.value} className="text-xs">
|
||||
{lang.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage className="text-xs" />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormItem className="flex-1 space-y-0">
|
||||
<EngineModelSelector form={form} compact />
|
||||
</FormItem>
|
||||
|
||||
<FormItem className="flex-1 space-y-0">
|
||||
<Select
|
||||
value={selectedPresetId || 'none'}
|
||||
onValueChange={(value) =>
|
||||
setSelectedPresetId(value === 'none' ? null : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
|
||||
<SelectValue placeholder={t('generation.effects.none')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none" className="text-xs">
|
||||
{t('generation.effects.none')}
|
||||
</SelectItem>
|
||||
{selectedProfile?.effects_chain &&
|
||||
selectedProfile.effects_chain.length > 0 && (
|
||||
<SelectItem value="_profile" className="text-xs">
|
||||
{t('generation.effects.profileDefault')}
|
||||
</SelectItem>
|
||||
)}
|
||||
{effectPresets?.map((preset) => (
|
||||
<SelectItem key={preset.id} value={preset.id} className="text-xs">
|
||||
{preset.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</form>
|
||||
</Form>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Loader2, Mic } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useGeneration } from '@/lib/hooks/useGeneration';
|
||||
import { useProfile } from '@/lib/hooks/useProfiles';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
|
||||
const generationSchema = z.object({
|
||||
text: z.string().min(1, 'Text is required').max(5000),
|
||||
language: z.enum(['en', 'zh']),
|
||||
seed: z.number().int().optional(),
|
||||
modelSize: z.enum(['1.7B', '0.6B']).optional(),
|
||||
instruct: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
type GenerationFormValues = z.infer<typeof generationSchema>;
|
||||
|
||||
export function GenerationForm() {
|
||||
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
||||
const { data: selectedProfile } = useProfile(selectedProfileId || '');
|
||||
const generation = useGeneration();
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm<GenerationFormValues>({
|
||||
resolver: zodResolver(generationSchema),
|
||||
defaultValues: {
|
||||
text: '',
|
||||
language: 'en',
|
||||
seed: undefined,
|
||||
modelSize: '1.7B',
|
||||
instruct: '',
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(data: GenerationFormValues) {
|
||||
if (!selectedProfileId) {
|
||||
toast({
|
||||
title: 'No profile selected',
|
||||
description: 'Please select a voice profile from the cards above.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await generation.mutateAsync({
|
||||
profile_id: selectedProfileId,
|
||||
text: data.text,
|
||||
language: data.language,
|
||||
seed: data.seed,
|
||||
model_size: data.modelSize,
|
||||
instruct: data.instruct || undefined,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Generation complete!',
|
||||
description: `Audio generated (${result.duration.toFixed(2)}s)`,
|
||||
});
|
||||
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Generation failed',
|
||||
description: error instanceof Error ? error.message : 'Failed to generate audio',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Generate Speech</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<FormLabel>Voice Profile</FormLabel>
|
||||
{selectedProfile ? (
|
||||
<div className="mt-2 p-3 border rounded-md bg-muted/50 flex items-center gap-2">
|
||||
<Mic className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{selectedProfile.name}</span>
|
||||
<Badge variant="outline">{selectedProfile.language}</Badge>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 p-3 border border-dashed rounded-md text-sm text-muted-foreground">
|
||||
Click on a profile card above to select a voice profile
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="text"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Text to Speak</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Enter the text you want to generate..."
|
||||
className="min-h-[150px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Max 5000 characters</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="instruct"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Delivery Instructions (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="e.g. Speak slowly with emphasis, Warm and friendly tone, Professional and authoritative..."
|
||||
className="min-h-[80px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Natural language instructions to control speech delivery (tone, emotion, pace).
|
||||
Max 500 characters
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="language"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Language</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
<SelectItem value="zh">Chinese</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="modelSize"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Model Size</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="1.7B">Qwen TTS 1.7B (Higher Quality)</SelectItem>
|
||||
<SelectItem value="0.6B">Qwen TTS 0.6B (Faster)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>Larger models produce better quality</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="seed"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Seed (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Random"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(e.target.value ? parseInt(e.target.value, 10) : undefined)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>For reproducible results</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={generation.isPending || !selectedProfileId}
|
||||
>
|
||||
{generation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
'Generate Speech'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
/**
|
||||
* ParalinguisticInput — a contentEditable rich text input that renders
|
||||
* Chatterbox Turbo paralinguistic tags (e.g. [laugh]) as inline badges.
|
||||
*
|
||||
* Trigger: typing "/" opens an autocomplete dropdown.
|
||||
* Paste: pasting text with [tag] patterns auto-converts to badges.
|
||||
* Output: serializes badges back to plain [tag] text for the API.
|
||||
*/
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
// ── Tag definitions ─────────────────────────────────────────────────
|
||||
const PARALINGUISTIC_TAGS = [
|
||||
{ tag: '[laugh]', label: 'laugh', emoji: '\u{1F602}' },
|
||||
{ tag: '[chuckle]', label: 'chuckle', emoji: '\u{1F60F}' },
|
||||
{ tag: '[gasp]', label: 'gasp', emoji: '\u{1F62E}' },
|
||||
{ tag: '[cough]', label: 'cough', emoji: '\u{1F637}' },
|
||||
{ tag: '[sigh]', label: 'sigh', emoji: '\u{1F614}' },
|
||||
{ tag: '[groan]', label: 'groan', emoji: '\u{1F629}' },
|
||||
{ tag: '[sniff]', label: 'sniff', emoji: '\u{1F443}' },
|
||||
{ tag: '[shush]', label: 'shush', emoji: '\u{1F92B}' },
|
||||
{ tag: '[clear throat]', label: 'clear throat', emoji: '\u{1F64A}' },
|
||||
] as const;
|
||||
|
||||
const TAG_REGEX = /\[(laugh|chuckle|gasp|cough|sigh|groan|sniff|shush|clear throat)\]/gi;
|
||||
|
||||
// Data attribute used to identify badge spans in the DOM
|
||||
const BADGE_ATTR = 'data-ptag';
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Build an inline badge <span> for a tag. */
|
||||
function makeBadgeHTML(tag: string): string {
|
||||
const entry = PARALINGUISTIC_TAGS.find((t) => t.tag.toLowerCase() === tag.toLowerCase());
|
||||
const label = entry?.label ?? tag.replace(/[[\]]/g, '');
|
||||
const emoji = entry?.emoji ?? '';
|
||||
// Non-editable inline badge. Zero-width spaces around it let the
|
||||
// caret sit on either side so the user can type before/after.
|
||||
return `\u200B<span ${BADGE_ATTR}="${tag}" contenteditable="false" class="ptag-badge">${emoji ? `${emoji}\u00A0` : ''}${label}</span>\u200B`;
|
||||
}
|
||||
|
||||
/** Convert plain text with [tag] patterns into HTML with badge spans. */
|
||||
function textToHTML(text: string): string {
|
||||
// Escape HTML entities first
|
||||
const escaped = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
// Replace tag patterns with badge HTML
|
||||
return escaped.replace(TAG_REGEX, (match) => makeBadgeHTML(match));
|
||||
}
|
||||
|
||||
/** Serialize the contentEditable innerHTML back to plain text with [tag] syntax. */
|
||||
function htmlToText(container: HTMLElement): string {
|
||||
let result = '';
|
||||
for (const node of container.childNodes) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
// Strip zero-width spaces we added around badges
|
||||
result += (node.textContent ?? '').replace(/\u200B/g, '');
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = node as HTMLElement;
|
||||
if (el.hasAttribute(BADGE_ATTR)) {
|
||||
result += el.getAttribute(BADGE_ATTR) ?? '';
|
||||
} else if (el.tagName === 'BR') {
|
||||
result += '\n';
|
||||
} else {
|
||||
// Recurse for nested elements (e.g. spans from paste)
|
||||
result += htmlToText(el);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Get the text content from the current caret position back to the last
|
||||
* whitespace or start of container, to detect the "/" trigger. */
|
||||
function getWordBeforeCaret(_container: HTMLElement): { word: string; range: Range | null } {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0) return { word: '', range: null };
|
||||
const range = sel.getRangeAt(0).cloneRange();
|
||||
range.collapse(true);
|
||||
|
||||
// Walk backwards from caret through the text node
|
||||
const textNode = range.startContainer;
|
||||
if (textNode.nodeType !== Node.TEXT_NODE) return { word: '', range: null };
|
||||
const text = textNode.textContent ?? '';
|
||||
const offset = range.startOffset;
|
||||
|
||||
let start = offset;
|
||||
while (
|
||||
start > 0 &&
|
||||
text[start - 1] !== ' ' &&
|
||||
text[start - 1] !== '\n' &&
|
||||
text[start - 1] !== '\u00A0'
|
||||
) {
|
||||
start--;
|
||||
}
|
||||
|
||||
const word = text.slice(start, offset);
|
||||
const wordRange = document.createRange();
|
||||
wordRange.setStart(textNode, start);
|
||||
wordRange.setEnd(textNode, offset);
|
||||
|
||||
return { word, range: wordRange };
|
||||
}
|
||||
|
||||
// ── Component ───────────────────────────────────────────────────────
|
||||
|
||||
export interface ParalinguisticInputProps {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
onClick?: () => void;
|
||||
onFocus?: () => void;
|
||||
}
|
||||
|
||||
export interface ParalinguisticInputRef {
|
||||
focus: () => void;
|
||||
element: HTMLDivElement | null;
|
||||
}
|
||||
|
||||
export const ParalinguisticInput = forwardRef<ParalinguisticInputRef, ParalinguisticInputProps>(
|
||||
function ParalinguisticInput(
|
||||
{ value, onChange, placeholder, disabled, className, style, onClick, onFocus },
|
||||
ref,
|
||||
) {
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const [menuFilter, setMenuFilter] = useState('');
|
||||
const [menuIndex, setMenuIndex] = useState(0);
|
||||
const [menuPosition, setMenuPosition] = useState<{ bottom: number; left: number }>({
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
});
|
||||
const triggerRangeRef = useRef<Range | null>(null);
|
||||
const lastSerializedRef = useRef<string>('');
|
||||
const isComposingRef = useRef(false);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => editorRef.current?.focus(),
|
||||
element: editorRef.current,
|
||||
}));
|
||||
|
||||
// Filtered tag list for the autocomplete menu
|
||||
const filteredTags = PARALINGUISTIC_TAGS.filter((t) =>
|
||||
t.label.toLowerCase().includes(menuFilter.toLowerCase()),
|
||||
);
|
||||
|
||||
// ── Sync external value → editor ──────────────────────────────
|
||||
useEffect(() => {
|
||||
const el = editorRef.current;
|
||||
if (!el) return;
|
||||
// Only update DOM if the external value differs from what we last emitted
|
||||
if (value !== undefined && value !== lastSerializedRef.current) {
|
||||
lastSerializedRef.current = value;
|
||||
el.innerHTML = value ? textToHTML(value) : '';
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// ── Emit plain-text value on input ────────────────────────────
|
||||
const emitChange = useCallback(() => {
|
||||
const el = editorRef.current;
|
||||
if (!el || !onChange) return;
|
||||
const text = htmlToText(el);
|
||||
lastSerializedRef.current = text;
|
||||
onChange(text);
|
||||
}, [onChange]);
|
||||
|
||||
// ── Insert a tag badge at the caret ───────────────────────────
|
||||
const insertTag = useCallback(
|
||||
(tag: string) => {
|
||||
const el = editorRef.current;
|
||||
if (!el) return;
|
||||
|
||||
// Delete the /filter text
|
||||
const wordRange = triggerRangeRef.current;
|
||||
if (wordRange) {
|
||||
wordRange.deleteContents();
|
||||
}
|
||||
|
||||
// Insert badge HTML
|
||||
const temp = document.createElement('span');
|
||||
temp.innerHTML = makeBadgeHTML(tag);
|
||||
const frag = document.createDocumentFragment();
|
||||
let lastNode: Node | null = null;
|
||||
while (temp.firstChild) {
|
||||
lastNode = frag.appendChild(temp.firstChild);
|
||||
}
|
||||
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.rangeCount > 0) {
|
||||
const range = sel.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
range.insertNode(frag);
|
||||
|
||||
// Move caret after the badge
|
||||
if (lastNode) {
|
||||
const newRange = document.createRange();
|
||||
newRange.setStartAfter(lastNode);
|
||||
newRange.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(newRange);
|
||||
}
|
||||
}
|
||||
|
||||
setShowMenu(false);
|
||||
setMenuFilter('');
|
||||
emitChange();
|
||||
el.focus();
|
||||
},
|
||||
[emitChange],
|
||||
);
|
||||
|
||||
// ── Handle keydown for autocomplete navigation ────────────────
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (showMenu) {
|
||||
if (filteredTags.length === 0) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setShowMenu(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setMenuIndex((i) => (i + 1) % filteredTags.length);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setMenuIndex((i) => (i - 1 + filteredTags.length) % filteredTags.length);
|
||||
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
if (filteredTags[menuIndex]) {
|
||||
insertTag(filteredTags[menuIndex].tag);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setShowMenu(false);
|
||||
}
|
||||
} else {
|
||||
// Prevent Enter from creating <div> blocks in contentEditable
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
// Let the form handle submit
|
||||
}
|
||||
}
|
||||
},
|
||||
[showMenu, filteredTags, menuIndex, insertTag],
|
||||
);
|
||||
|
||||
// ── Handle input (check for / trigger) ────────────────────────
|
||||
const handleInput = useCallback(() => {
|
||||
if (isComposingRef.current) return;
|
||||
const el = editorRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const { word, range } = getWordBeforeCaret(el);
|
||||
|
||||
if (word.startsWith('/')) {
|
||||
const filter = word.slice(1); // strip the /
|
||||
setMenuFilter(filter);
|
||||
setMenuIndex(0);
|
||||
triggerRangeRef.current = range;
|
||||
|
||||
// Position the menu above the caret using viewport coords (portalled)
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.rangeCount > 0) {
|
||||
const rect = sel.getRangeAt(0).getBoundingClientRect();
|
||||
setMenuPosition({
|
||||
bottom: window.innerHeight - rect.top + 4,
|
||||
left: rect.left,
|
||||
});
|
||||
}
|
||||
|
||||
setShowMenu(true);
|
||||
} else {
|
||||
setShowMenu(false);
|
||||
}
|
||||
|
||||
emitChange();
|
||||
}, [emitChange]);
|
||||
|
||||
// ── Handle paste — convert [tag] patterns to badges ───────────
|
||||
const handlePaste = useCallback(
|
||||
(e: React.ClipboardEvent) => {
|
||||
e.preventDefault();
|
||||
const text = e.clipboardData.getData('text/plain');
|
||||
if (!text) return;
|
||||
|
||||
const el = editorRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const html = textToHTML(text);
|
||||
|
||||
// Insert at caret
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.rangeCount > 0) {
|
||||
const range = sel.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = html;
|
||||
const frag = document.createDocumentFragment();
|
||||
let lastNode: Node | null = null;
|
||||
while (temp.firstChild) {
|
||||
lastNode = frag.appendChild(temp.firstChild);
|
||||
}
|
||||
range.insertNode(frag);
|
||||
if (lastNode) {
|
||||
const newRange = document.createRange();
|
||||
newRange.setStartAfter(lastNode);
|
||||
newRange.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(newRange);
|
||||
}
|
||||
}
|
||||
|
||||
emitChange();
|
||||
},
|
||||
[emitChange],
|
||||
);
|
||||
|
||||
// ── Show placeholder ──────────────────────────────────────────
|
||||
const isEmpty = !value || value.trim() === '';
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Placeholder */}
|
||||
{isEmpty && placeholder && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 text-sm text-muted-foreground/60 px-3 py-2 select-none"
|
||||
aria-hidden
|
||||
>
|
||||
{placeholder}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Editable area */}
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable={!disabled}
|
||||
suppressContentEditableWarning
|
||||
role={disabled ? undefined : 'textbox'}
|
||||
aria-multiline={disabled ? undefined : true}
|
||||
aria-placeholder={placeholder}
|
||||
aria-disabled={disabled}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
className={cn(
|
||||
'min-h-[32px] text-sm whitespace-pre-wrap break-words outline-none',
|
||||
'[&_.ptag-badge]:inline-flex [&_.ptag-badge]:items-center [&_.ptag-badge]:rounded-full',
|
||||
'[&_.ptag-badge]:bg-accent/20 [&_.ptag-badge]:text-accent [&_.ptag-badge]:border [&_.ptag-badge]:border-accent/30',
|
||||
'[&_.ptag-badge]:px-2 [&_.ptag-badge]:py-0 [&_.ptag-badge]:text-xs [&_.ptag-badge]:font-medium',
|
||||
'[&_.ptag-badge]:mx-0.5 [&_.ptag-badge]:select-none [&_.ptag-badge]:cursor-default',
|
||||
'[&_.ptag-badge]:align-baseline',
|
||||
disabled && 'opacity-50 cursor-not-allowed',
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
onInput={!disabled ? handleInput : undefined}
|
||||
onKeyDown={!disabled ? handleKeyDown : undefined}
|
||||
onPaste={!disabled ? handlePaste : undefined}
|
||||
onClick={!disabled ? onClick : undefined}
|
||||
onFocus={!disabled ? onFocus : undefined}
|
||||
onBlur={() => {
|
||||
setShowMenu(false);
|
||||
triggerRangeRef.current = null;
|
||||
}}
|
||||
onCompositionStart={() => {
|
||||
isComposingRef.current = true;
|
||||
}}
|
||||
onCompositionEnd={() => {
|
||||
isComposingRef.current = false;
|
||||
handleInput();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Autocomplete dropdown — portalled to body, positioned above the caret */}
|
||||
{showMenu &&
|
||||
filteredTags.length > 0 &&
|
||||
createPortal(
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 4 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
className="fixed z-[9999] min-w-[200px] max-h-[280px] overflow-y-auto rounded-lg border border-border bg-popover shadow-lg"
|
||||
style={{
|
||||
bottom: menuPosition.bottom,
|
||||
left: menuPosition.left,
|
||||
}}
|
||||
>
|
||||
{filteredTags.map((t, i) => (
|
||||
<button
|
||||
key={t.tag}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left transition-colors',
|
||||
i === menuIndex
|
||||
? 'bg-accent/20 text-accent-foreground'
|
||||
: 'text-popover-foreground hover:bg-muted/50',
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault(); // Keep focus in editor
|
||||
insertTag(t.tag);
|
||||
}}
|
||||
onMouseEnter={() => setMenuIndex(i)}
|
||||
>
|
||||
<span className="text-base leading-none">{t.emoji}</span>
|
||||
<span>{t.label}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground font-mono">{t.tag}</span>
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
</AnimatePresence>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1 +0,0 @@
|
||||
# Generation history components
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { AlertTriangle, ExternalLink } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
/**
|
||||
* Tracks macOS Input Monitoring permission state. Without it, `rdev::listen`
|
||||
* sees no key events and the chord engine never fires — but neither does
|
||||
* anything error-out visibly, so we surface an inline prompt next to the
|
||||
* hotkey toggle instead of leaving the user wondering why the shortcut is
|
||||
* dead.
|
||||
*
|
||||
* Re-checked on mount and on window focus (cheap way to pick up the user
|
||||
* flipping the toggle in System Settings and alt-tabbing back).
|
||||
*/
|
||||
export function useInputMonitoringPermission() {
|
||||
const platform = usePlatform();
|
||||
const [needsPermission, setNeedsPermission] = useState(false);
|
||||
const [checking, setChecking] = useState(false);
|
||||
|
||||
const recheck = useCallback(async (): Promise<boolean> => {
|
||||
if (!platform.metadata.isTauri) return true;
|
||||
setChecking(true);
|
||||
try {
|
||||
const trusted = await invoke<boolean>('check_input_monitoring_permission');
|
||||
setNeedsPermission(!trusted);
|
||||
return trusted;
|
||||
} catch (err) {
|
||||
console.warn('[input-monitoring] check failed:', err);
|
||||
return false;
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}, [platform.metadata.isTauri]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!platform.metadata.isTauri) return;
|
||||
recheck();
|
||||
const onFocus = () => {
|
||||
recheck();
|
||||
};
|
||||
window.addEventListener('focus', onFocus);
|
||||
return () => window.removeEventListener('focus', onFocus);
|
||||
}, [platform.metadata.isTauri, recheck]);
|
||||
|
||||
const openSettings = useCallback(async () => {
|
||||
try {
|
||||
await invoke('open_input_monitoring_settings');
|
||||
} catch (err) {
|
||||
console.warn('[input-monitoring] open settings failed:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { needsPermission, checking, recheck, openSettings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline notice rendered under the global-shortcut toggle when the user has
|
||||
* opted in but macOS Input Monitoring is not granted. Returns null when the
|
||||
* permission is present (or when the toggle is off and the notice would just
|
||||
* be noise).
|
||||
*/
|
||||
export function InputMonitoringNotice({ enabled }: { enabled: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const { needsPermission, checking, recheck, openSettings } =
|
||||
useInputMonitoringPermission();
|
||||
const [stillMissing, setStillMissing] = useState(false);
|
||||
|
||||
const handleRecheck = useCallback(async () => {
|
||||
setStillMissing(false);
|
||||
const trusted = await recheck();
|
||||
if (!trusted) setStillMissing(true);
|
||||
}, [recheck]);
|
||||
|
||||
if (!enabled || !needsPermission) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3.5 py-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5 text-amber-500" />
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t('captures.permissions.inputMonitoring.title')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
<Trans i18nKey="captures.permissions.inputMonitoring.body" components={{ path: <span /> }} />
|
||||
</p>
|
||||
<div className="flex items-center gap-2 pt-1.5">
|
||||
<Button size="sm" onClick={openSettings} className="gap-1.5">
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
{t('captures.permissions.inputMonitoring.openSettings')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleRecheck} disabled={checking}>
|
||||
{checking ? t('captures.permissions.inputMonitoring.rechecking') : t('captures.permissions.inputMonitoring.recheck')}
|
||||
</Button>
|
||||
</div>
|
||||
{stillMissing && !checking && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400 pt-1">
|
||||
{t('captures.permissions.inputMonitoring.stillMissing')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
interface ListPaneProps {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ListPane({ className, children }: ListPaneProps) {
|
||||
return (
|
||||
<div className={cn('h-full flex flex-col relative overflow-hidden', className)}>
|
||||
<div
|
||||
className="absolute top-0 right-0 bottom-0 w-px bg-border pointer-events-none z-30"
|
||||
style={{
|
||||
maskImage: 'linear-gradient(to bottom, transparent 0, black 50px)',
|
||||
WebkitMaskImage: 'linear-gradient(to bottom, transparent 0, black 50px)',
|
||||
}}
|
||||
/>
|
||||
<div className="absolute top-0 left-0 right-0 h-20 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ListPaneHeaderProps {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ListPaneHeader({ className, children }: ListPaneHeaderProps) {
|
||||
return (
|
||||
<div className={cn('absolute top-0 left-0 right-0 z-20 px-4', className)}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ListPaneTitleRowProps {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ListPaneTitleRow({ className, children }: ListPaneTitleRowProps) {
|
||||
return <div className={cn('flex items-center mb-2', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
interface ListPaneTitleProps {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ListPaneTitle({ className, children }: ListPaneTitleProps) {
|
||||
return <h2 className={cn('text-2xl px-4 font-bold truncate', className)}>{children}</h2>;
|
||||
}
|
||||
|
||||
interface ListPaneActionsProps {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ListPaneActions({ className, children }: ListPaneActionsProps) {
|
||||
return <div className={cn('ml-auto flex items-center gap-2', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
interface ListPaneSearchProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ListPaneSearch({ value, onChange, placeholder, className }: ListPaneSearchProps) {
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="h-9 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ListPaneScrollProps {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ListPaneScroll({ className, style, children }: ListPaneScrollProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex-1 overflow-y-auto overflow-x-hidden pt-24', className)}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { Sparkles, Upload } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';
|
||||
import { HistoryTable } from '@/components/History/HistoryTable';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { ProfileList } from '@/components/VoiceProfiles/ProfileList';
|
||||
|
||||
import { useImportProfile } from '@/lib/hooks/useProfiles';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
|
||||
export function MainEditor() {
|
||||
const { t } = useTranslation();
|
||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||
const isPlayerVisible = !!audioUrl;
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||
const importProfile = useImportProfile();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleImportClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (!file.name.endsWith('.voicebox.zip')) {
|
||||
toast({
|
||||
title: t('main.import.invalidTitle'),
|
||||
description: t('main.import.invalidDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSelectedFile(file);
|
||||
setImportDialogOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportConfirm = () => {
|
||||
if (selectedFile) {
|
||||
importProfile.mutate(selectedFile, {
|
||||
onSuccess: () => {
|
||||
setImportDialogOpen(false);
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
toast({
|
||||
title: t('main.import.successTitle'),
|
||||
description: t('main.import.successDescription'),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: t('main.import.failedTitle'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 lg:gap-6 h-full min-h-0 overflow-hidden relative">
|
||||
<div className="flex flex-col min-h-0 overflow-hidden relative lg:overflow-hidden">
|
||||
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-0 pointer-events-none" />
|
||||
|
||||
<div className="absolute top-0 left-0 right-0 z-10">
|
||||
<div className="flex items-center justify-between mb-4 px-1">
|
||||
<h2 className="text-2xl font-bold">Voicebox</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleImportClick}>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{t('main.importVoice')}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".voicebox.zip"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
{t('main.createVoice')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cn('flex-1 min-h-0 overflow-y-auto pt-14 pb-4', isPlayerVisible && 'lg:pb-32')}
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="shrink-0 flex flex-col">
|
||||
<ProfileList />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col min-h-0 overflow-hidden">
|
||||
<HistoryTable />
|
||||
</div>
|
||||
|
||||
<FloatingGenerateBox isPlayerOpen={!!audioUrl} />
|
||||
|
||||
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('main.import.dialogTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('main.import.dialogDescription', { name: selectedFile?.name })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setImportDialogOpen(false);
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImportConfirm}
|
||||
disabled={importProfile.isPending || !selectedFile}
|
||||
>
|
||||
{importProfile.isPending ? t('main.import.importing') : t('main.import.action')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ModelManagement } from '@/components/ServerSettings/ModelManagement';
|
||||
|
||||
export function ModelsTab() {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<ModelManagement />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
# Server settings and connection components
|
||||
@@ -1,9 +1,12 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Loader2, XCircle } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -14,8 +17,9 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useServerHealth } from '@/lib/hooks/useServer';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
|
||||
const connectionSchema = z.object({
|
||||
@@ -25,11 +29,15 @@ const connectionSchema = z.object({
|
||||
type ConnectionFormValues = z.infer<typeof connectionSchema>;
|
||||
|
||||
export function ConnectionForm() {
|
||||
const platform = usePlatform();
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
const setServerUrl = useServerStore((state) => state.setServerUrl);
|
||||
const keepServerRunningOnClose = useServerStore((state) => state.keepServerRunningOnClose);
|
||||
const setKeepServerRunningOnClose = useServerStore((state) => state.setKeepServerRunningOnClose);
|
||||
const mode = useServerStore((state) => state.mode);
|
||||
const setMode = useServerStore((state) => state.setMode);
|
||||
const { toast } = useToast();
|
||||
const { data: health, isLoading, error: healthError } = useServerHealth();
|
||||
|
||||
const form = useForm<ConnectionFormValues>({
|
||||
resolver: zodResolver(connectionSchema),
|
||||
@@ -47,7 +55,7 @@ export function ConnectionForm() {
|
||||
|
||||
function onSubmit(data: ConnectionFormValues) {
|
||||
setServerUrl(data.serverUrl);
|
||||
form.reset(data); // Reset form state after successful submission
|
||||
form.reset(data);
|
||||
toast({
|
||||
title: 'Server URL updated',
|
||||
description: `Connected to ${data.serverUrl}`,
|
||||
@@ -55,7 +63,7 @@ export function ConnectionForm() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card role="region" aria-label="Server Connection" tabIndex={0}>
|
||||
<CardHeader>
|
||||
<CardTitle>Server Connection</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -69,7 +77,7 @@ export function ConnectionForm() {
|
||||
<FormItem>
|
||||
<FormLabel>Server URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="http://localhost:8000" {...field} />
|
||||
<Input placeholder="http://127.0.0.1:17493" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Enter the URL of your voicebox backend server</FormDescription>
|
||||
<FormMessage />
|
||||
@@ -81,13 +89,48 @@ export function ConnectionForm() {
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
{/* Connection status */}
|
||||
<div className="mt-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm text-muted-foreground">Checking connection...</span>
|
||||
</div>
|
||||
) : healthError ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<XCircle className="h-4 w-4 text-destructive" />
|
||||
<span className="text-sm text-destructive">
|
||||
Connection failed: {healthError.message}
|
||||
</span>
|
||||
</div>
|
||||
) : health ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge
|
||||
variant={health.model_loaded || health.model_downloaded ? 'default' : 'secondary'}
|
||||
>
|
||||
{health.model_loaded || health.model_downloaded ? 'Model Ready' : 'No Model'}
|
||||
</Badge>
|
||||
<Badge variant={health.gpu_available ? 'default' : 'secondary'}>
|
||||
GPU: {health.gpu_available ? 'Available' : 'Not Available'}
|
||||
</Badge>
|
||||
{health.vram_used_mb != null && health.vram_used_mb > 0 && (
|
||||
<Badge variant="outline">VRAM: {health.vram_used_mb.toFixed(0)} MB</Badge>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="keepServerRunning"
|
||||
className="mt-[6px]"
|
||||
checked={keepServerRunningOnClose}
|
||||
onCheckedChange={(checked: boolean) => {
|
||||
setKeepServerRunningOnClose(checked);
|
||||
platform.lifecycle.setKeepServerRunning(checked).catch((error) => {
|
||||
console.error('Failed to sync setting to Rust:', error);
|
||||
});
|
||||
toast({
|
||||
title: 'Setting updated',
|
||||
description: checked
|
||||
@@ -110,6 +153,39 @@ export function ConnectionForm() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{platform.metadata.isTauri && (
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="allowNetworkAccess"
|
||||
className="mt-[6px]"
|
||||
checked={mode === 'remote'}
|
||||
onCheckedChange={(checked: boolean) => {
|
||||
setMode(checked ? 'remote' : 'local');
|
||||
toast({
|
||||
title: 'Setting updated',
|
||||
description: checked
|
||||
? 'Network access enabled. Restart the app to apply.'
|
||||
: 'Network access disabled. Restart the app to apply.',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="allowNetworkAccess"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||
>
|
||||
Allow network access
|
||||
</label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Makes the server accessible from other devices on your network. Restart the app
|
||||
after changing this setting.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { AlertCircle, Download, Loader2, RotateCw, Trash2 } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { CudaDownloadProgress } from '@/lib/api/types';
|
||||
import { useServerHealth } from '@/lib/hooks/useServer';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
|
||||
type RestartPhase = 'idle' | 'stopping' | 'waiting' | 'ready';
|
||||
|
||||
export function GpuAcceleration() {
|
||||
const platform = usePlatform();
|
||||
const queryClient = useQueryClient();
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
const { data: health } = useServerHealth();
|
||||
|
||||
const [restartPhase, setRestartPhase] = useState<RestartPhase>('idle');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [downloadProgress, setDownloadProgress] = useState<CudaDownloadProgress | null>(null);
|
||||
const healthPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Query CUDA backend status
|
||||
const {
|
||||
data: cudaStatus,
|
||||
isLoading: _cudaStatusLoading,
|
||||
refetch: refetchCudaStatus,
|
||||
} = useQuery({
|
||||
queryKey: ['cuda-status', serverUrl],
|
||||
queryFn: () => apiClient.getCudaStatus(),
|
||||
refetchInterval: (query) => (query.state.status === 'pending' ? false : 10000),
|
||||
retry: 1,
|
||||
enabled: !!health, // Only fetch when backend is reachable
|
||||
});
|
||||
|
||||
// Derived state
|
||||
const isCurrentlyCuda = health?.backend_variant === 'cuda';
|
||||
const cudaAvailable = cudaStatus?.available ?? false;
|
||||
const cudaDownloading = cudaStatus?.downloading ?? false;
|
||||
|
||||
// Clean up health poll on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (healthPollRef.current) {
|
||||
clearInterval(healthPollRef.current);
|
||||
healthPollRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// SSE progress tracking during download
|
||||
useEffect(() => {
|
||||
if (!cudaDownloading || !serverUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventSource = new EventSource(`${serverUrl}/backend/cuda-progress`);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as CudaDownloadProgress;
|
||||
setDownloadProgress(data);
|
||||
|
||||
if (data.status === 'complete') {
|
||||
eventSource.close();
|
||||
setDownloadProgress(null);
|
||||
refetchCudaStatus();
|
||||
} else if (data.status === 'error') {
|
||||
eventSource.close();
|
||||
setError(data.error || 'Download failed');
|
||||
setDownloadProgress(null);
|
||||
refetchCudaStatus();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing CUDA progress event:', e);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
}, [cudaDownloading, serverUrl, refetchCudaStatus]);
|
||||
|
||||
// Start aggressive health polling during restart
|
||||
const startHealthPolling = useCallback(() => {
|
||||
if (healthPollRef.current) return;
|
||||
|
||||
healthPollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const result = await apiClient.getHealth();
|
||||
if (result.status === 'healthy') {
|
||||
// Server is back up
|
||||
if (healthPollRef.current) {
|
||||
clearInterval(healthPollRef.current);
|
||||
healthPollRef.current = null;
|
||||
}
|
||||
setRestartPhase('ready');
|
||||
// Invalidate all queries to refresh UI
|
||||
queryClient.invalidateQueries();
|
||||
// Reset after a moment
|
||||
setTimeout(() => setRestartPhase('idle'), 2000);
|
||||
}
|
||||
} catch {
|
||||
// Server still down, keep polling
|
||||
}
|
||||
}, 1000);
|
||||
}, [queryClient]);
|
||||
|
||||
const handleDownload = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await apiClient.downloadCudaBackend();
|
||||
refetchCudaStatus();
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : 'Failed to start download';
|
||||
if (msg.includes('already downloaded')) {
|
||||
refetchCudaStatus();
|
||||
} else {
|
||||
setError(msg);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestart = async () => {
|
||||
setError(null);
|
||||
setRestartPhase('stopping');
|
||||
|
||||
try {
|
||||
setRestartPhase('waiting');
|
||||
startHealthPolling();
|
||||
await platform.lifecycle.restartServer();
|
||||
// Invoke resolved — server is likely ready. Stop polling and refresh.
|
||||
if (healthPollRef.current) {
|
||||
clearInterval(healthPollRef.current);
|
||||
healthPollRef.current = null;
|
||||
}
|
||||
setRestartPhase('ready');
|
||||
queryClient.invalidateQueries();
|
||||
setTimeout(() => setRestartPhase('idle'), 2000);
|
||||
} catch (e: unknown) {
|
||||
setRestartPhase('idle');
|
||||
if (healthPollRef.current) {
|
||||
clearInterval(healthPollRef.current);
|
||||
healthPollRef.current = null;
|
||||
}
|
||||
setError(e instanceof Error ? e.message : 'Restart failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwitchToCpu = async () => {
|
||||
// To switch to CPU: delete the CUDA binary, then restart.
|
||||
// start_server always prefers CUDA if present, so we must remove it first.
|
||||
setError(null);
|
||||
setRestartPhase('stopping');
|
||||
|
||||
try {
|
||||
await apiClient.deleteCudaBackend();
|
||||
setRestartPhase('waiting');
|
||||
startHealthPolling();
|
||||
await platform.lifecycle.restartServer();
|
||||
// Invoke resolved — server is likely ready
|
||||
if (healthPollRef.current) {
|
||||
clearInterval(healthPollRef.current);
|
||||
healthPollRef.current = null;
|
||||
}
|
||||
setRestartPhase('ready');
|
||||
queryClient.invalidateQueries();
|
||||
setTimeout(() => setRestartPhase('idle'), 2000);
|
||||
} catch (e: unknown) {
|
||||
setRestartPhase('idle');
|
||||
if (healthPollRef.current) {
|
||||
clearInterval(healthPollRef.current);
|
||||
healthPollRef.current = null;
|
||||
}
|
||||
setError(e instanceof Error ? e.message : 'Failed to switch to CPU');
|
||||
refetchCudaStatus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await apiClient.deleteCudaBackend();
|
||||
refetchCudaStatus();
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to delete CUDA backend');
|
||||
}
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
// Don't render until health data is available
|
||||
if (!health) return null;
|
||||
|
||||
// If the system already has native GPU (MPS, etc.), only show info - no CUDA needed
|
||||
const hasNativeGpu =
|
||||
health.gpu_available &&
|
||||
!isCurrentlyCuda &&
|
||||
health.gpu_type &&
|
||||
!health.gpu_type.includes('CUDA');
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>GPU Acceleration</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* GPU status */}
|
||||
<div className="space-y-1">
|
||||
{health.gpu_available && health.gpu_type ? (
|
||||
<>
|
||||
<div className="text-sm font-medium">
|
||||
{health.gpu_type.replace(/^(CUDA|ROCm|MPS|Metal|XPU|DirectML)\s*\((.+)\)$/, '$2') ||
|
||||
health.gpu_type}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{health.gpu_type.replace(/\s*\(.+\)$/, '')}
|
||||
{health.vram_used_mb != null && health.vram_used_mb > 0
|
||||
? ` \u00b7 ${health.vram_used_mb.toFixed(0)} MB VRAM used`
|
||||
: ''}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm font-medium">CPU</div>
|
||||
<div className="text-sm text-muted-foreground">No GPU acceleration available</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Native GPU detected - no CUDA download needed */}
|
||||
|
||||
{/* Currently running CUDA - show switch back to CPU */}
|
||||
{isCurrentlyCuda && platform.metadata.isTauri && (
|
||||
<>
|
||||
{restartPhase !== 'idle' ? (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-primary/5 border">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">
|
||||
{restartPhase === 'stopping' && 'Stopping server...'}
|
||||
{restartPhase === 'waiting' && 'Restarting server...'}
|
||||
{restartPhase === 'ready' && 'Server restarted successfully!'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Running with CUDA GPU acceleration. Switch back to CPU if needed (you can
|
||||
re-download later).
|
||||
</p>
|
||||
<Button onClick={handleSwitchToCpu} variant="outline" className="w-full" size="sm">
|
||||
<RotateCw className="h-4 w-4 mr-2" />
|
||||
Switch to CPU Backend
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* CUDA download/manage section - show when no native GPU and not currently running CUDA */}
|
||||
{!hasNativeGpu && !isCurrentlyCuda && (
|
||||
<>
|
||||
{/* Download progress (manual download or auto-update) */}
|
||||
{cudaDownloading && downloadProgress && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>
|
||||
{downloadProgress.filename ||
|
||||
(cudaAvailable
|
||||
? 'Updating CUDA backend...'
|
||||
: 'Downloading CUDA backend...')}
|
||||
</span>
|
||||
</div>
|
||||
{downloadProgress.total > 0 && (
|
||||
<span className="text-muted-foreground">
|
||||
{downloadProgress.progress.toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{downloadProgress.total > 0 && (
|
||||
<>
|
||||
<Progress value={downloadProgress.progress} className="h-2" />
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatBytes(downloadProgress.current)} /{' '}
|
||||
{formatBytes(downloadProgress.total)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Restart in progress */}
|
||||
{restartPhase !== 'idle' && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-primary/5 border">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">
|
||||
{restartPhase === 'stopping' && 'Stopping server...'}
|
||||
{restartPhase === 'waiting' && 'Restarting server...'}
|
||||
{restartPhase === 'ready' && 'Server restarted successfully!'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{restartPhase === 'idle' && !cudaDownloading && (
|
||||
<div className="space-y-2">
|
||||
{/* Not downloaded yet - show download button */}
|
||||
{!cudaAvailable && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Download the CUDA backend (~2.4 GB) for NVIDIA GPU acceleration. Requires an
|
||||
NVIDIA GPU with CUDA support.
|
||||
</p>
|
||||
<Button onClick={handleDownload} className="w-full" size="sm">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download CUDA Backend
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Downloaded but not active - show switch button */}
|
||||
{cudaAvailable && platform.metadata.isTauri && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
CUDA backend is downloaded and ready. Restart the server to enable GPU
|
||||
acceleration.
|
||||
</p>
|
||||
<Button onClick={handleRestart} className="w-full" size="sm">
|
||||
<RotateCw className="h-4 w-4 mr-2" />
|
||||
Switch to CUDA Backend
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete option when downloaded (and not active) */}
|
||||
{cudaAvailable && (
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
variant="ghost"
|
||||
className="w-full text-muted-foreground "
|
||||
size="sm"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Remove CUDA Backend
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,22 +1,34 @@
|
||||
import { Loader2, XCircle } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import type { ModelProgress as ModelProgressType } from '@/lib/api/types';
|
||||
import { Loader2, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
|
||||
interface ModelProgressProps {
|
||||
modelName: string;
|
||||
displayName: string;
|
||||
/** Only connect to SSE when actively downloading - prevents connection exhaustion */
|
||||
isDownloading?: boolean;
|
||||
}
|
||||
|
||||
export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
||||
export function ModelProgress({
|
||||
modelName,
|
||||
displayName,
|
||||
isDownloading = false,
|
||||
}: ModelProgressProps) {
|
||||
const [progress, setProgress] = useState<ModelProgressType | null>(null);
|
||||
const [isSubscribed, setIsSubscribed] = useState(false);
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverUrl || isSubscribed) return;
|
||||
// IMPORTANT: Only connect to SSE when this specific model is downloading
|
||||
// Opening SSE connections for all models exhausts HTTP/1.1 connection limits (6 per origin)
|
||||
// which causes other fetches (like the download trigger) to be queued/blocked
|
||||
if (!serverUrl || !isDownloading) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[ModelProgress] Connecting SSE for ${modelName}`);
|
||||
|
||||
// Subscribe to progress updates via Server-Sent Events
|
||||
const eventSource = new EventSource(`${serverUrl}/models/progress/${modelName}`);
|
||||
@@ -28,8 +40,8 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
||||
|
||||
// Close connection if complete or error
|
||||
if (data.status === 'complete' || data.status === 'error') {
|
||||
console.log(`[ModelProgress] Download ${data.status} for ${modelName}, closing SSE`);
|
||||
eventSource.close();
|
||||
setIsSubscribed(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing progress event:', error);
|
||||
@@ -37,18 +49,15 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE error:', error);
|
||||
console.error(`[ModelProgress] SSE error for ${modelName}:`, error);
|
||||
eventSource.close();
|
||||
setIsSubscribed(false);
|
||||
};
|
||||
|
||||
setIsSubscribed(true);
|
||||
|
||||
return () => {
|
||||
console.log(`[ModelProgress] Cleanup - closing SSE for ${modelName}`);
|
||||
eventSource.close();
|
||||
setIsSubscribed(false);
|
||||
};
|
||||
}, [serverUrl, modelName, isSubscribed]);
|
||||
}, [serverUrl, modelName, isDownloading]);
|
||||
|
||||
// Don't render if no progress or if complete/error and some time has passed
|
||||
if (
|
||||
@@ -63,13 +72,11 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
||||
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (progress.status) {
|
||||
case 'complete':
|
||||
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
|
||||
case 'error':
|
||||
return <XCircle className="h-4 w-4 text-destructive" />;
|
||||
case 'downloading':
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { CheckCircle2, Loader2, XCircle } from 'lucide-react';
|
||||
import { Loader2, XCircle } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useServerHealth } from '@/lib/hooks/useServer';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { ModelProgress } from './ModelProgress';
|
||||
|
||||
export function ServerStatus() {
|
||||
const { data: health, isLoading, error } = useServerHealth();
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card role="region" aria-label="Server Status" tabIndex={0}>
|
||||
<CardHeader>
|
||||
<CardTitle>Server Status</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -20,16 +19,6 @@ export function ServerStatus() {
|
||||
<div className="font-mono text-sm">{serverUrl}</div>
|
||||
</div>
|
||||
|
||||
{/* Model download progress */}
|
||||
<div className="space-y-2">
|
||||
<ModelProgress modelName="qwen-tts-1.7B" displayName="Qwen TTS 1.7B" />
|
||||
<ModelProgress modelName="qwen-tts-0.6B" displayName="Qwen TTS 0.6B" />
|
||||
<ModelProgress modelName="whisper-base" displayName="Whisper Base" />
|
||||
<ModelProgress modelName="whisper-small" displayName="Whisper Small" />
|
||||
<ModelProgress modelName="whisper-medium" displayName="Whisper Medium" />
|
||||
<ModelProgress modelName="whisper-large" displayName="Whisper Large" />
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
@@ -43,7 +32,6 @@ export function ServerStatus() {
|
||||
) : health ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span className="text-sm">Connected</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { RefreshCw, Download, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import { AlertCircle, Download, RefreshCw } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
export function UpdateStatus() {
|
||||
const { status, checkForUpdates, downloadAndInstall } = useAutoUpdater(false);
|
||||
const platform = usePlatform();
|
||||
const { status, checkForUpdates, downloadAndInstall, restartAndInstall } = useAutoUpdater(false);
|
||||
const [currentVersion, setCurrentVersion] = useState<string>('');
|
||||
const isDev = !import.meta.env?.PROD;
|
||||
|
||||
useEffect(() => {
|
||||
getVersion()
|
||||
platform.metadata
|
||||
.getVersion()
|
||||
.then(setCurrentVersion)
|
||||
.catch(() => setCurrentVersion('0.1.0'));
|
||||
}, []);
|
||||
.catch(() => setCurrentVersion('Unknown'));
|
||||
}, [platform]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card role="region" aria-label="App Updates" tabIndex={0}>
|
||||
<CardHeader>
|
||||
<CardTitle>App Updates</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -26,74 +29,110 @@ export function UpdateStatus() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">Current Version</div>
|
||||
<div className="text-sm text-muted-foreground">v{currentVersion}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
v{currentVersion}
|
||||
{isDev ? ' (dev)' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={checkForUpdates}
|
||||
disabled={status.checking || status.downloading || status.installing}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${status.checking ? 'animate-spin' : ''}`} />
|
||||
Check for Updates
|
||||
</Button>
|
||||
{!isDev && (
|
||||
<Button
|
||||
onClick={checkForUpdates}
|
||||
disabled={status.checking || status.downloading || status.readyToInstall}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${status.checking ? 'animate-spin' : ''}`} />
|
||||
Check for Updates
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status.checking && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
Checking for updates...
|
||||
{isDev ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Auto-updates are disabled in development mode.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.error && (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{status.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.available && !status.downloading && !status.installing && (
|
||||
<div className="space-y-3 p-4 border rounded-lg bg-primary/5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-semibold">Update Available</div>
|
||||
<div className="text-sm text-muted-foreground">Version {status.version}</div>
|
||||
) : (
|
||||
<>
|
||||
{status.checking && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
Checking for updates...
|
||||
</div>
|
||||
<Badge>New</Badge>
|
||||
</div>
|
||||
<Button onClick={downloadAndInstall} className="w-full" size="sm">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Install Update
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{status.downloading && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Download className="h-4 w-4" />
|
||||
Downloading update...
|
||||
</div>
|
||||
<Progress />
|
||||
</div>
|
||||
)}
|
||||
{status.error && (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{status.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.installing && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
Installing update...
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">App will restart automatically</div>
|
||||
</div>
|
||||
)}
|
||||
{status.available && !status.downloading && !status.readyToInstall && (
|
||||
<div className="space-y-3 p-4 border rounded-lg bg-primary/5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-semibold">Update Available</div>
|
||||
<div className="text-sm text-muted-foreground">Version {status.version}</div>
|
||||
</div>
|
||||
<Badge>New</Badge>
|
||||
</div>
|
||||
<Button onClick={downloadAndInstall} className="w-full" size="sm">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download Update
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!status.available && !status.checking && !status.error && status.checking === false && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
You're up to date
|
||||
</div>
|
||||
{status.downloading && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
Downloading update...
|
||||
</div>
|
||||
{status.downloadProgress !== undefined && (
|
||||
<span className="text-muted-foreground">{status.downloadProgress}%</span>
|
||||
)}
|
||||
</div>
|
||||
<Progress value={status.downloadProgress} />
|
||||
{status.downloadedBytes !== undefined &&
|
||||
status.totalBytes !== undefined &&
|
||||
status.totalBytes > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{(status.downloadedBytes / 1024 / 1024).toFixed(1)} MB /{' '}
|
||||
{(status.totalBytes / 1024 / 1024).toFixed(1)} MB
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.readyToInstall && (
|
||||
<div className="space-y-3 p-4 border rounded-lg bg-accent/30 border-accent/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
<div className="font-semibold">Update Ready to Install</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Version {status.version} has been downloaded
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
The app needs to restart to complete the installation. You can do this now or
|
||||
later at your convenience.
|
||||
</div>
|
||||
<Button onClick={restartAndInstall} className="w-full" size="sm">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Restart Now
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!status.available && !status.checking && !status.error && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
You're up to date
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { ArrowUpRight } from 'lucide-react';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
||||
import { SPONSORS } from '@/lib/sponsors';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
function FadeIn({ delay = 0, children }: { delay?: number; children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className="animate-[fadeInUp_0.5s_ease_both]"
|
||||
style={{ animationDelay: `${delay}ms` } as CSSProperties}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AboutPage() {
|
||||
const { t } = useTranslation();
|
||||
const platform = usePlatform();
|
||||
const [version, setVersion] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
platform.metadata
|
||||
.getVersion()
|
||||
.then(setVersion)
|
||||
.catch(() => setVersion(''));
|
||||
}, [platform]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
<div className="max-w-md mx-auto h-full flex items-center">
|
||||
<div className="flex flex-col items-center text-center space-y-5">
|
||||
<FadeIn delay={0}>
|
||||
<img src={voiceboxLogo} alt="Voicebox" className="w-20 h-20 object-contain" />
|
||||
</FadeIn>
|
||||
|
||||
<FadeIn delay={80}>
|
||||
<div className="space-y-1.5">
|
||||
<h1 className="text-lg font-semibold">Voicebox</h1>
|
||||
<p className="text-xs text-muted-foreground/60 h-4">
|
||||
{version ? `v${version}` : '\u00A0'}
|
||||
</p>
|
||||
</div>
|
||||
</FadeIn>
|
||||
|
||||
<FadeIn delay={160}>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed max-w-sm">
|
||||
{t('settings.about.tagline')}
|
||||
</p>
|
||||
</FadeIn>
|
||||
|
||||
<FadeIn delay={240}>
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<span>{t('settings.about.createdBy')}</span>
|
||||
<a
|
||||
href="https://github.com/jamiepine"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
Jamie Pine
|
||||
</a>
|
||||
</div>
|
||||
</FadeIn>
|
||||
|
||||
<FadeIn delay={320}>
|
||||
<div className="flex flex-wrap justify-center gap-3 pt-2">
|
||||
<a
|
||||
href="https://buymeacoffee.com/jamiepine"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group inline-flex items-center gap-2 rounded-lg border border-border/60 px-4 py-2 text-sm transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4 text-[#FFDD00]"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m20.216 6.415-.132-.666c-.119-.598-.388-1.163-1.001-1.379-.197-.069-.42-.098-.57-.241-.152-.143-.196-.366-.231-.572-.065-.378-.125-.756-.192-1.133-.057-.325-.102-.69-.25-.987-.195-.4-.597-.634-.996-.788a5.723 5.723 0 0 0-.626-.194c-1-.263-2.05-.36-3.077-.416a25.834 25.834 0 0 0-3.7.062c-.915.083-1.88.184-2.75.5-.318.116-.646.256-.888.501-.297.302-.393.77-.177 1.146.154.267.415.456.692.58.36.162.737.284 1.123.366 1.075.238 2.189.331 3.287.37 1.218.05 2.437.01 3.65-.118.299-.033.598-.073.896-.119.352-.054.578-.513.474-.834-.124-.383-.457-.531-.834-.473-.466.074-.96.108-1.382.146-1.177.08-2.358.082-3.536.006a22.228 22.228 0 0 1-1.157-.107c-.086-.01-.18-.025-.258-.036-.243-.036-.484-.08-.724-.13-.111-.027-.111-.185 0-.212h.005c.277-.06.557-.108.838-.147h.002c.131-.009.263-.032.394-.048a25.076 25.076 0 0 1 3.426-.12c.674.019 1.347.067 2.017.144l.228.031c.267.04.533.088.798.145.392.085.895.113 1.07.542.055.137.08.288.111.431l.319 1.484a.237.237 0 0 1-.199.284h-.003c-.037.006-.075.01-.112.015a36.704 36.704 0 0 1-4.743.295 37.059 37.059 0 0 1-4.699-.304c-.14-.017-.293-.042-.417-.06-.326-.048-.649-.108-.973-.161-.393-.065-.768-.032-1.123.161-.29.16-.527.404-.675.701-.154.316-.199.66-.267 1-.069.34-.176.707-.135 1.056.087.753.613 1.365 1.37 1.502a39.69 39.69 0 0 0 11.343.376.483.483 0 0 1 .535.53l-.071.697-1.018 9.907c-.041.41-.047.832-.125 1.237-.122.637-.553 1.028-1.182 1.171-.577.131-1.165.2-1.756.205-.656.004-1.31-.025-1.966-.022-.699.004-1.556-.06-2.095-.58-.475-.458-.54-1.174-.605-1.793l-.731-7.013-.322-3.094c-.037-.351-.286-.695-.678-.678-.336.015-.718.3-.678.679l.228 2.185.949 9.112c.147 1.344 1.174 2.068 2.446 2.272.742.12 1.503.144 2.257.156.966.016 1.942.053 2.892-.122 1.408-.258 2.465-1.198 2.616-2.657.34-3.332.683-6.663 1.024-9.995l.215-2.087a.484.484 0 0 1 .39-.426c.402-.078.787-.212 1.074-.518.455-.488.546-1.124.385-1.766zm-1.478.772c-.145.137-.363.201-.578.233-2.416.359-4.866.54-7.308.46-1.748-.06-3.477-.254-5.207-.498-.17-.024-.353-.055-.47-.18-.22-.236-.111-.71-.054-.995.052-.26.152-.609.463-.646.484-.057 1.046.148 1.526.22.577.088 1.156.159 1.737.212 2.48.226 5.002.19 7.472-.14.45-.06.899-.13 1.345-.21.399-.072.84-.206 1.08.206.166.281.188.657.162.974a.544.544 0 0 1-.169.364zm-6.159 3.9c-.862.37-1.84.788-3.109.788a5.884 5.884 0 0 1-1.569-.217l.877 9.004c.065.78.717 1.38 1.5 1.38 0 0 1.243.065 1.658.065.447 0 1.786-.065 1.786-.065.783 0 1.434-.6 1.499-1.38l.94-9.95a3.996 3.996 0 0 0-1.322-.238c-.826 0-1.491.284-2.26.613z" />
|
||||
</svg>
|
||||
{t('settings.about.buyCoffee')}
|
||||
<ArrowUpRight className="h-3.5 w-3.5 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/jamiepine/voicebox"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group inline-flex items-center gap-2 rounded-lg border border-border/60 px-4 py-2 text-sm transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4 text-muted-foreground"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
|
||||
</svg>
|
||||
GitHub
|
||||
<ArrowUpRight className="h-3.5 w-3.5 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
|
||||
</a>
|
||||
</div>
|
||||
</FadeIn>
|
||||
|
||||
{SPONSORS.length > 0 && (
|
||||
<FadeIn delay={400}>
|
||||
<div className="pt-4 flex flex-col items-center gap-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-muted-foreground/60">
|
||||
Sponsored by
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
{SPONSORS.map((sponsor) => (
|
||||
<a
|
||||
key={sponsor.name}
|
||||
href={sponsor.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={sponsor.name}
|
||||
className="group flex h-12 min-w-[120px] items-center justify-center rounded-lg border border-border/60 bg-card/50 px-4 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<img
|
||||
src={sponsor.logoSrc}
|
||||
alt={sponsor.logoAlt ?? sponsor.name}
|
||||
className={`h-5 w-auto max-w-[100px] object-contain opacity-80 transition-opacity group-hover:opacity-100 ${
|
||||
sponsor.invertOnDark ? 'dark:brightness-0 dark:invert' : ''
|
||||
}`}
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</FadeIn>
|
||||
)}
|
||||
|
||||
<FadeIn delay={480}>
|
||||
<p className="text-xs text-muted-foreground/40 pt-4">
|
||||
<Trans
|
||||
i18nKey="settings.about.license"
|
||||
components={{
|
||||
link: (
|
||||
// biome-ignore lint/a11y/useAnchorContent: Trans fills content at runtime
|
||||
<a
|
||||
href="https://github.com/jamiepine/voicebox/blob/main/LICENSE"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-muted-foreground/60 transition-colors"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</FadeIn>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
import { Check, ChevronDown, FolderOpen, Info, Keyboard, Laptop, Lock, Volume2 } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AccessibilityNotice } from '@/components/AccessibilityGate/AccessibilityGate';
|
||||
import { InputMonitoringNotice } from '@/components/InputMonitoringGate/InputMonitoringGate';
|
||||
import { CapturePill, type PillState } from '@/components/CapturePill/CapturePill';
|
||||
import { DictationReadinessChecklist } from '@/components/CapturesTab/DictationReadinessChecklist';
|
||||
import { ChordPicker } from '@/components/ChordPicker/ChordPicker';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Toggle } from '@/components/ui/toggle';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useDictationReadiness } from '@/lib/hooks/useDictationReadiness';
|
||||
import { useCaptureSettings } from '@/lib/hooks/useSettings';
|
||||
import { useProfiles } from '@/lib/hooks/useProfiles';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { defaultChordKeys, displayLabelForKey, modifierSideHint } from '@/lib/utils/keyCodes';
|
||||
import type { Qwen3ModelSize, VoiceProfileResponse, WhisperModelSize } from '@/lib/api/types';
|
||||
import { SettingRow, SettingSection } from './SettingRow';
|
||||
|
||||
function ChordPreview({ keys }: { keys: string[] }) {
|
||||
const { t } = useTranslation();
|
||||
if (keys.length === 0) {
|
||||
return <span className="text-xs text-muted-foreground italic">{t('captures.chord.notSet')}</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{keys.map((k) => {
|
||||
const side = modifierSideHint(k);
|
||||
return (
|
||||
<span
|
||||
key={k}
|
||||
className="relative inline-flex items-center justify-center h-6 min-w-[1.5rem] px-1.5 rounded-md border border-border bg-muted/60 font-mono text-[11px] font-medium shadow-sm text-foreground"
|
||||
>
|
||||
{displayLabelForKey(k)}
|
||||
{side ? (
|
||||
<span className="absolute -top-1 -right-1 h-3 min-w-[0.75rem] px-0.5 rounded-sm bg-accent text-[7px] font-bold leading-none flex items-center justify-center text-accent-foreground">
|
||||
{side}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isWindows =
|
||||
typeof navigator !== 'undefined' && navigator.userAgent.includes('Windows');
|
||||
|
||||
const PILL_SEQUENCE: PillState[] = ['recording', 'transcribing', 'refining', 'rest'];
|
||||
const PILL_DURATIONS: Partial<Record<PillState, number>> = {
|
||||
recording: 2600,
|
||||
transcribing: 1500,
|
||||
refining: 1500,
|
||||
rest: 900,
|
||||
};
|
||||
|
||||
function HotkeyPillPreview({ enabled }: { enabled: boolean }) {
|
||||
const [state, setState] = useState<PillState>('recording');
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
// Cycle recording → transcribing → refining → rest → …
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => {
|
||||
const next = PILL_SEQUENCE[(PILL_SEQUENCE.indexOf(state) + 1) % PILL_SEQUENCE.length];
|
||||
setState(next);
|
||||
}, PILL_DURATIONS[state] ?? 1000);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [state]);
|
||||
|
||||
// Timer only advances while recording; holds its final value through
|
||||
// transcribing and refining so users see the duration of the clip being
|
||||
// processed.
|
||||
useEffect(() => {
|
||||
if (state !== 'recording') return;
|
||||
setTick(0);
|
||||
const iv = window.setInterval(() => setTick((n) => n + 1), 90);
|
||||
return () => window.clearInterval(iv);
|
||||
}, [state]);
|
||||
|
||||
const elapsedMs = tick * 90;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative rounded-xl border overflow-hidden transition-opacity',
|
||||
'bg-muted/30',
|
||||
'aspect-[6/1]',
|
||||
enabled ? 'border-border' : 'border-border/50 opacity-50',
|
||||
)}
|
||||
style={{
|
||||
backgroundImage: `
|
||||
linear-gradient(to right, hsl(var(--foreground) / 0.06) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, hsl(var(--foreground) / 0.06) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: '22px 22px',
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<CapturePill state={state} elapsedMs={elapsedMs} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CapturesPage() {
|
||||
const { t } = useTranslation();
|
||||
const platform = usePlatform();
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
const { settings, update } = useCaptureSettings();
|
||||
const { data: profiles } = useProfiles();
|
||||
const { toast } = useToast();
|
||||
const readiness = useDictationReadiness();
|
||||
const sttModel = settings?.stt_model ?? 'turbo';
|
||||
const language = settings?.language ?? 'auto';
|
||||
const autoRefine = settings?.auto_refine ?? true;
|
||||
const llmModel = settings?.llm_model ?? '0.6B';
|
||||
const smartCleanup = settings?.smart_cleanup ?? true;
|
||||
const selfCorrection = settings?.self_correction ?? true;
|
||||
const preserveTechnical = settings?.preserve_technical ?? true;
|
||||
const allowAutoPaste = settings?.allow_auto_paste ?? true;
|
||||
const defaultVoiceId = settings?.default_playback_voice_id ?? null;
|
||||
const hotkeyEnabled = settings?.hotkey_enabled ?? false;
|
||||
const pushToTalkKeys = settings?.chord_push_to_talk_keys ?? defaultChordKeys('push');
|
||||
const toggleToTalkKeys = settings?.chord_toggle_to_talk_keys ?? defaultChordKeys('toggle');
|
||||
|
||||
const [chordEditor, setChordEditor] = useState<'push' | 'toggle' | null>(null);
|
||||
const [opening, setOpening] = useState(false);
|
||||
const [capturesPath, setCapturesPath] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${serverUrl}/health/filesystem`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
const dir = data.directories?.find((d: { path: string }) =>
|
||||
d.path.includes('captures'),
|
||||
);
|
||||
if (dir?.path) setCapturesPath(dir.path);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [serverUrl]);
|
||||
|
||||
const openCapturesFolder = useCallback(async () => {
|
||||
if (!capturesPath) return;
|
||||
setOpening(true);
|
||||
try {
|
||||
await platform.filesystem.openPath(capturesPath);
|
||||
} catch (e) {
|
||||
console.error('Failed to open captures folder:', e);
|
||||
} finally {
|
||||
setOpening(false);
|
||||
}
|
||||
}, [platform, capturesPath]);
|
||||
|
||||
const voices: VoiceProfileResponse[] = profiles ?? [];
|
||||
const defaultVoice =
|
||||
voices.find((v) => v.id === defaultVoiceId) ?? null;
|
||||
|
||||
return (
|
||||
<div className="flex gap-8 items-start max-w-5xl">
|
||||
<div className="flex-1 min-w-0 max-w-2xl space-y-10">
|
||||
<SettingSection
|
||||
title={t('settings.captures.dictation.title')}
|
||||
description={t('settings.captures.dictation.description')}
|
||||
>
|
||||
<div>
|
||||
<SettingRow
|
||||
title={t('settings.captures.dictation.globalShortcut.title')}
|
||||
description={t('settings.captures.dictation.globalShortcut.description')}
|
||||
htmlFor="hotkeyEnabled"
|
||||
action={
|
||||
<Toggle
|
||||
id="hotkeyEnabled"
|
||||
checked={hotkeyEnabled}
|
||||
onCheckedChange={(v) => {
|
||||
update({ hotkey_enabled: v });
|
||||
// Surface model-readiness blocks at the toggle. The
|
||||
// InputMonitoringNotice below already covers TCC, but
|
||||
// missing models would otherwise be invisible from this
|
||||
// page — the user toggles on, presses the chord, and
|
||||
// nothing happens because useChordSync gates on readiness.
|
||||
if (!v) return;
|
||||
const missingModels = readiness.missing.filter(
|
||||
(g) => g === 'stt' || g === 'llm',
|
||||
);
|
||||
if (missingModels.length === 0) return;
|
||||
const names = [
|
||||
missingModels.includes('stt') ? readiness.stt?.display_name : null,
|
||||
missingModels.includes('llm') ? readiness.llm?.display_name : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' and ');
|
||||
toast({
|
||||
title: t('captures.toast.shortcutNotArmed'),
|
||||
description: t('captures.toast.shortcutNotArmedDescription', {
|
||||
names,
|
||||
count: missingModels.length,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputMonitoringNotice enabled={hotkeyEnabled} />
|
||||
</div>
|
||||
|
||||
<SettingRow
|
||||
title={t('settings.captures.dictation.pushToTalk.title')}
|
||||
description={t('settings.captures.dictation.pushToTalk.description')}
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<ChordPreview keys={pushToTalkKeys} />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hotkeyEnabled}
|
||||
onClick={() => setChordEditor('push')}
|
||||
>
|
||||
<Keyboard className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('settings.captures.dictation.pushToTalk.change')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title={t('settings.captures.dictation.toggle.title')}
|
||||
description={t('settings.captures.dictation.toggle.description')}
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<ChordPreview keys={toggleToTalkKeys} />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hotkeyEnabled}
|
||||
onClick={() => setChordEditor('toggle')}
|
||||
>
|
||||
<Keyboard className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('settings.captures.dictation.toggle.change')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<ChordPicker
|
||||
open={chordEditor === 'push'}
|
||||
title={t('settings.captures.dictation.chordPicker.pttTitle')}
|
||||
description={t('settings.captures.dictation.chordPicker.pttDescription')}
|
||||
initialKeys={pushToTalkKeys}
|
||||
onCancel={() => setChordEditor(null)}
|
||||
onSave={(keys) => {
|
||||
update({ chord_push_to_talk_keys: keys });
|
||||
setChordEditor(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ChordPicker
|
||||
open={chordEditor === 'toggle'}
|
||||
title={t('settings.captures.dictation.chordPicker.toggleTitle')}
|
||||
description={t('settings.captures.dictation.chordPicker.toggleDescription')}
|
||||
initialKeys={toggleToTalkKeys}
|
||||
onCancel={() => setChordEditor(null)}
|
||||
onSave={(keys) => {
|
||||
update({ chord_toggle_to_talk_keys: keys });
|
||||
setChordEditor(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title={t('settings.captures.dictation.preview.title')}
|
||||
description={t('settings.captures.dictation.preview.description')}
|
||||
>
|
||||
<HotkeyPillPreview enabled={hotkeyEnabled} />
|
||||
</SettingRow>
|
||||
|
||||
<div>
|
||||
<SettingRow
|
||||
title={t('settings.captures.dictation.autoPaste.title')}
|
||||
description={t('settings.captures.dictation.autoPaste.description')}
|
||||
htmlFor="autoPaste"
|
||||
action={
|
||||
<Toggle
|
||||
id="autoPaste"
|
||||
checked={allowAutoPaste}
|
||||
onCheckedChange={(v) => update({ allow_auto_paste: v })}
|
||||
disabled={!hotkeyEnabled}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<AccessibilityNotice />
|
||||
</div>
|
||||
</SettingSection>
|
||||
|
||||
<SettingSection
|
||||
title={t('settings.captures.transcription.title')}
|
||||
description={t('settings.captures.transcription.description')}
|
||||
>
|
||||
<SettingRow
|
||||
title={t('settings.captures.transcription.model.title')}
|
||||
description={t('settings.captures.transcription.model.description')}
|
||||
action={
|
||||
<Select
|
||||
value={sttModel}
|
||||
onValueChange={(v) => update({ stt_model: v as WhisperModelSize })}
|
||||
>
|
||||
<SelectTrigger className="w-[300px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="base">
|
||||
{t('settings.captures.transcription.model.base', { tail: t('settings.captures.transcription.model.tail.fast') })}
|
||||
</SelectItem>
|
||||
<SelectItem value="small">
|
||||
{t('settings.captures.transcription.model.small', { tail: t('settings.captures.transcription.model.tail.balanced') })}
|
||||
</SelectItem>
|
||||
<SelectItem value="medium">
|
||||
{t('settings.captures.transcription.model.medium', { tail: t('settings.captures.transcription.model.tail.higher') })}
|
||||
</SelectItem>
|
||||
<SelectItem value="large">
|
||||
{t('settings.captures.transcription.model.large', { tail: t('settings.captures.transcription.model.tail.best') })}
|
||||
</SelectItem>
|
||||
<SelectItem value="turbo">
|
||||
{t('settings.captures.transcription.model.turbo', { tail: t('settings.captures.transcription.model.tail.nearBest') })}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title={t('settings.captures.transcription.language.title')}
|
||||
description={t('settings.captures.transcription.language.description')}
|
||||
action={
|
||||
<Select value={language} onValueChange={(v) => update({ language: v })}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t('settings.captures.transcription.language.auto')}</SelectItem>
|
||||
<SelectItem value="en">{t('settings.captures.transcription.language.en')}</SelectItem>
|
||||
<SelectItem value="es">{t('settings.captures.transcription.language.es')}</SelectItem>
|
||||
<SelectItem value="fr">{t('settings.captures.transcription.language.fr')}</SelectItem>
|
||||
<SelectItem value="de">{t('settings.captures.transcription.language.de')}</SelectItem>
|
||||
<SelectItem value="ja">{t('settings.captures.transcription.language.ja')}</SelectItem>
|
||||
<SelectItem value="zh">{t('settings.captures.transcription.language.zh')}</SelectItem>
|
||||
<SelectItem value="hi">{t('settings.captures.transcription.language.hi')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
|
||||
</SettingSection>
|
||||
|
||||
<SettingSection
|
||||
title={t('settings.captures.refinement.title')}
|
||||
description={t('settings.captures.refinement.description')}
|
||||
>
|
||||
<SettingRow
|
||||
title={t('settings.captures.refinement.auto.title')}
|
||||
description={t('settings.captures.refinement.auto.description')}
|
||||
htmlFor="autoRefine"
|
||||
action={
|
||||
<Toggle
|
||||
id="autoRefine"
|
||||
checked={autoRefine}
|
||||
onCheckedChange={(v) => update({ auto_refine: v })}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title={t('settings.captures.refinement.model.title')}
|
||||
description={t('settings.captures.refinement.model.description')}
|
||||
action={
|
||||
<Select
|
||||
value={llmModel}
|
||||
onValueChange={(v) => update({ llm_model: v as Qwen3ModelSize })}
|
||||
disabled={!autoRefine}
|
||||
>
|
||||
<SelectTrigger className="w-[260px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0.6B">
|
||||
{t('settings.captures.refinement.model.size06', { tail: t('settings.captures.refinement.model.tail.veryFast') })}
|
||||
</SelectItem>
|
||||
<SelectItem value="1.7B">
|
||||
{t('settings.captures.refinement.model.size17', { tail: t('settings.captures.refinement.model.tail.fast') })}
|
||||
</SelectItem>
|
||||
<SelectItem value="4B">
|
||||
{t('settings.captures.refinement.model.size40', { tail: t('settings.captures.refinement.model.tail.fullQuality') })}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title={t('settings.captures.refinement.smartCleanup.title')}
|
||||
description={t('settings.captures.refinement.smartCleanup.description')}
|
||||
htmlFor="smartCleanup"
|
||||
action={
|
||||
<Toggle
|
||||
id="smartCleanup"
|
||||
checked={smartCleanup}
|
||||
onCheckedChange={(v) => update({ smart_cleanup: v })}
|
||||
disabled={!autoRefine}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title={t('settings.captures.refinement.selfCorrection.title')}
|
||||
description={t('settings.captures.refinement.selfCorrection.description')}
|
||||
htmlFor="selfCorrection"
|
||||
action={
|
||||
<Toggle
|
||||
id="selfCorrection"
|
||||
checked={selfCorrection}
|
||||
onCheckedChange={(v) => update({ self_correction: v })}
|
||||
disabled={!autoRefine}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title={t('settings.captures.refinement.preserveTechnical.title')}
|
||||
description={t('settings.captures.refinement.preserveTechnical.description')}
|
||||
htmlFor="preserveTechnical"
|
||||
action={
|
||||
<Toggle
|
||||
id="preserveTechnical"
|
||||
checked={preserveTechnical}
|
||||
onCheckedChange={(v) => update({ preserve_technical: v })}
|
||||
disabled={!autoRefine}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SettingSection>
|
||||
|
||||
<SettingSection
|
||||
title={t('settings.captures.playback.title')}
|
||||
description={t('settings.captures.playback.description')}
|
||||
>
|
||||
<SettingRow
|
||||
title={t('settings.captures.playback.defaultVoice.title')}
|
||||
description={t('settings.captures.playback.defaultVoice.description')}
|
||||
action={
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 min-w-[220px] justify-between"
|
||||
disabled={voices.length === 0}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{defaultVoice ? (
|
||||
<span className="truncate">{defaultVoice.name}</span>
|
||||
) : (
|
||||
<span className="truncate text-muted-foreground">
|
||||
{voices.length === 0
|
||||
? t('settings.captures.playback.defaultVoice.noClonedVoices')
|
||||
: t('settings.captures.playback.defaultVoice.noneSelected')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-60 shrink-0" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
<DropdownMenuLabel className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
|
||||
{t('settings.captures.playback.defaultVoice.clonedVoices')}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{voices.map((v) => (
|
||||
<DropdownMenuItem
|
||||
key={v.id}
|
||||
onClick={() => update({ default_playback_voice_id: v.id })}
|
||||
className="gap-2.5 py-2"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{v.name}</div>
|
||||
{v.description ? (
|
||||
<div className="text-[11px] text-muted-foreground truncate">
|
||||
{v.description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{v.id === defaultVoiceId && <Check className="h-3.5 w-3.5 text-accent shrink-0" />}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
}
|
||||
/>
|
||||
</SettingSection>
|
||||
|
||||
<SettingSection
|
||||
title={t('settings.captures.storage.title')}
|
||||
description={t('settings.captures.storage.description')}
|
||||
>
|
||||
<SettingRow
|
||||
title={t('settings.captures.storage.folder.title')}
|
||||
description={capturesPath ?? t('settings.captures.storage.folder.description')}
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={openCapturesFolder}
|
||||
disabled={opening || !capturesPath}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('settings.captures.storage.folder.open')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</SettingSection>
|
||||
</div>
|
||||
|
||||
<aside className="hidden lg:block w-[280px] shrink-0 space-y-6 sticky top-0">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">{t('settings.captures.sidebar.aboutTitle')}</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{t('settings.captures.sidebar.aboutBody')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">{t('settings.captures.sidebar.differencesTitle')}</h3>
|
||||
<ul className="space-y-3 text-sm text-muted-foreground">
|
||||
<li className="flex gap-2.5">
|
||||
<Lock className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
|
||||
<span className="leading-relaxed">
|
||||
<span className="text-foreground font-medium">{t('settings.captures.sidebar.local.title')}</span>{' '}
|
||||
{t('settings.captures.sidebar.local.body')}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex gap-2.5">
|
||||
<Volume2 className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
|
||||
<span className="leading-relaxed">
|
||||
<span className="text-foreground font-medium">
|
||||
{t('settings.captures.sidebar.playAs.title')}
|
||||
</span>{' '}
|
||||
{t('settings.captures.sidebar.playAs.body')}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex gap-2.5">
|
||||
<Laptop className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
|
||||
<span className="leading-relaxed">
|
||||
<span className="text-foreground font-medium">
|
||||
{t('settings.captures.sidebar.crossPlatform.title')}
|
||||
</span>{' '}
|
||||
{t('settings.captures.sidebar.crossPlatform.body')}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
{isWindows && (
|
||||
<div className="rounded-lg border border-accent/20 bg-accent/5 px-3 py-2.5">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Info className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
|
||||
<div className="flex-1 min-w-0 space-y-0.5">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t('settings.captures.sidebar.windowsCaveat.title')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{t('settings.captures.sidebar.windowsCaveat.body')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Same six-gate checklist the CapturesTab empty state uses.
|
||||
Surfaces missing models / permissions persistently while
|
||||
users configure this page, so a red gate can't hide behind
|
||||
a green toggle. Hidden once every gate is green — no value
|
||||
in real estate full of checkmarks. */}
|
||||
{!readiness.allReady && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">{t('captures.readiness.title')}</h3>
|
||||
<DictationReadinessChecklist readiness={readiness} compact />
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import changelogRaw from 'virtual:changelog';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { type ChangelogEntry, parseChangelog } from '@/lib/utils/parseChangelog';
|
||||
|
||||
function renderMarkdown(md: string): React.ReactNode[] {
|
||||
const lines = md.split('\n');
|
||||
const elements: React.ReactNode[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
// Skip empty lines
|
||||
if (line.trim() === '') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Tables — collect all lines starting with |
|
||||
if (line.trim().startsWith('|')) {
|
||||
const tableLines: string[] = [];
|
||||
while (i < lines.length && lines[i].trim().startsWith('|')) {
|
||||
tableLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
elements.push(renderTable(tableLines, elements.length));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Headings
|
||||
if (line.startsWith('#### ')) {
|
||||
elements.push(
|
||||
<h5 key={elements.length} className="text-sm font-medium mt-5 mb-1">
|
||||
{inlineMarkdown(line.slice(5))}
|
||||
</h5>,
|
||||
);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('### ')) {
|
||||
elements.push(
|
||||
<h4 key={elements.length} className="text-sm font-medium mt-6 mb-2">
|
||||
{inlineMarkdown(line.slice(4))}
|
||||
</h4>,
|
||||
);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// List items — collect consecutive
|
||||
if (line.startsWith('- ')) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && lines[i].startsWith('- ')) {
|
||||
items.push(lines[i].slice(2));
|
||||
i++;
|
||||
}
|
||||
elements.push(
|
||||
<ul key={elements.length} className="space-y-1 my-2">
|
||||
{items.map((item, idx) => (
|
||||
<li key={idx} className="text-sm text-muted-foreground flex gap-2">
|
||||
<span className="text-muted-foreground/50 select-none shrink-0">•</span>
|
||||
<span>{inlineMarkdown(item)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Paragraph
|
||||
elements.push(
|
||||
<p key={elements.length} className="text-sm text-muted-foreground my-2">
|
||||
{inlineMarkdown(line)}
|
||||
</p>,
|
||||
);
|
||||
i++;
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
function renderTable(tableLines: string[], keyBase: number): React.ReactNode {
|
||||
const parseRow = (line: string) =>
|
||||
line
|
||||
.split('|')
|
||||
.slice(1, -1)
|
||||
.map((c) => c.trim());
|
||||
|
||||
const headers = parseRow(tableLines[0]);
|
||||
// Skip separator line (index 1)
|
||||
const rows = tableLines.slice(2).map(parseRow);
|
||||
|
||||
return (
|
||||
<div key={keyBase} className="overflow-x-auto my-3">
|
||||
<table className="text-sm w-full">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
{headers.map((h, hIdx) => (
|
||||
<th
|
||||
key={hIdx}
|
||||
className="text-left py-1.5 pr-4 text-muted-foreground font-medium text-xs"
|
||||
>
|
||||
{inlineMarkdown(h)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, rowIdx) => (
|
||||
<tr key={rowIdx} className="border-b border-border/50">
|
||||
{row.map((cell, cellIdx) => (
|
||||
<td key={cellIdx} className="py-1.5 pr-4 text-muted-foreground">
|
||||
{inlineMarkdown(cell)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function inlineMarkdown(text: string): React.ReactNode {
|
||||
// Process inline markdown: bold, code, links
|
||||
const parts: React.ReactNode[] = [];
|
||||
// Regex matches: **bold**, `code`, [text](url)
|
||||
const inlineRe = /\*\*(.+?)\*\*|`([^`]+)`|\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null = inlineRe.exec(text);
|
||||
|
||||
while (match !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
if (match[1] !== undefined) {
|
||||
// Bold
|
||||
parts.push(
|
||||
<strong key={parts.length} className="font-medium text-foreground">
|
||||
{match[1]}
|
||||
</strong>,
|
||||
);
|
||||
} else if (match[2] !== undefined) {
|
||||
// Code
|
||||
parts.push(
|
||||
<code key={parts.length} className="px-1 py-0.5 rounded bg-muted text-xs font-mono">
|
||||
{match[2]}
|
||||
</code>,
|
||||
);
|
||||
} else if (match[3] !== undefined && match[4] !== undefined) {
|
||||
// Link
|
||||
parts.push(
|
||||
<a
|
||||
key={parts.length}
|
||||
href={match[4]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
{match[3]}
|
||||
</a>,
|
||||
);
|
||||
}
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
match = inlineRe.exec(text);
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return parts.length === 1 ? parts[0] : parts;
|
||||
}
|
||||
|
||||
function ChangelogEntryCard({ entry }: { entry: ChangelogEntry }) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const content = useMemo(() => renderMarkdown(entry.body), [entry.body]);
|
||||
const isLong = entry.body.split('\n').length > 12;
|
||||
|
||||
return (
|
||||
<div className="border-b border-border/50 pb-6">
|
||||
<div className="flex items-baseline gap-3 mb-3">
|
||||
<h3 className="text-xl font-semibold tracking-tight">{entry.version}</h3>
|
||||
{entry.date && <span className="text-xs text-muted-foreground">{entry.date}</span>}
|
||||
{entry.version === 'Unreleased' && (
|
||||
<Badge variant="outline">{t('settings.changelog.devBadge')}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={isLong && !expanded ? 'max-h-48 overflow-hidden relative' : ''}>
|
||||
{content}
|
||||
{isLong && !expanded && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-background to-transparent" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLong && (
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="text-xs text-accent hover:underline mt-2"
|
||||
>
|
||||
{expanded ? t('settings.changelog.showLess') : t('settings.changelog.showMore')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChangelogPage() {
|
||||
const entries = useMemo(() => parseChangelog(changelogRaw), []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
{entries.map((entry) => (
|
||||
<ChangelogEntryCard key={entry.version} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { AlertCircle, ArrowUpRight, Book, Download, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import * as z from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Toggle } from '@/components/ui/toggle';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
|
||||
import { useServerHealth } from '@/lib/hooks/useServer';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { LanguageSelect } from './LanguageSelect';
|
||||
import { SettingRow, SettingSection } from './SettingRow';
|
||||
import { ThemeSelect } from './ThemeSelect';
|
||||
|
||||
function makeConnectionSchema(invalidUrl: string) {
|
||||
return z.object({
|
||||
serverUrl: z.string().url(invalidUrl),
|
||||
});
|
||||
}
|
||||
|
||||
type ConnectionFormValues = { serverUrl: string };
|
||||
|
||||
export function GeneralPage() {
|
||||
const { t } = useTranslation();
|
||||
const platform = usePlatform();
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
const setServerUrl = useServerStore((state) => state.setServerUrl);
|
||||
const keepServerRunningOnClose = useServerStore((state) => state.keepServerRunningOnClose);
|
||||
const setKeepServerRunningOnClose = useServerStore((state) => state.setKeepServerRunningOnClose);
|
||||
const mode = useServerStore((state) => state.mode);
|
||||
const setMode = useServerStore((state) => state.setMode);
|
||||
const { toast } = useToast();
|
||||
const { data: health, isLoading, error: healthError } = useServerHealth();
|
||||
|
||||
const resolver = useMemo(
|
||||
() => zodResolver(makeConnectionSchema(t('settings.general.serverUrl.invalidUrl'))),
|
||||
[t],
|
||||
);
|
||||
const form = useForm<ConnectionFormValues>({
|
||||
resolver,
|
||||
defaultValues: { serverUrl },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({ serverUrl });
|
||||
}, [serverUrl, form]);
|
||||
|
||||
// Re-run validation when the locale changes so existing error messages retranslate.
|
||||
useEffect(() => {
|
||||
if (form.formState.errors.serverUrl) {
|
||||
form.trigger('serverUrl');
|
||||
}
|
||||
}, [t, form]);
|
||||
|
||||
const { isDirty } = form.formState;
|
||||
|
||||
function onSubmit(data: ConnectionFormValues) {
|
||||
setServerUrl(data.serverUrl);
|
||||
form.reset(data);
|
||||
toast({
|
||||
title: t('settings.general.serverUrl.updatedTitle'),
|
||||
description: t('settings.general.serverUrl.updatedDescription', { url: data.serverUrl }),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 max-w-2xl">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<a
|
||||
href="https://docs.voicebox.sh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center gap-3 rounded-lg border border-border/60 p-4 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<Book className="h-5 w-5 shrink-0 text-accent" strokeWidth={2.5} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">{t('settings.general.docs.title')}</div>
|
||||
<div className="text-xs text-muted-foreground">docs.voicebox.sh</div>
|
||||
</div>
|
||||
<ArrowUpRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.gg/StkzQasqPS"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center gap-3 rounded-lg border border-border/60 p-4 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5 shrink-0 text-accent"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
|
||||
</svg>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">{t('settings.general.discord.title')}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('settings.general.discord.subtitle')}
|
||||
</div>
|
||||
</div>
|
||||
<ArrowUpRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<SettingSection>
|
||||
<SettingRow
|
||||
title={t('settings.general.serverUrl.title')}
|
||||
description={t('settings.general.serverUrl.description')}
|
||||
action={
|
||||
<ConnectionStatus health={health} isLoading={isLoading} healthError={healthError} />
|
||||
}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="serverUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input placeholder="http://127.0.0.1:17493" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{isDirty && (
|
||||
<Button type="submit" size="sm">
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title={t('settings.general.keepServerRunning.title')}
|
||||
description={t('settings.general.keepServerRunning.description')}
|
||||
htmlFor="keepServerRunning"
|
||||
action={
|
||||
<Toggle
|
||||
id="keepServerRunning"
|
||||
checked={keepServerRunningOnClose}
|
||||
onCheckedChange={(checked: boolean) => {
|
||||
setKeepServerRunningOnClose(checked);
|
||||
platform.lifecycle.setKeepServerRunning(checked).catch((error) => {
|
||||
console.error('Failed to sync setting to Rust:', error);
|
||||
setKeepServerRunningOnClose(!checked);
|
||||
toast({
|
||||
title: t('settings.general.keepServerRunning.failedTitle'),
|
||||
description: t('settings.general.keepServerRunning.failedDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
});
|
||||
toast({
|
||||
title: t('settings.general.keepServerRunning.updatedTitle'),
|
||||
description: checked
|
||||
? t('settings.general.keepServerRunning.runningDescription')
|
||||
: t('settings.general.keepServerRunning.stoppedDescription'),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{platform.metadata.isTauri && (
|
||||
<SettingRow
|
||||
title={t('settings.general.networkAccess.title')}
|
||||
description={t('settings.general.networkAccess.description')}
|
||||
htmlFor="allowNetworkAccess"
|
||||
action={
|
||||
<Toggle
|
||||
id="allowNetworkAccess"
|
||||
checked={mode === 'remote'}
|
||||
onCheckedChange={(checked: boolean) => {
|
||||
setMode(checked ? 'remote' : 'local');
|
||||
toast({
|
||||
title: t('settings.general.networkAccess.updatedTitle'),
|
||||
description: checked
|
||||
? t('settings.general.networkAccess.enabled')
|
||||
: t('settings.general.networkAccess.disabled'),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SettingRow
|
||||
title={t('settings.language.label')}
|
||||
description={t('settings.language.description')}
|
||||
action={<LanguageSelect />}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title={t('settings.theme.label')}
|
||||
description={t('settings.theme.description')}
|
||||
action={<ThemeSelect />}
|
||||
/>
|
||||
</SettingSection>
|
||||
|
||||
<ApiReferenceCard serverUrl={serverUrl} />
|
||||
|
||||
{platform.metadata.isTauri && <UpdatesSection />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionStatus({
|
||||
health,
|
||||
isLoading,
|
||||
healthError,
|
||||
}: {
|
||||
health: ReturnType<typeof useServerHealth>['data'];
|
||||
isLoading: boolean;
|
||||
healthError: ReturnType<typeof useServerHealth>['error'];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full border border-border/60 px-3 py-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('settings.general.connection.connecting')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (healthError) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full border border-destructive/30 px-3 py-1">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full rounded-full bg-destructive/40" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-destructive" />
|
||||
</span>
|
||||
<span className="text-xs text-destructive">{t('settings.general.connection.offline')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (health) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full border border-accent/30 px-3 py-1">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-accent/60" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-accent shadow-[0_0_6px_1px_hsl(var(--accent)/0.5)]" />
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('settings.general.connection.online')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function UpdatesSection() {
|
||||
const { t } = useTranslation();
|
||||
const platform = usePlatform();
|
||||
const { status, checkForUpdates, downloadAndInstall, restartAndInstall } = useAutoUpdater(false);
|
||||
const [currentVersion, setCurrentVersion] = useState<string | null>('');
|
||||
const isDev = !import.meta.env?.PROD;
|
||||
|
||||
useEffect(() => {
|
||||
platform.metadata
|
||||
.getVersion()
|
||||
.then(setCurrentVersion)
|
||||
.catch(() => setCurrentVersion(null));
|
||||
}, [platform]);
|
||||
|
||||
const versionLabel = currentVersion ?? t('common.unknown');
|
||||
|
||||
return (
|
||||
<SettingSection
|
||||
title={t('settings.general.updates.title')}
|
||||
description={`v${versionLabel}${isDev ? t('settings.general.updates.devSuffix') : ''}`}
|
||||
>
|
||||
{isDev ? (
|
||||
<SettingRow
|
||||
title={t('settings.general.updates.devMode.title')}
|
||||
description={t('settings.general.updates.devMode.description')}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<SettingRow
|
||||
title={t('settings.general.updates.check.title')}
|
||||
description={
|
||||
status.available
|
||||
? t('settings.general.updates.check.available', { version: status.version })
|
||||
: status.checking
|
||||
? t('settings.general.updates.check.checking')
|
||||
: t('settings.general.updates.check.upToDate')
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
onClick={checkForUpdates}
|
||||
disabled={status.checking || status.downloading || status.readyToInstall}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3.5 w-3.5 mr-1.5 ${status.checking ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
{t('settings.general.updates.check.button')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{status.error && (
|
||||
<SettingRow title={t('settings.general.updates.error')}>
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{status.error}
|
||||
</div>
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
{status.available && !status.downloading && !status.readyToInstall && (
|
||||
<SettingRow
|
||||
title={t('settings.general.updates.download.title', { version: status.version })}
|
||||
description={t('settings.general.updates.download.description')}
|
||||
action={
|
||||
<Button onClick={downloadAndInstall} size="sm">
|
||||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('settings.general.updates.download.button')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{status.downloading && (
|
||||
<SettingRow title={t('settings.general.updates.downloading')}>
|
||||
<div className="space-y-1.5">
|
||||
<Progress value={status.downloadProgress} />
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
{status.downloadedBytes !== undefined &&
|
||||
status.totalBytes !== undefined &&
|
||||
status.totalBytes > 0 ? (
|
||||
<span>
|
||||
{(status.downloadedBytes / 1024 / 1024).toFixed(1)} MB /{' '}
|
||||
{(status.totalBytes / 1024 / 1024).toFixed(1)} MB
|
||||
</span>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{status.downloadProgress !== undefined && <span>{status.downloadProgress}%</span>}
|
||||
</div>
|
||||
</div>
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
{status.readyToInstall && (
|
||||
<SettingRow
|
||||
title={t('settings.general.updates.ready.title')}
|
||||
description={t('settings.general.updates.ready.description', {
|
||||
version: status.version,
|
||||
})}
|
||||
action={
|
||||
<Button onClick={restartAndInstall} size="sm">
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('settings.general.updates.ready.button')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SettingSection>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiReferenceCard({ serverUrl }: { serverUrl: string }) {
|
||||
const { t } = useTranslation();
|
||||
const endpoints = [
|
||||
{ method: 'POST', path: '/generate', label: t('settings.general.api.endpoints.generate') },
|
||||
{ method: 'GET', path: '/health', label: t('settings.general.api.endpoints.health') },
|
||||
{ method: 'GET', path: '/profiles', label: t('settings.general.api.endpoints.profiles') },
|
||||
{ method: 'GET', path: '/history', label: t('settings.general.api.endpoints.history') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 p-4 space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">{t('settings.general.api.title')}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans
|
||||
i18nKey="settings.general.api.description"
|
||||
values={{ url: serverUrl }}
|
||||
components={{
|
||||
code: <code className="text-xs bg-muted px-1 py-0.5 rounded font-mono" />,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{endpoints.map((ep) => (
|
||||
<div key={ep.path} className="flex items-center gap-2.5 py-1">
|
||||
<span
|
||||
className={`text-[10px] font-mono font-semibold w-9 text-center rounded px-1 py-px ${
|
||||
ep.method === 'POST' ? 'bg-accent/10 text-accent' : 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{ep.method}
|
||||
</span>
|
||||
<code className="text-xs font-mono text-muted-foreground">{ep.path}</code>
|
||||
<span className="text-xs text-muted-foreground/50 ml-auto">{ep.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<a
|
||||
href={`${serverUrl}/docs`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
{t('settings.general.api.viewReference')}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { FolderOpen, Languages, Mic, Zap } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Toggle } from '@/components/ui/toggle';
|
||||
import { useGenerationSettings } from '@/lib/hooks/useSettings';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { SettingRow, SettingSection } from './SettingRow';
|
||||
|
||||
export function GenerationPage() {
|
||||
const { t } = useTranslation();
|
||||
const platform = usePlatform();
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
const { settings, update } = useGenerationSettings();
|
||||
const persistedMaxChunkChars = settings?.max_chunk_chars ?? 800;
|
||||
const persistedCrossfadeMs = settings?.crossfade_ms ?? 50;
|
||||
const normalizeAudio = settings?.normalize_audio ?? true;
|
||||
const autoplayOnGenerate = settings?.autoplay_on_generate ?? true;
|
||||
// Slider mirrors persist on commit (pointer-up / keyboard-release) only —
|
||||
// onValueChange would fire a PATCH for every pointer-move pixel and round-
|
||||
// trip mid-drag failures could leave persisted state out of sync with UI.
|
||||
const [maxChunkChars, setMaxChunkChars] = useState(persistedMaxChunkChars);
|
||||
const [crossfadeMs, setCrossfadeMs] = useState(persistedCrossfadeMs);
|
||||
useEffect(() => setMaxChunkChars(persistedMaxChunkChars), [persistedMaxChunkChars]);
|
||||
useEffect(() => setCrossfadeMs(persistedCrossfadeMs), [persistedCrossfadeMs]);
|
||||
const [opening, setOpening] = useState(false);
|
||||
const [generationsPath, setGenerationsPath] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${serverUrl}/health/filesystem`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
const genDir = data.directories?.find((d: { path: string }) =>
|
||||
d.path.includes('generations'),
|
||||
);
|
||||
if (genDir?.path) setGenerationsPath(genDir.path);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [serverUrl]);
|
||||
|
||||
const openGenerationsFolder = useCallback(async () => {
|
||||
if (!generationsPath) return;
|
||||
setOpening(true);
|
||||
try {
|
||||
await platform.filesystem.openPath(generationsPath);
|
||||
} catch (e) {
|
||||
console.error('Failed to open generations folder:', e);
|
||||
} finally {
|
||||
setOpening(false);
|
||||
}
|
||||
}, [platform, generationsPath]);
|
||||
|
||||
return (
|
||||
<div className="flex gap-8 items-start max-w-5xl">
|
||||
<div className="flex-1 min-w-0 max-w-2xl space-y-8">
|
||||
<SettingSection
|
||||
title={t('settings.generation.title')}
|
||||
description={t('settings.generation.description')}
|
||||
>
|
||||
<SettingRow
|
||||
title={t('settings.generation.chunkLimit.title')}
|
||||
description={t('settings.generation.chunkLimit.description')}
|
||||
action={
|
||||
<span className="text-sm tabular-nums text-muted-foreground">
|
||||
{t('settings.generation.chunkLimit.value', { chars: maxChunkChars })}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Slider
|
||||
id="maxChunkChars"
|
||||
value={[maxChunkChars]}
|
||||
onValueChange={([value]) => setMaxChunkChars(value)}
|
||||
onValueCommit={([value]) => update({ max_chunk_chars: value })}
|
||||
min={100}
|
||||
max={5000}
|
||||
step={50}
|
||||
aria-label={t('settings.generation.chunkLimit.title')}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title={t('settings.generation.crossfade.title')}
|
||||
description={t('settings.generation.crossfade.description')}
|
||||
action={
|
||||
<span className="text-sm tabular-nums text-muted-foreground">
|
||||
{crossfadeMs === 0
|
||||
? t('settings.generation.crossfade.cut')
|
||||
: t('settings.generation.crossfade.ms', { ms: crossfadeMs })}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Slider
|
||||
id="crossfadeMs"
|
||||
value={[crossfadeMs]}
|
||||
onValueChange={([value]) => setCrossfadeMs(value)}
|
||||
onValueCommit={([value]) => update({ crossfade_ms: value })}
|
||||
min={0}
|
||||
max={200}
|
||||
step={10}
|
||||
aria-label={t('settings.generation.crossfade.title')}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title={t('settings.generation.normalize.title')}
|
||||
description={t('settings.generation.normalize.description')}
|
||||
htmlFor="normalizeAudio"
|
||||
action={
|
||||
<Toggle
|
||||
id="normalizeAudio"
|
||||
checked={normalizeAudio}
|
||||
onCheckedChange={(v) => update({ normalize_audio: v })}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title={t('settings.generation.autoplay.title')}
|
||||
description={t('settings.generation.autoplay.description')}
|
||||
htmlFor="autoplayOnGenerate"
|
||||
action={
|
||||
<Toggle
|
||||
id="autoplayOnGenerate"
|
||||
checked={autoplayOnGenerate}
|
||||
onCheckedChange={(v) => update({ autoplay_on_generate: v })}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title={t('settings.generation.folder.title')}
|
||||
description={generationsPath ?? t('settings.generation.folder.description')}
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={openGenerationsFolder}
|
||||
disabled={opening || !generationsPath}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('settings.generation.folder.open')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</SettingSection>
|
||||
</div>
|
||||
|
||||
<aside className="hidden lg:block w-[280px] shrink-0 space-y-6 sticky top-0">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">{t('settings.generation.sidebar.aboutTitle')}</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{t('settings.generation.sidebar.aboutBody')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">{t('settings.generation.sidebar.differencesTitle')}</h3>
|
||||
<ul className="space-y-3 text-sm text-muted-foreground">
|
||||
<li className="flex gap-2.5">
|
||||
<Mic className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
|
||||
<span className="leading-relaxed">
|
||||
<span className="text-foreground font-medium">
|
||||
{t('settings.generation.sidebar.clone.title')}
|
||||
</span>{' '}
|
||||
{t('settings.generation.sidebar.clone.body')}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex gap-2.5">
|
||||
<Languages className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
|
||||
<span className="leading-relaxed">
|
||||
<span className="text-foreground font-medium">
|
||||
{t('settings.generation.sidebar.engines.title')}
|
||||
</span>{' '}
|
||||
{t('settings.generation.sidebar.engines.body')}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex gap-2.5">
|
||||
<Zap className="h-4 w-4 shrink-0 mt-0.5 text-accent" />
|
||||
<span className="leading-relaxed">
|
||||
<span className="text-foreground font-medium">{t('settings.generation.sidebar.agentReady.title')}</span>{' '}
|
||||
{t('settings.generation.sidebar.agentReady.body')}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { AlertCircle, Cpu, Download, Loader2, RotateCw, Trash2 } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { CudaDownloadProgress, HealthResponse } from '@/lib/api/types';
|
||||
import { useServerHealth } from '@/lib/hooks/useServer';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { SettingRow, SettingSection } from './SettingRow';
|
||||
|
||||
type RestartPhase = 'idle' | 'stopping' | 'waiting' | 'ready';
|
||||
|
||||
function AppleLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GpuIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="4" y="6" width="16" height="12" rx="2" />
|
||||
<path d="M2 10h2M2 14h2M20 10h2M20 14h2" />
|
||||
<path d="M9 10h6M9 14h4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GpuInfoCard({ health }: { health: HealthResponse }) {
|
||||
const { t } = useTranslation();
|
||||
const hasGpu = health.gpu_available && health.gpu_type;
|
||||
|
||||
const gpuName = hasGpu
|
||||
? health.gpu_type!.replace(/^(CUDA|ROCm|MPS|Metal|XPU|DirectML)\s*\((.+)\)$/, '$2') ||
|
||||
health.gpu_type!
|
||||
: null;
|
||||
const gpuBackend = hasGpu ? health.gpu_type!.replace(/\s*\(.+\)$/, '') : null;
|
||||
const isApple = gpuBackend === 'MPS' || gpuBackend === 'Metal';
|
||||
const showBackendVariant = health.backend_variant && health.backend_variant !== 'cpu';
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{hasGpu ? (
|
||||
isApple ? (
|
||||
<AppleLogo className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<GpuIcon className="h-5 w-5 shrink-0 text-accent" />
|
||||
)
|
||||
) : (
|
||||
<Cpu className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0 space-y-0.5">
|
||||
<div className="text-sm font-medium">{hasGpu ? gpuName : t('settings.gpu.cpuOnly')}</div>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
{hasGpu ? (
|
||||
<>
|
||||
<span>{gpuBackend}</span>
|
||||
{showBackendVariant && (
|
||||
<>
|
||||
<span className="text-border">|</span>
|
||||
<span className="uppercase">{health.backend_variant}</span>
|
||||
</>
|
||||
)}
|
||||
{health.vram_used_mb != null && health.vram_used_mb > 0 && (
|
||||
<>
|
||||
<span className="text-border">|</span>
|
||||
<span>
|
||||
{t('settings.gpu.vramUsed', { mb: health.vram_used_mb.toFixed(0) })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span>{t('settings.gpu.noAcceleration')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{hasGpu && (
|
||||
<div className="flex items-center gap-2 rounded-full border border-accent/30 px-2.5 py-0.5">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-accent/60" />
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-accent shadow-[0_0_4px_1px_hsl(var(--accent)/0.4)]" />
|
||||
</span>
|
||||
<span className="text-[10px] font-medium text-muted-foreground">
|
||||
{t('settings.gpu.active')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GpuPage() {
|
||||
const { t } = useTranslation();
|
||||
const platform = usePlatform();
|
||||
const queryClient = useQueryClient();
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
const { data: health } = useServerHealth();
|
||||
|
||||
const [restartPhase, setRestartPhase] = useState<RestartPhase>('idle');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [downloadProgress, setDownloadProgress] = useState<CudaDownloadProgress | null>(null);
|
||||
const healthPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
// Hold the latest `t` in a ref so the CUDA progress SSE effect below doesn't
|
||||
// tear down and reconnect the EventSource every time the language changes.
|
||||
const tRef = useRef(t);
|
||||
useEffect(() => {
|
||||
tRef.current = t;
|
||||
}, [t]);
|
||||
|
||||
const {
|
||||
data: cudaStatus,
|
||||
isLoading: _cudaStatusLoading,
|
||||
refetch: refetchCudaStatus,
|
||||
} = useQuery({
|
||||
queryKey: ['cuda-status', serverUrl],
|
||||
queryFn: () => apiClient.getCudaStatus(),
|
||||
refetchInterval: (query) => (query.state.status === 'pending' ? false : 10000),
|
||||
retry: 1,
|
||||
enabled: !!health,
|
||||
});
|
||||
|
||||
const isCurrentlyCuda = health?.backend_variant === 'cuda';
|
||||
const cudaAvailable = cudaStatus?.available ?? false;
|
||||
const cudaDownloading = cudaStatus?.downloading ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (healthPollRef.current) {
|
||||
clearInterval(healthPollRef.current);
|
||||
healthPollRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cudaDownloading || !serverUrl) return;
|
||||
|
||||
const eventSource = new EventSource(`${serverUrl}/backend/cuda-progress`);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as CudaDownloadProgress;
|
||||
setDownloadProgress(data);
|
||||
|
||||
if (data.status === 'complete') {
|
||||
eventSource.close();
|
||||
setDownloadProgress(null);
|
||||
refetchCudaStatus();
|
||||
} else if (data.status === 'error') {
|
||||
eventSource.close();
|
||||
setError(data.error || tRef.current('settings.gpu.errors.downloadFailed'));
|
||||
setDownloadProgress(null);
|
||||
refetchCudaStatus();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing CUDA progress event:', e);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
}, [cudaDownloading, serverUrl, refetchCudaStatus]);
|
||||
|
||||
const clearHealthPolling = useCallback(() => {
|
||||
if (healthPollRef.current) {
|
||||
clearInterval(healthPollRef.current);
|
||||
healthPollRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startHealthPolling = useCallback(() => {
|
||||
clearHealthPolling();
|
||||
|
||||
healthPollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const result = await apiClient.getHealth();
|
||||
if (result.status === 'healthy') {
|
||||
clearHealthPolling();
|
||||
setRestartPhase('ready');
|
||||
queryClient.invalidateQueries();
|
||||
setTimeout(() => setRestartPhase('idle'), 2000);
|
||||
}
|
||||
} catch {
|
||||
// Server still down, keep polling
|
||||
}
|
||||
}, 1000);
|
||||
}, [queryClient, clearHealthPolling]);
|
||||
|
||||
const restartServerWithPolling = useCallback(
|
||||
async (errorMessage: string) => {
|
||||
setRestartPhase('stopping');
|
||||
try {
|
||||
await platform.lifecycle.restartServer();
|
||||
setRestartPhase('waiting');
|
||||
startHealthPolling();
|
||||
} catch (e: unknown) {
|
||||
clearHealthPolling();
|
||||
setRestartPhase('idle');
|
||||
throw new Error(e instanceof Error ? e.message : errorMessage);
|
||||
}
|
||||
},
|
||||
[platform, startHealthPolling, clearHealthPolling],
|
||||
);
|
||||
|
||||
const handleDownload = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await apiClient.downloadCudaBackend();
|
||||
refetchCudaStatus();
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : t('settings.gpu.errors.downloadStart');
|
||||
if (msg.includes('already downloaded')) {
|
||||
refetchCudaStatus();
|
||||
} else {
|
||||
setError(msg);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestart = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await restartServerWithPolling(t('settings.gpu.errors.restartFailed'));
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : t('settings.gpu.errors.restartFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwitchToCpu = async () => {
|
||||
setError(null);
|
||||
setRestartPhase('stopping');
|
||||
try {
|
||||
await apiClient.deleteCudaBackend();
|
||||
await restartServerWithPolling(t('settings.gpu.errors.switchCpu'));
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : t('settings.gpu.errors.switchCpu'));
|
||||
refetchCudaStatus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await apiClient.deleteCudaBackend();
|
||||
refetchCudaStatus();
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : t('settings.gpu.errors.deleteCuda'));
|
||||
}
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
if (!health) return null;
|
||||
|
||||
const hasNativeGpu =
|
||||
health.gpu_available &&
|
||||
!isCurrentlyCuda &&
|
||||
health.gpu_type &&
|
||||
!health.gpu_type.includes('CUDA');
|
||||
|
||||
return (
|
||||
<div className="space-y-8 max-w-2xl">
|
||||
<GpuInfoCard health={health} />
|
||||
|
||||
{!hasNativeGpu && !isCurrentlyCuda && (
|
||||
<SettingSection
|
||||
title={t('settings.gpu.cuda.title')}
|
||||
description={t('settings.gpu.cuda.description')}
|
||||
>
|
||||
{cudaDownloading && downloadProgress && (
|
||||
<SettingRow title={t('settings.gpu.cuda.downloading')}>
|
||||
<div className="space-y-1.5">
|
||||
<Progress value={downloadProgress.progress} className="h-2" />
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{downloadProgress.filename ||
|
||||
(cudaAvailable
|
||||
? t('settings.gpu.cuda.updating')
|
||||
: t('settings.gpu.cuda.downloadingShort'))}
|
||||
</span>
|
||||
<span>
|
||||
{downloadProgress.total > 0
|
||||
? `${formatBytes(downloadProgress.current)} / ${formatBytes(downloadProgress.total)}`
|
||||
: `${downloadProgress.progress.toFixed(1)}%`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
{restartPhase !== 'idle' && (
|
||||
<SettingRow
|
||||
title={
|
||||
restartPhase === 'ready'
|
||||
? t('settings.gpu.restart.ready')
|
||||
: restartPhase === 'waiting'
|
||||
? t('settings.gpu.restart.waiting')
|
||||
: t('settings.gpu.restart.stopping')
|
||||
}
|
||||
action={<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<SettingRow title={t('common.error')}>
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
{restartPhase === 'idle' && !cudaDownloading && (
|
||||
<>
|
||||
{!cudaAvailable && !isCurrentlyCuda && (
|
||||
<SettingRow
|
||||
title={t('settings.gpu.download.title')}
|
||||
description={t('settings.gpu.download.description')}
|
||||
action={
|
||||
<Button onClick={handleDownload} size="sm">
|
||||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('settings.gpu.download.button')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{cudaAvailable && !isCurrentlyCuda && platform.metadata.isTauri && (
|
||||
<SettingRow
|
||||
title={t('settings.gpu.switchToCuda.title')}
|
||||
description={t('settings.gpu.switchToCuda.description')}
|
||||
action={
|
||||
<Button onClick={handleRestart} size="sm">
|
||||
<RotateCw className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('settings.gpu.switchToCuda.button')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isCurrentlyCuda && platform.metadata.isTauri && (
|
||||
<SettingRow
|
||||
title={t('settings.gpu.switchToCpu.title')}
|
||||
description={t('settings.gpu.switchToCpu.description')}
|
||||
action={
|
||||
<Button onClick={handleSwitchToCpu} variant="outline" size="sm">
|
||||
<RotateCw className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('settings.gpu.switchToCpu.button')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{cudaAvailable && !isCurrentlyCuda && (
|
||||
<SettingRow
|
||||
title={t('settings.gpu.remove.title')}
|
||||
description={t('settings.gpu.remove.description')}
|
||||
action={
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground "
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('settings.gpu.remove.button')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SettingSection>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground/60 leading-relaxed">{t('settings.gpu.footer')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { type LanguageCode, SUPPORTED_LANGUAGES } from '@/i18n';
|
||||
|
||||
export function LanguageSelect() {
|
||||
const { i18n } = useTranslation();
|
||||
const current = SUPPORTED_LANGUAGES.find((l) => l.code === i18n.language)?.code ?? 'en';
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={current}
|
||||
onValueChange={(value) => {
|
||||
void i18n.changeLanguage(value as LanguageCode);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SUPPORTED_LANGUAGES.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
{lang.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { type LogEntry, useLogStore } from '@/stores/logStore';
|
||||
|
||||
function formatTime(timestamp: number): string {
|
||||
const d = new Date(timestamp);
|
||||
return d.toLocaleTimeString(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function LogLine({ entry }: { entry: LogEntry }) {
|
||||
return (
|
||||
<div className="flex gap-3 font-mono text-xs leading-5 hover:bg-muted/30">
|
||||
<span className="text-muted-foreground/50 select-none shrink-0">
|
||||
{formatTime(entry.timestamp)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'whitespace-pre-wrap break-all',
|
||||
entry.stream === 'stderr' ? 'text-orange-400/80' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{entry.line}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogsPage() {
|
||||
const { t } = useTranslation();
|
||||
const entries = useLogStore((s) => s.entries);
|
||||
const clear = useLogStore((s) => s.clear);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
|
||||
// Auto-scroll to bottom when new entries arrive
|
||||
useEffect(() => {
|
||||
if (autoScroll && containerRef.current) {
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||
}
|
||||
}, [entries.length, autoScroll]);
|
||||
|
||||
// Detect manual scroll to disable auto-scroll
|
||||
const handleScroll = () => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
setAutoScroll(atBottom);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">{t('settings.logs.title')}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.logs.lineCount', { count: entries.length })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!autoScroll && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setAutoScroll(true);
|
||||
containerRef.current?.scrollTo({ top: containerRef.current.scrollHeight });
|
||||
}}
|
||||
>
|
||||
{t('settings.logs.scrollToBottom')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={clear}>
|
||||
{t('settings.logs.clear')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 min-h-0 overflow-y-auto rounded-md border bg-black/20 p-3"
|
||||
>
|
||||
{entries.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground/50 font-mono space-y-1">
|
||||
<p>{t('settings.logs.empty')}</p>
|
||||
{!import.meta.env?.PROD && <p>{t('settings.logs.devHint')}</p>}
|
||||
</div>
|
||||
) : (
|
||||
entries.map((entry) => <LogLine key={entry.id} entry={entry} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import { Check, Copy, Plug, Trash2, Waypoints } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useMCPBindings } from '@/lib/hooks/useMCPBindings';
|
||||
import { useProfiles } from '@/lib/hooks/useProfiles';
|
||||
import { useCaptureSettings } from '@/lib/hooks/useSettings';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { formatDate } from '@/lib/utils/format';
|
||||
import { SettingRow, SettingSection } from './SettingRow';
|
||||
|
||||
function getStdioShimCommand(): string {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return '/Applications/Voicebox.app/Contents/MacOS/voicebox-mcp';
|
||||
}
|
||||
|
||||
const platform = `${navigator.platform} ${navigator.userAgent}`.toLowerCase();
|
||||
if (platform.includes('win')) {
|
||||
return 'C:\\Program Files\\Voicebox\\voicebox-mcp.exe';
|
||||
}
|
||||
if (platform.includes('linux')) {
|
||||
return '/opt/voicebox/voicebox-mcp';
|
||||
}
|
||||
return '/Applications/Voicebox.app/Contents/MacOS/voicebox-mcp';
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings → MCP — configure per-agent voice binding and show copy-paste
|
||||
* install snippets for major MCP clients. Backend runs at /mcp on the
|
||||
* existing Voicebox server; this page is the agent-onboarding surface.
|
||||
*/
|
||||
export function MCPPage() {
|
||||
const { t } = useTranslation();
|
||||
const serverUrl = useServerStore((s) => s.serverUrl);
|
||||
const { bindings, upsertAsync, remove } = useMCPBindings();
|
||||
const { data: profiles } = useProfiles();
|
||||
const { settings: captureSettings, update: updateCapture } = useCaptureSettings();
|
||||
|
||||
const defaultProfileId = captureSettings?.default_playback_voice_id ?? '';
|
||||
const mcpUrl = `${serverUrl}/mcp`;
|
||||
const stdioShimCommand = getStdioShimCommand();
|
||||
|
||||
const [newClientId, setNewClientId] = useState('');
|
||||
const [newLabel, setNewLabel] = useState('');
|
||||
const [newProfileId, setNewProfileId] = useState('');
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!newClientId.trim()) return;
|
||||
setAdding(true);
|
||||
try {
|
||||
await upsertAsync({
|
||||
client_id: newClientId.trim(),
|
||||
label: newLabel.trim() || null,
|
||||
profile_id: newProfileId || null,
|
||||
});
|
||||
setNewClientId('');
|
||||
setNewLabel('');
|
||||
setNewProfileId('');
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-8 items-start max-w-5xl">
|
||||
<div className="flex-1 min-w-0 max-w-2xl space-y-8">
|
||||
<SettingSection
|
||||
title={t('settings.mcp.install.title')}
|
||||
description={t('settings.mcp.install.description')}
|
||||
>
|
||||
<SnippetRow
|
||||
title={t('settings.mcp.install.http.title')}
|
||||
description={t('settings.mcp.install.http.description')}
|
||||
snippet={JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
voicebox: {
|
||||
url: mcpUrl,
|
||||
headers: { 'X-Voicebox-Client-Id': 'claude-code' },
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
/>
|
||||
<SnippetRow
|
||||
title={t('settings.mcp.install.claudeCode.title')}
|
||||
description={t('settings.mcp.install.claudeCode.description')}
|
||||
snippet={`claude mcp add voicebox --transport http --url ${mcpUrl} --header "X-Voicebox-Client-Id: claude-code"`}
|
||||
/>
|
||||
<SnippetRow
|
||||
title={t('settings.mcp.install.stdio.title')}
|
||||
description={t('settings.mcp.install.stdio.description')}
|
||||
snippet={JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
voicebox: {
|
||||
command: stdioShimCommand,
|
||||
env: { VOICEBOX_CLIENT_ID: 'claude-code' },
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
/>
|
||||
</SettingSection>
|
||||
|
||||
<SettingSection
|
||||
title={t('settings.mcp.defaultVoice.title')}
|
||||
description={t('settings.mcp.defaultVoice.description')}
|
||||
>
|
||||
<SettingRow
|
||||
title={t('settings.mcp.defaultVoice.label')}
|
||||
description={t('settings.mcp.defaultVoice.labelHint')}
|
||||
action={
|
||||
<Select
|
||||
value={defaultProfileId || '__default__'}
|
||||
onValueChange={(v) =>
|
||||
updateCapture({
|
||||
default_playback_voice_id: v === '__default__' ? null : v,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[220px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">
|
||||
{t('settings.mcp.defaultVoice.none')}
|
||||
</SelectItem>
|
||||
{(profiles ?? []).map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
</SettingSection>
|
||||
|
||||
<SettingSection
|
||||
title={t('settings.mcp.bindings.title')}
|
||||
description={t('settings.mcp.bindings.description')}
|
||||
>
|
||||
{bindings.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 italic">
|
||||
<Trans i18nKey="settings.mcp.bindings.empty" components={{ code: <code /> }} />
|
||||
</p>
|
||||
) : (
|
||||
<div className="divide-y divide-border/60">
|
||||
{bindings.map((b) => (
|
||||
<div
|
||||
key={b.client_id}
|
||||
className="py-3 grid grid-cols-[1fr_auto_auto] gap-4 items-center"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-sm truncate">
|
||||
{b.label || b.client_id}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
<code className="text-[11px]">{b.client_id}</code>
|
||||
{' · '}
|
||||
{b.last_seen_at ? (
|
||||
<span title={t('settings.mcp.bindings.lastSeenTitle', { when: b.last_seen_at })}>
|
||||
<Plug className="inline h-3 w-3 text-emerald-500" />{' '}
|
||||
{t('settings.mcp.bindings.lastSeen', { when: formatDate(b.last_seen_at) })}
|
||||
</span>
|
||||
) : (
|
||||
<span>{t('settings.mcp.bindings.neverConnected')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Select
|
||||
value={b.profile_id ?? '__default__'}
|
||||
onValueChange={(v) =>
|
||||
upsertAsync({
|
||||
client_id: b.client_id,
|
||||
label: b.label,
|
||||
profile_id: v === '__default__' ? null : v,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">
|
||||
{t('settings.mcp.bindings.defaultOption')}
|
||||
</SelectItem>
|
||||
{(profiles ?? []).map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => remove(b.client_id)}
|
||||
aria-label={t('settings.mcp.bindings.removeAria', { client: b.client_id })}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-4 space-y-2">
|
||||
<div className="text-sm font-medium">{t('settings.mcp.bindings.add.title')}</div>
|
||||
<div className="grid grid-cols-[1fr_1fr_auto] gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('settings.mcp.bindings.add.clientIdPlaceholder')}
|
||||
value={newClientId}
|
||||
onChange={(e) => setNewClientId(e.target.value)}
|
||||
className="h-9 px-3 rounded-md border bg-background text-sm"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('settings.mcp.bindings.add.labelPlaceholder')}
|
||||
value={newLabel}
|
||||
onChange={(e) => setNewLabel(e.target.value)}
|
||||
className="h-9 px-3 rounded-md border bg-background text-sm"
|
||||
/>
|
||||
<Select
|
||||
value={newProfileId || '__default__'}
|
||||
onValueChange={(v) => setNewProfileId(v === '__default__' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger className="h-9 min-w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">
|
||||
{t('settings.mcp.bindings.defaultOption')}
|
||||
</SelectItem>
|
||||
{(profiles ?? []).map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleAdd}
|
||||
disabled={!newClientId.trim() || adding}
|
||||
>
|
||||
{t('settings.mcp.bindings.add.action')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingSection>
|
||||
</div>
|
||||
|
||||
<aside className="hidden lg:block w-[280px] shrink-0 space-y-6 sticky top-0">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">{t('settings.mcp.sidebar.aboutTitle')}</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{t('settings.mcp.sidebar.aboutBody')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">{t('settings.mcp.sidebar.toolsTitle')}</h3>
|
||||
<ul className="text-sm text-muted-foreground space-y-1.5 leading-relaxed">
|
||||
<li>
|
||||
<code className="text-accent">voicebox.speak</code>
|
||||
<div>{t('settings.mcp.sidebar.tools.speak')}</div>
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-accent">voicebox.transcribe</code>
|
||||
<div>{t('settings.mcp.sidebar.tools.transcribe')}</div>
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-accent">voicebox.list_captures</code>
|
||||
<div>{t('settings.mcp.sidebar.tools.listCaptures')}</div>
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-accent">voicebox.list_profiles</code>
|
||||
<div>{t('settings.mcp.sidebar.tools.listProfiles')}</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Waypoints className="h-3.5 w-3.5 text-accent" />
|
||||
<span>
|
||||
<Trans i18nKey="settings.mcp.sidebar.postSpeak" components={{ code: <code /> }} />
|
||||
</span>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SnippetRow({
|
||||
title,
|
||||
description,
|
||||
snippet,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
snippet: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(snippet);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// ignore; user can still select-and-copy the pre content
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="py-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{title}</div>
|
||||
<div className="text-xs text-muted-foreground">{description}</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={copy}>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('settings.mcp.install.copied')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3.5 w-3.5 mr-1.5" />
|
||||
{t('settings.mcp.install.copy')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="text-[11px] font-mono p-3 rounded-md bg-muted/50 overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{snippet}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Link, Outlet, useMatchRoute } from '@tanstack/react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
|
||||
interface SettingsTab {
|
||||
labelKey?: string;
|
||||
label?: string;
|
||||
path:
|
||||
| '/settings'
|
||||
| '/settings/generation'
|
||||
| '/settings/captures'
|
||||
| '/settings/mcp'
|
||||
| '/settings/gpu'
|
||||
| '/settings/logs'
|
||||
| '/settings/changelog'
|
||||
| '/settings/about';
|
||||
tauriOnly?: boolean;
|
||||
}
|
||||
|
||||
const tabs: SettingsTab[] = [
|
||||
{ labelKey: 'settings.tabs.general', path: '/settings' },
|
||||
{ labelKey: 'settings.tabs.generation', path: '/settings/generation' },
|
||||
{ labelKey: 'settings.tabs.captures', path: '/settings/captures' },
|
||||
{ labelKey: 'settings.tabs.mcp', path: '/settings/mcp' },
|
||||
{ labelKey: 'settings.tabs.gpu', path: '/settings/gpu', tauriOnly: true },
|
||||
{ labelKey: 'settings.tabs.logs', path: '/settings/logs', tauriOnly: true },
|
||||
{ labelKey: 'settings.tabs.changelog', path: '/settings/changelog' },
|
||||
{ labelKey: 'settings.tabs.about', path: '/settings/about' },
|
||||
];
|
||||
|
||||
export function SettingsLayout() {
|
||||
const { t } = useTranslation();
|
||||
const platform = usePlatform();
|
||||
const isPlayerVisible = !!usePlayerStore((state) => state.audioUrl);
|
||||
const matchRoute = useMatchRoute();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<nav className="flex gap-1 border-b shrink-0">
|
||||
{tabs.map((tab) => {
|
||||
if (tab.tauriOnly && !platform.metadata.isTauri) return null;
|
||||
|
||||
const isActive =
|
||||
tab.path === '/settings'
|
||||
? matchRoute({ to: tab.path, fuzzy: false })
|
||||
: matchRoute({ to: tab.path });
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={tab.path}
|
||||
to={tab.path}
|
||||
className={cn(
|
||||
'px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px',
|
||||
isActive
|
||||
? 'border-accent text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground/30',
|
||||
)}
|
||||
>
|
||||
{tab.label ?? (tab.labelKey ? t(tab.labelKey) : '')}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 overflow-y-auto pt-6 pb-6 px-2 -mx-2',
|
||||
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
|
||||
)}
|
||||
>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* A section header with title and optional description, separated by a border.
|
||||
*/
|
||||
export function SettingSection({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{title && <h3 className="text-lg font-semibold">{title}</h3>}
|
||||
{description && <p className="text-sm text-muted-foreground">{description}</p>}
|
||||
<div className={`${title || description ? 'pt-3' : ''} space-y-0 divide-y divide-border/60`}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A single settings row: label+description on the left, action on the right.
|
||||
* Use for toggles, inputs, buttons, badges — any control type.
|
||||
*/
|
||||
export function SettingRow({
|
||||
title,
|
||||
description,
|
||||
htmlFor,
|
||||
action,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
htmlFor?: string;
|
||||
/** Right-aligned control (checkbox, button, badge, etc.) */
|
||||
action?: ReactNode;
|
||||
/** Full-width content rendered below the label row (for sliders, inputs, etc.) */
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="py-3">
|
||||
<div className="flex items-center justify-between gap-8">
|
||||
<div className="min-w-0">
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
className={`text-sm font-medium leading-none select-none ${htmlFor ? 'cursor-pointer' : ''}`}
|
||||
>
|
||||
{title}
|
||||
</label>
|
||||
{description && <p className="text-sm text-muted-foreground mt-0.5">{description}</p>}
|
||||
</div>
|
||||
{action && <div className="shrink-0">{action}</div>}
|
||||
</div>
|
||||
{children && <div className="mt-3">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { type Theme, useUIStore } from '@/stores/uiStore';
|
||||
|
||||
export function ThemeSelect() {
|
||||
const { t } = useTranslation();
|
||||
const theme = useUIStore((s) => s.theme);
|
||||
const setTheme = useUIStore((s) => s.setTheme);
|
||||
|
||||
return (
|
||||
<Select value={theme} onValueChange={(value) => setTheme(value as Theme)}>
|
||||
<SelectTrigger className="h-9 w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="system">{t('settings.theme.options.system')}</SelectItem>
|
||||
<SelectItem value="light">{t('settings.theme.options.light')}</SelectItem>
|
||||
<SelectItem value="dark">{t('settings.theme.options.dark')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { motion, useAnimationFrame, useMotionValue, useTransform } from 'motion/react';
|
||||
import type React from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface ShinyTextProps {
|
||||
text: string;
|
||||
disabled?: boolean;
|
||||
speed?: number;
|
||||
className?: string;
|
||||
color?: string;
|
||||
shineColor?: string;
|
||||
spread?: number;
|
||||
yoyo?: boolean;
|
||||
pauseOnHover?: boolean;
|
||||
direction?: 'left' | 'right';
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
const ShinyText: React.FC<ShinyTextProps> = ({
|
||||
text,
|
||||
disabled = false,
|
||||
speed = 2,
|
||||
className = '',
|
||||
color = '#b5b5b5',
|
||||
shineColor = '#ffffff',
|
||||
spread = 120,
|
||||
yoyo = false,
|
||||
pauseOnHover = false,
|
||||
direction = 'left',
|
||||
delay = 0,
|
||||
}) => {
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const progress = useMotionValue(0);
|
||||
const elapsedRef = useRef(0);
|
||||
const lastTimeRef = useRef<number | null>(null);
|
||||
const directionRef = useRef(direction === 'left' ? 1 : -1);
|
||||
|
||||
const animationDuration = speed * 1000;
|
||||
const delayDuration = delay * 1000;
|
||||
|
||||
useAnimationFrame((time) => {
|
||||
if (disabled || isPaused) {
|
||||
lastTimeRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastTimeRef.current === null) {
|
||||
lastTimeRef.current = time;
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaTime = time - lastTimeRef.current;
|
||||
lastTimeRef.current = time;
|
||||
|
||||
elapsedRef.current += deltaTime;
|
||||
|
||||
// Animation goes from 0 to 100
|
||||
if (yoyo) {
|
||||
const cycleDuration = animationDuration + delayDuration;
|
||||
const fullCycle = cycleDuration * 2;
|
||||
const cycleTime = elapsedRef.current % fullCycle;
|
||||
|
||||
if (cycleTime < animationDuration) {
|
||||
// Forward animation: 0 -> 100
|
||||
const p = (cycleTime / animationDuration) * 100;
|
||||
progress.set(directionRef.current === 1 ? p : 100 - p);
|
||||
} else if (cycleTime < cycleDuration) {
|
||||
// Delay at end
|
||||
progress.set(directionRef.current === 1 ? 100 : 0);
|
||||
} else if (cycleTime < cycleDuration + animationDuration) {
|
||||
// Reverse animation: 100 -> 0
|
||||
const reverseTime = cycleTime - cycleDuration;
|
||||
const p = 100 - (reverseTime / animationDuration) * 100;
|
||||
progress.set(directionRef.current === 1 ? p : 100 - p);
|
||||
} else {
|
||||
// Delay at start
|
||||
progress.set(directionRef.current === 1 ? 0 : 100);
|
||||
}
|
||||
} else {
|
||||
const cycleDuration = animationDuration + delayDuration;
|
||||
const cycleTime = elapsedRef.current % cycleDuration;
|
||||
|
||||
if (cycleTime < animationDuration) {
|
||||
// Animation phase: 0 -> 100
|
||||
const p = (cycleTime / animationDuration) * 100;
|
||||
progress.set(directionRef.current === 1 ? p : 100 - p);
|
||||
} else {
|
||||
// Delay phase - hold at end (shine off-screen)
|
||||
progress.set(directionRef.current === 1 ? 100 : 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
directionRef.current = direction === 'left' ? 1 : -1;
|
||||
elapsedRef.current = 0;
|
||||
progress.set(0);
|
||||
// eslint-d, progress.setisable-next-line react-hooks/exhaustive-deps
|
||||
}, [direction]);
|
||||
|
||||
// Transform: p=0 -> 150% (shine off right), p=100 -> -50% (shine off left)
|
||||
const backgroundPosition = useTransform(progress, (p) => `${150 - p * 2}% center`);
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
if (pauseOnHover) setIsPaused(true);
|
||||
}, [pauseOnHover]);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
if (pauseOnHover) setIsPaused(false);
|
||||
}, [pauseOnHover]);
|
||||
|
||||
const gradientStyle: React.CSSProperties = {
|
||||
backgroundImage: `linear-gradient(${spread}deg, ${color} 0%, ${color} 35%, ${shineColor} 50%, ${color} 65%, ${color} 100%)`,
|
||||
backgroundSize: '200% auto',
|
||||
WebkitBackgroundClip: 'text',
|
||||
backgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
className={`inline-block ${className}`}
|
||||
style={{ ...gradientStyle, backgroundPosition }}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{text}
|
||||
</motion.span>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShinyText;
|
||||
// plugins: [],
|
||||
// };
|
||||
@@ -1,53 +1,111 @@
|
||||
import { Home, Settings } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { Link, useMatchRoute } from '@tanstack/react-router';
|
||||
import { AudioLines, Box, Captions, type LucideIcon, Mic, Settings, Volume2, Wand2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import type { UpdateStatus } from '@/platform/types';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { version } from '../../package.json';
|
||||
|
||||
interface SidebarProps {
|
||||
activeTab: string;
|
||||
onTabChange: (tab: string) => void;
|
||||
isMacOS?: boolean;
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'main', icon: Home, label: 'Main' },
|
||||
{ id: 'settings', icon: Settings, label: 'Settings' },
|
||||
const tabs: Array<{
|
||||
id: string;
|
||||
path: string;
|
||||
icon: LucideIcon;
|
||||
labelKey?: string;
|
||||
label?: string;
|
||||
}> = [
|
||||
{ id: 'main', path: '/', icon: Volume2, labelKey: 'nav.generate' },
|
||||
{ id: 'stories', path: '/stories', icon: AudioLines, labelKey: 'nav.stories' },
|
||||
{ id: 'captures', path: '/captures', icon: Captions, labelKey: 'nav.captures' },
|
||||
{ id: 'voices', path: '/voices', icon: Mic, labelKey: 'nav.voices' },
|
||||
{ id: 'effects', path: '/effects', icon: Wand2, labelKey: 'nav.effects' },
|
||||
{ id: 'models', path: '/models', icon: Box, labelKey: 'nav.models' },
|
||||
{ id: 'settings', path: '/settings', icon: Settings, labelKey: 'nav.settings' },
|
||||
];
|
||||
|
||||
export function Sidebar({ activeTab, onTabChange }: SidebarProps) {
|
||||
export function Sidebar({ isMacOS }: SidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
const matchRoute = useMatchRoute();
|
||||
const isPlayerOpen = !!usePlayerStore((s) => s.audioUrl);
|
||||
const platform = usePlatform();
|
||||
|
||||
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>(platform.updater.getStatus());
|
||||
useEffect(() => platform.updater.subscribe(setUpdateStatus), [platform.updater]);
|
||||
|
||||
return (
|
||||
<div className="fixed left-0 top-0 h-full w-20 bg-sidebar border-r border-border flex flex-col items-center py-6 gap-6">
|
||||
<div
|
||||
className={cn(
|
||||
'fixed left-0 top-0 h-full w-20 bg-sidebar border-r border-border flex flex-col items-center py-6 gap-6',
|
||||
isMacOS && 'pt-14',
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="mb-2">
|
||||
<img
|
||||
src={voiceboxLogo}
|
||||
alt="Voicebox"
|
||||
className="w-12 h-12 object-contain"
|
||||
/>
|
||||
<img src={voiceboxLogo} alt="Voicebox" className="sidebar-logo w-12 h-12 object-contain" />
|
||||
</div>
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex flex-col gap-3">
|
||||
{tabs.map((tab) => {
|
||||
{tabs.map((tab, index) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.id;
|
||||
const isActive =
|
||||
tab.path === '/'
|
||||
? matchRoute({ to: '/', fuzzy: false })
|
||||
: matchRoute({ to: tab.path, fuzzy: true });
|
||||
|
||||
// Accent fades as buttons get further from the logo
|
||||
const accentOpacity = Math.max(0.08, 0.5 - index * 0.07);
|
||||
|
||||
return (
|
||||
<button
|
||||
<Link
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
to={tab.path}
|
||||
className={cn(
|
||||
'w-12 h-12 rounded-full flex items-center justify-center transition-all duration-200',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
isActive ? 'bg-accent text-accent-foreground shadow-lg' : 'text-muted-foreground',
|
||||
'relative w-12 h-12 rounded-full flex items-center justify-center transition-all duration-200 overflow-hidden',
|
||||
isActive
|
||||
? 'bg-white/[0.07] text-foreground shadow-lg backdrop-blur-sm border border-white/[0.08]'
|
||||
: 'text-muted-foreground hover:bg-muted/50',
|
||||
)}
|
||||
title={tab.label}
|
||||
aria-label={tab.label}
|
||||
title={tab.label ?? (tab.labelKey ? t(tab.labelKey) : tab.id)}
|
||||
aria-label={tab.label ?? (tab.labelKey ? t(tab.labelKey) : tab.id)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</button>
|
||||
{isActive && (
|
||||
<div
|
||||
className="absolute inset-0 rounded-full pointer-events-none"
|
||||
style={{
|
||||
maskImage: 'linear-gradient(to bottom, black, transparent 60%)',
|
||||
WebkitMaskImage: 'linear-gradient(to bottom, black, transparent 60%)',
|
||||
border: `1px solid hsl(var(--accent) / ${accentOpacity})`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Icon className="h-5 w-5 relative z-10" />
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Version */}
|
||||
<div
|
||||
className="mt-auto flex flex-col items-center gap-1.5 transition-all duration-300"
|
||||
style={{ paddingBottom: isPlayerOpen ? '7rem' : undefined }}
|
||||
>
|
||||
<span className="text-[10px] text-muted-foreground/50">v{version}</span>
|
||||
{updateStatus.available && (
|
||||
<Link
|
||||
to="/settings"
|
||||
className="text-[9px] font-semibold tracking-wide uppercase px-2 py-0.5 rounded-full bg-accent/15 text-accent hover:bg-accent/25 transition-colors"
|
||||
>
|
||||
{t('nav.updateBadge')}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { StoryContent } from './StoryContent';
|
||||
import { StoryList } from './StoryList';
|
||||
|
||||
export function StoriesTab() {
|
||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 overflow-hidden -mx-8">
|
||||
{/* Main content area */}
|
||||
<div className="flex-1 min-h-0 flex gap-6 overflow-hidden relative">
|
||||
{/* Left Column - Story List */}
|
||||
<div className="flex flex-col min-h-0 overflow-hidden w-full max-w-[360px] shrink-0">
|
||||
<StoryList />
|
||||
</div>
|
||||
|
||||
{/* Right Column - Story Content */}
|
||||
<div className="flex flex-col min-h-0 overflow-hidden flex-1 pr-8">
|
||||
<StoryContent />
|
||||
</div>
|
||||
|
||||
{/* Floating Generate Box - position is managed via storyStore.trackEditorHeight */}
|
||||
<FloatingGenerateBox showVoiceSelector isPlayerOpen={!!audioUrl} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { GripVertical, Mic, MoreHorizontal, Music, Play, RotateCcw, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import type { StoryItemDetail } from '@/lib/api/types';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { useStoryStore } from '@/stores/storyStore';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
|
||||
interface StoryChatItemProps {
|
||||
item: StoryItemDetail;
|
||||
storyId: string;
|
||||
index: number;
|
||||
onRemove: () => void;
|
||||
onRegenerate?: () => void;
|
||||
currentTimeMs: number;
|
||||
isPlaying: boolean;
|
||||
dragHandleProps?: React.HTMLAttributes<HTMLButtonElement>;
|
||||
isDragging?: boolean;
|
||||
}
|
||||
|
||||
export function StoryChatItem({
|
||||
item,
|
||||
onRemove,
|
||||
onRegenerate,
|
||||
currentTimeMs,
|
||||
isPlaying,
|
||||
dragHandleProps,
|
||||
isDragging,
|
||||
}: StoryChatItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const seek = useStoryStore((state) => state.seek);
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
const [avatarError, setAvatarError] = useState(false);
|
||||
|
||||
const avatarUrl = `${serverUrl}/profiles/${item.profile_id}/avatar`;
|
||||
|
||||
// Check if this item is currently playing based on timecode
|
||||
const itemStartMs = item.start_time_ms;
|
||||
const itemEndMs = item.start_time_ms + item.duration * 1000;
|
||||
const isCurrentlyPlaying = isPlaying && currentTimeMs >= itemStartMs && currentTimeMs < itemEndMs;
|
||||
|
||||
const handlePlay = () => {
|
||||
// Seek to the start of this item
|
||||
seek(itemStartMs);
|
||||
};
|
||||
|
||||
const formatTime = (ms: number): string => {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
const milliseconds = Math.floor((ms % 1000) / 100);
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}.${milliseconds}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start gap-3 p-4 rounded-lg border transition-colors',
|
||||
isCurrentlyPlaying && 'bg-muted/70 border-primary',
|
||||
!isCurrentlyPlaying && 'hover:bg-muted/50',
|
||||
isDragging && 'opacity-50 shadow-lg',
|
||||
)}
|
||||
>
|
||||
{/* Drag Handle */}
|
||||
{dragHandleProps && (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 cursor-grab active:cursor-grabbing touch-none text-muted-foreground hover:text-foreground transition-colors"
|
||||
{...dragHandleProps}
|
||||
>
|
||||
<GripVertical className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Voice Avatar */}
|
||||
<div className="shrink-0">
|
||||
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center overflow-hidden">
|
||||
{item.engine === 'import' ? (
|
||||
<Music className="h-5 w-5 text-muted-foreground" />
|
||||
) : !avatarError ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={`${item.profile_name} avatar`}
|
||||
className={cn(
|
||||
'h-full w-full object-cover transition-all duration-200',
|
||||
!isCurrentlyPlaying && 'grayscale',
|
||||
)}
|
||||
onError={() => setAvatarError(true)}
|
||||
/>
|
||||
) : (
|
||||
<Mic className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-medium text-sm truncate">
|
||||
{item.engine === 'import' ? item.text : item.profile_name}
|
||||
</span>
|
||||
{item.engine !== 'import' && (
|
||||
<span className="text-xs text-muted-foreground">{item.language}</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground tabular-nums ml-auto">
|
||||
{formatTime(itemStartMs)}
|
||||
</span>
|
||||
</div>
|
||||
{item.engine === 'import' ? null : (
|
||||
<Textarea
|
||||
value={item.text}
|
||||
className="flex-1 resize-none text-sm text-muted-foreground select-text bg-card cursor-text"
|
||||
readOnly
|
||||
onDoubleClick={handlePlay}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="shrink-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
aria-label={t('history.actions.menu')}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handlePlay}>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
{t('storyContent.itemActions.playFromHere')}
|
||||
</DropdownMenuItem>
|
||||
{onRegenerate && (
|
||||
<DropdownMenuItem onClick={onRegenerate}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
{t('storyContent.itemActions.regenerate')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={onRemove}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t('storyContent.itemActions.removeFromStory')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Sortable wrapper component
|
||||
export function SortableStoryChatItem(
|
||||
props: Omit<StoryChatItemProps, 'dragHandleProps' | 'isDragging'>,
|
||||
) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: props.item.generation_id,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes}>
|
||||
<StoryChatItem {...props} dragHandleProps={listeners} isDragging={isDragging} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Download, Music, Plus, Upload } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Loader from 'react-loaders';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { useHistory } from '@/lib/hooks/useHistory';
|
||||
import {
|
||||
useAddStoryItem,
|
||||
useExportStoryAudio,
|
||||
useRemoveStoryItem,
|
||||
useReorderStoryItems,
|
||||
useStory,
|
||||
} from '@/lib/hooks/useStories';
|
||||
import { useStoryPlayback } from '@/lib/hooks/useStoryPlayback';
|
||||
import { useGenerationStore } from '@/stores/generationStore';
|
||||
import { useStoryStore } from '@/stores/storyStore';
|
||||
import { SortableStoryChatItem } from './StoryChatItem';
|
||||
|
||||
export function StoryContent() {
|
||||
const { t } = useTranslation();
|
||||
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||
const { data: story, isLoading } = useStory(selectedStoryId);
|
||||
const removeItem = useRemoveStoryItem();
|
||||
const reorderItems = useReorderStoryItems();
|
||||
const exportAudio = useExportStoryAudio();
|
||||
const addStoryItem = useAddStoryItem();
|
||||
const { toast } = useToast();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const importInputRef = useRef<HTMLInputElement>(null);
|
||||
const pendingCount = useGenerationStore((s) => s.pendingGenerationIds.size);
|
||||
const addPendingGeneration = useGenerationStore((s) => s.addPendingGeneration);
|
||||
const [isDraggingFile, setIsDraggingFile] = useState(false);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const dragDepthRef = useRef(0);
|
||||
|
||||
// Add generation popover state
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||
const { data: historyData } = useHistory();
|
||||
|
||||
// Filter generations not in story and matching search
|
||||
const availableGenerations = useMemo(() => {
|
||||
if (!historyData?.items || !story) return [];
|
||||
const storyGenerationIds = new Set(story.items.map((i) => i.generation_id));
|
||||
const query = searchQuery.toLowerCase();
|
||||
return historyData.items.filter(
|
||||
(gen) =>
|
||||
gen.status === 'completed' &&
|
||||
!storyGenerationIds.has(gen.id) &&
|
||||
(gen.text.toLowerCase().includes(query) || gen.profile_name.toLowerCase().includes(query)),
|
||||
);
|
||||
}, [historyData, story, searchQuery]);
|
||||
|
||||
// Get track editor height from store for dynamic padding
|
||||
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
|
||||
|
||||
// Track editor is shown when story has items
|
||||
const hasBottomBar = story && story.items.length > 0;
|
||||
|
||||
// Clear the floating generate box (always visible on this route) and the
|
||||
// track editor bar when it's showing.
|
||||
const FLOATING_BOX_CLEARANCE = 140;
|
||||
const bottomPadding = hasBottomBar
|
||||
? trackEditorHeight + FLOATING_BOX_CLEARANCE
|
||||
: FLOATING_BOX_CLEARANCE;
|
||||
|
||||
// Drag and drop sensors
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
// Playback state (for auto-scroll and item highlighting)
|
||||
const isPlaying = useStoryStore((state) => state.isPlaying);
|
||||
const currentTimeMs = useStoryStore((state) => state.currentTimeMs);
|
||||
const playbackStoryId = useStoryStore((state) => state.playbackStoryId);
|
||||
|
||||
// Refs for auto-scrolling to playing item
|
||||
const itemRefsMap = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const lastScrolledItemRef = useRef<string | null>(null);
|
||||
|
||||
// Use playback hook
|
||||
useStoryPlayback(story?.items);
|
||||
|
||||
// Sort items by start_time_ms
|
||||
const sortedItems = useMemo(() => {
|
||||
if (!story?.items) return [];
|
||||
return [...story.items].sort((a, b) => a.start_time_ms - b.start_time_ms);
|
||||
}, [story?.items]);
|
||||
|
||||
// Find the currently playing item based on timecode
|
||||
const currentlyPlayingItemId = useMemo(() => {
|
||||
if (!isPlaying || playbackStoryId !== story?.id || !sortedItems.length) {
|
||||
return null;
|
||||
}
|
||||
const playingItem = sortedItems.find((item) => {
|
||||
const itemStart = item.start_time_ms;
|
||||
const itemEnd = item.start_time_ms + item.duration * 1000;
|
||||
return currentTimeMs >= itemStart && currentTimeMs < itemEnd;
|
||||
});
|
||||
return playingItem?.generation_id ?? null;
|
||||
}, [isPlaying, playbackStoryId, story?.id, sortedItems, currentTimeMs]);
|
||||
|
||||
// Auto-scroll to the currently playing item
|
||||
useEffect(() => {
|
||||
if (!currentlyPlayingItemId || currentlyPlayingItemId === lastScrolledItemRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = itemRefsMap.current.get(currentlyPlayingItemId);
|
||||
if (element && scrollRef.current) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
lastScrolledItemRef.current = currentlyPlayingItemId;
|
||||
}
|
||||
}, [currentlyPlayingItemId]);
|
||||
|
||||
// Reset last scrolled item when playback stops
|
||||
useEffect(() => {
|
||||
if (!isPlaying) {
|
||||
lastScrolledItemRef.current = null;
|
||||
}
|
||||
}, [isPlaying]);
|
||||
|
||||
const handleRegenerate = async (generationId: string) => {
|
||||
try {
|
||||
await apiClient.regenerateGeneration(generationId);
|
||||
addPendingGeneration(generationId);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('storyContent.toast.regenerateFailed'),
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveItem = (itemId: string) => {
|
||||
if (!story) return;
|
||||
|
||||
removeItem.mutate(
|
||||
{
|
||||
storyId: story.id,
|
||||
itemId,
|
||||
},
|
||||
{
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: t('storyContent.toast.removeFailed'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (!story || !over || active.id === over.id) return;
|
||||
|
||||
const oldIndex = sortedItems.findIndex((item) => item.generation_id === active.id);
|
||||
const newIndex = sortedItems.findIndex((item) => item.generation_id === over.id);
|
||||
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
|
||||
// Calculate the new order
|
||||
const newOrder = arrayMove(sortedItems, oldIndex, newIndex);
|
||||
const generationIds = newOrder.map((item) => item.generation_id);
|
||||
|
||||
// Send reorder request to backend
|
||||
reorderItems.mutate(
|
||||
{
|
||||
storyId: story.id,
|
||||
data: { generation_ids: generationIds },
|
||||
},
|
||||
{
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: t('storyContent.toast.reorderFailed'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleExportAudio = () => {
|
||||
if (!story) return;
|
||||
|
||||
exportAudio.mutate(
|
||||
{
|
||||
storyId: story.id,
|
||||
storyName: story.name,
|
||||
},
|
||||
{
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: t('storyContent.toast.exportFailed'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleImportAudio = async (file: File) => {
|
||||
if (!story) return;
|
||||
setIsImporting(true);
|
||||
try {
|
||||
const generation = await apiClient.importAudio(file);
|
||||
await addStoryItem.mutateAsync({
|
||||
storyId: story.id,
|
||||
data: { generation_id: generation.id },
|
||||
});
|
||||
setIsAddOpen(false);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('storyContent.toast.importFailed'),
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportFiles = async (files: FileList | File[]) => {
|
||||
for (const file of Array.from(files)) {
|
||||
await handleImportAudio(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddGeneration = (generationId: string) => {
|
||||
if (!story) return;
|
||||
|
||||
addStoryItem.mutate(
|
||||
{
|
||||
storyId: story.id,
|
||||
data: { generation_id: generationId },
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsAddOpen(false);
|
||||
setSearchQuery('');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: t('storyContent.toast.addFailed'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
if (!selectedStoryId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-medium mb-2">{t('storyContent.selectStory.title')}</p>
|
||||
<p className="text-sm">{t('storyContent.selectStory.hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-muted-foreground">{t('storyContent.loading')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!story) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-medium mb-2">{t('storyContent.notFound.title')}</p>
|
||||
<p className="text-sm">{t('storyContent.notFound.hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col h-full min-h-0 relative overflow-hidden"
|
||||
onDragEnter={(e) => {
|
||||
if (!e.dataTransfer?.types.includes('Files')) return;
|
||||
e.preventDefault();
|
||||
dragDepthRef.current += 1;
|
||||
setIsDraggingFile(true);
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (e.dataTransfer?.types.includes('Files')) e.preventDefault();
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (!e.dataTransfer?.types.includes('Files')) return;
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||
if (dragDepthRef.current === 0) setIsDraggingFile(false);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (!e.dataTransfer?.files?.length) return;
|
||||
e.preventDefault();
|
||||
dragDepthRef.current = 0;
|
||||
setIsDraggingFile(false);
|
||||
handleImportFiles(e.dataTransfer.files);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={importInputRef}
|
||||
type="file"
|
||||
accept="audio/*,.wav,.mp3,.flac,.ogg,.m4a,.aac,.webm"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.length) handleImportFiles(e.target.files);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
{isDraggingFile && (
|
||||
<div className="absolute inset-0 z-30 pointer-events-none flex items-center justify-center bg-accent/10 border-2 border-dashed border-accent rounded-lg m-4">
|
||||
<div className="flex flex-col items-center gap-2 text-accent">
|
||||
<Music className="h-8 w-8" />
|
||||
<span className="text-sm font-medium">{t('storyContent.dropToImport')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Scroll Mask */}
|
||||
<div className="absolute top-0 left-0 right-0 h-20 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
|
||||
|
||||
{/* Header */}
|
||||
<div className="absolute top-0 left-0 right-0 z-20 flex items-center justify-between px-1">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">{story.name}</h2>
|
||||
{story.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">{story.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<AnimatePresence>
|
||||
{pendingCount > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, width: 0 }}
|
||||
animate={{ opacity: 1, scale: 1, width: 'auto' }}
|
||||
exit={{ opacity: 0, scale: 0.9, width: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center gap-2 h-8 pl-1.5 pr-3 rounded-full bg-card border border-border hover:bg-muted/50 transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
<div className="shrink-0 w-10 h-5 overflow-hidden flex items-center justify-center">
|
||||
<div className="scale-[0.45]">
|
||||
<Loader type="line-scale" active />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{t('storyContent.generatingCount', { count: pendingCount })}
|
||||
</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<Popover open={isAddOpen} onOpenChange={setIsAddOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('storyContent.add')}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-0" align="end">
|
||||
<div className="p-2 border-b space-y-2">
|
||||
<Input
|
||||
placeholder={t('storyContent.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={() => importInputRef.current?.click()}
|
||||
disabled={isImporting}
|
||||
>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{isImporting ? t('storyContent.importing') : t('storyContent.importAudio')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{availableGenerations.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
{searchQuery
|
||||
? t('storyContent.searchNoMatches')
|
||||
: t('storyContent.searchNoAvailable')}
|
||||
</div>
|
||||
) : (
|
||||
availableGenerations.map((gen) => (
|
||||
<button
|
||||
key={gen.id}
|
||||
type="button"
|
||||
className="w-full text-left px-3 py-2 hover:bg-muted transition-colors border-b last:border-b-0"
|
||||
onClick={() => handleAddGeneration(gen.id)}
|
||||
>
|
||||
<div className="font-medium text-sm">{gen.profile_name}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{gen.text.length > 50 ? `${gen.text.substring(0, 50)}...` : gen.text}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{story.items.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleExportAudio}
|
||||
disabled={exportAudio.isPending}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{t('storyContent.exportAudio')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 min-h-0 overflow-y-auto space-y-3 pt-16 scroll-pt-16 relative z-0"
|
||||
style={{ paddingBottom: bottomPadding > 0 ? `${bottomPadding}px` : undefined }}
|
||||
>
|
||||
{sortedItems.length === 0 ? (
|
||||
<div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-md text-muted-foreground">
|
||||
<p className="text-sm">{t('storyContent.empty.title')}</p>
|
||||
<p className="text-xs mt-2">{t('storyContent.empty.hint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={sortedItems.map((item) => item.generation_id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{sortedItems.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
itemRefsMap.current.set(item.generation_id, el);
|
||||
} else {
|
||||
itemRefsMap.current.delete(item.generation_id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SortableStoryChatItem
|
||||
item={item}
|
||||
storyId={story.id}
|
||||
index={index}
|
||||
onRemove={() => handleRemoveItem(item.id)}
|
||||
onRegenerate={
|
||||
item.engine === 'import'
|
||||
? undefined
|
||||
: () => handleRegenerate(item.generation_id)
|
||||
}
|
||||
currentTimeMs={currentTimeMs}
|
||||
isPlaying={isPlaying && playbackStoryId === story.id}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
import { BookOpen, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
ListPane,
|
||||
ListPaneActions,
|
||||
ListPaneHeader,
|
||||
ListPaneScroll,
|
||||
ListPaneSearch,
|
||||
ListPaneTitle,
|
||||
ListPaneTitleRow,
|
||||
} from '@/components/ListPane';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import {
|
||||
useCreateStory,
|
||||
useDeleteStory,
|
||||
useStories,
|
||||
useStory,
|
||||
useUpdateStory,
|
||||
} from '@/lib/hooks/useStories';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { formatDate } from '@/lib/utils/format';
|
||||
import { useStoryStore } from '@/stores/storyStore';
|
||||
|
||||
export function StoryList() {
|
||||
const { t } = useTranslation();
|
||||
const { data: stories, isLoading } = useStories();
|
||||
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
|
||||
const setSelectedStoryId = useStoryStore((state) => state.setSelectedStoryId);
|
||||
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
|
||||
const { data: selectedStory } = useStory(selectedStoryId);
|
||||
const createStory = useCreateStory();
|
||||
const updateStory = useUpdateStory();
|
||||
const deleteStory = useDeleteStory();
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [editingStory, setEditingStory] = useState<{
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
} | null>(null);
|
||||
const [deletingStoryId, setDeletingStoryId] = useState<string | null>(null);
|
||||
const [newStoryName, setNewStoryName] = useState('');
|
||||
const [newStoryDescription, setNewStoryDescription] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const { toast } = useToast();
|
||||
|
||||
// Auto-select the first story when the list loads with no selection
|
||||
useEffect(() => {
|
||||
if (!selectedStoryId && stories && stories.length > 0) {
|
||||
setSelectedStoryId(stories[0].id);
|
||||
}
|
||||
}, [selectedStoryId, stories, setSelectedStoryId]);
|
||||
|
||||
const handleCreateStory = () => {
|
||||
if (!newStoryName.trim()) {
|
||||
toast({
|
||||
title: t('stories.toast.nameRequired'),
|
||||
description: t('stories.toast.nameRequiredDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
createStory.mutate(
|
||||
{
|
||||
name: newStoryName.trim(),
|
||||
description: newStoryDescription.trim() || undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: (story) => {
|
||||
setSelectedStoryId(story.id);
|
||||
setCreateDialogOpen(false);
|
||||
setNewStoryName('');
|
||||
setNewStoryDescription('');
|
||||
toast({
|
||||
title: t('stories.toast.created'),
|
||||
description: t('stories.toast.createdDescription', { name: story.name }),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: t('stories.toast.createFailed'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleEditClick = (story: { id: string; name: string; description?: string }) => {
|
||||
setEditingStory(story);
|
||||
setNewStoryName(story.name);
|
||||
setNewStoryDescription(story.description || '');
|
||||
setEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleUpdateStory = () => {
|
||||
if (!editingStory || !newStoryName.trim()) {
|
||||
toast({
|
||||
title: t('stories.toast.nameRequired'),
|
||||
description: t('stories.toast.nameRequiredDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
updateStory.mutate(
|
||||
{
|
||||
storyId: editingStory.id,
|
||||
data: {
|
||||
name: newStoryName.trim(),
|
||||
description: newStoryDescription.trim() || undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setEditDialogOpen(false);
|
||||
setEditingStory(null);
|
||||
setNewStoryName('');
|
||||
setNewStoryDescription('');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: t('stories.toast.updateFailed'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (storyId: string) => {
|
||||
setDeletingStoryId(storyId);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (!deletingStoryId) return;
|
||||
|
||||
deleteStory.mutate(deletingStoryId, {
|
||||
onSuccess: () => {
|
||||
// Clear selection if deleting the currently selected story
|
||||
if (selectedStoryId === deletingStoryId) {
|
||||
setSelectedStoryId(null);
|
||||
}
|
||||
setDeleteDialogOpen(false);
|
||||
setDeletingStoryId(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: t('stories.toast.deleteFailed'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const storyList = stories || [];
|
||||
const hasTrackEditor = selectedStoryId && selectedStory && selectedStory.items.length > 0;
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return storyList;
|
||||
return storyList.filter((s) => {
|
||||
const name = (s.name || '').toLowerCase();
|
||||
const description = (s.description || '').toLowerCase();
|
||||
return name.includes(q) || description.includes(q);
|
||||
});
|
||||
}, [search, storyList]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-muted-foreground">{t('stories.loading')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ListPane>
|
||||
<ListPaneHeader>
|
||||
<ListPaneTitleRow>
|
||||
<ListPaneTitle>{t('stories.title')}</ListPaneTitle>
|
||||
<ListPaneActions>
|
||||
<Button onClick={() => setCreateDialogOpen(true)} size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('stories.newStory')}
|
||||
</Button>
|
||||
</ListPaneActions>
|
||||
</ListPaneTitleRow>
|
||||
<ListPaneSearch
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder={t('stories.searchPlaceholder')}
|
||||
/>
|
||||
</ListPaneHeader>
|
||||
|
||||
<ListPaneScroll
|
||||
style={{ paddingBottom: hasTrackEditor ? `${trackEditorHeight + 140}px` : '170px' }}
|
||||
>
|
||||
{storyList.length === 0 ? (
|
||||
<div className="mx-4 text-center py-12 px-5 border-2 border-dashed border-muted rounded-2xl text-muted-foreground">
|
||||
<BookOpen className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-sm">{t('stories.empty.title')}</p>
|
||||
<p className="text-xs mt-2">{t('stories.empty.hint')}</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="px-4 py-12 text-center text-sm text-muted-foreground">
|
||||
<p>{t('stories.empty.noMatches', { query: search })}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 pb-6 space-y-1">
|
||||
{filtered.map((story) => {
|
||||
const isActive = selectedStoryId === story.id;
|
||||
return (
|
||||
<div key={story.id} className="relative group">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedStoryId(story.id)}
|
||||
aria-label={t('stories.row.ariaLabel', {
|
||||
name: story.name,
|
||||
count: story.item_count,
|
||||
updated: formatDate(story.updated_at),
|
||||
})}
|
||||
aria-pressed={isActive}
|
||||
className={cn(
|
||||
'w-full text-left p-3 rounded-lg transition-colors block',
|
||||
isActive
|
||||
? 'bg-muted/70 border border-border'
|
||||
: 'border border-transparent hover:bg-muted/30',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="text-[11px] text-muted-foreground font-medium">
|
||||
{formatDate(story.updated_at)}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
<div className="text-[13px] line-clamp-2 leading-snug mb-2">
|
||||
<span className="text-foreground font-medium">{story.name}</span>
|
||||
{story.description ? (
|
||||
<>
|
||||
<span className="mx-1.5 text-muted-foreground/50">·</span>
|
||||
<span className="text-muted-foreground">{story.description}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 px-1.5 text-[10px] gap-1 font-medium bg-muted/60 text-muted-foreground"
|
||||
>
|
||||
{t('stories.row.itemCount', { count: story.item_count })}
|
||||
</Badge>
|
||||
</div>
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={t('stories.row.actionsLabel', { name: story.name })}
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleEditClick(story)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
{t('common.edit')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteClick(story.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t('common.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ListPaneScroll>
|
||||
|
||||
{/* Create Story Dialog */}
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('stories.createDialog.title')}</DialogTitle>
|
||||
<DialogDescription>{t('stories.createDialog.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="story-name">{t('stories.fields.name')}</Label>
|
||||
<Input
|
||||
id="story-name"
|
||||
placeholder={t('stories.fields.namePlaceholder')}
|
||||
value={newStoryName}
|
||||
onChange={(e) => setNewStoryName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleCreateStory();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="story-description">{t('stories.fields.descriptionLabel')}</Label>
|
||||
<Textarea
|
||||
id="story-description"
|
||||
placeholder={t('stories.fields.descriptionPlaceholder')}
|
||||
value={newStoryDescription}
|
||||
onChange={(e) => setNewStoryDescription(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleCreateStory} disabled={createStory.isPending}>
|
||||
{createStory.isPending
|
||||
? t('stories.createDialog.creating')
|
||||
: t('stories.createDialog.action')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('stories.editDialog.title')}</DialogTitle>
|
||||
<DialogDescription>{t('stories.editDialog.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-story-name">{t('stories.fields.name')}</Label>
|
||||
<Input
|
||||
id="edit-story-name"
|
||||
placeholder={t('stories.fields.namePlaceholder')}
|
||||
value={newStoryName}
|
||||
onChange={(e) => setNewStoryName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleUpdateStory();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-story-description">{t('stories.fields.descriptionLabel')}</Label>
|
||||
<Textarea
|
||||
id="edit-story-description"
|
||||
placeholder={t('stories.fields.descriptionPlaceholder')}
|
||||
value={newStoryDescription}
|
||||
onChange={(e) => setNewStoryDescription(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleUpdateStory} disabled={updateStory.isPending}>
|
||||
{updateStory.isPending ? t('stories.editDialog.saving') : t('common.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('stories.deleteDialog.title')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t('stories.deleteDialog.description')}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<Button
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={deleteStory.isPending}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleteStory.isPending ? t('stories.deleteDialog.deleting') : t('common.delete')}
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</ListPane>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
const isWindows = navigator.userAgent.includes('Windows');
|
||||
|
||||
export function TitleBarDragRegion() {
|
||||
if (isWindows) return null;
|
||||
|
||||
return <div data-tauri-drag-region className="fixed top-0 left-0 right-0 h-12 z-[9999]" />;
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useAutoUpdater } from '../hooks/useAutoUpdater';
|
||||
import { Button } from './ui/button';
|
||||
import { Card } from './ui/card';
|
||||
import { Progress } from './ui/progress';
|
||||
|
||||
export function UpdateNotification() {
|
||||
const { status, checkForUpdates, downloadAndInstall } = useAutoUpdater(true);
|
||||
|
||||
if (status.error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!status.available && !status.checking) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (status.checking) {
|
||||
return (
|
||||
<Card className="p-4 mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin h-4 w-4 border-2 border-primary border-t-transparent rounded-full" />
|
||||
<span className="text-sm">Checking for updates...</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (status.available) {
|
||||
return (
|
||||
<Card className="p-4 mb-4 border-primary">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="font-semibold">Update Available</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Version {status.version} is ready to install
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{status.downloading && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm">Downloading update...</p>
|
||||
<Progress />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.installing && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm">Installing update...</p>
|
||||
<p className="text-xs text-muted-foreground">App will restart automatically</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!status.downloading && !status.installing && (
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={downloadAndInstall} size="sm">
|
||||
Install Now
|
||||
</Button>
|
||||
<Button onClick={() => window.location.reload()} variant="outline" size="sm">
|
||||
Later
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
# Voice profile management components
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Mic, Pause, Play, Square } from 'lucide-react';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Visualizer } from 'react-sound-visualizer';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
|
||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||
|
||||
const MemoizedWaveform = memo(function MemoizedWaveform({
|
||||
audioStream,
|
||||
}: {
|
||||
audioStream: MediaStream;
|
||||
}) {
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none flex items-center justify-center opacity-30">
|
||||
<Visualizer audio={audioStream} autoStart strokeColor="#b39a3d">
|
||||
{({ canvasRef }) => (
|
||||
<canvas ref={canvasRef} width={500} height={150} className="w-full h-full" />
|
||||
)}
|
||||
</Visualizer>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface AudioSampleRecordingProps {
|
||||
file: File | null | undefined;
|
||||
isRecording: boolean;
|
||||
duration: number;
|
||||
onStart: () => void;
|
||||
onStop: () => void;
|
||||
onCancel: () => void;
|
||||
onTranscribe: () => void;
|
||||
onPlayPause: () => void;
|
||||
isPlaying: boolean;
|
||||
isTranscribing?: boolean;
|
||||
showWaveform?: boolean;
|
||||
}
|
||||
|
||||
export function AudioSampleRecording({
|
||||
file,
|
||||
isRecording,
|
||||
duration,
|
||||
onStart,
|
||||
onStop,
|
||||
onCancel,
|
||||
onTranscribe,
|
||||
onPlayPause,
|
||||
isPlaying,
|
||||
isTranscribing = false,
|
||||
showWaveform = true,
|
||||
}: AudioSampleRecordingProps) {
|
||||
const { t } = useTranslation();
|
||||
const [audioStream, setAudioStream] = useState<MediaStream | null>(null);
|
||||
|
||||
// Request microphone access when component mounts
|
||||
useEffect(() => {
|
||||
if (!showWaveform) return;
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return;
|
||||
|
||||
let stream: MediaStream | null = null;
|
||||
|
||||
navigator.mediaDevices
|
||||
.getUserMedia({ audio: true, video: false })
|
||||
.then((s) => {
|
||||
stream = s;
|
||||
setAudioStream(s);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('Could not access microphone for visualization:', err);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (stream) {
|
||||
stream.getTracks().forEach((track) => {
|
||||
track.stop();
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [showWaveform]);
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="space-y-4">
|
||||
{!isRecording && !file && (
|
||||
<div className="relative flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px] overflow-hidden">
|
||||
{showWaveform && audioStream && <MemoizedWaveform audioStream={audioStream} />}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onStart}
|
||||
size="lg"
|
||||
className="relative z-10 flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-5 w-5" />
|
||||
{t('audioSample.startRecording')}
|
||||
</Button>
|
||||
<p className="relative z-10 text-sm text-muted-foreground text-center">
|
||||
{t('audioSample.recordHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRecording && (
|
||||
<div className="relative flex flex-col items-center justify-center gap-4 p-4 border-2 border-accent rounded-lg bg-accent/5 min-h-[180px] overflow-hidden">
|
||||
{showWaveform && audioStream && <MemoizedWaveform audioStream={audioStream} />}
|
||||
<div className="relative z-10 flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded-full bg-accent animate-pulse" />
|
||||
<span className="text-lg font-mono font-semibold">
|
||||
{formatAudioDuration(duration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
className="relative z-10 flex items-center gap-2 bg-accent text-accent-foreground hover:bg-accent/90"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
{t('audioSample.stopRecording')}
|
||||
</Button>
|
||||
<p className="relative z-10 text-sm text-muted-foreground text-center">
|
||||
{t('audioSample.remaining', { time: formatAudioDuration(30 - duration) })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{file && !isRecording && (
|
||||
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-primary rounded-lg bg-primary/5 min-h-[180px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mic className="h-5 w-5 text-primary" />
|
||||
<span className="font-medium">{t('audioSample.recordingComplete')}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{t('audioSample.fileLabel', { name: file.name })}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={onPlayPause}
|
||||
aria-label={isPlaying ? t('audioSample.pause') : t('audioSample.play')}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onTranscribe}
|
||||
disabled={isTranscribing}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
{isTranscribing ? t('audioSample.transcribing') : t('audioSample.transcribe')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{t('audioSample.recordAgain')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Mic, Monitor, Pause, Play, Square } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
|
||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||
|
||||
interface AudioSampleSystemProps {
|
||||
file: File | null | undefined;
|
||||
isRecording: boolean;
|
||||
duration: number;
|
||||
onStart: () => void;
|
||||
onStop: () => void;
|
||||
onCancel: () => void;
|
||||
onTranscribe: () => void;
|
||||
onPlayPause: () => void;
|
||||
isPlaying: boolean;
|
||||
isTranscribing?: boolean;
|
||||
}
|
||||
|
||||
export function AudioSampleSystem({
|
||||
file,
|
||||
isRecording,
|
||||
duration,
|
||||
onStart,
|
||||
onStop,
|
||||
onCancel,
|
||||
onTranscribe,
|
||||
onPlayPause,
|
||||
isPlaying,
|
||||
isTranscribing = false,
|
||||
}: AudioSampleSystemProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="space-y-4">
|
||||
{!isRecording && !file && (
|
||||
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-dashed rounded-lg min-h-[180px]">
|
||||
<Button type="button" onClick={onStart} size="lg" className="flex items-center gap-2">
|
||||
<Monitor className="h-5 w-5" />
|
||||
{t('audioSample.startCapture')}
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{t('audioSample.systemHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRecording && (
|
||||
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-destructive rounded-lg bg-destructive/5 min-h-[180px]">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded-full bg-destructive animate-pulse" />
|
||||
<span className="text-lg font-mono font-semibold">
|
||||
{formatAudioDuration(duration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
variant="destructive"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
{t('audioSample.stopCapture')}
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{t('audioSample.remaining', { time: formatAudioDuration(30 - duration) })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{file && !isRecording && (
|
||||
<div className="flex flex-col items-center justify-center gap-4 p-4 border-2 border-primary rounded-lg bg-primary/5 min-h-[180px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="h-5 w-5 text-primary" />
|
||||
<span className="font-medium">{t('audioSample.captureComplete')}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{t('audioSample.fileLabel', { name: file.name })}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={onPlayPause}
|
||||
aria-label={isPlaying ? t('audioSample.pause') : t('audioSample.play')}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onTranscribe}
|
||||
disabled={isTranscribing}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
{isTranscribing ? t('audioSample.transcribing') : t('audioSample.transcribe')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{t('audioSample.captureAgain')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Mic, Pause, Play, Upload } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FormControl, FormItem, FormMessage } from '@/components/ui/form';
|
||||
|
||||
interface AudioSampleUploadProps {
|
||||
file: File | null | undefined;
|
||||
onFileChange: (file: File | undefined) => void;
|
||||
onTranscribe: () => void;
|
||||
onPlayPause: () => void;
|
||||
isPlaying: boolean;
|
||||
isValidating?: boolean;
|
||||
isTranscribing?: boolean;
|
||||
isDisabled?: boolean;
|
||||
fieldName: string;
|
||||
}
|
||||
|
||||
export function AudioSampleUpload({
|
||||
file,
|
||||
onFileChange,
|
||||
onTranscribe,
|
||||
onPlayPause,
|
||||
isPlaying,
|
||||
isValidating = false,
|
||||
isTranscribing = false,
|
||||
isDisabled = false,
|
||||
fieldName,
|
||||
}: AudioSampleUploadProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
name={fieldName}
|
||||
ref={fileInputRef}
|
||||
onChange={(e) => {
|
||||
const selectedFile = e.target.files?.[0];
|
||||
if (selectedFile) {
|
||||
onFileChange(selectedFile);
|
||||
} else {
|
||||
onFileChange(undefined);
|
||||
}
|
||||
}}
|
||||
className="hidden"
|
||||
/>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const droppedFile = e.dataTransfer.files?.[0];
|
||||
if (droppedFile?.type.startsWith('audio/')) {
|
||||
onFileChange(droppedFile);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
}}
|
||||
className={`flex flex-col items-center justify-center gap-4 p-4 border-2 rounded-lg transition-colors min-h-[180px] ${
|
||||
file
|
||||
? 'border-primary bg-primary/5'
|
||||
: isDragging
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-dashed border-muted-foreground/25 hover:border-muted-foreground/50'
|
||||
}`}
|
||||
>
|
||||
{!file ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Upload className="h-5 w-5" />
|
||||
{t('audioSample.chooseFile')}
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{t('audioSample.uploadHint')}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Upload className="h-5 w-5 text-primary" />
|
||||
<span className="font-medium">{t('audioSample.fileUploaded')}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{t('audioSample.fileLabel', { name: file.name })}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={onPlayPause}
|
||||
disabled={isValidating}
|
||||
aria-label={isPlaying ? t('audioSample.pause') : t('audioSample.play')}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onTranscribe}
|
||||
disabled={isTranscribing || isValidating || isDisabled}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
{isTranscribing ? t('audioSample.transcribing') : t('audioSample.transcribe')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onFileChange(undefined);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('audioSample.remove')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Edit, Eye, Mic, Trash2 } from 'lucide-react';
|
||||
import { Download, Edit, Sparkles, Trash2, Wand2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -13,19 +14,27 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import type { VoiceProfileResponse } from '@/lib/api/types';
|
||||
import { useDeleteProfile } from '@/lib/hooks/useProfiles';
|
||||
import { useDeleteProfile, useExportProfile } from '@/lib/hooks/useProfiles';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
import { ProfileDetail } from './ProfileDetail';
|
||||
|
||||
/** Human-readable display names for preset engine badges. */
|
||||
const ENGINE_DISPLAY_NAMES: Record<string, string> = {
|
||||
kokoro: 'Kokoro',
|
||||
qwen_custom_voice: 'CustomVoice',
|
||||
};
|
||||
|
||||
interface ProfileCardProps {
|
||||
profile: VoiceProfileResponse;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ProfileCard({ profile }: ProfileCardProps) {
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
export function ProfileCard({ profile, disabled }: ProfileCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
||||
const deleteProfile = useDeleteProfile();
|
||||
const exportProfile = useExportProfile();
|
||||
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
|
||||
const setProfileDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
||||
@@ -34,6 +43,11 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
||||
const isSelected = selectedProfileId === profile.id;
|
||||
|
||||
const handleSelect = () => {
|
||||
if (disabled && isSelected) {
|
||||
setSelectedProfileId(null);
|
||||
setTimeout(() => setSelectedProfileId(profile.id), 0);
|
||||
return;
|
||||
}
|
||||
setSelectedProfileId(isSelected ? null : profile.id);
|
||||
};
|
||||
|
||||
@@ -52,40 +66,76 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
||||
setDeleteDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleExport = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
exportProfile.mutate(profile.id);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('button')) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleSelect();
|
||||
}
|
||||
};
|
||||
|
||||
const selectLabel = t(
|
||||
isSelected ? 'profiles.card.selectLabelSelected' : 'profiles.card.selectLabel',
|
||||
{ name: profile.name, language: profile.language },
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
className={cn(
|
||||
'cursor-pointer hover:shadow-md transition-all flex flex-col',
|
||||
isSelected && 'ring-2 ring-primary shadow-md',
|
||||
'cursor-pointer transition-all flex flex-col h-[162px]',
|
||||
disabled ? 'opacity-40 hover:opacity-60' : 'hover:shadow-md',
|
||||
isSelected && !disabled && 'ring-2 border-transparent ring-accent shadow-md',
|
||||
)}
|
||||
onClick={handleSelect}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={selectLabel}
|
||||
aria-pressed={isSelected}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<CardHeader className="p-3 pb-2">
|
||||
<CardTitle className="flex items-center gap-1.5 text-base font-medium">
|
||||
<div className="h-6 w-6 rounded-full bg-muted flex items-center justify-center shrink-0">
|
||||
<Mic className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-base font-medium">
|
||||
<span className="break-words">{profile.name}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3 pt-0 flex flex-col flex-1">
|
||||
<p className="text-xs text-muted-foreground mb-1.5 line-clamp-2 leading-relaxed">
|
||||
{profile.description || 'No description'}
|
||||
{profile.description || t('profiles.card.noDescription')}
|
||||
</p>
|
||||
<div className="mb-2">
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<Badge variant="outline" className="text-xs h-5 px-1.5 text-muted-foreground">
|
||||
{profile.language}
|
||||
</Badge>
|
||||
{profile.voice_type === 'preset' && (
|
||||
<Badge variant="secondary" className="text-xs h-5 px-1.5">
|
||||
{ENGINE_DISPLAY_NAMES[profile.preset_engine ?? ''] ?? profile.preset_engine}
|
||||
</Badge>
|
||||
)}
|
||||
{profile.voice_type === 'designed' && (
|
||||
<Badge variant="secondary" className="text-xs h-5 px-1.5">
|
||||
{t('profiles.card.designed')}
|
||||
</Badge>
|
||||
)}
|
||||
{profile.effects_chain && profile.effects_chain.length > 0 && (
|
||||
<Sparkles className="h-3.5 w-3.5 text-accent fill-accent" />
|
||||
)}
|
||||
{profile.personality?.trim() && (
|
||||
<Wand2 className="h-3.5 w-3.5 text-accent" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-0.5 justify-end items-end mt-auto">
|
||||
<CircleButton
|
||||
icon={Eye}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDetailOpen(true);
|
||||
}}
|
||||
aria-label="View details"
|
||||
icon={Download}
|
||||
onClick={handleExport}
|
||||
disabled={exportProfile.isPending}
|
||||
aria-label={t('profiles.card.export')}
|
||||
/>
|
||||
<CircleButton
|
||||
icon={Edit}
|
||||
@@ -93,38 +143,36 @@ export function ProfileCard({ profile }: ProfileCardProps) {
|
||||
e.stopPropagation();
|
||||
handleEdit();
|
||||
}}
|
||||
aria-label="Edit profile"
|
||||
aria-label={t('profiles.card.edit')}
|
||||
/>
|
||||
<CircleButton
|
||||
icon={Trash2}
|
||||
onClick={handleDeleteClick}
|
||||
disabled={deleteProfile.isPending}
|
||||
aria-label="Delete profile"
|
||||
aria-label={t('profiles.card.delete')}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ProfileDetail profileId={profile.id} open={detailOpen} onOpenChange={setDetailOpen} />
|
||||
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Profile</DialogTitle>
|
||||
<DialogTitle>{t('profiles.deleteDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{profile.name}"? This action cannot be undone.
|
||||
{t('profiles.deleteDialog.body', { name: profile.name })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={deleteProfile.isPending}
|
||||
>
|
||||
{deleteProfile.isPending ? 'Deleting...' : 'Delete'}
|
||||
{deleteProfile.isPending ? t('profiles.deleteDialog.deleting') : t('common.delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useProfile } from '@/lib/hooks/useProfiles';
|
||||
import { formatDate } from '@/lib/utils/format';
|
||||
import { SampleList } from './SampleList';
|
||||
|
||||
interface ProfileDetailProps {
|
||||
profileId: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function ProfileDetail({ profileId, open, onOpenChange }: ProfileDetailProps) {
|
||||
const { data: profile, isLoading } = useProfile(profileId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<div className="text-muted-foreground">Loading profile...</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{profile.name}</DialogTitle>
|
||||
<DialogDescription>Manage samples and view profile details</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{profile.description && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-1">Description</h3>
|
||||
<p className="text-sm text-muted-foreground">{profile.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Badge variant="outline">{profile.language}</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Created {formatDate(profile.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<SampleList profileId={profileId} />
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,6 @@
|
||||
import { Mic, Sparkles } from 'lucide-react';
|
||||
import { Info, Mic, Sparkles } from 'lucide-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useProfiles } from '@/lib/hooks/useProfiles';
|
||||
@@ -6,57 +8,102 @@ import { useUIStore } from '@/stores/uiStore';
|
||||
import { ProfileCard } from './ProfileCard';
|
||||
import { ProfileForm } from './ProfileForm';
|
||||
|
||||
/** Engines that use preset (built-in) voices instead of cloned profiles. */
|
||||
const PRESET_ENGINES = new Set(['kokoro', 'qwen_custom_voice']);
|
||||
|
||||
export function ProfileList() {
|
||||
const { t } = useTranslation();
|
||||
const { data: profiles, isLoading, error } = useProfiles();
|
||||
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||
const selectedEngine = useUIStore((state) => state.selectedEngine);
|
||||
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
|
||||
const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
|
||||
// Scroll to the selected profile after engine/sort changes
|
||||
useEffect(() => {
|
||||
if (!selectedProfileId) return;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
const el = cardRefs.current.get(selectedProfileId);
|
||||
if (!el) return;
|
||||
|
||||
// Temporarily apply scroll-margin so it doesn't land flush at the top
|
||||
el.style.scrollMarginTop = '180px';
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
|
||||
timeoutId = setTimeout(() => {
|
||||
el.style.scrollMarginTop = '';
|
||||
}, 500);
|
||||
});
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
};
|
||||
}, [selectedProfileId, selectedEngine]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="text-muted-foreground">Loading profiles...</div>
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="text-destructive">Error loading profiles: {error.message}</div>
|
||||
<div className="text-destructive">
|
||||
{t('profiles.list.errorLoading', { message: error.message })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const allProfiles = profiles || [];
|
||||
const isPresetEngine = PRESET_ENGINES.has(selectedEngine);
|
||||
|
||||
/** Whether a profile is supported by the currently selected engine. */
|
||||
const isSupported = (p: (typeof allProfiles)[number]) =>
|
||||
isPresetEngine
|
||||
? p.voice_type === 'preset' && p.preset_engine === selectedEngine
|
||||
: p.voice_type !== 'preset';
|
||||
|
||||
// Sort so supported profiles come first
|
||||
const sortedProfiles = [...allProfiles].sort(
|
||||
(a, b) => (isSupported(a) ? 0 : 1) - (isSupported(b) ? 0 : 1),
|
||||
);
|
||||
|
||||
const hasUnsupported = sortedProfiles.some((p) => !isSupported(p));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4 shrink-0">
|
||||
<h2 className="text-2xl font-bold">Voicebox</h2>
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
New Profile
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
{allProfiles.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Mic className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground mb-4">
|
||||
No voice profiles yet. Create your first profile to get started.
|
||||
</p>
|
||||
<p className="text-muted-foreground mb-4">{t('profiles.list.empty')}</p>
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Create Profile
|
||||
{t('profiles.list.createVoice')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 grid-cols-3 auto-rows-auto p-1">
|
||||
{allProfiles.map((profile) => (
|
||||
<ProfileCard key={profile.id} profile={profile} />
|
||||
<div className="flex gap-4 overflow-x-auto p-1 pb-1 lg:grid lg:grid-cols-3 lg:auto-rows-auto lg:overflow-x-visible lg:pb-[150px]">
|
||||
{sortedProfiles.map((profile) => (
|
||||
<div
|
||||
key={profile.id}
|
||||
className="shrink-0 w-[200px] lg:w-auto lg:shrink"
|
||||
ref={(el) => {
|
||||
if (el) cardRefs.current.set(profile.id, el);
|
||||
else cardRefs.current.delete(profile.id);
|
||||
}}
|
||||
>
|
||||
<ProfileCard profile={profile} disabled={!isSupported(profile)} />
|
||||
</div>
|
||||
))}
|
||||
{hasUnsupported && (
|
||||
<div className="col-span-full flex items-center gap-2 text-xs text-muted-foreground py-2">
|
||||
<Info className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>{t('profiles.list.unsupportedNote')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,91 +1,360 @@
|
||||
import { Plus, Trash2, Play } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Check, Edit, Pause, Play, Plus, Trash2, Volume2, X } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CircleButton } from '@/components/ui/circle-button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useDeleteSample, useProfileSamples } from '@/lib/hooks/useProfiles';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { useDeleteSample, useProfileSamples, useUpdateSample } from '@/lib/hooks/useProfiles';
|
||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { SampleUpload } from './SampleUpload';
|
||||
|
||||
interface MiniSamplePlayerProps {
|
||||
audioUrl: string;
|
||||
}
|
||||
|
||||
function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
|
||||
const { t } = useTranslation();
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = new Audio(audioUrl);
|
||||
audioRef.current = audio;
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
setDuration(audio.duration);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
setCurrentTime(audio.currentTime);
|
||||
};
|
||||
|
||||
const handleEnded = () => {
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(0);
|
||||
};
|
||||
|
||||
const handlePlay = () => setIsPlaying(true);
|
||||
const handlePause = () => setIsPlaying(false);
|
||||
|
||||
audio.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
audio.addEventListener('timeupdate', handleTimeUpdate);
|
||||
audio.addEventListener('ended', handleEnded);
|
||||
audio.addEventListener('play', handlePlay);
|
||||
audio.addEventListener('pause', handlePause);
|
||||
|
||||
return () => {
|
||||
audio.pause();
|
||||
audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
audio.removeEventListener('timeupdate', handleTimeUpdate);
|
||||
audio.removeEventListener('ended', handleEnded);
|
||||
audio.removeEventListener('play', handlePlay);
|
||||
audio.removeEventListener('pause', handlePause);
|
||||
audio.src = '';
|
||||
};
|
||||
}, [audioUrl]);
|
||||
|
||||
const handlePlayPause = () => {
|
||||
if (!audioRef.current) return;
|
||||
if (isPlaying) {
|
||||
audioRef.current.pause();
|
||||
} else {
|
||||
audioRef.current.play();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeek = (value: number[]) => {
|
||||
if (!audioRef.current || duration === 0) return;
|
||||
const progress = value[0] / 100;
|
||||
audioRef.current.currentTime = progress * duration;
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.currentTime = 0;
|
||||
}
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t bg-muted/30 px-3 py-2 mt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={handlePlayPause}
|
||||
disabled={isLoading}
|
||||
aria-label={isPlaying ? t('sampleList.player.pause') : t('sampleList.player.play')}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5 ml-0.5" />}
|
||||
</Button>
|
||||
|
||||
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||
<Slider
|
||||
value={duration > 0 ? [(currentTime / duration) * 100] : [0]}
|
||||
onValueChange={handleSeek}
|
||||
max={100}
|
||||
step={0.1}
|
||||
className="flex-1"
|
||||
aria-label={t('sampleList.player.position')}
|
||||
aria-valuetext={t('sampleList.player.positionValue', {
|
||||
current: formatAudioDuration(currentTime),
|
||||
total: formatAudioDuration(duration),
|
||||
})}
|
||||
/>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0 min-w-[70px]">
|
||||
<span className="font-mono">{formatAudioDuration(currentTime)}</span>
|
||||
<span>/</span>
|
||||
<span className="font-mono">{formatAudioDuration(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={handleStop}
|
||||
title={t('sampleList.player.stop')}
|
||||
aria-label={t('sampleList.player.stopAria')}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SampleListProps {
|
||||
profileId: string;
|
||||
}
|
||||
|
||||
export function SampleList({ profileId }: SampleListProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: samples, isLoading } = useProfileSamples(profileId);
|
||||
const deleteSample = useDeleteSample();
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const updateSample = useUpdateSample();
|
||||
const { toast } = useToast();
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
const setAudio = usePlayerStore((state) => state.setAudio);
|
||||
const currentAudioId = usePlayerStore((state) => state.audioId);
|
||||
const isPlaying = usePlayerStore((state) => state.isPlaying);
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [editingSampleId, setEditingSampleId] = useState<string | null>(null);
|
||||
const [editedText, setEditedText] = useState<string>('');
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [sampleToDelete, setSampleToDelete] = useState<string | null>(null);
|
||||
|
||||
const handleDelete = (sampleId: string) => {
|
||||
if (confirm('Are you sure you want to delete this sample?')) {
|
||||
deleteSample.mutate(sampleId);
|
||||
const handleDeleteClick = (sampleId: string) => {
|
||||
setSampleToDelete(sampleId);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (sampleToDelete) {
|
||||
deleteSample.mutate(sampleToDelete);
|
||||
setDeleteDialogOpen(false);
|
||||
setSampleToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlay = (audioPath: string, referenceText: string, sampleId: string) => {
|
||||
const audioUrl = `${serverUrl}${audioPath}`;
|
||||
setAudio(audioUrl, sampleId, referenceText.substring(0, 50));
|
||||
const handleStartEdit = (sampleId: string, currentText: string) => {
|
||||
setEditingSampleId(sampleId);
|
||||
setEditedText(currentText);
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingSampleId(null);
|
||||
setEditedText('');
|
||||
};
|
||||
|
||||
const handleSaveEdit = async (sampleId: string) => {
|
||||
if (!editedText.trim()) {
|
||||
toast({
|
||||
title: t('sampleList.toast.invalidText'),
|
||||
description: t('sampleList.toast.invalidTextDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateSample.mutateAsync({ sampleId, referenceText: editedText.trim() });
|
||||
toast({
|
||||
title: t('sampleList.toast.updated'),
|
||||
description: t('sampleList.toast.updatedDescription'),
|
||||
});
|
||||
setEditingSampleId(null);
|
||||
setEditedText('');
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('sampleList.toast.updateFailed'),
|
||||
description:
|
||||
error instanceof Error ? error.message : t('sampleList.toast.updateFailedFallback'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-sm text-muted-foreground">Loading samples...</div>;
|
||||
return <div className="text-sm text-muted-foreground">{t('sampleList.loading')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Audio Samples</h3>
|
||||
<Button size="sm" onClick={() => setUploadOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Sample
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-4">
|
||||
{samples && samples.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-4">
|
||||
No samples yet. Add your first audio sample.
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center border border-dashed rounded-lg">
|
||||
<Volume2 className="h-8 w-8 text-muted-foreground/50 mb-2" />
|
||||
<p className="text-sm text-muted-foreground">{t('sampleList.empty.title')}</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">{t('sampleList.empty.hint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{samples?.map((sample) => (
|
||||
<div
|
||||
key={sample.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{sample.reference_text}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{sample.audio_path}</p>
|
||||
{samples?.map((sample, index) => {
|
||||
const isEditing = editingSampleId === sample.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={sample.id}
|
||||
className={cn(
|
||||
'group relative rounded-lg border bg-card transition-all duration-200',
|
||||
isEditing ? 'ring-2 ring-primary/20' : 'hover:border-primary/30',
|
||||
)}
|
||||
>
|
||||
{isEditing ? (
|
||||
/* Edit Mode */
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-2">
|
||||
<Edit className="h-3 w-3" />
|
||||
<span>{t('sampleList.editing')}</span>
|
||||
</div>
|
||||
<Textarea
|
||||
value={editedText}
|
||||
onChange={(e) => setEditedText(e.target.value)}
|
||||
className="min-h-[100px] text-sm resize-none"
|
||||
placeholder={t('sampleList.placeholder')}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleCancelEdit}
|
||||
disabled={updateSample.isPending}
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => handleSaveEdit(sample.id)}
|
||||
disabled={updateSample.isPending}
|
||||
>
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
{updateSample.isPending ? t('sampleList.saving') : t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* View Mode */}
|
||||
<div className="flex items-center gap-3 p-3 h-[72px]">
|
||||
{/* Text Content */}
|
||||
<div className="flex-1 min-w-0 py-0.5">
|
||||
<p className="text-sm font-medium line-clamp-2 leading-snug">
|
||||
{sample.reference_text}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<CircleButton
|
||||
icon={Edit}
|
||||
title={t('sampleList.editTranscription')}
|
||||
onClick={() => handleStartEdit(sample.id, sample.reference_text)}
|
||||
/>
|
||||
<CircleButton
|
||||
icon={Trash2}
|
||||
title={t('sampleList.deleteSample')}
|
||||
onClick={() => handleDeleteClick(sample.id)}
|
||||
disabled={deleteSample.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sample Number Badge */}
|
||||
<div className="absolute top-1 right-2 text-[10px] text-muted-foreground/50 font-medium">
|
||||
#{index + 1}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mini Player - Always visible */}
|
||||
<MiniSamplePlayer audioUrl={apiClient.getSampleUrl(sample.id)} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handlePlay(sample.audio_path, sample.reference_text, sample.id)}
|
||||
className={currentAudioId === sample.id && isPlaying ? 'text-primary' : ''}
|
||||
>
|
||||
<Play className="h-4 w-4 mr-1" />
|
||||
Play
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(sample.id)}
|
||||
disabled={deleteSample.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setUploadOpen(true)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('sampleList.addSample')}
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-muted-foreground text-center px-2">{t('sampleList.note')}</p>
|
||||
|
||||
<SampleUpload profileId={profileId} open={uploadOpen} onOpenChange={setUploadOpen} />
|
||||
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('sampleList.deleteDialog.title')}</DialogTitle>
|
||||
<DialogDescription>{t('sampleList.deleteDialog.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(false);
|
||||
setSampleToDelete(null);
|
||||
}}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={deleteSample.isPending}
|
||||
>
|
||||
{deleteSample.isPending ? t('sampleList.deleteDialog.deleting') : t('common.delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Mic, Monitor, Upload } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useState, useEffect } from 'react';
|
||||
import * as z from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -13,21 +14,23 @@ import {
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useAddSample, useProfile } from '@/lib/hooks/useProfiles';
|
||||
import { useTranscription } from '@/lib/hooks/useTranscription';
|
||||
import { useAudioPlayer } from '@/lib/hooks/useAudioPlayer';
|
||||
import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
|
||||
import { Mic, Square, Upload } from 'lucide-react';
|
||||
import { formatAudioDuration } from '@/lib/utils/audio';
|
||||
import { useAddSample, useProfile } from '@/lib/hooks/useProfiles';
|
||||
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
|
||||
import { useTranscription } from '@/lib/hooks/useTranscription';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { AudioSampleRecording } from './AudioSampleRecording';
|
||||
import { AudioSampleSystem } from './AudioSampleSystem';
|
||||
import { AudioSampleUpload } from './AudioSampleUpload';
|
||||
|
||||
const sampleSchema = z.object({
|
||||
file: z.instanceof(File, { message: 'Please select an audio file' }),
|
||||
@@ -46,11 +49,13 @@ interface SampleUploadProps {
|
||||
}
|
||||
|
||||
export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProps) {
|
||||
const platform = usePlatform();
|
||||
const addSample = useAddSample();
|
||||
const transcribe = useTranscription();
|
||||
const { data: profile } = useProfile(profileId);
|
||||
const { toast } = useToast();
|
||||
const [mode, setMode] = useState<'upload' | 'record'>('upload');
|
||||
const [mode, setMode] = useState<'upload' | 'record' | 'system'>('upload');
|
||||
const { isPlaying, playPause, cleanup: cleanupAudio } = useAudioPlayer();
|
||||
|
||||
const form = useForm<SampleFormValues>({
|
||||
resolver: zodResolver(sampleSchema),
|
||||
@@ -69,12 +74,16 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
||||
stopRecording,
|
||||
cancelRecording,
|
||||
} = useAudioRecording({
|
||||
maxDurationSeconds: 30,
|
||||
onRecordingComplete: (blob) => {
|
||||
maxDurationSeconds: 29,
|
||||
onRecordingComplete: (blob, recordedDuration) => {
|
||||
// Convert blob to File object
|
||||
const file = new File([blob], `recording-${Date.now()}.webm`, {
|
||||
type: blob.type || 'audio/webm',
|
||||
});
|
||||
}) as File & { recordedDuration?: number };
|
||||
// Store the actual recorded duration to bypass metadata reading issues on Windows
|
||||
if (recordedDuration !== undefined) {
|
||||
file.recordedDuration = recordedDuration;
|
||||
}
|
||||
form.setValue('file', file, { shouldValidate: true });
|
||||
toast({
|
||||
title: 'Recording complete',
|
||||
@@ -83,6 +92,33 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
isRecording: isSystemRecording,
|
||||
duration: systemDuration,
|
||||
error: systemRecordingError,
|
||||
isSupported: isSystemAudioSupported,
|
||||
startRecording: startSystemRecording,
|
||||
stopRecording: stopSystemRecording,
|
||||
cancelRecording: cancelSystemRecording,
|
||||
} = useSystemAudioCapture({
|
||||
maxDurationSeconds: 29,
|
||||
onRecordingComplete: (blob, recordedDuration) => {
|
||||
// Convert blob to File object
|
||||
const file = new File([blob], `system-audio-${Date.now()}.wav`, {
|
||||
type: blob.type || 'audio/wav',
|
||||
}) as File & { recordedDuration?: number };
|
||||
// Store the actual recorded duration to bypass metadata reading issues on Windows
|
||||
if (recordedDuration !== undefined) {
|
||||
file.recordedDuration = recordedDuration;
|
||||
}
|
||||
form.setValue('file', file, { shouldValidate: true });
|
||||
toast({
|
||||
title: 'System audio captured',
|
||||
description: 'Audio has been captured successfully.',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Show recording errors
|
||||
useEffect(() => {
|
||||
if (recordingError) {
|
||||
@@ -94,6 +130,17 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
||||
}
|
||||
}, [recordingError, toast]);
|
||||
|
||||
// Show system audio recording errors
|
||||
useEffect(() => {
|
||||
if (systemRecordingError) {
|
||||
toast({
|
||||
title: 'System audio capture error',
|
||||
description: systemRecordingError,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}, [systemRecordingError, toast]);
|
||||
|
||||
async function handleTranscribe() {
|
||||
const file = form.getValues('file');
|
||||
if (!file) {
|
||||
@@ -110,11 +157,6 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
||||
const result = await transcribe.mutateAsync({ file, language });
|
||||
|
||||
form.setValue('referenceText', result.text, { shouldValidate: true });
|
||||
|
||||
toast({
|
||||
title: 'Transcription complete',
|
||||
description: 'Audio has been transcribed successfully.',
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Transcription failed',
|
||||
@@ -154,14 +196,27 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
||||
if (isRecording) {
|
||||
cancelRecording();
|
||||
}
|
||||
if (isSystemRecording) {
|
||||
cancelSystemRecording();
|
||||
}
|
||||
cleanupAudio();
|
||||
}
|
||||
onOpenChange(newOpen);
|
||||
}
|
||||
|
||||
function handleCancelRecording() {
|
||||
cancelRecording();
|
||||
// Reset file field by clearing the input
|
||||
if (mode === 'record') {
|
||||
cancelRecording();
|
||||
} else if (mode === 'system') {
|
||||
cancelSystemRecording();
|
||||
}
|
||||
form.resetField('file');
|
||||
cleanupAudio();
|
||||
}
|
||||
|
||||
function handlePlayPause() {
|
||||
const file = form.getValues('file');
|
||||
playPause(file);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -176,58 +231,40 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<Tabs value={mode} onValueChange={(v) => setMode(v as 'upload' | 'record')}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<Tabs value={mode} onValueChange={(v) => setMode(v as 'upload' | 'record' | 'system')}>
|
||||
<TabsList
|
||||
className={`grid w-full ${platform.metadata.isTauri && isSystemAudioSupported ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||
>
|
||||
<TabsTrigger value="upload" className="flex items-center gap-2">
|
||||
<Upload className="h-4 w-4" />
|
||||
<Upload className="h-4 w-4 shrink-0" />
|
||||
Upload
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="record" className="flex items-center gap-2">
|
||||
<Mic className="h-4 w-4" />
|
||||
<Mic className="h-4 w-4 shrink-0" />
|
||||
Record
|
||||
</TabsTrigger>
|
||||
{platform.metadata.isTauri && isSystemAudioSupported && (
|
||||
<TabsTrigger value="system" className="flex items-center gap-2">
|
||||
<Monitor className="h-4 w-4 shrink-0" />
|
||||
System Audio
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="upload" className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="file"
|
||||
render={({ field: { onChange, value, ...field } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Audio File</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
onChange(file);
|
||||
}
|
||||
}}
|
||||
{...field}
|
||||
/>
|
||||
{selectedFile && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleTranscribe}
|
||||
disabled={transcribe.isPending}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
{transcribe.isPending ? 'Transcribing...' : 'Transcribe'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Supported formats: WAV, MP3, M4A. Click "Transcribe" to automatically
|
||||
extract text from the audio.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
render={({ field: { onChange, name } }) => (
|
||||
<AudioSampleUpload
|
||||
file={selectedFile}
|
||||
onFileChange={onChange}
|
||||
onTranscribe={handleTranscribe}
|
||||
onPlayPause={handlePlayPause}
|
||||
isPlaying={isPlaying}
|
||||
isTranscribing={transcribe.isPending}
|
||||
fieldName={name}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
@@ -237,94 +274,44 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
||||
control={form.control}
|
||||
name="file"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>Record Audio</FormLabel>
|
||||
<FormControl>
|
||||
<div className="space-y-4">
|
||||
{!isRecording && !selectedFile && (
|
||||
<div className="flex flex-col items-center gap-4 p-6 border-2 border-dashed rounded-lg">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={startRecording}
|
||||
size="lg"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-5 w-5" />
|
||||
Start Recording
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Click to start recording. Maximum duration: 30 seconds.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRecording && (
|
||||
<div className="flex flex-col items-center gap-4 p-6 border-2 border-destructive rounded-lg bg-destructive/5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded-full bg-destructive animate-pulse" />
|
||||
<span className="text-lg font-mono font-semibold">
|
||||
{formatAudioDuration(duration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={stopRecording}
|
||||
variant="destructive"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
Stop Recording
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Recording in progress... ({formatAudioDuration(30 - duration)}{' '}
|
||||
remaining)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedFile && !isRecording && (
|
||||
<div className="flex flex-col items-center gap-4 p-6 border-2 border-primary rounded-lg bg-primary/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mic className="h-5 w-5 text-primary" />
|
||||
<span className="font-medium">Recording complete</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
File: {selectedFile.name}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleTranscribe}
|
||||
disabled={transcribe.isPending}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
{transcribe.isPending ? 'Transcribing...' : 'Transcribe'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancelRecording}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Record Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Record audio directly from your microphone. Maximum duration is 30 seconds.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
<AudioSampleRecording
|
||||
file={selectedFile}
|
||||
isRecording={isRecording}
|
||||
duration={duration}
|
||||
onStart={startRecording}
|
||||
onStop={stopRecording}
|
||||
onCancel={handleCancelRecording}
|
||||
onTranscribe={handleTranscribe}
|
||||
onPlayPause={handlePlayPause}
|
||||
isPlaying={isPlaying}
|
||||
isTranscribing={transcribe.isPending}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{platform.metadata.isTauri && isSystemAudioSupported && (
|
||||
<TabsContent value="system" className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="file"
|
||||
render={() => (
|
||||
<AudioSampleSystem
|
||||
file={selectedFile}
|
||||
isRecording={isSystemRecording}
|
||||
duration={systemDuration}
|
||||
onStart={startSystemRecording}
|
||||
onStop={stopSystemRecording}
|
||||
onCancel={handleCancelRecording}
|
||||
onTranscribe={handleTranscribe}
|
||||
onPlayPause={handlePlayPause}
|
||||
isPlaying={isPlaying}
|
||||
isTranscribing={transcribe.isPending}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
<FormField
|
||||
@@ -340,9 +327,6 @@ export function SampleUpload({ profileId, open, onOpenChange }: SampleUploadProp
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This should match exactly what is spoken in the audio file.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Edit2, Mic, X } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import * as z from 'zod';
|
||||
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { SampleList } from '@/components/VoiceProfiles/SampleList';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { EffectConfig } from '@/lib/api/types';
|
||||
import { LANGUAGE_CODES, LANGUAGE_OPTIONS, type LanguageCode } from '@/lib/constants/languages';
|
||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||
import {
|
||||
useDeleteAvatar,
|
||||
useProfile,
|
||||
useUpdateProfile,
|
||||
useUploadAvatar,
|
||||
} from '@/lib/hooks/useProfiles';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
|
||||
function makeProfileSchema(t: (key: string) => string) {
|
||||
return z.object({
|
||||
name: z.string().min(1, t('profileForm.validation.nameRequired')).max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
|
||||
});
|
||||
}
|
||||
|
||||
type ProfileFormValues = {
|
||||
name: string;
|
||||
description?: string;
|
||||
language: LanguageCode;
|
||||
};
|
||||
|
||||
interface VoiceInspectorProps {
|
||||
profileId: string;
|
||||
}
|
||||
|
||||
export function VoiceInspector({ profileId }: VoiceInspectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: profile } = useProfile(profileId);
|
||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||
const isPlayerVisible = !!audioUrl;
|
||||
const updateProfile = useUpdateProfile();
|
||||
const uploadAvatar = useUploadAvatar();
|
||||
const deleteAvatar = useDeleteAvatar();
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
const { toast } = useToast();
|
||||
|
||||
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
||||
const [avatarError, setAvatarError] = useState(false);
|
||||
const avatarInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [effectsChain, setEffectsChain] = useState<EffectConfig[]>([]);
|
||||
const [effectsDirty, setEffectsDirty] = useState(false);
|
||||
|
||||
const form = useForm<ProfileFormValues>({
|
||||
resolver: zodResolver(makeProfileSchema(t)),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
language: 'en',
|
||||
},
|
||||
});
|
||||
|
||||
// Populate form when profile loads
|
||||
useEffect(() => {
|
||||
if (profile) {
|
||||
form.reset({
|
||||
name: profile.name,
|
||||
description: profile.description || '',
|
||||
language: profile.language as LanguageCode,
|
||||
});
|
||||
setEffectsChain(profile.effects_chain ?? []);
|
||||
setEffectsDirty(false);
|
||||
}
|
||||
}, [profile, form]);
|
||||
|
||||
// Avatar preview
|
||||
useEffect(() => {
|
||||
if (profile?.avatar_path) {
|
||||
setAvatarPreview(`${serverUrl}/profiles/${profile.id}/avatar`);
|
||||
} else {
|
||||
setAvatarPreview(null);
|
||||
}
|
||||
setAvatarError(false);
|
||||
}, [profile, serverUrl]);
|
||||
|
||||
function handleAvatarFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast({
|
||||
title: t('profileForm.toast.invalidFile'),
|
||||
description: t('voiceInspector.toast.invalidImageFormat'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast({
|
||||
title: t('profileForm.toast.fileTooLarge'),
|
||||
description: t('profileForm.toast.imageTooLargeDescription'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
uploadAvatar.mutate(
|
||||
{ profileId, file },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setAvatarPreview(URL.createObjectURL(file));
|
||||
toast({ title: t('voiceInspector.toast.avatarUpdated') });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({
|
||||
title: t('profileForm.toast.avatarUploadFailed'),
|
||||
description: err instanceof Error ? err.message : t('common.unknownError'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function handleRemoveAvatar() {
|
||||
if (profile?.avatar_path) {
|
||||
try {
|
||||
await deleteAvatar.mutateAsync(profileId);
|
||||
toast({ title: t('profileForm.toast.avatarRemoved') });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t('profileForm.toast.avatarRemoveFailed'),
|
||||
description: err instanceof Error ? err.message : t('common.unknownError'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}
|
||||
setAvatarPreview(null);
|
||||
if (avatarInputRef.current) avatarInputRef.current.value = '';
|
||||
}
|
||||
|
||||
async function onSubmit(data: ProfileFormValues) {
|
||||
try {
|
||||
await updateProfile.mutateAsync({
|
||||
profileId,
|
||||
data: {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
language: data.language,
|
||||
},
|
||||
});
|
||||
|
||||
if (effectsDirty) {
|
||||
try {
|
||||
await apiClient.updateProfileEffects(
|
||||
profileId,
|
||||
effectsChain.length > 0 ? effectsChain : null,
|
||||
);
|
||||
setEffectsDirty(false);
|
||||
} catch (fxError) {
|
||||
toast({
|
||||
title: t('profileForm.toast.effectsUpdateFailed'),
|
||||
description:
|
||||
fxError instanceof Error
|
||||
? fxError.message
|
||||
: t('profileForm.toast.effectsUpdateFailedFallback'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t('profileForm.toast.voiceUpdated'),
|
||||
description: t('voiceInspector.toast.savedDescription', { name: data.name }),
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('common.error'),
|
||||
description: error instanceof Error ? error.message : t('profileForm.toast.saveFailed'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
{t('voiceInspector.loading')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isDirty = form.formState.isDirty || effectsDirty;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
<div className={cn('flex-1 overflow-y-auto', isPlayerVisible && BOTTOM_SAFE_AREA_PADDING)}>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-0">
|
||||
{/* Avatar */}
|
||||
<div className="flex justify-center pt-5 pb-3">
|
||||
<div className="relative group">
|
||||
<div className="h-20 w-20 rounded-full bg-muted flex items-center justify-center shrink-0 overflow-hidden border-2 border-border">
|
||||
{avatarPreview && !avatarError ? (
|
||||
<img
|
||||
src={avatarPreview}
|
||||
alt={profile.name}
|
||||
className="h-full w-full object-cover"
|
||||
onError={() => setAvatarError(true)}
|
||||
/>
|
||||
) : (
|
||||
<Mic className="h-8 w-8 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
className="absolute inset-0 rounded-full bg-accent/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center cursor-pointer"
|
||||
>
|
||||
<Edit2 className="h-5 w-5 text-accent-foreground" />
|
||||
</button>
|
||||
{avatarPreview && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemoveAvatar}
|
||||
disabled={deleteAvatar.isPending}
|
||||
className="absolute bottom-0 right-0 h-5 w-5 rounded-full bg-background/60 backdrop-blur-sm text-muted-foreground flex items-center justify-center hover:bg-background/80 hover:text-foreground transition-colors shadow-sm border border-border/50"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={avatarInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
onChange={handleAvatarFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Fields */}
|
||||
<div className="space-y-3 px-5">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('profileForm.fields.name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('profileForm.fields.namePlaceholder')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('voiceInspector.fields.description')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t('profileForm.fields.descriptionPlaceholder')}
|
||||
rows={2}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="language"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('profileForm.fields.language')}</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{LANGUAGE_OPTIONS.map((lang) => (
|
||||
<SelectItem key={lang.value} value={lang.value}>
|
||||
{lang.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Effects */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{t('profileForm.fields.defaultEffects')}</FormLabel>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('voiceInspector.defaultEffectsHint')}
|
||||
</p>
|
||||
<EffectsChainEditor
|
||||
value={effectsChain}
|
||||
onChange={(chain) => {
|
||||
setEffectsChain(chain);
|
||||
setEffectsDirty(true);
|
||||
}}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Save */}
|
||||
{isDirty && (
|
||||
<Button type="submit" className="w-full" disabled={updateProfile.isPending}>
|
||||
{updateProfile.isPending
|
||||
? t('profileForm.actions.saving')
|
||||
: t('profileForm.actions.saveChanges')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Samples */}
|
||||
<div className="px-5 pb-5">
|
||||
<SampleList profileId={profileId} />
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Mic, Plus, Search, Sparkles } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
import { MultiSelect } from '@/components/ui/multi-select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { ProfileForm } from '@/components/VoiceProfiles/ProfileForm';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { VoiceProfileResponse } from '@/lib/api/types';
|
||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||
import { useProfiles } from '@/lib/hooks/useProfiles';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
import { VoiceInspector } from './VoiceInspector';
|
||||
|
||||
export function VoicesTab() {
|
||||
const { t } = useTranslation();
|
||||
const { data: profiles, isLoading } = useProfiles();
|
||||
const queryClient = useQueryClient();
|
||||
const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
|
||||
const selectedVoiceId = useUIStore((state) => state.selectedVoiceId);
|
||||
const setSelectedVoiceId = useUIStore((state) => state.setSelectedVoiceId);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||
const isPlayerVisible = !!audioUrl;
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filteredProfiles = useMemo(() => {
|
||||
if (!profiles) return [];
|
||||
if (!search.trim()) return profiles;
|
||||
const q = search.toLowerCase();
|
||||
return profiles.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
p.description?.toLowerCase().includes(q) ||
|
||||
p.language.toLowerCase().includes(q),
|
||||
);
|
||||
}, [profiles, search]);
|
||||
|
||||
// Auto-select first profile if none selected
|
||||
useEffect(() => {
|
||||
if (!selectedVoiceId && profiles && profiles.length > 0) {
|
||||
setSelectedVoiceId(profiles[0].id);
|
||||
}
|
||||
// Clear selection if selected profile was deleted
|
||||
if (selectedVoiceId && profiles && !profiles.find((p) => p.id === selectedVoiceId)) {
|
||||
setSelectedVoiceId(profiles.length > 0 ? profiles[0].id : null);
|
||||
}
|
||||
}, [profiles, selectedVoiceId, setSelectedVoiceId]);
|
||||
|
||||
// Get channel assignments for each profile
|
||||
const { data: channelAssignments } = useQuery({
|
||||
queryKey: ['profile-channels'],
|
||||
queryFn: async () => {
|
||||
if (!profiles) return {};
|
||||
const assignments: Record<string, string[]> = {};
|
||||
for (const profile of profiles) {
|
||||
try {
|
||||
const result = await apiClient.getProfileChannels(profile.id);
|
||||
assignments[profile.id] = result.channel_ids;
|
||||
} catch {
|
||||
assignments[profile.id] = [];
|
||||
}
|
||||
}
|
||||
return assignments;
|
||||
},
|
||||
enabled: !!profiles,
|
||||
});
|
||||
|
||||
// Get all channels
|
||||
const { data: channels } = useQuery({
|
||||
queryKey: ['channels'],
|
||||
queryFn: () => apiClient.listChannels(),
|
||||
});
|
||||
|
||||
const handleChannelChange = async (profileId: string, channelIds: string[]) => {
|
||||
try {
|
||||
await apiClient.setProfileChannels(profileId, channelIds);
|
||||
queryClient.invalidateQueries({ queryKey: ['profile-channels'] });
|
||||
} catch (error) {
|
||||
console.error('Failed to update channels:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-muted-foreground">{t('voicesTab.loading')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex gap-0 overflow-hidden -mx-8">
|
||||
{/* Left: Table */}
|
||||
<div className="flex-1 min-w-0 flex flex-col relative overflow-hidden">
|
||||
{/* Scroll Mask */}
|
||||
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
|
||||
|
||||
{/* Fixed Header */}
|
||||
<div className="absolute top-0 left-0 right-0 z-20 pl-8 pr-8">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<h1 className="text-2xl font-bold">{t('voicesTab.title')}</h1>
|
||||
<div className="flex-1" />
|
||||
<div className="relative w-[240px]">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t('voicesTab.searchPlaceholder')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-10 pl-8 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t('voicesTab.newVoice')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cn(
|
||||
'flex-1 overflow-y-auto overflow-x-hidden pt-16 relative z-0',
|
||||
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
|
||||
)}
|
||||
>
|
||||
<Table className="table-fixed [&_td:first-child]:pl-8 [&_th:first-child]:pl-8">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[30%]">{t('voicesTab.columns.name')}</TableHead>
|
||||
<TableHead className="w-[10%]">{t('voicesTab.columns.language')}</TableHead>
|
||||
<TableHead className="w-[10%]">{t('voicesTab.columns.generations')}</TableHead>
|
||||
<TableHead className="w-[8%]">{t('voicesTab.columns.samples')}</TableHead>
|
||||
<TableHead className="w-[8%]">{t('voicesTab.columns.effects')}</TableHead>
|
||||
<TableHead className="w-[24%]">{t('voicesTab.columns.channels')}</TableHead>
|
||||
<TableHead className="w-6"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredProfiles.map((profile) => (
|
||||
<VoiceRow
|
||||
key={profile.id}
|
||||
profile={profile}
|
||||
isSelected={selectedVoiceId === profile.id}
|
||||
onSelect={() => setSelectedVoiceId(profile.id)}
|
||||
channelIds={channelAssignments?.[profile.id] || []}
|
||||
channels={channels || []}
|
||||
onChannelChange={(channelIds) => handleChannelChange(profile.id, channelIds)}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Inspector */}
|
||||
{selectedVoiceId && (
|
||||
<div className="w-[340px] shrink-0 border-l border-t rounded-tl-xl bg-muted/30">
|
||||
<VoiceInspector key={selectedVoiceId} profileId={selectedVoiceId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProfileForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface VoiceRowProps {
|
||||
profile: VoiceProfileResponse;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
channelIds: string[];
|
||||
channels: Array<{ id: string; name: string; is_default: boolean }>;
|
||||
onChannelChange: (channelIds: string[]) => void;
|
||||
}
|
||||
|
||||
function VoiceRow({
|
||||
profile,
|
||||
isSelected,
|
||||
onSelect,
|
||||
channelIds,
|
||||
channels,
|
||||
onChannelChange,
|
||||
}: VoiceRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
const [avatarError, setAvatarError] = useState(false);
|
||||
const avatarUrl = profile.avatar_path ? `${serverUrl}/profiles/${profile.id}/avatar` : null;
|
||||
|
||||
const enabledEffects = profile.effects_chain?.filter((e) => e.enabled) ?? [];
|
||||
const effectsSummary = enabledEffects.map((e) => e.type).join(' → ');
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
className={cn('cursor-pointer', isSelected ? 'bg-muted/50' : 'hover:bg-muted/50')}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex w-full min-w-0 items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center shrink-0 overflow-hidden">
|
||||
{avatarUrl && !avatarError ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={t('voicesTab.avatarAlt', { name: profile.name })}
|
||||
className="h-full w-full object-cover"
|
||||
onError={() => setAvatarError(true)}
|
||||
/>
|
||||
) : (
|
||||
<Mic className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">{profile.name}</div>
|
||||
{profile.description && (
|
||||
<div className="text-sm text-muted-foreground truncate">{profile.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{profile.language}</TableCell>
|
||||
<TableCell>{profile.generation_count}</TableCell>
|
||||
<TableCell>{profile.sample_count}</TableCell>
|
||||
<TableCell>
|
||||
{enabledEffects.length > 0 ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-xs text-accent"
|
||||
title={effectsSummary}
|
||||
>
|
||||
<Sparkles className="h-3 w-3 fill-accent" />
|
||||
{enabledEffects.length}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<MultiSelect
|
||||
options={channels.map((ch) => ({
|
||||
value: ch.id,
|
||||
label: ch.is_default ? t('voicesTab.channelDefaultLabel', { name: ch.name }) : ch.name,
|
||||
}))}
|
||||
value={channelIds}
|
||||
onChange={onChannelChange}
|
||||
placeholder={t('voicesTab.selectChannels')}
|
||||
className="w-full"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { buttonVariants } from './button';
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root;
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
));
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
|
||||
);
|
||||
AlertDialogHeader.displayName = 'AlertDialogHeader';
|
||||
|
||||
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogFooter.displayName = 'AlertDialogFooter';
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
|
||||
));
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
};
|
||||
@@ -9,7 +9,7 @@ const badgeVariants = cva(
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
'border-border bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||
outline: 'text-foreground',
|
||||
|
||||
@@ -3,14 +3,18 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
const buttonVariants = cva([
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm',
|
||||
'font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2',
|
||||
'focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
'[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0'
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-accent text-accent-foreground hover:bg-accent/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:border-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-accent underline-offset-4 hover:underline',
|
||||
|
||||
@@ -1,33 +1,41 @@
|
||||
import { Check } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
export interface CheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
export interface CheckboxProps {
|
||||
checked?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
({ className, onCheckedChange, ...props }, ref) => {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (onCheckedChange) {
|
||||
onCheckedChange(e.target.checked);
|
||||
}
|
||||
// Call original onChange if provided
|
||||
if (props.onChange) {
|
||||
props.onChange(e);
|
||||
}
|
||||
};
|
||||
|
||||
const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
|
||||
({ checked = false, onCheckedChange, disabled = false, className, id, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
<button
|
||||
type="button"
|
||||
ref={ref}
|
||||
id={id}
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (!disabled && onCheckedChange) {
|
||||
onCheckedChange(!checked);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-primary focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'h-4 w-4 rounded border-2 flex items-center justify-center shrink-0 transition-colors',
|
||||
checked ? 'bg-accent border-accent' : 'border-muted-foreground/30',
|
||||
disabled && 'opacity-50 cursor-not-allowed',
|
||||
!disabled && 'cursor-pointer',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
onChange={handleChange}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{checked && <Check className="h-3 w-3 text-accent-foreground" />}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -6,10 +6,11 @@ export interface CircleButtonProps extends React.ButtonHTMLAttributes<HTMLButton
|
||||
}
|
||||
|
||||
const CircleButton = React.forwardRef<HTMLButtonElement, CircleButtonProps>(
|
||||
({ className, icon: Icon, ...props }, ref) => {
|
||||
({ className, icon: Icon, type = 'button', ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={cn(
|
||||
'h-7 w-7 rounded-full flex items-center justify-center flex-shrink-0',
|
||||
'hover:bg-muted transition-colors',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user