Spaces:
Running on Zero
Running on Zero
v0.3.0: timed two-line reading, smooth waveform and custom voice input
Browse files- MANIFEST.sha256 +22 -14
- README.md +13 -4
- alignment.py +107 -0
- app.py +13 -9
- assets/generate.js +5 -0
- assets/player.js +8 -0
- assets/reference.css +21 -0
- assets/reference.html +11 -0
- assets/reference.js +108 -0
- assets/script.css +16 -0
- assets/script.html +6 -0
- assets/script.js +70 -0
- assets/settings.css +17 -0
- assets/settings.html +1 -1
- assets/settings.js +1 -0
- assets/style.css +5 -7
- assets/wave.js +32 -16
- config.py +4 -1
- controller.py +26 -2
- requirements.txt +4 -0
- scripts/package.py +1 -1
- tests/test_alignment.py +61 -0
- tests/test_ui_js.py +10 -8
MANIFEST.sha256
CHANGED
|
@@ -4,23 +4,30 @@ bcf4b74e7954a954781f7d6da93edd7514837498fadde60a56e94bde8d0564d7 CHANGELOG.md
|
|
| 4 |
3381fc95c5558560e1bb62d4cebea34bf6efa4adfbda7f4fe03bbf40bbcc55d3 LICENSE
|
| 5 |
70cfad55255dc3eadcbeb607da257aeda748292d01916aa922cc20ccacc9cecb NOTICE
|
| 6 |
902b4c71d4f5e55a8f32f3a879858edd1114ca2d329805085e9eee260005f6d4 PROVENANCE.md
|
| 7 |
-
|
| 8 |
ebd5e8888fc2d73953541e2fe4bbb68efb8a9f83bcd2334476d0c9950a2ded12 VALIDATION.md
|
| 9 |
-
|
| 10 |
-
|
|
|
|
| 11 |
dd23ac0a74f0682218938cf387a037e89724e8bbfbdeb5ff35ecff289ebc24d4 assets/layout.js
|
| 12 |
ecb1d25ae4aa218f69e269519fb68719a2c657ea1c24c0b6780df5f5dcf08259 assets/picker.js
|
| 13 |
98d10bfcd45f9d3dfe3cf1e433a4a2829d253496a90daefd30819a0abd5a4347 assets/player.css
|
| 14 |
95af93bc6375bcb3eca237c9fe0d7c5346b540f26b38516c18c89ba9ceb3458d assets/player.html
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
c476aa0fc7ed79b77cd6577d6e5efaf75dee8cccb6d5dc5f56fe92334b27d04d audio_files.py
|
| 22 |
-
|
| 23 |
-
|
| 24 |
6aa124baba94d5961a439a9467325e14aec4306f1e1fbdb3fef9a7a0b9bd511b engine.py
|
| 25 |
08396169ac070e226ebfdd11dbb2bf5469d475873e74680f91820f564ba803da lifecycle.py
|
| 26 |
75b9cc83944e78c88496c205ee2b4734e85ed84ee4d1d02643aaf45b532e6695 logging_setup.py
|
|
@@ -29,9 +36,10 @@ e24eb5e051506e749615d841771171683160338ed7cd7dfa96f090841995a14b patches/pin-mo
|
|
| 29 |
c993dd7bd048d7f9178a8409fedb903c828cc2ce7dcadb5987a79ed2f4bf3c98 pytest.ini
|
| 30 |
abaf2f4bf8cc50e5cc15c4b21bab4b7b6e77f92a5c265435f37e9b3ef38fb509 requests_schema.py
|
| 31 |
ebc319f05921f7d8d196fb93e7e47ffabc2cdda3dbd7e261d76a8fbc5ea099c8 requirements-dev.txt
|
| 32 |
-
|
| 33 |
-
|
| 34 |
c3d724d4147939688b1889a2055d4c80cb8878bf63e902742123a7da7acf8f5c tested-dependencies.json
|
|
|
|
| 35 |
70802a3c9077443d6e7c8e9d354f799981f96a321cca5b40f5f307fa364b2924 tests/test_audio.py
|
| 36 |
89a62cbb880d9495634fce62a659e531dba74a1efa8bb5db4dbfeb1b8d5bf44b tests/test_contracts.py
|
| 37 |
9c2d7b6aad84fa56d891bb7f79bedc8386b5cfca86f80a282c41777d9cd1a61b tests/test_http.py
|
|
@@ -39,7 +47,7 @@ a20f4145d11561ab4caeabd51952c9f9b14088d5d330a4549e79f6a32310221c tests/test_lif
|
|
| 39 |
0889b93f4d83986372f5dbc72ad902ebb076bfd730c6ad07f3232b01b5ed1ee9 tests/test_logging.py
|
| 40 |
31bfd349c66c1c31c06a7fc7d6cd1c94257679029bd512ddb4f133bfc4f6ccc8 tests/test_package.py
|
| 41 |
beae97c67f31cc89d948a5293bc820348cae1117e2f626ba97e7706819318ac8 tests/test_requests.py
|
| 42 |
-
|
| 43 |
98216c34413fa17aeaaba8aeec41d157609b6b8f6c07cf6250239128cf616bb5 vendor/ZYPHRA_LICENSE
|
| 44 |
5dd0692aae95e02965462827ccdd0fb1fc4f9762552e1cf979c4dc789cc34fc5 vendor/ZYPHRA_NOTICE
|
| 45 |
cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30 vendor/third_party/licenses/fusealign-multilingual-Apache-2.0.txt
|
|
|
|
| 4 |
3381fc95c5558560e1bb62d4cebea34bf6efa4adfbda7f4fe03bbf40bbcc55d3 LICENSE
|
| 5 |
70cfad55255dc3eadcbeb607da257aeda748292d01916aa922cc20ccacc9cecb NOTICE
|
| 6 |
902b4c71d4f5e55a8f32f3a879858edd1114ca2d329805085e9eee260005f6d4 PROVENANCE.md
|
| 7 |
+
68b70449f145477a6d0ce3a6376e8301f5afb9cb6fe6fa1781a0e65392ba7f50 README.md
|
| 8 |
ebd5e8888fc2d73953541e2fe4bbb68efb8a9f83bcd2334476d0c9950a2ded12 VALIDATION.md
|
| 9 |
+
c27fa4c518c35c000227a0a939c350999db6c4e45e71082ef32f74efb8ff3f92 alignment.py
|
| 10 |
+
91b49d5699b6430368bb5f666e8988c5c98ca20b07159c367e9dfc0566c982c8 app.py
|
| 11 |
+
00777c382b80d8b19ff23101d76fa27b86c0ed815d5bb0023fe21dda0e92ee99 assets/generate.js
|
| 12 |
dd23ac0a74f0682218938cf387a037e89724e8bbfbdeb5ff35ecff289ebc24d4 assets/layout.js
|
| 13 |
ecb1d25ae4aa218f69e269519fb68719a2c657ea1c24c0b6780df5f5dcf08259 assets/picker.js
|
| 14 |
98d10bfcd45f9d3dfe3cf1e433a4a2829d253496a90daefd30819a0abd5a4347 assets/player.css
|
| 15 |
95af93bc6375bcb3eca237c9fe0d7c5346b540f26b38516c18c89ba9ceb3458d assets/player.html
|
| 16 |
+
693246876df0103f7e6a9a952b9fa4ccb08bb8c8a69c2de6b1bb1f52b1a49179 assets/player.js
|
| 17 |
+
57347c4f3eb6f9aec59f1c8882c66e926e04acb87cc0154573594f063cf5df02 assets/reference.css
|
| 18 |
+
03b2f8dbea4891f5ceb8990727473184118536720f439ffc69ef7d83d2f9a396 assets/reference.html
|
| 19 |
+
0c810d357737c04392af97416f6723d40842b2f560a205061e01f0ecfbcae88f assets/reference.js
|
| 20 |
+
e15076d12c17909a9330347383fd28ddc81272f125f1e6e3ee3de9f56a6d5df0 assets/script.css
|
| 21 |
+
9519e9e91bc74d23ef72c0e4e8b3c02f0d16fee8143910bff75c1731f163ed44 assets/script.html
|
| 22 |
+
f7cfcf63e5622405ce6fe57d106231c1ce00bea379d995d74433ac854b5c099f assets/script.js
|
| 23 |
+
4c33f5471865f012e4900961414877d3d18cb8bfc783d97f1266ab5e7e579af2 assets/settings.css
|
| 24 |
+
06843a1901695e66d88c604a1c80605a15711cd55c4df582541794dc947b8e69 assets/settings.html
|
| 25 |
+
cecc8107b91ca09af1acf60b41906b5f8705196a61d7cf245264c383ef68af7b assets/settings.js
|
| 26 |
+
249fd190f8b58cf6e1b8f2582ed4afc40ce8ecf43292cbb409ee82f255604465 assets/style.css
|
| 27 |
+
0b0fec16825b1be308abc7009c3ffd417f78eb664913aa262ba18078595f6604 assets/wave.js
|
| 28 |
c476aa0fc7ed79b77cd6577d6e5efaf75dee8cccb6d5dc5f56fe92334b27d04d audio_files.py
|
| 29 |
+
5467af2927d4ea4658c0d25c12571e82262906a61d13b5987c3c21863f62c9c8 config.py
|
| 30 |
+
22cc095df3f023b95e4f7456f1b79144e341a33db6554db88ebe08a98c6b2f78 controller.py
|
| 31 |
6aa124baba94d5961a439a9467325e14aec4306f1e1fbdb3fef9a7a0b9bd511b engine.py
|
| 32 |
08396169ac070e226ebfdd11dbb2bf5469d475873e74680f91820f564ba803da lifecycle.py
|
| 33 |
75b9cc83944e78c88496c205ee2b4734e85ed84ee4d1d02643aaf45b532e6695 logging_setup.py
|
|
|
|
| 36 |
c993dd7bd048d7f9178a8409fedb903c828cc2ce7dcadb5987a79ed2f4bf3c98 pytest.ini
|
| 37 |
abaf2f4bf8cc50e5cc15c4b21bab4b7b6e77f92a5c265435f37e9b3ef38fb509 requests_schema.py
|
| 38 |
ebc319f05921f7d8d196fb93e7e47ffabc2cdda3dbd7e261d76a8fbc5ea099c8 requirements-dev.txt
|
| 39 |
+
26c55e23ff47b7fe307eb8b4258b6da07545992ad5e8ed9d91580171b582df42 requirements.txt
|
| 40 |
+
efe08b65cc84bffc56f7a101e2cab6687355eef1ffaf415673b0f769605c7d41 scripts/package.py
|
| 41 |
c3d724d4147939688b1889a2055d4c80cb8878bf63e902742123a7da7acf8f5c tested-dependencies.json
|
| 42 |
+
e25f3dd0e11ec835e7db001c703825430e5c4745a538efb8d445fe650821cf1b tests/test_alignment.py
|
| 43 |
70802a3c9077443d6e7c8e9d354f799981f96a321cca5b40f5f307fa364b2924 tests/test_audio.py
|
| 44 |
89a62cbb880d9495634fce62a659e531dba74a1efa8bb5db4dbfeb1b8d5bf44b tests/test_contracts.py
|
| 45 |
9c2d7b6aad84fa56d891bb7f79bedc8386b5cfca86f80a282c41777d9cd1a61b tests/test_http.py
|
|
|
|
| 47 |
0889b93f4d83986372f5dbc72ad902ebb076bfd730c6ad07f3232b01b5ed1ee9 tests/test_logging.py
|
| 48 |
31bfd349c66c1c31c06a7fc7d6cd1c94257679029bd512ddb4f133bfc4f6ccc8 tests/test_package.py
|
| 49 |
beae97c67f31cc89d948a5293bc820348cae1117e2f626ba97e7706819318ac8 tests/test_requests.py
|
| 50 |
+
ccbe51bb8d1e0e4f6d225b593a3192efaf299866de1830f3357fd2b4478e0a93 tests/test_ui_js.py
|
| 51 |
98216c34413fa17aeaaba8aeec41d157609b6b8f6c07cf6250239128cf616bb5 vendor/ZYPHRA_LICENSE
|
| 52 |
5dd0692aae95e02965462827ccdd0fb1fc4f9762552e1cf979c4dc789cc34fc5 vendor/ZYPHRA_NOTICE
|
| 53 |
cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30 vendor/third_party/licenses/fusealign-multilingual-Apache-2.0.txt
|
README.md
CHANGED
|
@@ -11,10 +11,11 @@ pinned: false
|
|
| 11 |
license: mit
|
| 12 |
models:
|
| 13 |
- Zyphra/ZONOS3-Mini
|
|
|
|
| 14 |
short_description: A voice, a line, and your words.
|
| 15 |
---
|
| 16 |
|
| 17 |
-
# ZONOS3 Mini · 0.
|
| 18 |
|
| 19 |
A minimal Hugging Face Space for [Zyphra's ZONOS3-Mini](https://huggingface.co/Zyphra/ZONOS3-Mini).
|
| 20 |
Centered editable text, a fine audio waveform, and one Generate button. The interface follows the supplied Zyphra presentation references. Model: Zyphra. UI/UX: [Sylvain Filoni / fffiloni](https://huggingface.co/fffiloni).
|
|
@@ -27,7 +28,7 @@ Centered editable text, a fine audio waveform, and one Generate button. The inte
|
|
| 27 |
2. Select **Generate speech**. The native Gradio/ZeroGPU progress and quota notices remain available.
|
| 28 |
3. Play, seek or download the completed WAV. Generation does not autoplay.
|
| 29 |
|
| 30 |
-
The waveform starts flat. Voice settings use documented Gradio HTML props, watch callbacks, custom example events and a
|
| 31 |
|
| 32 |
**Voice options** contains an optional reference clip, consent checkbox, seed, normalization, speaking rate and sampling. Selecting a preset selects its default text language; the language can then be changed independently. Examples only fill inputs. The English interface retains French text without requiring an interface language switch.
|
| 33 |
|
|
@@ -56,7 +57,7 @@ The two upstream NeMo `.far` language-rule files are real binary assets in this
|
|
| 56 |
| Product limits | 600 input characters; 1,350 generated frames; reference clip 0.5–60 seconds, upload at most 25 MiB |
|
| 57 |
| Sampling defaults | Temperature 0.75, top-p 0.69, top-k 80, repetition penalty 2.4; other defaults remain upstream |
|
| 58 |
|
| 59 |
-
Both presets and reference clips use **embedding conditioning**, also supported by the official Lite app. This is a deliberate choice: it avoids loading the separate dual-codec teacher needed by raw-reference conditioning. It is not a claim that the two conditioning modes produce equal quality. Raw-reference tokens, mood steering, accent adapters, voice blending,
|
| 60 |
|
| 61 |
There are **23 exposed presets**. Four published Hindi/Telugu embeddings are not offered because the pinned Python language registry does not support those language codes. `voices.json` records the paths and exclusion reasons. Supported language selections: US/UK English, French, German, Spanish, Italian, Portuguese, Japanese, Mandarin and Korean. A preset's native language does not constrain the selected text language. No fallback to English is inserted by the app.
|
| 62 |
|
|
@@ -66,7 +67,7 @@ Audio chunks are decoded internally through the upstream callback path to observ
|
|
| 66 |
|
| 67 |
## ZeroGPU lifecycle
|
| 68 |
|
| 69 |
-
`controller.py` validates inputs → prepares text on CPU → waits for the shared inference slot → calls the regular `@spaces.GPU` function → exports PCM on CPU → publishes the result.
|
| 70 |
|
| 71 |
- The engine is shared, with one active inference at a time. Request state, cancellation markers and output names are separate per session/request. Duplicate submissions in the same session are rejected.
|
| 72 |
- The decorated function is synchronous, not a GPU generator. The app launches no inference thread. The pinned SDK owns the worker and lease release on return/error; the app synchronizes CUDA before returning. `empty_cache()` is not used as a substitute for release.
|
|
@@ -139,3 +140,11 @@ On cancellation, the audio output is a Gradio skip update, not a new take. Valid
|
|
| 139 |
Model authors: **Gabriel Clark, Sofian Mejjoute, George Close and Beren Millidge**, Zyphra. [Model card](https://huggingface.co/Zyphra/ZONOS3-Mini): weights under Apache 2.0. [Official runtime](https://github.com/Zyphra/ZONOS3-Mini): MIT. UI/UX: **Sylvain Filoni / fffiloni**. Independent adaptation; no partnership implied.
|
| 140 |
|
| 141 |
Application code: MIT, see `LICENSE`. Original runtime notices and third-party licenses are retained under `vendor`. See [PROVENANCE.md](PROVENANCE.md) for the exact source, documented modification, dependency choice and integrity manifest.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
license: mit
|
| 12 |
models:
|
| 13 |
- Zyphra/ZONOS3-Mini
|
| 14 |
+
- Qwen/Qwen3-ForcedAligner-0.6B-hf
|
| 15 |
short_description: A voice, a line, and your words.
|
| 16 |
---
|
| 17 |
|
| 18 |
+
# ZONOS3 Mini · 0.3.0
|
| 19 |
|
| 20 |
A minimal Hugging Face Space for [Zyphra's ZONOS3-Mini](https://huggingface.co/Zyphra/ZONOS3-Mini).
|
| 21 |
Centered editable text, a fine audio waveform, and one Generate button. The interface follows the supplied Zyphra presentation references. Model: Zyphra. UI/UX: [Sylvain Filoni / fffiloni](https://huggingface.co/fffiloni).
|
|
|
|
| 28 |
2. Select **Generate speech**. The native Gradio/ZeroGPU progress and quota notices remain available.
|
| 29 |
3. Play, seek or download the completed WAV. Generation does not autoplay.
|
| 30 |
|
| 31 |
+
The waveform starts flat. Voice settings use documented Gradio HTML props, watch callbacks, custom example events and a custom audio upload/record/trim child. The generation event snapshots the custom controls into the unchanged public API inputs. After generation it displays samples from the output; during playback it uses the actual audio signal when Web Audio is available. A separate animated activity indicator appears while a request is pending; it is not an audio preview or a completion estimate. Native Gradio progress retains the real preparation/allocation/inference stages. Visual gain and edge fades affect only the drawing, never the audio.
|
| 32 |
|
| 33 |
**Voice options** contains an optional reference clip, consent checkbox, seed, normalization, speaking rate and sampling. Selecting a preset selects its default text language; the language can then be changed independently. Examples only fill inputs. The English interface retains French text without requiring an interface language switch.
|
| 34 |
|
|
|
|
| 57 |
| Product limits | 600 input characters; 1,350 generated frames; reference clip 0.5–60 seconds, upload at most 25 MiB |
|
| 58 |
| Sampling defaults | Temperature 0.75, top-p 0.69, top-k 80, repetition penalty 2.4; other defaults remain upstream |
|
| 59 |
|
| 60 |
+
Both presets and reference clips use **embedding conditioning**, also supported by the official Lite app. This is a deliberate choice: it avoids loading the separate dual-codec teacher needed by raw-reference conditioning. It is not a claim that the two conditioning modes produce equal quality. Raw-reference tokens, mood steering, accent adapters, voice blending, speech editing are not exposed in this first version.
|
| 61 |
|
| 62 |
There are **23 exposed presets**. Four published Hindi/Telugu embeddings are not offered because the pinned Python language registry does not support those language codes. `voices.json` records the paths and exclusion reasons. Supported language selections: US/UK English, French, German, Spanish, Italian, Portuguese, Japanese, Mandarin and Korean. A preset's native language does not constrain the selected text language. No fallback to English is inserted by the app.
|
| 63 |
|
|
|
|
| 67 |
|
| 68 |
## ZeroGPU lifecycle
|
| 69 |
|
| 70 |
+
`controller.py` validates inputs → prepares text on CPU → waits for the shared inference slot → calls the regular `@spaces.GPU` function → exports PCM on CPU once → prepares alignment on CPU → requests a separate GPU lease for Qwen3 ForcedAligner → validates timestamps → publishes the result.
|
| 71 |
|
| 72 |
- The engine is shared, with one active inference at a time. Request state, cancellation markers and output names are separate per session/request. Duplicate submissions in the same session are rejected.
|
| 73 |
- The decorated function is synchronous, not a GPU generator. The app launches no inference thread. The pinned SDK owns the worker and lease release on return/error; the app synchronizes CUDA before returning. `empty_cache()` is not used as a substitute for release.
|
|
|
|
| 140 |
Model authors: **Gabriel Clark, Sofian Mejjoute, George Close and Beren Millidge**, Zyphra. [Model card](https://huggingface.co/Zyphra/ZONOS3-Mini): weights under Apache 2.0. [Official runtime](https://github.com/Zyphra/ZONOS3-Mini): MIT. UI/UX: **Sylvain Filoni / fffiloni**. Independent adaptation; no partnership implied.
|
| 141 |
|
| 142 |
Application code: MIT, see `LICENSE`. Original runtime notices and third-party licenses are retained under `vendor`. See [PROVENANCE.md](PROVENANCE.md) for the exact source, documented modification, dependency choice and integrity manifest.
|
| 143 |
+
|
| 144 |
+
## Word synchronization · v0.3.0
|
| 145 |
+
|
| 146 |
+
The generated WAV and original text are passed to `Qwen/Qwen3-ForcedAligner-0.6B-hf`, pinned at `c07281df297b9905d24a508279258cccf987a064` (Apache 2.0). It uses the documented Transformers 5.22.2 processor and token-classification model. The aligner loads on CUDA at startup, then runs in a separate synchronous `spaces.GPU` call after synthesis has returned. One shared semaphore protects both models; session ownership spans both calls. Cancellation is checked before and after the alignment forward pass. Logs identify `alignment_gpu_enter`, `alignment_gpu_exit` and `alignment_gpu_returned`; SDK release logs must also be checked.
|
| 147 |
+
|
| 148 |
+
No second transcription rewrites the input. The aligner's default segmentation uses whitespace words and individual Chinese/Japanese characters. Predicted timestamps have 80 ms resolution. Invalid or incomplete alignment leaves the new WAV playable with an explicit unavailable status and manual text pagination; no evenly spaced or estimated timings are substituted. A failed/cancelled synthesis still preserves the previous result.
|
| 149 |
+
|
| 150 |
+
The custom HTML text viewport shows at most two lines and follows the actual audio clock, including seeking and replay. Editing preserves the draft separately from the last take. The custom reference component uses Gradio's documented `upload(file)` API. Recording starts only on a user click; media tracks stop on stop, error, removal or page disposal. Trimming exports one mono PCM16 clip only when Apply is pressed; Reset restores the original uploaded clip. The hidden native audio wire preserves the existing public API's FileData preprocessing. Generated output format and all 13 public inputs / 5 outputs are unchanged; the player JSON adds `text`, `words` and `alignment`.
|
alignment.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Known-transcript alignment, separate from synthesis and its GPU lease."""
|
| 2 |
+
import logging
|
| 3 |
+
import math
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
import unicodedata
|
| 6 |
+
|
| 7 |
+
import spaces # Must precede torch.
|
| 8 |
+
import numpy as np
|
| 9 |
+
import torch
|
| 10 |
+
import torchaudio.functional as AF
|
| 11 |
+
from transformers import AutoModelForTokenClassification, AutoProcessor
|
| 12 |
+
|
| 13 |
+
from config import ALIGNER_ID, ALIGNER_REVISION, SAMPLE_RATE
|
| 14 |
+
from lifecycle import check_cancelled
|
| 15 |
+
|
| 16 |
+
log = logging.getLogger("zonos.alignment")
|
| 17 |
+
ALIGNER = None
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def canonical(text):
|
| 21 |
+
# Keep a source-index map so punctuation and typography survive unchanged.
|
| 22 |
+
chars, positions = [], []
|
| 23 |
+
for i, char in enumerate(text):
|
| 24 |
+
for normalized in unicodedata.normalize("NFKC", char).casefold():
|
| 25 |
+
if normalized.isalnum():
|
| 26 |
+
chars.append(normalized)
|
| 27 |
+
positions.append(i)
|
| 28 |
+
return "".join(chars), positions
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def attach_timings(text, words, duration):
|
| 32 |
+
"""Validate measured timestamps and map them back to the exact input text."""
|
| 33 |
+
stream, positions = canonical(text)
|
| 34 |
+
if not stream or not words or not math.isfinite(duration) or duration <= 0:
|
| 35 |
+
raise ValueError("Missing alignment")
|
| 36 |
+
cursor, previous, segments = 0, 0.0, []
|
| 37 |
+
for word in words:
|
| 38 |
+
token, _ = canonical(str(word["text"]))
|
| 39 |
+
start, end = float(word["start_time"]), float(word["end_time"])
|
| 40 |
+
if not token or not stream.startswith(token, cursor):
|
| 41 |
+
raise ValueError("Alignment transcript mismatch")
|
| 42 |
+
if not all(math.isfinite(v) for v in (start,end)) or start < previous-.001 or end < start or start < -.08 or end > duration+.16:
|
| 43 |
+
raise ValueError("Invalid alignment timestamps")
|
| 44 |
+
start, end = max(0,start), min(duration,end)
|
| 45 |
+
if start > end:
|
| 46 |
+
raise ValueError("Alignment outside audio")
|
| 47 |
+
offset = positions[cursor]
|
| 48 |
+
segments.append({"offset":offset,"start":round(start,3),"end":round(end,3)})
|
| 49 |
+
cursor += len(token)
|
| 50 |
+
previous = end
|
| 51 |
+
if cursor != len(stream) or not any(w["end"] > w["start"] for w in segments):
|
| 52 |
+
raise ValueError("Incomplete alignment")
|
| 53 |
+
# Text strings, not offsets, cross the Python/JS boundary (Unicode-safe).
|
| 54 |
+
result=[]
|
| 55 |
+
for i, segment in enumerate(segments):
|
| 56 |
+
left = 0 if i == 0 else segment["offset"]
|
| 57 |
+
right = segments[i+1]["offset"] if i+1<len(segments) else len(text)
|
| 58 |
+
result.append({"text":text[left:right],"start":segment["start"],"end":segment["end"]})
|
| 59 |
+
return result
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class Aligner:
|
| 63 |
+
def __init__(self):
|
| 64 |
+
log.info("alignment_loading revision=%s", ALIGNER_REVISION)
|
| 65 |
+
self.processor=AutoProcessor.from_pretrained(ALIGNER_ID,revision=ALIGNER_REVISION)
|
| 66 |
+
self.model=AutoModelForTokenClassification.from_pretrained(
|
| 67 |
+
ALIGNER_ID, revision=ALIGNER_REVISION, dtype=torch.bfloat16,
|
| 68 |
+
).to("cuda:0").eval()
|
| 69 |
+
log.info("alignment_loaded")
|
| 70 |
+
|
| 71 |
+
def prepare(self, pcm, text):
|
| 72 |
+
audio=AF.resample(torch.from_numpy(np.asarray(pcm,dtype=np.float32).copy()), SAMPLE_RATE,16000).numpy()
|
| 73 |
+
# Documented default segmentation: whitespace words + individual CJK
|
| 74 |
+
# characters. Avoid substituting an ASR transcript for the user's text.
|
| 75 |
+
return self.processor.prepare_forced_aligner_inputs(audio=audio,transcript=text,return_tensors="pt")
|
| 76 |
+
|
| 77 |
+
def run(self, prepared, marker, progress):
|
| 78 |
+
return _align_gpu(prepared,str(marker),progress)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@spaces.GPU(duration=20)
|
| 82 |
+
def _align_gpu(prepared, marker, progress):
|
| 83 |
+
log.info("alignment_gpu_enter request=%s",Path(marker).stem)
|
| 84 |
+
inputs = outputs = None
|
| 85 |
+
try:
|
| 86 |
+
check_cancelled(marker)
|
| 87 |
+
progress(None,desc="Synchronizing words")
|
| 88 |
+
cpu_inputs,word_lists=prepared
|
| 89 |
+
inputs=cpu_inputs.to("cuda:0",torch.bfloat16)
|
| 90 |
+
with torch.inference_mode():
|
| 91 |
+
outputs=ALIGNER.model(**inputs)
|
| 92 |
+
check_cancelled(marker)
|
| 93 |
+
return ALIGNER.processor.decode_forced_alignment(
|
| 94 |
+
logits=outputs.logits,input_ids=inputs["input_ids"],word_lists=word_lists,
|
| 95 |
+
timestamp_token_id=ALIGNER.model.config.timestamp_token_id,
|
| 96 |
+
)[0]
|
| 97 |
+
finally:
|
| 98 |
+
del outputs, inputs
|
| 99 |
+
torch.cuda.synchronize()
|
| 100 |
+
log.info("alignment_gpu_exit request=%s",Path(marker).stem)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def load_aligner():
|
| 104 |
+
global ALIGNER
|
| 105 |
+
if ALIGNER is None:
|
| 106 |
+
ALIGNER=Aligner()
|
| 107 |
+
return ALIGNER
|
app.py
CHANGED
|
@@ -24,12 +24,15 @@ if UI_ONLY:
|
|
| 24 |
from requests_schema import InputError
|
| 25 |
raise InputError("UI preview only: model inference is disabled.")
|
| 26 |
engine = PreviewEngine()
|
|
|
|
| 27 |
else:
|
| 28 |
from engine import load_engine
|
| 29 |
engine = load_engine() # Synchronous startup download / load / CUDA placement.
|
|
|
|
|
|
|
| 30 |
|
| 31 |
RUNTIME = Path(os.environ.get("ZONOS_RUNTIME_DIR", str(Path(tempfile.gettempdir()) / "zonos3-mini")))
|
| 32 |
-
controller = Controller(engine, RUNTIME, [v["name"] for v in CATALOG])
|
| 33 |
ASSETS = ROOT / "assets"
|
| 34 |
DEFAULT_PICKER = json.dumps({"language":"en_us", "voice":"Savannah", "custom":False})
|
| 35 |
|
|
@@ -80,13 +83,13 @@ theme = gr.themes.Base(primary_hue="neutral", secondary_hue="neutral", neutral_h
|
|
| 80 |
link_text_color="#ccc", link_text_color_dark="#ccc",
|
| 81 |
)
|
| 82 |
|
| 83 |
-
with gr.Blocks(title="ZONOS3 Mini", delete_cache=(3600, 86400), analytics_enabled=False) as demo:
|
| 84 |
gr.HTML('<header class="app-header"><span>ZONOS3-Mini</span><span>text-to-speech · <a href="https://huggingface.co/Zyphra/ZONOS3-Mini" target="_blank" rel="noopener">Zyphra</a></span></header>', elem_id="app-header")
|
| 85 |
if UI_ONLY:
|
| 86 |
gr.HTML('<p id="preview-warning">UI preview · inference disabled</p>')
|
| 87 |
-
text = gr.
|
| 88 |
-
|
| 89 |
-
|
| 90 |
player = gr.HTML(value="{}", html_template=(ASSETS / "player.html").read_text(),
|
| 91 |
css_template=(ASSETS / "player.css").read_text(),
|
| 92 |
js_on_load=(ASSETS / "wave.js").read_text() + (ASSETS / "player.js").read_text(), elem_id="speech-player")
|
|
@@ -99,8 +102,10 @@ with gr.Blocks(title="ZONOS3 Mini", delete_cache=(3600, 86400), analytics_enable
|
|
| 99 |
with gr.HTML(value="{}", source="Preset", html_template=(ASSETS / "settings.html").read_text(),
|
| 100 |
css_template=(ASSETS / "settings.css").read_text(),
|
| 101 |
js_on_load=(ASSETS / "settings.js").read_text(), elem_id="voice-options") as settings:
|
| 102 |
-
|
| 103 |
-
|
|
|
|
|
|
|
| 104 |
# Stable API wire components. The HTML UI passes its state atomically via the
|
| 105 |
# documented event JS preprocessor; API callers continue using the same inputs.
|
| 106 |
source = gr.Radio(["Preset", "Voice clip"], value="Preset", label="Voice source", visible=False)
|
|
@@ -113,7 +118,7 @@ with gr.Blocks(title="ZONOS3 Mini", delete_cache=(3600, 86400), analytics_enable
|
|
| 113 |
top_p = gr.Slider(.1, 1, value=.69, step=.01, label="Top-p", visible=False)
|
| 114 |
top_k = gr.Slider(1, 400, value=80, step=1, label="Top-k", visible=False)
|
| 115 |
repetition = gr.Slider(1, 4, value=2.4, step=.1, label="Repetition penalty", visible=False)
|
| 116 |
-
gr.HTML(f'''<footer class="credits"><p>Model · <a href="https://huggingface.co/Zyphra/ZONOS3-Mini" target="_blank" rel="noopener">Zyphra</a></p><p>UI/UX · <a href="https://huggingface.co/fffiloni" target="_blank" rel="noopener">Sylvain Filoni / fffiloni</a></p><details><summary>About · v{VERSION}</summary><p>Model: Gabriel Clark, Sofian Mejjoute, George Close and Beren Millidge — Zyphra. Weights: Apache 2.0. Official runtime: MIT. <a href="https://github.com/Zyphra/ZONOS3-Mini" target="_blank" rel="noopener">Source and notices</a>. Hosted on Hugging Face with Gradio. Independent UI adaptation.</p></details></footer>''', elem_id="app-credits")
|
| 117 |
|
| 118 |
def example(language):
|
| 119 |
script, voice, _ = load_example(language)
|
|
@@ -139,7 +144,6 @@ with gr.Blocks(title="ZONOS3 Mini", delete_cache=(3600, 86400), analytics_enable
|
|
| 139 |
event.failure(fn=None, js=finish_ui, api_visibility="private")
|
| 140 |
cancel.click(controller.cancel, outputs=[status, cancel], queue=False, api_name="cancel", show_progress="hidden")
|
| 141 |
demo.unload(controller.on_disconnect)
|
| 142 |
-
demo.load(fn=None, js=(ASSETS / "layout.js").read_text(), api_visibility="private")
|
| 143 |
|
| 144 |
demo.queue(max_size=16, default_concurrency_limit=1, api_open=False)
|
| 145 |
|
|
|
|
| 24 |
from requests_schema import InputError
|
| 25 |
raise InputError("UI preview only: model inference is disabled.")
|
| 26 |
engine = PreviewEngine()
|
| 27 |
+
aligner = None
|
| 28 |
else:
|
| 29 |
from engine import load_engine
|
| 30 |
engine = load_engine() # Synchronous startup download / load / CUDA placement.
|
| 31 |
+
from alignment import load_aligner
|
| 32 |
+
aligner = load_aligner()
|
| 33 |
|
| 34 |
RUNTIME = Path(os.environ.get("ZONOS_RUNTIME_DIR", str(Path(tempfile.gettempdir()) / "zonos3-mini")))
|
| 35 |
+
controller = Controller(engine, RUNTIME, [v["name"] for v in CATALOG], aligner=aligner)
|
| 36 |
ASSETS = ROOT / "assets"
|
| 37 |
DEFAULT_PICKER = json.dumps({"language":"en_us", "voice":"Savannah", "custom":False})
|
| 38 |
|
|
|
|
| 83 |
link_text_color="#ccc", link_text_color_dark="#ccc",
|
| 84 |
)
|
| 85 |
|
| 86 |
+
with gr.Blocks(title="ZONOS3 Mini", fill_width=True, delete_cache=(3600, 86400), analytics_enabled=False) as demo:
|
| 87 |
gr.HTML('<header class="app-header"><span>ZONOS3-Mini</span><span>text-to-speech · <a href="https://huggingface.co/Zyphra/ZONOS3-Mini" target="_blank" rel="noopener">Zyphra</a></span></header>', elem_id="app-header")
|
| 88 |
if UI_ONLY:
|
| 89 |
gr.HTML('<p id="preview-warning">UI preview · inference disabled</p>')
|
| 90 |
+
text = gr.HTML(value=DEFAULT_TEXT, html_template=(ASSETS / "script.html").read_text(),
|
| 91 |
+
css_template=(ASSETS / "script.css").read_text(),
|
| 92 |
+
js_on_load=(ASSETS / "script.js").read_text(), elem_id="script-input")
|
| 93 |
player = gr.HTML(value="{}", html_template=(ASSETS / "player.html").read_text(),
|
| 94 |
css_template=(ASSETS / "player.css").read_text(),
|
| 95 |
js_on_load=(ASSETS / "wave.js").read_text() + (ASSETS / "player.js").read_text(), elem_id="speech-player")
|
|
|
|
| 102 |
with gr.HTML(value="{}", source="Preset", html_template=(ASSETS / "settings.html").read_text(),
|
| 103 |
css_template=(ASSETS / "settings.css").read_text(),
|
| 104 |
js_on_load=(ASSETS / "settings.js").read_text(), elem_id="voice-options") as settings:
|
| 105 |
+
reference = gr.HTML(value="null", html_template=(ASSETS / "reference.html").read_text(),
|
| 106 |
+
css_template=(ASSETS / "reference.css").read_text(),
|
| 107 |
+
js_on_load=(ASSETS / "reference.js").read_text(), elem_id="reference-clip")
|
| 108 |
+
clip = gr.Audio(label="Reference voice", type="filepath", format="wav", visible=False)
|
| 109 |
# Stable API wire components. The HTML UI passes its state atomically via the
|
| 110 |
# documented event JS preprocessor; API callers continue using the same inputs.
|
| 111 |
source = gr.Radio(["Preset", "Voice clip"], value="Preset", label="Voice source", visible=False)
|
|
|
|
| 118 |
top_p = gr.Slider(.1, 1, value=.69, step=.01, label="Top-p", visible=False)
|
| 119 |
top_k = gr.Slider(1, 400, value=80, step=1, label="Top-k", visible=False)
|
| 120 |
repetition = gr.Slider(1, 4, value=2.4, step=.1, label="Repetition penalty", visible=False)
|
| 121 |
+
gr.HTML(f'''<footer class="credits"><p>Model · <a href="https://huggingface.co/Zyphra/ZONOS3-Mini" target="_blank" rel="noopener">Zyphra</a></p><p>UI/UX · <a href="https://huggingface.co/fffiloni" target="_blank" rel="noopener">Sylvain Filoni / fffiloni</a></p><details><summary>About · v{VERSION}</summary><p>Model: Gabriel Clark, Sofian Mejjoute, George Close and Beren Millidge — Zyphra. Weights: Apache 2.0. Official runtime: MIT. <a href="https://github.com/Zyphra/ZONOS3-Mini" target="_blank" rel="noopener">Source and notices</a>. Word synchronization: <a href="https://huggingface.co/Qwen/Qwen3-ForcedAligner-0.6B-hf" target="_blank" rel="noopener">Qwen3 ForcedAligner</a> (Apache 2.0). Japanese and Chinese use character-level timing. Hosted on Hugging Face with Gradio. Independent UI adaptation.</p></details></footer>''', elem_id="app-credits")
|
| 122 |
|
| 123 |
def example(language):
|
| 124 |
script, voice, _ = load_example(language)
|
|
|
|
| 144 |
event.failure(fn=None, js=finish_ui, api_visibility="private")
|
| 145 |
cancel.click(controller.cancel, outputs=[status, cancel], queue=False, api_name="cancel", show_progress="hidden")
|
| 146 |
demo.unload(controller.on_disconnect)
|
|
|
|
| 147 |
|
| 148 |
demo.queue(max_size=16, default_concurrency_limit=1, api_open=False)
|
| 149 |
|
assets/generate.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
| 1 |
(...args) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
const settings = document.getElementById('voice-options');
|
| 3 |
const s = JSON.parse(settings.dataset.settings);
|
| 4 |
// The snapshot happens on the click, without waiting for network change events.
|
|
|
|
| 1 |
(...args) => {
|
| 2 |
+
const draft = document.getElementById('script-input');
|
| 3 |
+
if (draft?.dataset.draft !== undefined) args[0] = draft.dataset.draft;
|
| 4 |
+
const reference = document.getElementById('reference-clip');
|
| 5 |
+
if (document.getElementById('voice-options')?.dataset.clip === 'true' && reference?.dataset.busy === 'true') throw new Error('Wait for the voice clip to finish uploading or recording.');
|
| 6 |
+
if (reference?.dataset.file) args[3] = JSON.parse(reference.dataset.file);
|
| 7 |
const settings = document.getElementById('voice-options');
|
| 8 |
const s = JSON.parse(settings.dataset.settings);
|
| 9 |
// The snapshot happens on the click, without waiting for network change events.
|
assets/player.js
CHANGED
|
@@ -68,6 +68,7 @@ function mount() {
|
|
| 68 |
restoreWave = () => path.setAttribute('d', 'M0 70 L1000 70');
|
| 69 |
restoreWave(); return;
|
| 70 |
}
|
|
|
|
| 71 |
document.getElementById('voice-options')?.dispatchEvent(new CustomEvent('zonos:seed', {detail:data.seed}));
|
| 72 |
const url = new URL(data.url, document.baseURI);
|
| 73 |
// Files are generated by this Space only. Never accept arbitrary remote sources.
|
|
@@ -90,12 +91,18 @@ function mount() {
|
|
| 90 |
path.setAttribute('d', wavePath(values, gain));
|
| 91 |
}
|
| 92 |
restoreWave = () => draw(preview);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
function tick() {
|
| 94 |
if (player.paused || player.ended) return;
|
| 95 |
if (analyser && !reduced.matches) { analyser.getFloatTimeDomainData(samples); draw(samples, true); }
|
|
|
|
| 96 |
frame = requestAnimationFrame(tick);
|
| 97 |
}
|
| 98 |
function updateTime() {
|
|
|
|
| 99 |
element.querySelector("[data-time]").textContent = minutes(player.currentTime || 0);
|
| 100 |
if (Number.isFinite(player.duration) && player.duration > 0) seek.value = String(Math.round(player.currentTime / player.duration * 1000));
|
| 101 |
}
|
|
@@ -124,6 +131,7 @@ function mount() {
|
|
| 124 |
on(player, "pause", () => { button.textContent = "Play"; button.setAttribute("aria-label", "Play generated speech"); cancelAnimationFrame(frame); draw(preview); });
|
| 125 |
on(player, "ended", () => { button.textContent = "Replay"; button.setAttribute("aria-label", "Replay generated speech"); cancelAnimationFrame(frame); draw(preview); updateTime(); });
|
| 126 |
on(player, "timeupdate", updateTime);
|
|
|
|
| 127 |
on(player, "loadedmetadata", () => { element.querySelector("[data-duration]").textContent = minutes(player.duration); });
|
| 128 |
on(player, "error", () => { notice.hidden = false; notice.textContent = "This audio is no longer available. Generate a new take."; });
|
| 129 |
on(seek, "input", () => { if (Number.isFinite(player.duration)) player.currentTime = Number(seek.value) / 1000 * player.duration; });
|
|
|
|
| 68 |
restoreWave = () => path.setAttribute('d', 'M0 70 L1000 70');
|
| 69 |
restoreWave(); return;
|
| 70 |
}
|
| 71 |
+
document.getElementById('script-input')?.dispatchEvent(new CustomEvent('zonos:take', {detail:data}));
|
| 72 |
document.getElementById('voice-options')?.dispatchEvent(new CustomEvent('zonos:seed', {detail:data.seed}));
|
| 73 |
const url = new URL(data.url, document.baseURI);
|
| 74 |
// Files are generated by this Space only. Never accept arbitrary remote sources.
|
|
|
|
| 91 |
path.setAttribute('d', wavePath(values, gain));
|
| 92 |
}
|
| 93 |
restoreWave = () => draw(preview);
|
| 94 |
+
function sendClock() {
|
| 95 |
+
document.getElementById('script-input')?.dispatchEvent(new CustomEvent('zonos:clock', {detail:{time:player.currentTime || 0,playing:!player.paused}}));
|
| 96 |
+
}
|
| 97 |
+
on(host, 'zonos:pause', () => player.pause());
|
| 98 |
function tick() {
|
| 99 |
if (player.paused || player.ended) return;
|
| 100 |
if (analyser && !reduced.matches) { analyser.getFloatTimeDomainData(samples); draw(samples, true); }
|
| 101 |
+
sendClock();
|
| 102 |
frame = requestAnimationFrame(tick);
|
| 103 |
}
|
| 104 |
function updateTime() {
|
| 105 |
+
sendClock();
|
| 106 |
element.querySelector("[data-time]").textContent = minutes(player.currentTime || 0);
|
| 107 |
if (Number.isFinite(player.duration) && player.duration > 0) seek.value = String(Math.round(player.currentTime / player.duration * 1000));
|
| 108 |
}
|
|
|
|
| 131 |
on(player, "pause", () => { button.textContent = "Play"; button.setAttribute("aria-label", "Play generated speech"); cancelAnimationFrame(frame); draw(preview); });
|
| 132 |
on(player, "ended", () => { button.textContent = "Replay"; button.setAttribute("aria-label", "Replay generated speech"); cancelAnimationFrame(frame); draw(preview); updateTime(); });
|
| 133 |
on(player, "timeupdate", updateTime);
|
| 134 |
+
on(player, "seeked", updateTime);
|
| 135 |
on(player, "loadedmetadata", () => { element.querySelector("[data-duration]").textContent = minutes(player.duration); });
|
| 136 |
on(player, "error", () => { notice.hidden = false; notice.textContent = "This audio is no longer available. Generate a new take."; });
|
| 137 |
on(seek, "input", () => { if (Number.isFinite(player.duration)) player.currentTime = Number(seek.value) / 1000 * player.duration; });
|
assets/reference.css
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
color:#ccc; font:12px/1.5 Arial,sans-serif;
|
| 2 |
+
[hidden] { display:none!important; }
|
| 3 |
+
.reference { padding:14px 0; }
|
| 4 |
+
.drop-area { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:6px; min-height:100px; padding:14px; border:1px dashed #393939; border-radius:8px; text-align:center; cursor:pointer; }
|
| 5 |
+
.drop-area[data-over=true] { border-color:#ddd; background:#0d0d0d; }
|
| 6 |
+
[data-file-name] { overflow-wrap:anywhere; }
|
| 7 |
+
.help,.reference-status { color:#999; font:11px/1.6 ui-monospace,monospace; margin:5px 0; }
|
| 8 |
+
.reference-status:empty { display:none; }
|
| 9 |
+
button,summary { min-height:44px; padding:8px 10px; color:#ccc; background:none; border:0; font:12px ui-monospace,monospace; cursor:pointer; }
|
| 10 |
+
button:disabled { opacity:.45; cursor:not-allowed; }
|
| 11 |
+
.reference-actions { display:flex; align-items:center; justify-content:center; flex-wrap:wrap; gap:12px; }
|
| 12 |
+
[data-remove] { margin-left:auto; }
|
| 13 |
+
.reference-player { display:flex; align-items:center; gap:12px; font:11px ui-monospace,monospace; }
|
| 14 |
+
.reference-player button { min-width:65px; text-align:left; padding-left:0; }
|
| 15 |
+
input[type=range] { flex:1; width:100%; min-width:30px; height:44px; accent-color:#eee; }
|
| 16 |
+
summary { padding-left:0; color:#aaa; }
|
| 17 |
+
.trim-controls { display:flex; align-items:center; flex-wrap:wrap; gap:12px; }
|
| 18 |
+
.trim-controls label { display:flex; align-items:center; gap:8px; }
|
| 19 |
+
input[type=number] { width:72px; min-height:44px; background:#0d0d0d; color:#eee; border:0; border-bottom:1px solid #555; padding:8px; font:16px ui-monospace,monospace; }
|
| 20 |
+
:focus-visible { outline:2px solid #ddd; outline-offset:3px; }
|
| 21 |
+
@media(max-width:400px) { .reference-player { gap:8px; } }
|
assets/reference.html
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<div class="reference">
|
| 2 |
+
<div class="drop-area" tabindex="0" role="button" aria-label="Upload a reference voice clip">
|
| 3 |
+
<span data-file-name>Drop a voice clip here</span><span class="help">0.5–60 seconds · up to 25 MB</span>
|
| 4 |
+
</div>
|
| 5 |
+
<input type="file" accept="audio/*,.wav,.mp3,.flac,.m4a,.ogg,.webm" data-file hidden>
|
| 6 |
+
<div class="reference-actions"><button type="button" data-upload>Choose audio</button><button type="button" data-record>Record voice</button><button type="button" data-remove hidden>Remove</button></div>
|
| 7 |
+
<div class="reference-player" hidden><button type="button" data-play aria-label="Play reference voice">Play</button><span data-time>0:00</span><input type="range" data-seek min="0" max="1000" value="0" aria-label="Reference playback position"><span data-duration>0:00</span></div>
|
| 8 |
+
<details data-trim hidden><summary>Trim clip</summary><div class="trim-controls"><label>Start <input type="number" min="0" step="0.1" data-start value="0" aria-label="Trim start in seconds"></label><label>End <input type="number" min="0" step="0.1" data-end aria-label="Trim end in seconds"></label><button type="button" data-apply>Use selection</button><button type="button" data-reset>Reset trim</button></div><p class="help">Seconds · choose between 0.5 and 60 seconds.</p></details>
|
| 9 |
+
<p class="reference-status" role="status" aria-live="polite"></p>
|
| 10 |
+
<audio preload="metadata" hidden></audio>
|
| 11 |
+
</div>
|
assets/reference.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const host=document.getElementById('reference-clip');
|
| 2 |
+
const q=s=>element.querySelector(s), player=q('audio'), status=q('.reference-status');
|
| 3 |
+
const listeners=new AbortController(), on=(n,e,f)=>n.addEventListener(e,f,{signal:listeners.signal});
|
| 4 |
+
let original=null, current=null, objectURL=null, generation=0, recorder=null, stream=null, timer=0, recordStarted=0, disposed=false;
|
| 5 |
+
const minutes=s=>`${Math.floor(s/60)}:${String(Math.floor(s%60)).padStart(2,'0')}`;
|
| 6 |
+
function busy(value) {
|
| 7 |
+
host.dataset.busy=String(value); q('[data-upload]').disabled=value; q('[data-apply]').disabled=value;
|
| 8 |
+
if(document.getElementById('voice-options')?.dataset.clip==='true') {
|
| 9 |
+
const generate=document.getElementById('generate-speech');
|
| 10 |
+
if(generate)generate.disabled=value || document.getElementById('status-state')?.dataset.busy==='true';
|
| 11 |
+
}
|
| 12 |
+
}
|
| 13 |
+
function tell(text) { status.textContent=text; }
|
| 14 |
+
function setFileData(data) { host.dataset.file=JSON.stringify(data); props.value=JSON.stringify(data); }
|
| 15 |
+
function stopTracks() { stream?.getTracks().forEach(t=>t.stop());stream=null;clearInterval(timer); }
|
| 16 |
+
function wav(buffer,start,end) {
|
| 17 |
+
const rate=buffer.sampleRate, first=Math.floor(start*rate), frames=Math.floor(end*rate)-first;
|
| 18 |
+
const bytes=new ArrayBuffer(44+frames*2), v=new DataView(bytes);
|
| 19 |
+
const str=(o,s)=>[...s].forEach((c,i)=>v.setUint8(o+i,c.charCodeAt(0)));
|
| 20 |
+
str(0,'RIFF');v.setUint32(4,36+frames*2,true);str(8,'WAVE');str(12,'fmt ');v.setUint32(16,16,true);v.setUint16(20,1,true);v.setUint16(22,1,true);v.setUint32(24,rate,true);v.setUint32(28,rate*2,true);v.setUint16(32,2,true);v.setUint16(34,16,true);str(36,'data');v.setUint32(40,frames*2,true);
|
| 21 |
+
const channels=Array.from({length:buffer.numberOfChannels},(_,i)=>buffer.getChannelData(i));
|
| 22 |
+
for(let i=0;i<frames;i++) { let sample=0;for(const c of channels) sample+=c[first+i]/channels.length;sample=Math.max(-1,Math.min(1,sample));v.setInt16(44+i*2,sample<0?sample*32768:sample*32767,true); }
|
| 23 |
+
return new File([bytes],'reference.wav',{type:'audio/wav'});
|
| 24 |
+
}
|
| 25 |
+
async function decode(file) {
|
| 26 |
+
const AudioContextClass=window.AudioContext||window.webkitAudioContext;
|
| 27 |
+
const context=new AudioContextClass();
|
| 28 |
+
try {return await context.decodeAudioData(await file.arrayBuffer());}
|
| 29 |
+
finally {await context.close();}
|
| 30 |
+
}
|
| 31 |
+
function preview(item) {
|
| 32 |
+
player.pause();if(objectURL) URL.revokeObjectURL(objectURL);
|
| 33 |
+
objectURL=URL.createObjectURL(item.file);player.src=objectURL;current=item;
|
| 34 |
+
q('.reference-player').hidden=false;q('[data-trim]').hidden=false;q('[data-remove]').hidden=false;
|
| 35 |
+
q('[data-file-name]').textContent=item.name;q('[data-duration]').textContent=minutes(item.buffer.duration);
|
| 36 |
+
q('[data-start]').value='0';q('[data-end]').value=original.buffer.duration.toFixed(2);
|
| 37 |
+
q('[data-start]').max=q('[data-end]').max=original.buffer.duration;
|
| 38 |
+
q('[data-play]').textContent='Play';q('[data-time]').textContent='0:00';q('[data-seek]').value='0';
|
| 39 |
+
}
|
| 40 |
+
async function accept(file,fromTrim=false) {
|
| 41 |
+
if(!file) return;
|
| 42 |
+
const version=++generation;busy(true);tell('Preparing voice clip…');
|
| 43 |
+
try {
|
| 44 |
+
if(file.size>25*1024*1024) throw Error('Choose an audio file smaller than 25 MB.');
|
| 45 |
+
const buffer=await decode(file);
|
| 46 |
+
if(disposed || version!==generation) return;
|
| 47 |
+
if(buffer.duration<.5) throw Error('The clip must be at least 0.5 seconds long.');
|
| 48 |
+
if(buffer.duration>60) throw Error('Choose a clip no longer than 60 seconds.');
|
| 49 |
+
tell('Uploading voice clip…');
|
| 50 |
+
const uploaded=await upload(file);
|
| 51 |
+
if(disposed || version!==generation) return;
|
| 52 |
+
const item={file,buffer,name:file.name};if(!fromTrim) original=item;
|
| 53 |
+
setFileData({...uploaded,orig_name:file.name,mime_type:file.type||'audio/wav',meta:{_type:'gradio.FileData'}});
|
| 54 |
+
preview(item);tell('Voice clip ready.');
|
| 55 |
+
} catch(error) { if(version===generation) tell((error.message||'Could not load this audio.')+(current?' Your previous clip is still selected.':'')); }
|
| 56 |
+
finally {if(version===generation) busy(false);}
|
| 57 |
+
}
|
| 58 |
+
function remove() {
|
| 59 |
+
generation++;busy(false);stopTracks();if(recorder?.state==='recording') recorder.stop();recorder=null;
|
| 60 |
+
player.pause();player.removeAttribute('src');player.load();if(objectURL)URL.revokeObjectURL(objectURL);objectURL=null;original=current=null;setFileData(null);
|
| 61 |
+
q('[data-file]').value='';q('[data-file-name]').textContent='Drop a voice clip here';
|
| 62 |
+
for(const selector of ['.reference-player','[data-trim]','[data-remove]'])q(selector).hidden=true;
|
| 63 |
+
q('[data-record]').textContent='Record voice';tell('');
|
| 64 |
+
}
|
| 65 |
+
on(q('[data-upload]'),'click',()=>q('[data-file]').click());
|
| 66 |
+
on(q('.drop-area'),'click',()=>q('[data-file]').click());
|
| 67 |
+
on(q('.drop-area'),'keydown',e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();q('[data-file]').click();}});
|
| 68 |
+
on(q('[data-file]'),'change',e=>accept(e.target.files[0]));
|
| 69 |
+
on(q('.drop-area'),'dragover',e=>{e.preventDefault();q('.drop-area').dataset.over='true';});
|
| 70 |
+
on(q('.drop-area'),'dragleave',()=>q('.drop-area').dataset.over='false');
|
| 71 |
+
on(q('.drop-area'),'drop',e=>{e.preventDefault();q('.drop-area').dataset.over='false';accept(e.dataTransfer.files[0]);});
|
| 72 |
+
on(q('[data-remove]'),'click',remove);
|
| 73 |
+
on(q('[data-play]'),'click',async()=>{try{if(player.paused)await player.play();else player.pause();}catch{tell('Playback unavailable. Choose a different audio file.');}});
|
| 74 |
+
on(player,'play',()=>q('[data-play]').textContent='Pause');on(player,'pause',()=>q('[data-play]').textContent=player.ended?'Replay':'Play');
|
| 75 |
+
on(player,'timeupdate',()=>{q('[data-time]').textContent=minutes(player.currentTime);if(player.duration)q('[data-seek]').value=player.currentTime/player.duration*1000;});
|
| 76 |
+
on(q('[data-seek]'),'input',()=>{if(Number.isFinite(player.duration))player.currentTime=+q('[data-seek]').value/1000*player.duration;});
|
| 77 |
+
on(q('[data-apply]'),'click',()=>{
|
| 78 |
+
if(!original)return;const start=+q('[data-start]').value,end=+q('[data-end]').value;
|
| 79 |
+
if(!Number.isFinite(start+end)||start<0||end>original.buffer.duration||end-start<.5||end-start>60){tell('Select 0.5–60 seconds within the clip.');return;}
|
| 80 |
+
accept(wav(original.buffer,start,end),true);
|
| 81 |
+
});
|
| 82 |
+
on(q('[data-reset]'),'click',()=>{if(original)accept(original.file);});
|
| 83 |
+
on(q('[data-record]'),'click',async()=>{
|
| 84 |
+
if(recorder?.state==='recording'){recorder.stop();stopTracks();return;}
|
| 85 |
+
if(host.dataset.busy==='true')return;
|
| 86 |
+
const version=++generation;busy(true);tell('Waiting for microphone permission…');
|
| 87 |
+
try {
|
| 88 |
+
if(!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder)throw Error('Recording is unavailable in this browser. Choose an audio file.');
|
| 89 |
+
const acquired=await navigator.mediaDevices.getUserMedia({audio:true});
|
| 90 |
+
if(disposed||version!==generation){acquired.getTracks().forEach(t=>t.stop());return;}
|
| 91 |
+
stream=acquired;const type=['audio/webm;codecs=opus','audio/mp4','audio/webm'].find(t=>MediaRecorder.isTypeSupported(t));
|
| 92 |
+
recorder=new MediaRecorder(stream,type?{mimeType:type}:undefined);const chunks=[];
|
| 93 |
+
recorder.ondataavailable=e=>{if(e.data.size)chunks.push(e.data);};
|
| 94 |
+
recorder.onerror=()=>{generation++;stopTracks();if(recorder?.state==='recording')recorder.stop();q('[data-record]').textContent='Record voice';busy(false);tell('Recording failed. Try uploading a clip.');};
|
| 95 |
+
recorder.onstop=()=>{
|
| 96 |
+
stopTracks();q('[data-record]').textContent='Record voice';busy(false);
|
| 97 |
+
if(disposed||version!==generation)return;
|
| 98 |
+
const mime=recorder.mimeType,extension=mime.includes('mp4')?'m4a':'webm';
|
| 99 |
+
accept(new File(chunks,`recording.${extension}`,{type:mime}));
|
| 100 |
+
};
|
| 101 |
+
recorder.start();recordStarted=performance.now();q('[data-record]').textContent='Stop recording';
|
| 102 |
+
timer=setInterval(()=>{const seconds=(performance.now()-recordStarted)/1000;tell('Recording · '+minutes(seconds));if(seconds>=59){recorder.stop();stopTracks();}},200);
|
| 103 |
+
} catch(error){stopTracks();busy(false);tell(error.message||'Microphone unavailable. Choose an audio file.');}
|
| 104 |
+
});
|
| 105 |
+
on(host,'zonos:source',e=>{if(e.detail==='Preset' && recorder?.state==='recording'){generation++;recorder.stop();stopTracks();q('[data-record]').textContent='Record voice';busy(false);tell('Recording cancelled.');}else busy(host.dataset.busy==='true');});
|
| 106 |
+
function dispose(){disposed=true;generation++;stopTracks();if(recorder?.state==='recording')recorder.stop();player.pause();if(objectURL)URL.revokeObjectURL(objectURL);listeners.abort();observer.disconnect();}
|
| 107 |
+
const observer=new MutationObserver(()=>{if(!element.isConnected)dispose();});observer.observe(document.documentElement,{childList:true,subtree:true});
|
| 108 |
+
on(window,'pagehide',dispose);setFileData(null);busy(false);
|
assets/script.css
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
color:#eee; width:100%;
|
| 2 |
+
[hidden] { display:none!important; }
|
| 3 |
+
.script-stage { width:100%; }
|
| 4 |
+
textarea, .reading-window { box-sizing:border-box; width:100%; height:2.8em; padding:0; margin:0; color:#f2f2ef; background:transparent; border:0; text-align:center; font:400 clamp(24px,3vw,35px)/1.4 Arial,Helvetica,sans-serif; letter-spacing:-.6px; }
|
| 5 |
+
textarea { display:block; resize:none; overflow-y:auto; border-radius:0; scrollbar-width:thin; scrollbar-color:#444 transparent; }
|
| 6 |
+
.reading-window { overflow:hidden; position:relative; }
|
| 7 |
+
.reading-track { white-space:pre-wrap; overflow-wrap:anywhere; transition:transform .22s ease; }
|
| 8 |
+
.word { color:#747474; transition:color .12s linear; }
|
| 9 |
+
.word.spoken { color:#f2f2ef; }
|
| 10 |
+
.script-tools { display:flex; min-height:44px; align-items:center; justify-content:center; gap:16px; margin-top:8px; color:#888; font:10px ui-monospace,monospace; }
|
| 11 |
+
.script-tools button { color:#aaa; font:11px ui-monospace,monospace; border:0; background:none; min-height:44px; padding:6px 10px; cursor:pointer; }
|
| 12 |
+
[data-pages] { display:inline-flex; }
|
| 13 |
+
:focus-visible { outline:1px solid #aaa; outline-offset:4px; }
|
| 14 |
+
.sr { position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); }
|
| 15 |
+
@media(max-width:600px) { textarea,.reading-window { font-size:24px; } }
|
| 16 |
+
@media(prefers-reduced-motion:reduce) { .reading-track,.word { transition:none; } }
|
assets/script.html
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<div class="script-stage">
|
| 2 |
+
<label class="sr" for="speech-draft">Text to speak · 600 characters maximum</label>
|
| 3 |
+
<textarea id="speech-draft" rows="2" maxlength="600" spellcheck="true" placeholder="Write something to say…" aria-describedby="script-hint"></textarea>
|
| 4 |
+
<div class="reading-window" hidden aria-label="Generated speech text"><div class="reading-track"></div></div>
|
| 5 |
+
<div class="script-tools"><span id="script-hint">Write your text</span><span data-pages hidden><button type="button" data-previous aria-label="Previous two lines">←</button><button type="button" data-next aria-label="Next two lines">→</button></span><button type="button" data-edit hidden>Edit text</button></div>
|
| 6 |
+
</div>
|
assets/script.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const host=document.getElementById('script-input');
|
| 2 |
+
const draft=element.querySelector('textarea'), viewport=element.querySelector('.reading-window');
|
| 3 |
+
const track=element.querySelector('.reading-track'), edit=element.querySelector('[data-edit]');
|
| 4 |
+
const hint=element.querySelector('#script-hint'), pages=element.querySelector('[data-pages]');
|
| 5 |
+
let take=null, reading=false, clock=0, page=0, pageCount=1, spans=[], lineHeight=35;
|
| 6 |
+
const listeners=new AbortController();
|
| 7 |
+
const on=(node,event,fn)=>node.addEventListener(event,fn,{signal:listeners.signal});
|
| 8 |
+
draft.value=props.value || '';
|
| 9 |
+
function publish() { props.value=draft.value; host.dataset.draft=draft.value; }
|
| 10 |
+
function showMode() {
|
| 11 |
+
draft.hidden=reading; viewport.hidden=!reading;
|
| 12 |
+
edit.hidden=!take; edit.textContent=reading?'Edit text':'View take';
|
| 13 |
+
pages.hidden=!reading || take?.alignment==='ready' || pageCount<2;
|
| 14 |
+
hint.textContent=reading?(take.alignment==='ready'?'': 'Word timing unavailable'):'Write your text · '+draft.value.length+'/600';
|
| 15 |
+
if(reading && take.text!==draft.value) hint.textContent='Previous take';
|
| 16 |
+
}
|
| 17 |
+
function layout() {
|
| 18 |
+
if(!reading || !spans.length) return;
|
| 19 |
+
track.style.transform='none';
|
| 20 |
+
lineHeight=parseFloat(getComputedStyle(viewport).lineHeight);
|
| 21 |
+
spans.forEach(s=>s.line=Math.max(0,Math.round(s.node.offsetTop/lineHeight)));
|
| 22 |
+
pageCount=Math.max(1,Math.ceil((Math.max(...spans.map(s=>s.line))+1)/2));
|
| 23 |
+
renderClock(); showMode();
|
| 24 |
+
}
|
| 25 |
+
function renderClock() {
|
| 26 |
+
if(!reading || !take) return;
|
| 27 |
+
if(take.alignment==='ready') {
|
| 28 |
+
let index=-1;
|
| 29 |
+
for(let i=0;i<spans.length;i++) {
|
| 30 |
+
const spoken=clock>=spans[i].start;
|
| 31 |
+
spans[i].node.classList.toggle('spoken',spoken);
|
| 32 |
+
if(spoken) index=i;
|
| 33 |
+
}
|
| 34 |
+
page=index<0?0:Math.floor(spans[index].line/2);
|
| 35 |
+
} else spans.forEach(s=>s.node.classList.add('spoken'));
|
| 36 |
+
page=Math.max(0,Math.min(pageCount-1,page));
|
| 37 |
+
track.style.transform=`translateY(${-page*2*lineHeight}px)`;
|
| 38 |
+
element.querySelector('[data-previous]').disabled=page===0;
|
| 39 |
+
element.querySelector('[data-next]').disabled=page===pageCount-1;
|
| 40 |
+
}
|
| 41 |
+
function receive(data) {
|
| 42 |
+
if(!data?.text) return;
|
| 43 |
+
take=data; clock=0; page=0; track.replaceChildren();
|
| 44 |
+
const words=data.alignment==='ready' && data.words?.length?data.words:
|
| 45 |
+
(data.text.match(/\S+\s*|\s+/gu)||[data.text]).map(text=>({text,start:0,end:0}));
|
| 46 |
+
spans=words.map(word=>{
|
| 47 |
+
const node=document.createElement('span'); node.className='word'; node.textContent=word.text;
|
| 48 |
+
track.append(node); return {...word,node,line:0};
|
| 49 |
+
});
|
| 50 |
+
reading=true; showMode(); requestAnimationFrame(layout);
|
| 51 |
+
}
|
| 52 |
+
on(draft,'input',()=>{publish();showMode();});
|
| 53 |
+
on(edit,'click',()=>{
|
| 54 |
+
reading=!reading;
|
| 55 |
+
if(!reading) document.getElementById('speech-player')?.dispatchEvent(new CustomEvent('zonos:pause'));
|
| 56 |
+
showMode(); if(reading) layout(); else draft.focus();
|
| 57 |
+
});
|
| 58 |
+
on(element.querySelector('[data-previous]'),'click',()=>{page--;renderClock();});
|
| 59 |
+
on(element.querySelector('[data-next]'),'click',()=>{page++;renderClock();});
|
| 60 |
+
on(host,'zonos:take',e=>receive(e.detail));
|
| 61 |
+
on(host,'zonos:clock',e=>{
|
| 62 |
+
clock=e.detail.time;
|
| 63 |
+
if(e.detail.playing && !reading){reading=true;showMode();layout();}
|
| 64 |
+
renderClock();
|
| 65 |
+
});
|
| 66 |
+
watch('value',()=>{if(props.value!==draft.value){draft.value=props.value||'';reading=false;showMode();}host.dataset.draft=draft.value;});
|
| 67 |
+
const resize=new ResizeObserver(layout);resize.observe(viewport);
|
| 68 |
+
const observer=new MutationObserver(()=>{if(!element.isConnected){listeners.abort();resize.disconnect();observer.disconnect();}});
|
| 69 |
+
observer.observe(document.documentElement,{childList:true,subtree:true});
|
| 70 |
+
publish();showMode();
|
assets/settings.css
CHANGED
|
@@ -39,3 +39,20 @@ input[type=range] { width:100%; min-width:50px; height:44px; accent-color:#e7e7e
|
|
| 39 |
:focus-visible { outline:2px solid #eee; outline-offset:4px; }
|
| 40 |
@media(max-width:600px) { .setting-row { gap:12px; } .seed-controls { gap:10px; } .rate-row { align-items:flex-start; flex-direction:column; gap:4px; } .range-control { width:100%; } .sampling-grid { grid-template-columns:1fr; gap:8px; } .examples { flex-wrap:wrap; gap:6px; } .retention { font-size:10px; } }
|
| 41 |
@media(prefers-reduced-motion:reduce) { svg, input::after { transition:none; } }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
:focus-visible { outline:2px solid #eee; outline-offset:4px; }
|
| 40 |
@media(max-width:600px) { .setting-row { gap:12px; } .seed-controls { gap:10px; } .rate-row { align-items:flex-start; flex-direction:column; gap:4px; } .range-control { width:100%; } .sampling-grid { grid-template-columns:1fr; gap:8px; } .examples { flex-wrap:wrap; gap:6px; } .retention { font-size:10px; } }
|
| 41 |
@media(prefers-reduced-motion:reduce) { svg, input::after { transition:none; } }
|
| 42 |
+
/* Controls follow the label row, independently of wrapping help text. */
|
| 43 |
+
.setting-row { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:start; gap:18px; }
|
| 44 |
+
.setting-row > :first-child { padding-top:10px; }
|
| 45 |
+
.setting-row > input[role=switch] { margin-top:12px; }
|
| 46 |
+
.seed-controls { justify-content:flex-end; min-height:44px; }
|
| 47 |
+
.source-switch button { min-height:40px; }
|
| 48 |
+
.examples { align-items:flex-start; }
|
| 49 |
+
@media(max-width:600px) {
|
| 50 |
+
.setting-row { column-gap:12px; }
|
| 51 |
+
.seed-row, .rate-row { grid-template-columns:1fr; gap:8px; }
|
| 52 |
+
.seed-controls { justify-content:space-between; }
|
| 53 |
+
#take-seed { flex:1; width:auto; max-width:180px; text-align:left; }
|
| 54 |
+
input[type=number] { font-size:16px; min-height:44px; }
|
| 55 |
+
.source-switch button { font-size:14px; padding:6px 12px; }
|
| 56 |
+
.examples { display:grid; grid-template-columns:1fr; gap:4px; }
|
| 57 |
+
.examples > div:last-child { gap:24px; }
|
| 58 |
+
}
|
assets/settings.html
CHANGED
|
@@ -7,7 +7,7 @@
|
|
| 7 |
@children
|
| 8 |
<div class="settings-body" data-settings-rest hidden>
|
| 9 |
<label class="consent-row" data-consent-row hidden><input type="checkbox" data-setting="consent"><span>I have permission to use this voice.</span></label>
|
| 10 |
-
<div class="setting-row"><div><label class="setting-label" for="take-seed">Seed</label><p class="setting-help">Repeat a take with the same settings.</p></div><div class="seed-controls"><input id="take-seed" type="number" min="0" max="2147483647" step="1" data-setting="seed" aria-label="Seed"><label class="switch-label"><input type="checkbox" role="switch" data-setting="randomize"><span>Random</span></label></div></div>
|
| 11 |
<div class="setting-row"><div><label class="setting-label" for="normalize-text">Text normalization</label><p class="setting-help">Expand numbers and abbreviations.</p></div><input id="normalize-text" type="checkbox" role="switch" data-setting="normalize"></div>
|
| 12 |
<div class="setting-row rate-row"><div><label class="setting-label" for="speaking-rate">Speaking rate</label><p class="setting-help">UTF-8 bytes / second · 0 uses the natural pace.</p></div><div class="range-control"><input id="speaking-rate" type="range" min="0" max="30" step="0.5" data-setting="rate" aria-label="Speaking rate"><input type="number" min="0" max="30" step="0.5" data-setting="rate" aria-label="Speaking rate value"></div></div>
|
| 13 |
<details class="sampling"><summary>Sampling <svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4 6 4 4 4-4"/></svg></summary><div class="sampling-grid">
|
|
|
|
| 7 |
@children
|
| 8 |
<div class="settings-body" data-settings-rest hidden>
|
| 9 |
<label class="consent-row" data-consent-row hidden><input type="checkbox" data-setting="consent"><span>I have permission to use this voice.</span></label>
|
| 10 |
+
<div class="setting-row seed-row"><div><label class="setting-label" for="take-seed">Seed</label><p class="setting-help">Repeat a take with the same settings.</p></div><div class="seed-controls"><input id="take-seed" type="number" min="0" max="2147483647" step="1" data-setting="seed" aria-label="Seed"><label class="switch-label"><input type="checkbox" role="switch" data-setting="randomize"><span>Random</span></label></div></div>
|
| 11 |
<div class="setting-row"><div><label class="setting-label" for="normalize-text">Text normalization</label><p class="setting-help">Expand numbers and abbreviations.</p></div><input id="normalize-text" type="checkbox" role="switch" data-setting="normalize"></div>
|
| 12 |
<div class="setting-row rate-row"><div><label class="setting-label" for="speaking-rate">Speaking rate</label><p class="setting-help">UTF-8 bytes / second · 0 uses the natural pace.</p></div><div class="range-control"><input id="speaking-rate" type="range" min="0" max="30" step="0.5" data-setting="rate" aria-label="Speaking rate"><input type="number" min="0" max="30" step="0.5" data-setting="rate" aria-label="Speaking rate value"></div></div>
|
| 13 |
<details class="sampling"><summary>Sampling <svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4 6 4 4 4-4"/></svg></summary><div class="sampling-grid">
|
assets/settings.js
CHANGED
|
@@ -23,6 +23,7 @@ function sync() {
|
|
| 23 |
publish();
|
| 24 |
}
|
| 25 |
function sourceChanged() {
|
|
|
|
| 26 |
document.getElementById('voice-picker')?.dispatchEvent(new CustomEvent('zonos:source', {detail:state.source}));
|
| 27 |
}
|
| 28 |
element.addEventListener('input', event => {
|
|
|
|
| 23 |
publish();
|
| 24 |
}
|
| 25 |
function sourceChanged() {
|
| 26 |
+
document.getElementById('reference-clip')?.dispatchEvent(new CustomEvent('zonos:source', {detail:state.source}));
|
| 27 |
document.getElementById('voice-picker')?.dispatchEvent(new CustomEvent('zonos:source', {detail:state.source}));
|
| 28 |
}
|
| 29 |
element.addEventListener('input', event => {
|
assets/style.css
CHANGED
|
@@ -1,15 +1,13 @@
|
|
| 1 |
:root { color-scheme:dark; }
|
| 2 |
body { background:#000; }
|
| 3 |
-
.gradio-container { background:#000!important; color:#f1f1f1; max-width:1200px!important; margin:auto; padding:
|
| 4 |
#app-header, #speech-player, #voice-picker, #request-status, #app-credits, #voice-options { --block-padding:0px; }
|
| 5 |
#app-header { margin:0 0 65px; padding:0; border:0; background:transparent; }
|
| 6 |
.app-header { display:flex; align-items:center; justify-content:space-between; gap:20px; color:#a6a6a6; font:12px/1.5 ui-monospace,monospace; }
|
| 7 |
.app-header a, .credits a { color:inherit; text-decoration:none; }
|
| 8 |
.app-header a:hover, .credits a:hover { text-decoration:underline; text-underline-offset:4px; }
|
| 9 |
#script-input { max-width:760px; margin:0 auto; border:0!important; box-shadow:none!important; background:transparent!important; }
|
| 10 |
-
#
|
| 11 |
-
#script-input textarea:focus-visible { outline:1px solid #777!important; outline-offset:5px; }
|
| 12 |
-
#speech-player { margin:13px -34px 0; width:calc(100% + 68px); max-width:none; padding:0; background:transparent; border:0; }
|
| 13 |
#voice-picker { margin:8px 0 18px; background:transparent; border:0; padding:0; }
|
| 14 |
#action-row { justify-content:center; align-items:center; gap:12px; margin:0 auto; max-width:370px; }
|
| 15 |
#generate-speech { flex:0 1 196px; min-width:196px; max-width:220px; min-height:46px; border:1px solid #f3f3f0; border-radius:8px; background:#f3f3f0; color:#111; box-shadow:none; font:500 15px Arial,sans-serif; }
|
|
@@ -30,11 +28,9 @@ body { background:#000; }
|
|
| 30 |
#preview-warning { text-align:center; color:#e5c078; font:12px/1.5 ui-monospace,monospace; }
|
| 31 |
button:focus-visible, select:focus-visible, a:focus-visible, summary:focus-visible { outline:2px solid #eee!important; outline-offset:4px; }
|
| 32 |
@media(max-width:600px) {
|
| 33 |
-
.gradio-container { padding:24px 18px 18px!important; }
|
| 34 |
#app-header { margin-bottom:45px; }
|
| 35 |
.app-header { font-size:11px; gap:12px; }
|
| 36 |
-
#
|
| 37 |
-
#speech-player { margin:6px -18px 0; width:calc(100% + 36px); }
|
| 38 |
#voice-picker { margin-bottom:20px; }
|
| 39 |
#action-row { width:100%; gap:10px; }
|
| 40 |
#voice-options { margin-top:8px; }
|
|
@@ -43,3 +39,5 @@ button:focus-visible, select:focus-visible, a:focus-visible, summary:focus-visib
|
|
| 43 |
.credits p, .credits details { width:100%; }
|
| 44 |
}
|
| 45 |
@media(prefers-reduced-motion:reduce) { * { scroll-behavior:auto!important; } }
|
|
|
|
|
|
|
|
|
| 1 |
:root { color-scheme:dark; }
|
| 2 |
body { background:#000; }
|
| 3 |
+
.gradio-container { background:#000!important; color:#f1f1f1; max-width:1200px!important; margin:auto; padding:0!important; --app-gutter:clamp(18px,3vw,34px); --size-8:var(--app-gutter); font-family:Arial,Helvetica,sans-serif!important; }
|
| 4 |
#app-header, #speech-player, #voice-picker, #request-status, #app-credits, #voice-options { --block-padding:0px; }
|
| 5 |
#app-header { margin:0 0 65px; padding:0; border:0; background:transparent; }
|
| 6 |
.app-header { display:flex; align-items:center; justify-content:space-between; gap:20px; color:#a6a6a6; font:12px/1.5 ui-monospace,monospace; }
|
| 7 |
.app-header a, .credits a { color:inherit; text-decoration:none; }
|
| 8 |
.app-header a:hover, .credits a:hover { text-decoration:underline; text-underline-offset:4px; }
|
| 9 |
#script-input { max-width:760px; margin:0 auto; border:0!important; box-shadow:none!important; background:transparent!important; }
|
| 10 |
+
#speech-player { margin:13px calc(-1 * var(--app-gutter)) 0; width:calc(100% + 2 * var(--app-gutter)); max-width:none; padding:0; background:transparent; border:0; }
|
|
|
|
|
|
|
| 11 |
#voice-picker { margin:8px 0 18px; background:transparent; border:0; padding:0; }
|
| 12 |
#action-row { justify-content:center; align-items:center; gap:12px; margin:0 auto; max-width:370px; }
|
| 13 |
#generate-speech { flex:0 1 196px; min-width:196px; max-width:220px; min-height:46px; border:1px solid #f3f3f0; border-radius:8px; background:#f3f3f0; color:#111; box-shadow:none; font:500 15px Arial,sans-serif; }
|
|
|
|
| 28 |
#preview-warning { text-align:center; color:#e5c078; font:12px/1.5 ui-monospace,monospace; }
|
| 29 |
button:focus-visible, select:focus-visible, a:focus-visible, summary:focus-visible { outline:2px solid #eee!important; outline-offset:4px; }
|
| 30 |
@media(max-width:600px) {
|
|
|
|
| 31 |
#app-header { margin-bottom:45px; }
|
| 32 |
.app-header { font-size:11px; gap:12px; }
|
| 33 |
+
#speech-player { margin-top:6px; }
|
|
|
|
| 34 |
#voice-picker { margin-bottom:20px; }
|
| 35 |
#action-row { width:100%; gap:10px; }
|
| 36 |
#voice-options { margin-top:8px; }
|
|
|
|
| 39 |
.credits p, .credits details { width:100%; }
|
| 40 |
}
|
| 41 |
@media(prefers-reduced-motion:reduce) { * { scroll-behavior:auto!important; } }
|
| 42 |
+
#script-input { --block-padding:0px; width:100%; margin-bottom:12px; }
|
| 43 |
+
#reference-clip { background:transparent; border:0; --block-padding:0px; }
|
assets/wave.js
CHANGED
|
@@ -1,22 +1,38 @@
|
|
| 1 |
-
//
|
| 2 |
-
function wavePath(values, gain = 1) {
|
| 3 |
-
const count = Math.min(240, values.length);
|
| 4 |
-
if (count < 2) return 'M0 70 L1000 70';
|
| 5 |
-
let d = 'M0 70 L140 70';
|
| 6 |
-
for (let i = 0; i < count; i++) {
|
| 7 |
-
const t = i / (count - 1);
|
| 8 |
-
// Quintic fades join the baseline with zero first and second derivatives.
|
| 9 |
-
const edge = Math.min(1, t / .22, (1 - t) / .22);
|
| 10 |
-
const envelope = edge * edge * edge * (edge * (edge * 6 - 15) + 10);
|
| 11 |
-
const sample = Number(values[Math.min(values.length - 1, Math.floor(t * (values.length - 1)))]) || 0;
|
| 12 |
-
const amplitude = Math.tanh(sample * gain * 1.2) * 58 * envelope;
|
| 13 |
-
d += ` L${(140 + 720 * t).toFixed(2)} ${(70 - amplitude).toFixed(2)}`;
|
| 14 |
-
}
|
| 15 |
-
return d + ' L860 70 L1000 70';
|
| 16 |
-
}
|
| 17 |
function visualGain(values) {
|
| 18 |
let sum = 0;
|
| 19 |
for (const value of values) sum += value * value;
|
| 20 |
const rms = Math.sqrt(sum / Math.max(1, values.length));
|
| 21 |
return rms < .0005 ? 1 : Math.min(10, Math.max(1, .32 / rms));
|
| 22 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Smooth display geometry only. Playback and exported PCM remain untouched.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
function visualGain(values) {
|
| 3 |
let sum = 0;
|
| 4 |
for (const value of values) sum += value * value;
|
| 5 |
const rms = Math.sqrt(sum / Math.max(1, values.length));
|
| 6 |
return rms < .0005 ? 1 : Math.min(10, Math.max(1, .32 / rms));
|
| 7 |
}
|
| 8 |
+
function wavePath(values, gain = 1) {
|
| 9 |
+
if (values.length < 2) return 'M0 70 L1000 70';
|
| 10 |
+
// Low-pass the display signal before interpolation: no sharp PCM corners.
|
| 11 |
+
const count = 72, filtered = [];
|
| 12 |
+
const radius = Math.max(2, values.length / count * .7);
|
| 13 |
+
for (let i=0; i<count; i++) {
|
| 14 |
+
const center = i / (count-1) * (values.length-1);
|
| 15 |
+
let sum=0, weight=0;
|
| 16 |
+
for (let j=Math.max(0,Math.floor(center-radius*2)); j<=Math.min(values.length-1,Math.ceil(center+radius*2)); j++) {
|
| 17 |
+
const w=Math.exp(-.5*((j-center)/radius)**2);
|
| 18 |
+
sum+=(Number(values[j])||0)*w; weight+=w;
|
| 19 |
+
}
|
| 20 |
+
filtered.push(sum/weight);
|
| 21 |
+
}
|
| 22 |
+
const compensation = Math.max(1, Math.min(4, visualGain(filtered) / Math.max(1, gain)));
|
| 23 |
+
const points = filtered.map((sample,i) => {
|
| 24 |
+
const t=i/(count-1), e=Math.min(1,t/.13,(1-t)/.13);
|
| 25 |
+
const fade=e*e*e*(e*(e*6-15)+10);
|
| 26 |
+
return [24+952*t,70-58*Math.tanh(sample*gain*compensation*1.7)*fade];
|
| 27 |
+
});
|
| 28 |
+
// Monotone Hermite tangents prevent cubic overshoot at peaks.
|
| 29 |
+
const slopes=points.slice(1).map((p,i)=>(p[1]-points[i][1])/(p[0]-points[i][0]));
|
| 30 |
+
const tangent=points.map((_,i)=>i===0||i===count-1?0:
|
| 31 |
+
slopes[i-1]*slopes[i]<=0?0:2*slopes[i-1]*slopes[i]/(slopes[i-1]+slopes[i]));
|
| 32 |
+
let d='M0 70 L24 70';
|
| 33 |
+
for(let i=1;i<count;i++) {
|
| 34 |
+
const a=points[i-1],b=points[i],h=(b[0]-a[0])/3;
|
| 35 |
+
d+=` C${(a[0]+h).toFixed(2)} ${(a[1]+tangent[i-1]*h).toFixed(2)} ${(b[0]-h).toFixed(2)} ${(b[1]-tangent[i]*h).toFixed(2)} ${b[0].toFixed(2)} ${b[1].toFixed(2)}`;
|
| 36 |
+
}
|
| 37 |
+
return d+' L1000 70';
|
| 38 |
+
}
|
config.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"""Release contract. Changing a revision requires rerunning the contract tests."""
|
| 2 |
from pathlib import Path
|
| 3 |
|
| 4 |
-
VERSION = "0.
|
| 5 |
ROOT = Path(__file__).resolve().parent
|
| 6 |
MODEL_ID = "Zyphra/ZONOS3-Mini"
|
| 7 |
MODEL_REVISION = "eaac314642440e8f1f497f1505ca376f8e618f2a"
|
|
@@ -22,3 +22,6 @@ LANGUAGES = {
|
|
| 22 |
"ja": "Japanese", "cmn": "Mandarin", "ko": "Korean",
|
| 23 |
}
|
| 24 |
DEFAULT_TEXT = "Some stories begin with a voice.\nThis one begins with yours."
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""Release contract. Changing a revision requires rerunning the contract tests."""
|
| 2 |
from pathlib import Path
|
| 3 |
|
| 4 |
+
VERSION = "0.3.0"
|
| 5 |
ROOT = Path(__file__).resolve().parent
|
| 6 |
MODEL_ID = "Zyphra/ZONOS3-Mini"
|
| 7 |
MODEL_REVISION = "eaac314642440e8f1f497f1505ca376f8e618f2a"
|
|
|
|
| 22 |
"ja": "Japanese", "cmn": "Mandarin", "ko": "Korean",
|
| 23 |
}
|
| 24 |
DEFAULT_TEXT = "Some stories begin with a voice.\nThis one begins with yours."
|
| 25 |
+
|
| 26 |
+
ALIGNER_ID = "Qwen/Qwen3-ForcedAligner-0.6B-hf"
|
| 27 |
+
ALIGNER_REVISION = "c07281df297b9905d24a508279258cccf987a064"
|
controller.py
CHANGED
|
@@ -21,8 +21,9 @@ def message(text, error=False, busy=False):
|
|
| 21 |
|
| 22 |
|
| 23 |
class Controller:
|
| 24 |
-
def __init__(self, engine, runtime_dir, voice_names):
|
| 25 |
self.engine = engine
|
|
|
|
| 26 |
self.root = Path(runtime_dir)
|
| 27 |
self.outputs = self.root / "outputs"
|
| 28 |
self.outputs.mkdir(parents=True, exist_ok=True)
|
|
@@ -96,11 +97,34 @@ class Controller:
|
|
| 96 |
exported, result = export_take(outcome.pcm, self.outputs, seed=request.seed,
|
| 97 |
request_seconds=time.monotonic()-job.started,
|
| 98 |
inference_seconds=outcome.seconds)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
del outcome
|
| 100 |
check_cancelled(job.marker)
|
| 101 |
prune_outputs(self.outputs)
|
| 102 |
result["request_seconds"] = round(time.monotonic()-job.started, 2)
|
| 103 |
-
final = (json.dumps(result), message("Ready to play."),
|
| 104 |
gr.update(interactive=True, value="Generate speech"),
|
| 105 |
gr.update(visible=False, interactive=True), request.seed)
|
| 106 |
keep_export = True
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
class Controller:
|
| 24 |
+
def __init__(self, engine, runtime_dir, voice_names, aligner=None):
|
| 25 |
self.engine = engine
|
| 26 |
+
self.aligner = aligner
|
| 27 |
self.root = Path(runtime_dir)
|
| 28 |
self.outputs = self.root / "outputs"
|
| 29 |
self.outputs.mkdir(parents=True, exist_ok=True)
|
|
|
|
| 97 |
exported, result = export_take(outcome.pcm, self.outputs, seed=request.seed,
|
| 98 |
request_seconds=time.monotonic()-job.started,
|
| 99 |
inference_seconds=outcome.seconds)
|
| 100 |
+
result.update(text=request.text, words=[], alignment="unavailable")
|
| 101 |
+
if self.aligner is not None:
|
| 102 |
+
yield (gr.skip(), message("Synchronizing words…", busy=True), gr.update(interactive=False, value="Synchronizing…"),
|
| 103 |
+
gr.update(visible=True, interactive=True), gr.skip())
|
| 104 |
+
try:
|
| 105 |
+
from alignment import attach_timings
|
| 106 |
+
progress(None, desc="Preparing word synchronization")
|
| 107 |
+
alignment_inputs = self.aligner.prepare(outcome.pcm, request.text)
|
| 108 |
+
check_cancelled(job.marker)
|
| 109 |
+
with self.inference_slot(job):
|
| 110 |
+
progress(None, desc="Requesting GPU for word synchronization")
|
| 111 |
+
words = self.aligner.run(alignment_inputs, job.marker, progress)
|
| 112 |
+
log.info("alignment_gpu_returned request=%s", job.id)
|
| 113 |
+
check_cancelled(job.marker)
|
| 114 |
+
result["words"] = attach_timings(request.text, words, result["duration"])
|
| 115 |
+
result["alignment"] = "ready"
|
| 116 |
+
except Cancelled:
|
| 117 |
+
raise
|
| 118 |
+
except Exception as exc:
|
| 119 |
+
check_cancelled(job.marker)
|
| 120 |
+
log.warning("alignment_unavailable request=%s error_type=%s", job.id, type(exc).__name__)
|
| 121 |
+
finally:
|
| 122 |
+
alignment_inputs = None
|
| 123 |
del outcome
|
| 124 |
check_cancelled(job.marker)
|
| 125 |
prune_outputs(self.outputs)
|
| 126 |
result["request_seconds"] = round(time.monotonic()-job.started, 2)
|
| 127 |
+
final = (json.dumps(result), message("Ready to play." if result["alignment"] == "ready" or self.aligner is None else "Audio ready. Word synchronization unavailable for this take."),
|
| 128 |
gr.update(interactive=True, value="Generate speech"),
|
| 129 |
gr.update(visible=False, interactive=True), request.seed)
|
| 130 |
keep_export = True
|
requirements.txt
CHANGED
|
@@ -20,3 +20,7 @@ sacremoses==0.1.1
|
|
| 20 |
pyyaml==6.0.3
|
| 21 |
safetensors==0.8.0
|
| 22 |
omegaconf==2.3.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
pyyaml==6.0.3
|
| 21 |
safetensors==0.8.0
|
| 22 |
omegaconf==2.3.0
|
| 23 |
+
|
| 24 |
+
# Audio feature extraction used by the pinned Qwen forced aligner.
|
| 25 |
+
librosa==1.0.0
|
| 26 |
+
scipy==1.17.0
|
scripts/package.py
CHANGED
|
@@ -18,7 +18,7 @@ FORBIDDEN = {".pyc", ".wav", ".mp3", ".pt", ".pth", ".safetensors", ".gguf", ".b
|
|
| 18 |
def eligible(path):
|
| 19 |
rel = path.relative_to(ROOT)
|
| 20 |
return (path.is_file() and not path.is_symlink() and not EXCLUDED.intersection(rel.parts)
|
| 21 |
-
and rel.parts[0] not in ROOT_EXCLUDED
|
| 22 |
and path.suffix not in FORBIDDEN and not path.name.startswith(".env")
|
| 23 |
and path.name != "MANIFEST.sha256")
|
| 24 |
|
|
|
|
| 18 |
def eligible(path):
|
| 19 |
rel = path.relative_to(ROOT)
|
| 20 |
return (path.is_file() and not path.is_symlink() and not EXCLUDED.intersection(rel.parts)
|
| 21 |
+
and rel.parts[0] not in ROOT_EXCLUDED and not rel.parts[0].startswith("pytest-of-")
|
| 22 |
and path.suffix not in FORBIDDEN and not path.name.startswith(".env")
|
| 23 |
and path.name != "MANIFEST.sha256")
|
| 24 |
|
tests/test_alignment.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import math
|
| 3 |
+
import pytest
|
| 4 |
+
import gradio as gr
|
| 5 |
+
|
| 6 |
+
from alignment import attach_timings
|
| 7 |
+
from controller import Controller
|
| 8 |
+
from lifecycle import check_cancelled
|
| 9 |
+
from test_lifecycle import FakeEngine, invoke
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def test_preserves_exact_unicode_and_punctuation():
|
| 13 |
+
text=" L’été, c’est déjà 2026 ! 你好。"
|
| 14 |
+
tokens=["L'été", "c'est", "déjà", "2026", "你", "好"]
|
| 15 |
+
words=[dict(text=t,start_time=i*.2,end_time=(i+1)*.2) for i,t in enumerate(tokens)]
|
| 16 |
+
result=attach_timings(text,words,1.2)
|
| 17 |
+
assert ''.join(w['text'] for w in result)==text
|
| 18 |
+
assert result[-1]['end']==1.2
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@pytest.mark.parametrize('words',[
|
| 22 |
+
[dict(text='Other',start_time=0,end_time=.1)],
|
| 23 |
+
[dict(text='Hello',start_time=math.nan,end_time=.1)],
|
| 24 |
+
[dict(text='Hello',start_time=0,end_time=2)],
|
| 25 |
+
[dict(text='Hello',start_time=.1,end_time=0)],
|
| 26 |
+
[dict(text='Hello',start_time=0,end_time=0)],
|
| 27 |
+
])
|
| 28 |
+
def test_rejects_unreliable_timing(words):
|
| 29 |
+
with pytest.raises(ValueError):attach_timings('Hello',words,.5)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class FakeAligner:
|
| 33 |
+
def __init__(self,fail=False):self.fail=fail;self.calls=0
|
| 34 |
+
def prepare(self,pcm,text):return text
|
| 35 |
+
def run(self,text,marker,progress):
|
| 36 |
+
self.calls+=1
|
| 37 |
+
check_cancelled(marker)
|
| 38 |
+
if self.fail:raise RuntimeError('alignment failure')
|
| 39 |
+
return [dict(text='Hello',start_time=0,end_time=.08)]
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def test_second_stage_failure_retains_new_audio(tmp_path):
|
| 43 |
+
c=Controller(FakeEngine(),tmp_path,['Savannah'],FakeAligner(fail=True))
|
| 44 |
+
result=list(invoke(c,text='Hello'))[-1]
|
| 45 |
+
payload=json.loads(result[0])
|
| 46 |
+
assert payload['alignment']=='unavailable' and payload['words']==[]
|
| 47 |
+
assert 'unavailable' in result[1] and len(list(c.outputs.glob('*.wav')))==1
|
| 48 |
+
assert c.jobs.active()==0
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_cancel_between_calls_cleans_export_and_retries(tmp_path):
|
| 52 |
+
a=FakeAligner();c=Controller(FakeEngine(),tmp_path,['Savannah'],a)
|
| 53 |
+
iterator=invoke(c,text='Hello')
|
| 54 |
+
next(iterator);next(iterator);state=next(iterator)
|
| 55 |
+
assert 'Synchronizing' in state[1]
|
| 56 |
+
c.cancel(gr.Request(session_hash='session-a'))
|
| 57 |
+
result=list(iterator)[-1]
|
| 58 |
+
assert 'Cancelled' in result[1] and result[0]==gr.skip() and a.calls==0
|
| 59 |
+
assert not list(c.outputs.glob('*.wav')) and c.jobs.active()==0
|
| 60 |
+
result=list(invoke(c,text='Hello'))[-1]
|
| 61 |
+
assert json.loads(result[0])['alignment']=='ready' and a.calls==1
|
tests/test_ui_js.py
CHANGED
|
@@ -13,14 +13,16 @@ const quiet = Array.from({length:240}, (_,i)=>.035*Math.sin(i*.2));
|
|
| 13 |
assert.ok(visualGain(quiet) > 8);
|
| 14 |
const input = Array(240).fill(1);
|
| 15 |
const d = wavePath(input, 3);
|
| 16 |
-
|
| 17 |
-
assert.
|
| 18 |
-
|
| 19 |
-
assert.equal(
|
| 20 |
-
assert.equal(
|
| 21 |
-
assert.
|
| 22 |
-
assert.ok(Math.min(...
|
| 23 |
-
assert.ok(Math.min(...
|
|
|
|
|
|
|
| 24 |
assert.ok(!d.includes('NaN'));
|
| 25 |
'''
|
| 26 |
subprocess.run(['node','-e',script], check=True)
|
|
|
|
| 13 |
assert.ok(visualGain(quiet) > 8);
|
| 14 |
const input = Array(240).fill(1);
|
| 15 |
const d = wavePath(input, 3);
|
| 16 |
+
assert.ok(d.startsWith('M0 70 L24 70 C'));
|
| 17 |
+
assert.ok(d.endsWith('976.00 70.00 L1000 70'));
|
| 18 |
+
const curves=[...d.matchAll(/C([\d.]+) ([\d.]+) ([\d.]+) ([\d.]+) ([\d.]+) ([\d.]+)/g)].map(x=>x.slice(1).map(Number));
|
| 19 |
+
assert.equal(curves.length,71);
|
| 20 |
+
assert.equal(curves[0][1],70); // horizontal starting tangent
|
| 21 |
+
assert.equal(curves.at(-1)[3],70); // horizontal ending tangent
|
| 22 |
+
assert.ok(Math.min(...curves.map(p=>p[5]))<15);
|
| 23 |
+
assert.ok(Math.min(...curves.flatMap(p=>[p[1],p[3],p[5]]))>=12);
|
| 24 |
+
const flat=wavePath(zeros);
|
| 25 |
+
assert.ok([...flat.matchAll(/C([\d.]+) ([\d.]+) ([\d.]+) ([\d.]+) ([\d.]+) ([\d.]+)/g)].every(p=>+p[2]===70 && +p[4]===70 && +p[6]===70));
|
| 26 |
assert.ok(!d.includes('NaN'));
|
| 27 |
'''
|
| 28 |
subprocess.run(['node','-e',script], check=True)
|