Spaces:
Running on Zero
Running on Zero
v0.3.1: smooth audio-reactive line and aligned multilingual examples
Browse files- CHANGELOG.md +8 -0
- MANIFEST.sha256 +14 -12
- README.md +5 -5
- VALIDATION.md +12 -1
- app.py +15 -6
- assets/player.html +2 -2
- assets/player.js +102 -130
- assets/settings.css +9 -6
- assets/settings.html +1 -1
- assets/settings.js +2 -1
- assets/wave.js +39 -34
- config.py +1 -1
- examples.json +11 -0
- tests/test_examples.py +19 -0
- tests/test_ui_js.py +29 -30
CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
| 1 |
# Changelog
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
## 0.3.0 · 2026-11-10
|
| 4 |
|
| 5 |
- Add a two-line HTML reader with audio-clock word highlighting, seek/replay synchronization and a preserved editable draft.
|
|
|
|
| 1 |
# Changelog
|
| 2 |
|
| 3 |
+
## 0.3.1 · 2026-11-10
|
| 4 |
+
|
| 5 |
+
- Replace sample-based waveform rendering with a rounded, continuous curve whose amplitude follows actual playback energy. Use time-based attack/release and smooth edge fades; settle to a flat line on pause/end. Audio data and inference are unchanged.
|
| 6 |
+
- Keep the pending-request activity indicator separate from playback, and respect reduced-motion preferences.
|
| 7 |
+
- Replace uneven example buttons with a fixed three-column grid; neutralize the inherited 4-pixel margin that displaced the first button.
|
| 8 |
+
- Add German, Spanish, Italian, Portuguese, Japanese, Mandarin and Korean examples alongside English and French. Examples select text, language and voice without generating audio.
|
| 9 |
+
- Derive Play/Pause/Replay from the actual audio state, including a seek back from the end.
|
| 10 |
+
|
| 11 |
## 0.3.0 · 2026-11-10
|
| 12 |
|
| 13 |
- Add a two-line HTML reader with audio-clock word highlighting, seek/replay synchronization and a preserved editable draft.
|
MANIFEST.sha256
CHANGED
|
@@ -1,34 +1,35 @@
|
|
| 1 |
378f694f97eb755989fc6139455a58bc8c1bca7ae32c7a11be939a42ab27eeda .gitattributes
|
| 2 |
f63fc2a4a5e19102c55cf01aa092aa8982403ef6ef808f4c2c65a9f766ccbf3e .gitignore
|
| 3 |
-
|
| 4 |
3381fc95c5558560e1bb62d4cebea34bf6efa4adfbda7f4fe03bbf40bbcc55d3 LICENSE
|
| 5 |
14ff7f6e037dae2f40983b6408a1570d587bbcdecd7a7b061f2d11f94044f812 NOTICE
|
| 6 |
5ea53dcd52ecacb21ea6ae4fb94cc2ed7163fe02300f792a0ed35d09dcc0709c PROVENANCE.md
|
| 7 |
-
|
| 8 |
-
|
| 9 |
c27fa4c518c35c000227a0a939c350999db6c4e45e71082ef32f74efb8ff3f92 alignment.py
|
| 10 |
-
|
| 11 |
ca28255ebe082f8104b27f52fcf5c05bf899e6edfe8fdaa853eb30adcfdd0e52 assets/generate.js
|
| 12 |
dd23ac0a74f0682218938cf387a037e89724e8bbfbdeb5ff35ecff289ebc24d4 assets/layout.js
|
| 13 |
ecb1d25ae4aa218f69e269519fb68719a2c657ea1c24c0b6780df5f5dcf08259 assets/picker.js
|
| 14 |
98d10bfcd45f9d3dfe3cf1e433a4a2829d253496a90daefd30819a0abd5a4347 assets/player.css
|
| 15 |
-
|
| 16 |
-
|
| 17 |
57347c4f3eb6f9aec59f1c8882c66e926e04acb87cc0154573594f063cf5df02 assets/reference.css
|
| 18 |
03b2f8dbea4891f5ceb8990727473184118536720f439ffc69ef7d83d2f9a396 assets/reference.html
|
| 19 |
697bc75149164c4737f1d07d1ba95cffcae9214fee3c6e7ba1210c497783c5f1 assets/reference.js
|
| 20 |
74a71fe3c3cee7795fdd1e80f79b29f22c82c7bd27f2486c1a88578e5de1e821 assets/script.css
|
| 21 |
9519e9e91bc74d23ef72c0e4e8b3c02f0d16fee8143910bff75c1731f163ed44 assets/script.html
|
| 22 |
f7cfcf63e5622405ce6fe57d106231c1ce00bea379d995d74433ac854b5c099f assets/script.js
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
249fd190f8b58cf6e1b8f2582ed4afc40ce8ecf43292cbb409ee82f255604465 assets/style.css
|
| 27 |
-
|
| 28 |
c476aa0fc7ed79b77cd6577d6e5efaf75dee8cccb6d5dc5f56fe92334b27d04d audio_files.py
|
| 29 |
-
|
| 30 |
22cc095df3f023b95e4f7456f1b79144e341a33db6554db88ebe08a98c6b2f78 controller.py
|
| 31 |
6aa124baba94d5961a439a9467325e14aec4306f1e1fbdb3fef9a7a0b9bd511b engine.py
|
|
|
|
| 32 |
08396169ac070e226ebfdd11dbb2bf5469d475873e74680f91820f564ba803da lifecycle.py
|
| 33 |
75b9cc83944e78c88496c205ee2b4734e85ed84ee4d1d02643aaf45b532e6695 logging_setup.py
|
| 34 |
4c4ae63883d93e276095cea265e84224054a61f3c53666888708f79636df95bd packages.txt
|
|
@@ -42,12 +43,13 @@ f364eca49b31d8637a504c9644b5c522c6702d33c89df06e229438fb124a11dc tested-depende
|
|
| 42 |
e25f3dd0e11ec835e7db001c703825430e5c4745a538efb8d445fe650821cf1b tests/test_alignment.py
|
| 43 |
70802a3c9077443d6e7c8e9d354f799981f96a321cca5b40f5f307fa364b2924 tests/test_audio.py
|
| 44 |
89a62cbb880d9495634fce62a659e531dba74a1efa8bb5db4dbfeb1b8d5bf44b tests/test_contracts.py
|
|
|
|
| 45 |
9c2d7b6aad84fa56d891bb7f79bedc8386b5cfca86f80a282c41777d9cd1a61b tests/test_http.py
|
| 46 |
a20f4145d11561ab4caeabd51952c9f9b14088d5d330a4549e79f6a32310221c tests/test_lifecycle.py
|
| 47 |
0889b93f4d83986372f5dbc72ad902ebb076bfd730c6ad07f3232b01b5ed1ee9 tests/test_logging.py
|
| 48 |
31bfd349c66c1c31c06a7fc7d6cd1c94257679029bd512ddb4f133bfc4f6ccc8 tests/test_package.py
|
| 49 |
beae97c67f31cc89d948a5293bc820348cae1117e2f626ba97e7706819318ac8 tests/test_requests.py
|
| 50 |
-
|
| 51 |
98216c34413fa17aeaaba8aeec41d157609b6b8f6c07cf6250239128cf616bb5 vendor/ZYPHRA_LICENSE
|
| 52 |
5dd0692aae95e02965462827ccdd0fb1fc4f9762552e1cf979c4dc789cc34fc5 vendor/ZYPHRA_NOTICE
|
| 53 |
cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30 vendor/third_party/licenses/fusealign-multilingual-Apache-2.0.txt
|
|
|
|
| 1 |
378f694f97eb755989fc6139455a58bc8c1bca7ae32c7a11be939a42ab27eeda .gitattributes
|
| 2 |
f63fc2a4a5e19102c55cf01aa092aa8982403ef6ef808f4c2c65a9f766ccbf3e .gitignore
|
| 3 |
+
c8635c735b9f6dc0e4927ed607b6192be8536321a2e693d287af9c9822fcb048 CHANGELOG.md
|
| 4 |
3381fc95c5558560e1bb62d4cebea34bf6efa4adfbda7f4fe03bbf40bbcc55d3 LICENSE
|
| 5 |
14ff7f6e037dae2f40983b6408a1570d587bbcdecd7a7b061f2d11f94044f812 NOTICE
|
| 6 |
5ea53dcd52ecacb21ea6ae4fb94cc2ed7163fe02300f792a0ed35d09dcc0709c PROVENANCE.md
|
| 7 |
+
ed674c9c8eb005cf69e51955d0fe6cb2f83ce22f89dfc370b24b84dc92f49ad6 README.md
|
| 8 |
+
ec991525358dd3d5a89efdb67526f6c51e87b25f6712686d9722d0b1420c9266 VALIDATION.md
|
| 9 |
c27fa4c518c35c000227a0a939c350999db6c4e45e71082ef32f74efb8ff3f92 alignment.py
|
| 10 |
+
d37f555428e5506798c579213b3e7db08fcaa37e5856976061f1006f9ca8c6d8 app.py
|
| 11 |
ca28255ebe082f8104b27f52fcf5c05bf899e6edfe8fdaa853eb30adcfdd0e52 assets/generate.js
|
| 12 |
dd23ac0a74f0682218938cf387a037e89724e8bbfbdeb5ff35ecff289ebc24d4 assets/layout.js
|
| 13 |
ecb1d25ae4aa218f69e269519fb68719a2c657ea1c24c0b6780df5f5dcf08259 assets/picker.js
|
| 14 |
98d10bfcd45f9d3dfe3cf1e433a4a2829d253496a90daefd30819a0abd5a4347 assets/player.css
|
| 15 |
+
08722bf8146befa0b2acc859ff8130235a9dfe2ca2c7fc1d66fb2fe34c8efb4c assets/player.html
|
| 16 |
+
dd739d27e9317bf9534e872c67b060e0d3f5c490bbb2923b28748beebb6a9386 assets/player.js
|
| 17 |
57347c4f3eb6f9aec59f1c8882c66e926e04acb87cc0154573594f063cf5df02 assets/reference.css
|
| 18 |
03b2f8dbea4891f5ceb8990727473184118536720f439ffc69ef7d83d2f9a396 assets/reference.html
|
| 19 |
697bc75149164c4737f1d07d1ba95cffcae9214fee3c6e7ba1210c497783c5f1 assets/reference.js
|
| 20 |
74a71fe3c3cee7795fdd1e80f79b29f22c82c7bd27f2486c1a88578e5de1e821 assets/script.css
|
| 21 |
9519e9e91bc74d23ef72c0e4e8b3c02f0d16fee8143910bff75c1731f163ed44 assets/script.html
|
| 22 |
f7cfcf63e5622405ce6fe57d106231c1ce00bea379d995d74433ac854b5c099f assets/script.js
|
| 23 |
+
b379b615bb1f52b355b60bdde94a9fde4ef732f635a1ed56adbdfb7e279baca7 assets/settings.css
|
| 24 |
+
11205e51e46d238be4958947cb0bebd35eb3cb69903b38903ab624ba25a40f32 assets/settings.html
|
| 25 |
+
bd82d406c7554e4f0f7410b9dec78b85c0f3aea5b4eba028add750a4e9dd928c assets/settings.js
|
| 26 |
249fd190f8b58cf6e1b8f2582ed4afc40ce8ecf43292cbb409ee82f255604465 assets/style.css
|
| 27 |
+
3aaf1734e4a4122d4968b2ff1cd7124a6aad8fe243f364cd5f1d4ff6b3b7065a assets/wave.js
|
| 28 |
c476aa0fc7ed79b77cd6577d6e5efaf75dee8cccb6d5dc5f56fe92334b27d04d audio_files.py
|
| 29 |
+
219e8fa0a84356f1c9c1ff589d23a38e7c4b0f1164404da8e880cc04adca1400 config.py
|
| 30 |
22cc095df3f023b95e4f7456f1b79144e341a33db6554db88ebe08a98c6b2f78 controller.py
|
| 31 |
6aa124baba94d5961a439a9467325e14aec4306f1e1fbdb3fef9a7a0b9bd511b engine.py
|
| 32 |
+
e419cf08810b1fb969523c597a03c728d2f697b03a3e5e9a2ef81e63620d4046 examples.json
|
| 33 |
08396169ac070e226ebfdd11dbb2bf5469d475873e74680f91820f564ba803da lifecycle.py
|
| 34 |
75b9cc83944e78c88496c205ee2b4734e85ed84ee4d1d02643aaf45b532e6695 logging_setup.py
|
| 35 |
4c4ae63883d93e276095cea265e84224054a61f3c53666888708f79636df95bd packages.txt
|
|
|
|
| 43 |
e25f3dd0e11ec835e7db001c703825430e5c4745a538efb8d445fe650821cf1b tests/test_alignment.py
|
| 44 |
70802a3c9077443d6e7c8e9d354f799981f96a321cca5b40f5f307fa364b2924 tests/test_audio.py
|
| 45 |
89a62cbb880d9495634fce62a659e531dba74a1efa8bb5db4dbfeb1b8d5bf44b tests/test_contracts.py
|
| 46 |
+
8133a4f9345f726307b0cac6530b948faa710e9d0405b1021b6d72b006d71ea1 tests/test_examples.py
|
| 47 |
9c2d7b6aad84fa56d891bb7f79bedc8386b5cfca86f80a282c41777d9cd1a61b tests/test_http.py
|
| 48 |
a20f4145d11561ab4caeabd51952c9f9b14088d5d330a4549e79f6a32310221c tests/test_lifecycle.py
|
| 49 |
0889b93f4d83986372f5dbc72ad902ebb076bfd730c6ad07f3232b01b5ed1ee9 tests/test_logging.py
|
| 50 |
31bfd349c66c1c31c06a7fc7d6cd1c94257679029bd512ddb4f133bfc4f6ccc8 tests/test_package.py
|
| 51 |
beae97c67f31cc89d948a5293bc820348cae1117e2f626ba97e7706819318ac8 tests/test_requests.py
|
| 52 |
+
af5fbdeb7451697c35a2ba85ed2e585e5e9322bd2fd5873e724b9a5cc4f766ed tests/test_ui_js.py
|
| 53 |
98216c34413fa17aeaaba8aeec41d157609b6b8f6c07cf6250239128cf616bb5 vendor/ZYPHRA_LICENSE
|
| 54 |
5dd0692aae95e02965462827ccdd0fb1fc4f9762552e1cf979c4dc789cc34fc5 vendor/ZYPHRA_NOTICE
|
| 55 |
cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30 vendor/third_party/licenses/fusealign-multilingual-Apache-2.0.txt
|
README.md
CHANGED
|
@@ -15,10 +15,10 @@ models:
|
|
| 15 |
short_description: A voice, a line, and your words.
|
| 16 |
---
|
| 17 |
|
| 18 |
-
# ZONOS3 Mini · 0.3.
|
| 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
|
| 22 |
|
| 23 |
**Deployed on [Hugging Face ZeroGPU](https://huggingface.co/spaces/fffiloni/ZONOS3-Mini).** Short French/English generations, a synthetic reference clip, cancellation/retry and browser playback have passed live checks. See [VALIDATION.md](VALIDATION.md) for the evidence and remaining limits; this is not a comprehensive audio-quality benchmark.
|
| 24 |
|
|
@@ -28,14 +28,14 @@ Centered editable text, a fine audio waveform, and one Generate button. The inte
|
|
| 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.
|
| 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.
|
| 34 |
|
| 35 |
## Deploy to Hugging Face
|
| 36 |
|
| 37 |
1. Create a **Gradio** Space under an account with ZeroGPU access. Select **ZeroGPU** hardware in its settings. Consult the [current HF ZeroGPU instructions](https://huggingface.co/docs/hub/spaces-zerogpu).
|
| 38 |
-
2. Upload this directory's contents at the repository root, including `vendor`, `assets`, `voices.json`, `requirements.txt` and `packages.txt`. `app.py` must be at the root, not inside an additional archive folder.
|
| 39 |
3. Keep `sdk_version: 6.30.0` aligned with `gradio==6.30.0`. The Space installs the pinned runtime dependencies plus FFmpeg and libsndfile. Do not set `ZONOS_UI_ONLY` in production.
|
| 40 |
4. Wait for startup loading to finish. The app imports `spaces` first, downloads the pinned model assets, and places the LM, decoder and speaker encoder on CUDA **at startup**, as required by the SDK's CUDA emulation.
|
| 41 |
5. Run the live checks in [VALIDATION.md](VALIDATION.md) before public release. A green Space badge alone does not validate an inference or GPU lease release.
|
|
|
|
| 15 |
short_description: A voice, a line, and your words.
|
| 16 |
---
|
| 17 |
|
| 18 |
+
# ZONOS3 Mini · 0.3.1
|
| 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-reactive line, and one Generate button. The interface follows the supplied Zyphra presentation references. Model: Zyphra. UI/UX: [Sylvain Filoni / fffiloni](https://huggingface.co/fffiloni).
|
| 22 |
|
| 23 |
**Deployed on [Hugging Face ZeroGPU](https://huggingface.co/spaces/fffiloni/ZONOS3-Mini).** Short French/English generations, a synthetic reference clip, cancellation/retry and browser playback have passed live checks. See [VALIDATION.md](VALIDATION.md) for the evidence and remaining limits; this is not a comprehensive audio-quality benchmark.
|
| 24 |
|
|
|
|
| 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. During playback the line follows the actual audio energy when Web Audio is available. Its rounded shape is an illustration rather than a sample-by-sample oscilloscope: a continuous phase and time-based attack/release avoid jagged peaks and abrupt jumps. It returns smoothly to flat when paused or finished; no frozen full-take waveform remains. Reduced-motion mode keeps the playback line still, without disabling word synchronization. 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. Nine examples fill text, language and voice without starting inference: English, French, German, Spanish, Italian, Portuguese, Japanese, Mandarin and Korean. The Japanese example uses Savannah across languages because the exposed catalog has no native Japanese preset. The buttons use a fixed three-column grid with equal touch targets and underline positions. The English interface retains French text without requiring an interface language switch.
|
| 34 |
|
| 35 |
## Deploy to Hugging Face
|
| 36 |
|
| 37 |
1. Create a **Gradio** Space under an account with ZeroGPU access. Select **ZeroGPU** hardware in its settings. Consult the [current HF ZeroGPU instructions](https://huggingface.co/docs/hub/spaces-zerogpu).
|
| 38 |
+
2. Upload this directory's contents at the repository root, including `vendor`, `assets`, `voices.json`, `examples.json`, `requirements.txt` and `packages.txt`. `app.py` must be at the root, not inside an additional archive folder.
|
| 39 |
3. Keep `sdk_version: 6.30.0` aligned with `gradio==6.30.0`. The Space installs the pinned runtime dependencies plus FFmpeg and libsndfile. Do not set `ZONOS_UI_ONLY` in production.
|
| 40 |
4. Wait for startup loading to finish. The app imports `spaces` first, downloads the pinned model assets, and places the LM, decoder and speaker encoder on CUDA **at startup**, as required by the SDK's CUDA emulation.
|
| 41 |
5. Run the live checks in [VALIDATION.md](VALIDATION.md) before public release. A green Space badge alone does not validate an inference or GPU lease release.
|
VALIDATION.md
CHANGED
|
@@ -1,7 +1,18 @@
|
|
| 1 |
-
# Validation · 0.3.
|
| 2 |
|
| 3 |
## Current release
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
- 58 local tests passed, including exact Unicode transcript preservation, rejection of invalid timestamps, alignment failure retaining the new WAV, cancellation between allocations, session isolation, the original 13/5 public API, HTTP transport and complete archive imports. The pinned Qwen processor was loaded and prepared actual 16 kHz features locally; GPU inference was tested separately below.
|
| 6 |
- Chromium UI tests used a clearly identified CPU fixture for simulated synthesis and timing. Checked at 320, 390, 768 and 1440 px: no horizontal overflow; two-line viewport and automatic page changes; seek/replay; preserving a draft while replaying an old take; custom upload, 2-second trim, reset/remove; microphone capture using Chromium's simulated device; no JavaScript errors. No real microphone or physical iPhone/Safari test is implied.
|
| 7 |
- Simulated delayed microphone permission: switching back to Preset re-enabled submission, prevented recording, and ended all acquired tracks when the pending permission request resolved.
|
|
|
|
| 1 |
+
# Validation · 0.3.1 · 2026-11-10
|
| 2 |
|
| 3 |
## Current release
|
| 4 |
|
| 5 |
+
- 59 local tests passed: examples and preset/language mappings, unchanged public API, wave energy/geometry, time-based attack and release, generation lifecycle, session isolation and complete source-package imports. The existing protobuf deprecation warning remains visible.
|
| 6 |
+
- The live v0.3.0 baseline at `ba8c1f3fd8218768930ce6ab5bf372657908cc05` reproduced the example-button mismatch: English inherited a 4 px bottom margin and measured 44 px high; Français measured 48 px. The replacement grid has nine 44 px buttons, zero margins and equal underline positions within each row.
|
| 7 |
+
- Actual Chromium rendering at 320, 390, 768 and 1440 CSS px: no horizontal overflow; all nine example buttons update text, language and preset without publishing an audio result or requesting synthesis. English, French, German, Spanish, Italian, Portuguese, Japanese, Mandarin and Korean are included.
|
| 8 |
+
- A single live English/Savannah request on the unchanged v0.3.0 engine produced a 3.933-second WAV with ready alignment. The v0.3.1 UI fixture replayed this actual WAV; fixture request progress was simulated, not a new GPU inference.
|
| 9 |
+
- In that browser fixture, the activity curve starts while the request is pending. Playback changes the curve using the actual signal; pause and end settle to the exact flat baseline. Seeking back after the end restores Play instead of leaving Replay. Reduced-motion playback stays flat. No JavaScript errors were observed. Mobile and desktop screenshots were inspected.
|
| 10 |
+
- Models, runtime revisions, inference parameters, dependencies, GPU lifecycle and public 13-input / 5-output contract are unchanged. The waveform is now explicitly an audio-reactive illustration; its shape is not a literal PCM plot.
|
| 11 |
+
|
| 12 |
+
Remaining limits: physical Safari/iPhone testing and acoustic review of the seven new language examples. Browser checks above used Chromium with responsive viewports, not actual mobile hardware. Earlier live GPU-release evidence below applies to the unchanged engine, and is not replaced by a green Space badge.
|
| 13 |
+
|
| 14 |
+
## v0.3.0 evidence
|
| 15 |
+
|
| 16 |
- 58 local tests passed, including exact Unicode transcript preservation, rejection of invalid timestamps, alignment failure retaining the new WAV, cancellation between allocations, session isolation, the original 13/5 public API, HTTP transport and complete archive imports. The pinned Qwen processor was loaded and prepared actual 16 kHz features locally; GPU inference was tested separately below.
|
| 17 |
- Chromium UI tests used a clearly identified CPU fixture for simulated synthesis and timing. Checked at 320, 390, 768 and 1440 px: no horizontal overflow; two-line viewport and automatic page changes; seek/replay; preserving a draft while replaying an old take; custom upload, 2-second trim, reset/remove; microphone capture using Chromium's simulated device; no JavaScript errors. No real microphone or physical iPhone/Safari test is implied.
|
| 18 |
- Simulated delayed microphone permission: switching back to Preset re-enabled submission, prevented recording, and ended all acquired tracks when the pending permission request resolved.
|
app.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
"""ZONOS3 Mini · a minimal, session-isolated ZeroGPU Space."""
|
| 2 |
import html
|
|
|
|
| 3 |
import json
|
| 4 |
import os
|
| 5 |
from pathlib import Path
|
|
@@ -34,6 +35,7 @@ else:
|
|
| 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 |
|
| 39 |
|
|
@@ -59,9 +61,16 @@ PICKER_CSS = """
|
|
| 59 |
|
| 60 |
|
| 61 |
def load_example(language):
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
|
| 67 |
theme = gr.themes.Base(primary_hue="neutral", secondary_hue="neutral", neutral_hue="neutral",
|
|
@@ -99,7 +108,7 @@ with gr.Blocks(title="ZONOS3 Mini", fill_width=True, delete_cache=(3600, 86400),
|
|
| 99 |
generate = gr.Button("Generate speech", variant="primary", elem_id="generate-speech")
|
| 100 |
cancel = gr.Button("Cancel", visible=False, elem_id="cancel-speech")
|
| 101 |
status = gr.HTML(message(""), elem_id="request-status")
|
| 102 |
-
with gr.HTML(value="{}", source="Preset", html_template=(
|
| 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(),
|
|
@@ -124,8 +133,8 @@ with gr.Blocks(title="ZONOS3 Mini", fill_width=True, delete_cache=(3600, 86400),
|
|
| 124 |
script, voice, _ = load_example(language)
|
| 125 |
return script, voice, gr.HTML(source="Preset")
|
| 126 |
|
| 127 |
-
|
| 128 |
-
|
| 129 |
event = generate.click(
|
| 130 |
controller.generate,
|
| 131 |
[text, picker, source, clip, consent, normalize, seed, randomize, temperature, top_p, top_k, repetition, rate],
|
|
|
|
| 1 |
"""ZONOS3 Mini · a minimal, session-isolated ZeroGPU Space."""
|
| 2 |
import html
|
| 3 |
+
from functools import partial
|
| 4 |
import json
|
| 5 |
import os
|
| 6 |
from pathlib import Path
|
|
|
|
| 35 |
RUNTIME = Path(os.environ.get("ZONOS_RUNTIME_DIR", str(Path(tempfile.gettempdir()) / "zonos3-mini")))
|
| 36 |
controller = Controller(engine, RUNTIME, [v["name"] for v in CATALOG], aligner=aligner)
|
| 37 |
ASSETS = ROOT / "assets"
|
| 38 |
+
EXAMPLES = json.loads((ROOT / "examples.json").read_text())
|
| 39 |
DEFAULT_PICKER = json.dumps({"language":"en_us", "voice":"Savannah", "custom":False})
|
| 40 |
|
| 41 |
|
|
|
|
| 61 |
|
| 62 |
|
| 63 |
def load_example(language):
|
| 64 |
+
example = EXAMPLES[language]
|
| 65 |
+
return example["text"], json.dumps({"voice":example["voice"], "language":example["language"], "custom":False}), "Preset"
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def settings_markup():
|
| 69 |
+
buttons = "".join(
|
| 70 |
+
f'<button type="button" class="example-button" data-example="{html.escape(code, quote=True)}"><span>{html.escape(item["label"])}</span></button>'
|
| 71 |
+
for code, item in EXAMPLES.items()
|
| 72 |
+
)
|
| 73 |
+
return (ASSETS / "settings.html").read_text().replace("<!-- example-buttons -->", buttons)
|
| 74 |
|
| 75 |
|
| 76 |
theme = gr.themes.Base(primary_hue="neutral", secondary_hue="neutral", neutral_hue="neutral",
|
|
|
|
| 108 |
generate = gr.Button("Generate speech", variant="primary", elem_id="generate-speech")
|
| 109 |
cancel = gr.Button("Cancel", visible=False, elem_id="cancel-speech")
|
| 110 |
status = gr.HTML(message(""), elem_id="request-status")
|
| 111 |
+
with gr.HTML(value="{}", source="Preset", html_template=settings_markup(),
|
| 112 |
css_template=(ASSETS / "settings.css").read_text(),
|
| 113 |
js_on_load=(ASSETS / "settings.js").read_text(), elem_id="voice-options") as settings:
|
| 114 |
reference = gr.HTML(value="null", html_template=(ASSETS / "reference.html").read_text(),
|
|
|
|
| 133 |
script, voice, _ = load_example(language)
|
| 134 |
return script, voice, gr.HTML(source="Preset")
|
| 135 |
|
| 136 |
+
for code in EXAMPLES:
|
| 137 |
+
getattr(settings, "example_" + code)(partial(example, code), outputs=[text, picker, settings], queue=False, api_visibility="private")
|
| 138 |
event = generate.click(
|
| 139 |
controller.generate,
|
| 140 |
[text, picker, source, clip, consent, normalize, seed, randomize, temperature, top_p, top_k, repetition, rate],
|
assets/player.html
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
<div class="voice-player">
|
| 2 |
-
<svg class="wave" viewBox="0 0 1000 140" preserveAspectRatio="none" role="img" aria-label="
|
| 3 |
-
<path d="M0 70 L1000 70" fill="none" stroke="currentColor" stroke-width="1.25" vector-effect="non-scaling-stroke"></path>
|
| 4 |
</svg>
|
| 5 |
<audio controls preload="metadata" hidden></audio>
|
| 6 |
<div class="playback" hidden>
|
|
|
|
| 1 |
<div class="voice-player">
|
| 2 |
+
<svg class="wave" viewBox="0 0 1000 140" preserveAspectRatio="none" role="img" aria-label="Voice-reactive visualization">
|
| 3 |
+
<path d="M0 70 L1000 70" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke"></path>
|
| 4 |
</svg>
|
| 5 |
<audio controls preload="metadata" hidden></audio>
|
| 6 |
<div class="playback" hidden>
|
assets/player.js
CHANGED
|
@@ -1,148 +1,120 @@
|
|
| 1 |
-
const host
|
| 2 |
-
// Gradio
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
for (let i = 0; i < values.length; i++) {
|
| 22 |
-
const x = i / (values.length - 1);
|
| 23 |
-
values[i] = (.24 + .09 * Math.sin(time * 1.3)) *
|
| 24 |
-
(Math.sin(x * Math.PI * 18 - time * 2.5) + .32 * Math.sin(x * Math.PI * 31 + time));
|
| 25 |
-
}
|
| 26 |
-
svg.querySelector('path').setAttribute('d', wavePath(values, 1));
|
| 27 |
-
if (!reducedMotion.matches) activityFrame = requestAnimationFrame(animate);
|
| 28 |
}
|
| 29 |
-
|
|
|
|
| 30 |
}
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
| 38 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
});
|
| 40 |
-
statusObserver.observe(document.getElementById('request-status')
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
-
const minutes
|
| 45 |
function mount() {
|
| 46 |
-
dispose();
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
const
|
| 53 |
-
const
|
| 54 |
-
player.
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
const
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
const
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
player.src = url.href;
|
| 77 |
-
player.hidden = false; // Native controls survive if initialization below fails.
|
| 78 |
-
const link = element.querySelector("[data-download]");
|
| 79 |
-
link.href = url.href;
|
| 80 |
-
link.download = data.download_name || "zonos3.wav";
|
| 81 |
-
element.querySelector("[data-duration]").textContent = minutes(data.duration || 0);
|
| 82 |
-
let context = null, analyser = null, source = null, frame = 0, observer = null;
|
| 83 |
-
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");
|
| 84 |
-
const samples = new Float32Array(2048);
|
| 85 |
-
const preview = Array.isArray(data.wave) ? data.wave : [];
|
| 86 |
-
let gain = visualGain(preview);
|
| 87 |
-
function draw(values, adaptive = false) {
|
| 88 |
-
if (active) return;
|
| 89 |
-
const target = visualGain(values);
|
| 90 |
-
gain = adaptive ? gain * .9 + target * .1 : target;
|
| 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 |
-
|
| 106 |
-
element.querySelector(
|
| 107 |
-
if
|
| 108 |
}
|
| 109 |
async function play() {
|
| 110 |
try {
|
| 111 |
-
if
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
source
|
| 117 |
-
source.connect(analyser); analyser.connect(context.destination);
|
| 118 |
}
|
| 119 |
-
if
|
| 120 |
-
await player.play();
|
| 121 |
-
notice.hidden = true;
|
| 122 |
} catch {
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
notice.textContent = "Playback needs another click. You can also download the WAV.";
|
| 126 |
-
notice.hidden = false;
|
| 127 |
}
|
| 128 |
}
|
| 129 |
-
on(button,
|
| 130 |
-
on(
|
| 131 |
-
on(player,
|
| 132 |
-
on(player,
|
| 133 |
-
on(player,
|
| 134 |
-
on(player,
|
| 135 |
-
on(
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
player.removeAttribute("src"); player.load(); source?.disconnect(); analyser?.disconnect();
|
| 142 |
-
if (context && context.state !== "closed") context.close().catch(() => {});
|
| 143 |
};
|
| 144 |
-
observer = new MutationObserver(() => { if (!element.isConnected) dispose(); });
|
| 145 |
-
observer.observe(document.documentElement, {childList:true, subtree:true});
|
| 146 |
}
|
| 147 |
-
watch(
|
| 148 |
mount();
|
|
|
|
| 1 |
+
const host=document.getElementById('speech-player');
|
| 2 |
+
// Documented Gradio HTML API; all external events use our own component ID.
|
| 3 |
+
const reducedMotion=window.matchMedia('(prefers-reduced-motion: reduce)');
|
| 4 |
+
const motion=new WaveMotion();
|
| 5 |
+
let dispose=()=>{}, activityFrame=0, lastFrame=0, active=false;
|
| 6 |
+
let energy=()=>0, playing=()=>false, clock=()=>{};
|
| 7 |
+
function animate(now) {
|
| 8 |
+
activityFrame=0;
|
| 9 |
+
if(!element.isConnected)return;
|
| 10 |
+
const dt=lastFrame?Math.min(.05,(now-lastFrame)/1000):1/60;
|
| 11 |
+
lastFrame=now;
|
| 12 |
+
const isPlaying=playing();
|
| 13 |
+
if(isPlaying)clock();
|
| 14 |
+
const target=active ? .32+.055*Math.sin(now/1300): isPlaying ? energy() : 0;
|
| 15 |
+
const path=element.querySelector('.wave path');
|
| 16 |
+
if(reducedMotion.matches) {
|
| 17 |
+
// A still activity curve; playback never animates when motion is reduced.
|
| 18 |
+
motion.level=0;path.setAttribute('d',wavePath(active ? .27 : 0,.6));
|
| 19 |
+
} else {
|
| 20 |
+
motion.step(target,dt);path.setAttribute('d',wavePath(motion.level,motion.phase));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
}
|
| 22 |
+
if(isPlaying || (active && !reducedMotion.matches) || motion.level>0)activityFrame=requestAnimationFrame(animate);
|
| 23 |
+
else lastFrame=0;
|
| 24 |
}
|
| 25 |
+
function scheduleWave() { if(!activityFrame)activityFrame=requestAnimationFrame(animate); }
|
| 26 |
+
function setActivity(busy) {
|
| 27 |
+
active=busy;
|
| 28 |
+
element.querySelector('.wave').setAttribute('aria-label',busy?'Request activity indicator; not an audio preview':'Voice-reactive visualization');
|
| 29 |
+
host.dataset.busy=String(busy);
|
| 30 |
+
if(!busy) {
|
| 31 |
+
const generate=document.getElementById('generate-speech');
|
| 32 |
+
if(generate)generate.disabled=document.getElementById('voice-options')?.dataset.clip==='true' && document.getElementById('reference-clip')?.dataset.busy==='true';
|
| 33 |
}
|
| 34 |
+
scheduleWave();
|
| 35 |
+
}
|
| 36 |
+
const globalListeners=new AbortController();
|
| 37 |
+
host.addEventListener('zonos:activity',event=>setActivity(event.detail===true),{signal:globalListeners.signal});
|
| 38 |
+
const statusObserver=new MutationObserver(()=>{
|
| 39 |
+
if(!element.isConnected){cleanup();return;}
|
| 40 |
+
const status=document.getElementById('status-state');
|
| 41 |
+
if(status && (status.dataset.busy==='true')!==active)setActivity(status.dataset.busy==='true');
|
| 42 |
});
|
| 43 |
+
statusObserver.observe(document.getElementById('request-status')||document.documentElement,
|
| 44 |
+
{childList:true,subtree:true,attributes:true,attributeFilter:['data-busy']});
|
| 45 |
+
const removalObserver=new MutationObserver(()=>{if(!element.isConnected)cleanup();});
|
| 46 |
+
removalObserver.observe(document.documentElement,{childList:true,subtree:true});
|
| 47 |
+
function cleanup(){dispose();cancelAnimationFrame(activityFrame);statusObserver.disconnect();removalObserver.disconnect();globalListeners.abort();}
|
| 48 |
+
window.addEventListener('pagehide',cleanup,{once:true,signal:globalListeners.signal});
|
| 49 |
+
reducedMotion.addEventListener('change',scheduleWave,{signal:globalListeners.signal});
|
| 50 |
|
| 51 |
+
const minutes=seconds=>`${Math.floor(seconds/60)}:${String(Math.floor(seconds%60)).padStart(2,'0')}`;
|
| 52 |
function mount() {
|
| 53 |
+
dispose();dispose=()=>{};
|
| 54 |
+
let data;try{data=JSON.parse(props.value||'{}');}catch{return;}
|
| 55 |
+
const previousPlayer=element.querySelector('audio');
|
| 56 |
+
const player=previousPlayer.cloneNode(false);player.removeAttribute('src');previousPlayer.replaceWith(player);
|
| 57 |
+
const controls=element.querySelector('.playback'), button=element.querySelector('[data-play]');
|
| 58 |
+
const seek=element.querySelector('[data-seek]'),notice=element.querySelector('.audio-notice');
|
| 59 |
+
const listeners=new AbortController();
|
| 60 |
+
const on=(target,name,handler)=>target.addEventListener(name,handler,{signal:listeners.signal});
|
| 61 |
+
controls.hidden=true;notice.hidden=true;player.hidden=true;
|
| 62 |
+
button.textContent='Play';button.setAttribute('aria-label','Play generated speech');
|
| 63 |
+
seek.value='0';element.querySelector('[data-time]').textContent='0:00';
|
| 64 |
+
if(!data.url){scheduleWave();return;}
|
| 65 |
+
const url=new URL(data.url,document.baseURI);
|
| 66 |
+
if(url.origin!==location.origin || !url.pathname.includes('/gradio_api/file='))return;
|
| 67 |
+
document.getElementById('script-input')?.dispatchEvent(new CustomEvent('zonos:take',{detail:data}));
|
| 68 |
+
document.getElementById('voice-options')?.dispatchEvent(new CustomEvent('zonos:seed',{detail:data.seed}));
|
| 69 |
+
player.src=url.href;player.hidden=false;
|
| 70 |
+
const link=element.querySelector('[data-download]');link.href=url.href;link.download=data.download_name||'zonos3.wav';
|
| 71 |
+
element.querySelector('[data-duration]').textContent=minutes(data.duration||0);
|
| 72 |
+
let context=null,analyser=null,source=null;
|
| 73 |
+
const samples=new Float32Array(2048);
|
| 74 |
+
playing=()=>!player.paused && !player.ended;
|
| 75 |
+
energy=()=>{if(!analyser)return 0;analyser.getFloatTimeDomainData(samples);return voiceEnergy(samples);};
|
| 76 |
+
clock=()=>document.getElementById('script-input')?.dispatchEvent(new CustomEvent('zonos:clock',{
|
| 77 |
+
detail:{time:player.currentTime||0,playing:playing()}
|
| 78 |
+
}));
|
| 79 |
+
function transport() {
|
| 80 |
+
const ended=player.ended || (Number.isFinite(player.duration) && player.duration>0 && player.currentTime>=player.duration-.01);
|
| 81 |
+
const label=playing()?'Pause':ended?'Replay':'Play';
|
| 82 |
+
button.textContent=label;button.setAttribute('aria-label',label+' generated speech');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
}
|
| 84 |
function updateTime() {
|
| 85 |
+
clock();transport();
|
| 86 |
+
element.querySelector('[data-time]').textContent=minutes(player.currentTime||0);
|
| 87 |
+
if(Number.isFinite(player.duration)&&player.duration>0)seek.value=String(Math.round(player.currentTime/player.duration*1000));
|
| 88 |
}
|
| 89 |
async function play() {
|
| 90 |
try {
|
| 91 |
+
if(playing()){player.pause();return;}
|
| 92 |
+
if(player.ended || (player.duration>0 && player.currentTime>=player.duration-.01))player.currentTime=0;
|
| 93 |
+
const AudioContextClass=window.AudioContext||window.webkitAudioContext;
|
| 94 |
+
if(!context && AudioContextClass) {
|
| 95 |
+
context=new AudioContextClass();analyser=context.createAnalyser();analyser.fftSize=2048;
|
| 96 |
+
source=context.createMediaElementSource(player);source.connect(analyser);analyser.connect(context.destination);
|
|
|
|
| 97 |
}
|
| 98 |
+
if(context?.state==='suspended')await context.resume();
|
| 99 |
+
await player.play();transport();scheduleWave();notice.hidden=true;
|
|
|
|
| 100 |
} catch {
|
| 101 |
+
player.hidden=false;player.controls=true;
|
| 102 |
+
notice.textContent='Playback needs another click. You can also download the WAV.';notice.hidden=false;
|
|
|
|
|
|
|
| 103 |
}
|
| 104 |
}
|
| 105 |
+
on(button,'click',play);
|
| 106 |
+
on(host,'zonos:pause',()=>player.pause());
|
| 107 |
+
for(const event of ['play','playing','pause','ended','seeked'])on(player,event,()=>{updateTime();scheduleWave();});
|
| 108 |
+
on(player,'timeupdate',updateTime);
|
| 109 |
+
on(player,'loadedmetadata',()=>{element.querySelector('[data-duration]').textContent=minutes(player.duration);updateTime();});
|
| 110 |
+
on(player,'error',()=>{notice.hidden=false;notice.textContent='This audio is no longer available. Generate a new take.';scheduleWave();});
|
| 111 |
+
on(seek,'input',()=>{if(Number.isFinite(player.duration)){player.currentTime=Number(seek.value)/1000*player.duration;updateTime();}});
|
| 112 |
+
player.controls=false;player.hidden=true;controls.hidden=false;scheduleWave();
|
| 113 |
+
dispose=()=>{
|
| 114 |
+
listeners.abort();player.pause();player.removeAttribute('src');player.load();source?.disconnect();analyser?.disconnect();
|
| 115 |
+
if(context && context.state!=='closed')context.close().catch(()=>{});
|
| 116 |
+
playing=()=>false;energy=()=>0;clock=()=>{};
|
|
|
|
|
|
|
| 117 |
};
|
|
|
|
|
|
|
| 118 |
}
|
| 119 |
+
watch('value',mount);
|
| 120 |
mount();
|
assets/settings.css
CHANGED
|
@@ -31,13 +31,11 @@ input[type=range] { width:100%; min-width:50px; height:44px; accent-color:#e7e7e
|
|
| 31 |
.sampling-grid { display:grid; grid-template-columns:repeat(2,1fr); gap:12px 30px; padding-bottom:12px; }
|
| 32 |
.sampling-grid label { display:flex; align-items:center; justify-content:space-between; gap:8px; color:#aaa; font-size:12px; }
|
| 33 |
.text-button { min-height:44px; padding:8px 0; color:#ccc; background:transparent; border:0; text-decoration:underline; text-underline-offset:5px; font:11px ui-monospace,monospace; }
|
| 34 |
-
.examples { display:flex; align-items:center; justify-content:space-between; gap:15px; padding:22px 0 12px; }
|
| 35 |
-
.examples > div:last-child { display:flex; gap:24px; }
|
| 36 |
.retention { color:#8f8f8f; font:10px/1.7 ui-monospace,monospace; margin:14px 0 0; }
|
| 37 |
.consent-row { display:flex; align-items:center; gap:10px; min-height:44px; font-size:12px; color:#bbb; }
|
| 38 |
.consent-row input { accent-color:#ddd; width:18px; height:18px; }
|
| 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; } .
|
| 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; }
|
|
@@ -45,7 +43,6 @@ input[type=range] { width:100%; min-width:50px; height:44px; accent-color:#e7e7e
|
|
| 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; }
|
|
@@ -53,6 +50,12 @@ input[type=range] { width:100%; min-width:50px; height:44px; accent-color:#e7e7e
|
|
| 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 |
-
|
| 57 |
-
|
| 58 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
.sampling-grid { display:grid; grid-template-columns:repeat(2,1fr); gap:12px 30px; padding-bottom:12px; }
|
| 32 |
.sampling-grid label { display:flex; align-items:center; justify-content:space-between; gap:8px; color:#aaa; font-size:12px; }
|
| 33 |
.text-button { min-height:44px; padding:8px 0; color:#ccc; background:transparent; border:0; text-decoration:underline; text-underline-offset:5px; font:11px ui-monospace,monospace; }
|
|
|
|
|
|
|
| 34 |
.retention { color:#8f8f8f; font:10px/1.7 ui-monospace,monospace; margin:14px 0 0; }
|
| 35 |
.consent-row { display:flex; align-items:center; gap:10px; min-height:44px; font-size:12px; color:#bbb; }
|
| 36 |
.consent-row input { accent-color:#ddd; width:18px; height:18px; }
|
| 37 |
:focus-visible { outline:2px solid #eee; outline-offset:4px; }
|
| 38 |
+
@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; } .retention { font-size:10px; } }
|
| 39 |
@media(prefers-reduced-motion:reduce) { svg, input::after { transition:none; } }
|
| 40 |
/* Controls follow the label row, independently of wrapping help text. */
|
| 41 |
.setting-row { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:start; gap:18px; }
|
|
|
|
| 43 |
.setting-row > input[role=switch] { margin-top:12px; }
|
| 44 |
.seed-controls { justify-content:flex-end; min-height:44px; }
|
| 45 |
.source-switch button { min-height:40px; }
|
|
|
|
| 46 |
@media(max-width:600px) {
|
| 47 |
.setting-row { column-gap:12px; }
|
| 48 |
.seed-row, .rate-row { grid-template-columns:1fr; gap:8px; }
|
|
|
|
| 50 |
#take-seed { flex:1; width:auto; max-width:180px; text-align:left; }
|
| 51 |
input[type=number] { font-size:16px; min-height:44px; }
|
| 52 |
.source-switch button { font-size:14px; padding:6px 12px; }
|
| 53 |
+
|
| 54 |
+
|
| 55 |
}
|
| 56 |
+
.examples { display:grid; grid-template-columns:1fr; gap:12px; padding:22px 0 12px; }
|
| 57 |
+
.example-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:4px 16px; align-items:start; }
|
| 58 |
+
.example-button { appearance:none; -webkit-appearance:none; display:flex; align-items:center; justify-content:flex-start; box-sizing:border-box; height:44px; min-height:44px; margin:0!important; padding:0; border:0; border-radius:0; color:#ccc; background:transparent; font:12px/20px ui-monospace,monospace; text-align:left; }
|
| 59 |
+
.example-button span { display:block; height:22px; line-height:20px; border-bottom:1px solid currentColor; }
|
| 60 |
+
.example-button:hover { color:#fff; }
|
| 61 |
+
@media(max-width:360px) { .example-grid { gap:4px 10px; } .example-button { font-size:11px; } }
|
assets/settings.html
CHANGED
|
@@ -16,6 +16,6 @@
|
|
| 16 |
<label>Top-k <input type="number" min="1" max="400" step="1" data-setting="top_k"></label>
|
| 17 |
<label>Repetition penalty <input type="number" min="1" max="4" step="0.1" data-setting="repetition"></label>
|
| 18 |
</div><button type="button" class="text-button" data-reset>Reset sampling</button></details>
|
| 19 |
-
<
|
| 20 |
<p class="retention">600 characters per take · Download your audio to keep it beyond 24 hours.</p>
|
| 21 |
</div>
|
|
|
|
| 16 |
<label>Top-k <input type="number" min="1" max="400" step="1" data-setting="top_k"></label>
|
| 17 |
<label>Repetition penalty <input type="number" min="1" max="4" step="0.1" data-setting="repetition"></label>
|
| 18 |
</div><button type="button" class="text-button" data-reset>Reset sampling</button></details>
|
| 19 |
+
<section class="examples" aria-labelledby="examples-title"><div><span class="setting-label" id="examples-title">Try a text</span><p class="setting-help">Loads text, language and voice. Does not generate audio.</p></div><div class="example-grid" role="group" aria-labelledby="examples-title"><!-- example-buttons --></div></section>
|
| 20 |
<p class="retention">600 characters per take · Download your audio to keep it beyond 24 hours.</p>
|
| 21 |
</div>
|
assets/settings.js
CHANGED
|
@@ -4,6 +4,7 @@ const host = document.getElementById('voice-options');
|
|
| 4 |
const defaults = {source:'Preset',consent:false,seed:0,randomize:true,normalize:true,rate:0,temperature:.75,top_p:.69,top_k:80,repetition:2.4};
|
| 5 |
let state = {...defaults, ...JSON.parse(props.value || '{}')};
|
| 6 |
let open = false;
|
|
|
|
| 7 |
const listeners = new AbortController();
|
| 8 |
function publish() { props.value = JSON.stringify(state); host.dataset.settings = props.value; }
|
| 9 |
function sync() {
|
|
@@ -38,7 +39,7 @@ element.addEventListener('click', event => {
|
|
| 38 |
if (button.matches('.settings-toggle')) { open = !open; sync(); }
|
| 39 |
if (button.dataset.source) { state.source = button.dataset.source; props.source = state.source; sync(); sourceChanged(); }
|
| 40 |
if (button.hasAttribute('data-reset')) { for (const key of ['temperature','top_p','top_k','repetition']) state[key] = defaults[key]; sync(); }
|
| 41 |
-
if (button.dataset.example) trigger(button.dataset.example
|
| 42 |
}, {signal:listeners.signal});
|
| 43 |
host.addEventListener('zonos:seed', event => { state.seed = event.detail; sync(); }, {signal:listeners.signal});
|
| 44 |
watch('source', () => { state.source = props.source; sync(); sourceChanged(); });
|
|
|
|
| 4 |
const defaults = {source:'Preset',consent:false,seed:0,randomize:true,normalize:true,rate:0,temperature:.75,top_p:.69,top_k:80,repetition:2.4};
|
| 5 |
let state = {...defaults, ...JSON.parse(props.value || '{}')};
|
| 6 |
let open = false;
|
| 7 |
+
const exampleEvents={en:'example_en',fr:'example_fr',de:'example_de',es:'example_es',it:'example_it',pt:'example_pt',ja:'example_ja',zh:'example_zh',ko:'example_ko'};
|
| 8 |
const listeners = new AbortController();
|
| 9 |
function publish() { props.value = JSON.stringify(state); host.dataset.settings = props.value; }
|
| 10 |
function sync() {
|
|
|
|
| 39 |
if (button.matches('.settings-toggle')) { open = !open; sync(); }
|
| 40 |
if (button.dataset.source) { state.source = button.dataset.source; props.source = state.source; sync(); sourceChanged(); }
|
| 41 |
if (button.hasAttribute('data-reset')) { for (const key of ['temperature','top_p','top_k','repetition']) state[key] = defaults[key]; sync(); }
|
| 42 |
+
if (exampleEvents[button.dataset.example]) trigger(exampleEvents[button.dataset.example]);
|
| 43 |
}, {signal:listeners.signal});
|
| 44 |
host.addEventListener('zonos:seed', event => { state.seed = event.detail; sync(); }, {signal:listeners.signal});
|
| 45 |
watch('source', () => { state.source = props.source; sync(); sourceChanged(); });
|
assets/wave.js
CHANGED
|
@@ -1,39 +1,44 @@
|
|
| 1 |
-
//
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
| 7 |
}
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
const
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
}
|
| 20 |
-
filtered.push(sum/weight);
|
| 21 |
}
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
}
|
| 38 |
return d+' L1000 70';
|
| 39 |
}
|
|
|
|
| 1 |
+
// An audio-reactive illustration, not an oscilloscope or a PCM preview.
|
| 2 |
+
// Energy comes from the playing audio; shape and continuous phase are visual.
|
| 3 |
+
function voiceEnergy(values) {
|
| 4 |
+
if (!values.length) return 0;
|
| 5 |
+
let sum=0, squares=0;
|
| 6 |
+
for (const value of values) { const v=Number.isFinite(value)?value:0; sum+=v; squares+=v*v; }
|
| 7 |
+
const rms=Math.sqrt(Math.max(0,squares/values.length-(sum/values.length)**2));
|
| 8 |
+
return Math.sqrt(1-Math.exp(-18*Math.max(0,rms-.003)));
|
| 9 |
}
|
| 10 |
+
class WaveMotion {
|
| 11 |
+
constructor() { this.level=0; this.phase=0; }
|
| 12 |
+
step(target,seconds) {
|
| 13 |
+
const dt=Math.min(.05,Math.max(0,seconds));
|
| 14 |
+
target=Math.min(1,Math.max(0,Number.isFinite(target)?target:0));
|
| 15 |
+
// Time-based attack/release behaves alike at 30, 60 and 120 Hz.
|
| 16 |
+
const tau=target > this.level ? .09 : .24;
|
| 17 |
+
this.level+=(target-this.level)*(1-Math.exp(-dt/tau));
|
| 18 |
+
this.phase=(this.phase+dt*(1.15+.3*this.level))%(Math.PI*20);
|
| 19 |
+
if(target===0 && this.level<.001)this.level=0;
|
| 20 |
+
return this;
|
|
|
|
|
|
|
| 21 |
}
|
| 22 |
+
}
|
| 23 |
+
function wavePath(level,phase=0) {
|
| 24 |
+
level=Math.min(1,Math.max(0,Number.isFinite(level)?level:0));
|
| 25 |
+
if(level<.001)return 'M0 70 L1000 70';
|
| 26 |
+
function y(t) {
|
| 27 |
+
const edge=Math.min(1,t/.16,(1-t)/.16);
|
| 28 |
+
const fade=edge*edge*edge*(edge*(edge*6-15)+10);
|
| 29 |
+
const broad=.72+.28*Math.sin(Math.PI*t)**2;
|
| 30 |
+
const carrier=(Math.sin(Math.PI*2*6.2*t+phase)+.23*Math.sin(Math.PI*2*3.1*t-phase*.7+.7))/1.23;
|
| 31 |
+
return 70-56*level*fade*broad*carrier;
|
| 32 |
+
}
|
| 33 |
+
const derivative=t=>t<=0||t>=1?0:(y(t+.0001)-y(t-.0001))/.0002;
|
| 34 |
+
// Cubics follow the analytic curve, without saturation or flattened peaks.
|
| 35 |
+
const count=128, width=960;
|
| 36 |
+
let d='M0 70 L20 70';
|
| 37 |
+
for(let i=1;i<=count;i++) {
|
| 38 |
+
const a=(i-1)/count,b=i/count,h=(b-a)/3;
|
| 39 |
+
d+=` C${(20+width*(a+h)).toFixed(2)} ${(y(a)+derivative(a)*h).toFixed(2)} ${
|
| 40 |
+
(20+width*(b-h)).toFixed(2)} ${(y(b)-derivative(b)*h).toFixed(2)} ${
|
| 41 |
+
(20+width*b).toFixed(2)} ${y(b).toFixed(2)}`;
|
| 42 |
}
|
| 43 |
return d+' L1000 70';
|
| 44 |
}
|
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.3.
|
| 5 |
ROOT = Path(__file__).resolve().parent
|
| 6 |
MODEL_ID = "Zyphra/ZONOS3-Mini"
|
| 7 |
MODEL_REVISION = "eaac314642440e8f1f497f1505ca376f8e618f2a"
|
|
|
|
| 1 |
"""Release contract. Changing a revision requires rerunning the contract tests."""
|
| 2 |
from pathlib import Path
|
| 3 |
|
| 4 |
+
VERSION = "0.3.1"
|
| 5 |
ROOT = Path(__file__).resolve().parent
|
| 6 |
MODEL_ID = "Zyphra/ZONOS3-Mini"
|
| 7 |
MODEL_REVISION = "eaac314642440e8f1f497f1505ca376f8e618f2a"
|
examples.json
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"en": {"label":"English", "language":"en_us", "voice":"Savannah", "text":"Some stories begin with a voice.\nThis one begins with yours."},
|
| 3 |
+
"fr": {"label":"Français", "language":"fr_fr", "voice":"Gaspard", "text":"Un seul modèle.\nUne voix pour chaque idée."},
|
| 4 |
+
"de": {"label":"Deutsch", "language":"de", "voice":"Liesel", "text":"Manche Geschichten beginnen mit einer Stimme.\nDiese beginnt mit deiner."},
|
| 5 |
+
"es": {"label":"Español", "language":"es", "voice":"Ximena", "text":"Algunas historias comienzan con una voz.\nEsta empieza con la tuya."},
|
| 6 |
+
"it": {"label":"Italiano", "language":"it", "voice":"Ginevra", "text":"Alcune storie iniziano con una voce.\nQuesta comincia con la tua."},
|
| 7 |
+
"pt": {"label":"Português", "language":"pt_br", "voice":"Thiago", "text":"Algumas histórias começam com uma voz.\nEsta começa com a sua."},
|
| 8 |
+
"ja": {"label":"日本語", "language":"ja", "voice":"Savannah", "text":"物語は、ひとつの声から始まります。\nこの物語は、あなたの声から。"},
|
| 9 |
+
"zh": {"label":"中文", "language":"cmn", "voice":"Meilin", "text":"有些故事,从一个声音开始。\n这个故事,从你的声音开始。"},
|
| 10 |
+
"ko": {"label":"한국어", "language":"ko", "voice":"Seoyeon", "text":"어떤 이야기는 목소리에서 시작됩니다.\n이 이야기는 당신의 목소리로 시작됩니다."}
|
| 11 |
+
}
|
tests/test_examples.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from config import ROOT, LANGUAGES, MAX_CHARS
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def test_examples_cover_all_languages_and_use_real_presets(monkeypatch):
|
| 6 |
+
monkeypatch.setenv("ZONOS_UI_ONLY", "1")
|
| 7 |
+
import app
|
| 8 |
+
catalog={v["name"] for v in app.CATALOG}
|
| 9 |
+
assert len(app.EXAMPLES)==9
|
| 10 |
+
assert {v["language"] for v in app.EXAMPLES.values()}==set(LANGUAGES)-{"en_gb"}
|
| 11 |
+
for code in app.EXAMPLES:
|
| 12 |
+
text,picker,source=app.load_example(code)
|
| 13 |
+
voice=json.loads(picker)
|
| 14 |
+
assert 0<len(text)<=MAX_CHARS and voice["voice"] in catalog and source=="Preset"
|
| 15 |
+
assert voice["language"]==app.EXAMPLES[code]["language"] and voice["custom"] is False
|
| 16 |
+
assert set(app.demo.get_api_info()["named_endpoints"])=={"/generate","/cancel"}
|
| 17 |
+
assert app.settings_markup().count('class="example-button"')==9
|
| 18 |
+
example_events=[e for e in app.demo.config["dependencies"] if any(t[1].startswith("example_") for t in e["targets"])]
|
| 19 |
+
assert len(example_events)==9 and all(e["queue"] is False for e in example_events)
|
tests/test_ui_js.py
CHANGED
|
@@ -3,30 +3,34 @@ import subprocess
|
|
| 3 |
from config import ROOT
|
| 4 |
|
| 5 |
|
| 6 |
-
def
|
| 7 |
source = (ROOT / 'assets/wave.js').read_text()
|
| 8 |
script = source + r'''
|
| 9 |
const assert = require('node:assert/strict');
|
| 10 |
-
|
| 11 |
-
assert.equal(
|
| 12 |
-
const
|
| 13 |
-
assert.ok(
|
| 14 |
-
|
| 15 |
-
const
|
| 16 |
-
|
| 17 |
-
assert.ok(d.
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
assert.equal(
|
| 21 |
-
assert.
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
const
|
| 28 |
-
|
| 29 |
-
assert.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
'''
|
| 31 |
subprocess.run(['node','-e',script], check=True)
|
| 32 |
|
|
@@ -51,10 +55,8 @@ assert.equal(nodes['generate-speech'].disabled,true);
|
|
| 51 |
|
| 52 |
|
| 53 |
def test_activity_completion_restores_submit_after_validation_error():
|
| 54 |
-
# Gradio may leave an imperative disabled property untouched when its
|
| 55 |
-
# interactive prop stays True across a validation failure.
|
| 56 |
source = (ROOT / 'assets/wave.js').read_text()
|
| 57 |
-
source += (ROOT / 'assets/player.js').read_text().split("
|
| 58 |
script = r'''
|
| 59 |
const assert = require('node:assert/strict');
|
| 60 |
const generate = {disabled:true};
|
|
@@ -64,12 +66,9 @@ const svg = {setAttribute:()=>{},querySelector:()=>path};
|
|
| 64 |
global.element = {querySelector:()=>svg,isConnected:true};
|
| 65 |
global.document = {getElementById:id=>id==='generate-speech'?generate:hostNode};
|
| 66 |
global.window = {matchMedia:()=>({matches:true})};
|
| 67 |
-
global.cancelAnimationFrame=()=>{};
|
| 68 |
''' + source + r'''
|
| 69 |
-
setActivity(true);
|
| 70 |
-
assert.equal(hostNode.dataset.busy,'
|
| 71 |
-
setActivity(false);
|
| 72 |
-
assert.equal(generate.disabled,false);
|
| 73 |
-
assert.equal(hostNode.dataset.busy,'false');
|
| 74 |
'''
|
| 75 |
subprocess.run(['node','-e',script], check=True)
|
|
|
|
| 3 |
from config import ROOT
|
| 4 |
|
| 5 |
|
| 6 |
+
def test_wave_energy_geometry_and_temporal_continuity():
|
| 7 |
source = (ROOT / 'assets/wave.js').read_text()
|
| 8 |
script = source + r'''
|
| 9 |
const assert = require('node:assert/strict');
|
| 10 |
+
assert.equal(voiceEnergy(Array(200).fill(0)),0);
|
| 11 |
+
assert.equal(voiceEnergy(Array(200).fill(.8)),0); // DC is not audible energy.
|
| 12 |
+
const tone=a=>Array.from({length:2048},(_,i)=>a*Math.sin(i*.18));
|
| 13 |
+
assert.ok(voiceEnergy(tone(.1))>voiceEnergy(tone(.015)));
|
| 14 |
+
assert.equal(wavePath(0),'M0 70 L1000 70');
|
| 15 |
+
for(const phase of [0,1,3,12]) {
|
| 16 |
+
const d=wavePath(1,phase);
|
| 17 |
+
assert.ok(d.startsWith('M0 70 L20 70 C'));
|
| 18 |
+
assert.ok(d.endsWith('980.00 70.00 L1000 70'));
|
| 19 |
+
const c=[...d.matchAll(/C([\d.]+) ([\d.]+) ([\d.]+) ([\d.]+) ([\d.]+) ([\d.]+)/g)].map(x=>x.slice(1).map(Number));
|
| 20 |
+
assert.equal(c[0][1],70);assert.equal(c.at(-1)[3],70);
|
| 21 |
+
assert.ok(c.flatMap(p=>[p[1],p[3],p[5]]).every(y=>y>=13 && y<=127));
|
| 22 |
+
}
|
| 23 |
+
const motion=new WaveMotion();
|
| 24 |
+
motion.step(1,1/60);assert.ok(motion.level>0 && motion.level<.2);
|
| 25 |
+
for(let i=0;i<60;i++)motion.step(1,1/60);
|
| 26 |
+
assert.ok(motion.level>.99);
|
| 27 |
+
const before=motion.level;motion.step(0,1/60);assert.ok(motion.level<before && motion.level>.9);
|
| 28 |
+
for(let i=0;i<120;i++)motion.step(0,1/60);
|
| 29 |
+
assert.equal(motion.level,0);assert.equal(wavePath(motion.level,motion.phase),'M0 70 L1000 70');
|
| 30 |
+
const m30=new WaveMotion(),m120=new WaveMotion();
|
| 31 |
+
for(let i=0;i<30;i++)m30.step(.6,1/30);
|
| 32 |
+
for(let i=0;i<120;i++)m120.step(.6,1/120);
|
| 33 |
+
assert.ok(Math.abs(m30.level-m120.level)<.0001);
|
| 34 |
'''
|
| 35 |
subprocess.run(['node','-e',script], check=True)
|
| 36 |
|
|
|
|
| 55 |
|
| 56 |
|
| 57 |
def test_activity_completion_restores_submit_after_validation_error():
|
|
|
|
|
|
|
| 58 |
source = (ROOT / 'assets/wave.js').read_text()
|
| 59 |
+
source += (ROOT / 'assets/player.js').read_text().split("const globalListeners=")[0]
|
| 60 |
script = r'''
|
| 61 |
const assert = require('node:assert/strict');
|
| 62 |
const generate = {disabled:true};
|
|
|
|
| 66 |
global.element = {querySelector:()=>svg,isConnected:true};
|
| 67 |
global.document = {getElementById:id=>id==='generate-speech'?generate:hostNode};
|
| 68 |
global.window = {matchMedia:()=>({matches:true})};
|
| 69 |
+
global.cancelAnimationFrame=()=>{};global.requestAnimationFrame=()=>1;
|
| 70 |
''' + source + r'''
|
| 71 |
+
setActivity(true);assert.equal(hostNode.dataset.busy,'true');
|
| 72 |
+
setActivity(false);assert.equal(generate.disabled,false);assert.equal(hostNode.dataset.busy,'false');
|
|
|
|
|
|
|
|
|
|
| 73 |
'''
|
| 74 |
subprocess.run(['node','-e',script], check=True)
|