Spaces:
Running on Zero
Running on Zero
v0.3.2: compact loading, moving wave and optional auto-play
Browse files- CHANGELOG.md +7 -0
- MANIFEST.sha256 +15 -12
- README.md +9 -3
- VALIDATION.md +12 -1
- app.py +4 -1
- assets/boot.html +40 -0
- assets/player.html +1 -1
- assets/player.js +138 -95
- assets/script.js +2 -0
- assets/settings.html +1 -0
- assets/settings.js +4 -1
- assets/wave.js +4 -3
- boot.py +45 -0
- config.py +1 -1
- tests/test_boot.py +32 -0
- tests/test_ui_js.py +5 -1
CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
| 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.
|
|
|
|
| 1 |
# Changelog
|
| 2 |
|
| 3 |
+
## 0.3.2 · 2026-11-10
|
| 4 |
+
|
| 5 |
+
- Show a compact loading shell in the initial HTML, with reload/direct-tab recovery if initialization is delayed. Use explicit client rendering and prevent caching of the document entry; leave streaming API and media responses unchanged.
|
| 6 |
+
- Increase wave travel and vary its shape locally while retaining smooth edges, real audio-energy response, pause decay and reduced-motion support.
|
| 7 |
+
- Add Auto-play result, enabled by default and remembered in the browser. Prepare playback on Generate and reuse the media element across takes; handle policy refusal, cancellation, background tabs and manual playback.
|
| 8 |
+
- Preserve all model revisions, generation parameters and the 13-input / 5-output API.
|
| 9 |
+
|
| 10 |
## 0.3.1 · 2026-11-10
|
| 11 |
|
| 12 |
- 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.
|
MANIFEST.sha256
CHANGED
|
@@ -1,32 +1,34 @@
|
|
| 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 |
-
|
| 23 |
b379b615bb1f52b355b60bdde94a9fde4ef732f635a1ed56adbdfb7e279baca7 assets/settings.css
|
| 24 |
-
|
| 25 |
-
|
| 26 |
249fd190f8b58cf6e1b8f2582ed4afc40ce8ecf43292cbb409ee82f255604465 assets/style.css
|
| 27 |
-
|
| 28 |
c476aa0fc7ed79b77cd6577d6e5efaf75dee8cccb6d5dc5f56fe92334b27d04d audio_files.py
|
| 29 |
-
|
|
|
|
| 30 |
22cc095df3f023b95e4f7456f1b79144e341a33db6554db88ebe08a98c6b2f78 controller.py
|
| 31 |
6aa124baba94d5961a439a9467325e14aec4306f1e1fbdb3fef9a7a0b9bd511b engine.py
|
| 32 |
e419cf08810b1fb969523c597a03c728d2f697b03a3e5e9a2ef81e63620d4046 examples.json
|
|
@@ -42,6 +44,7 @@ efe08b65cc84bffc56f7a101e2cab6687355eef1ffaf415673b0f769605c7d41 scripts/packag
|
|
| 42 |
f364eca49b31d8637a504c9644b5c522c6702d33c89df06e229438fb124a11dc tested-dependencies.json
|
| 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
|
|
@@ -49,7 +52,7 @@ a20f4145d11561ab4caeabd51952c9f9b14088d5d330a4549e79f6a32310221c tests/test_lif
|
|
| 49 |
0889b93f4d83986372f5dbc72ad902ebb076bfd730c6ad07f3232b01b5ed1ee9 tests/test_logging.py
|
| 50 |
31bfd349c66c1c31c06a7fc7d6cd1c94257679029bd512ddb4f133bfc4f6ccc8 tests/test_package.py
|
| 51 |
beae97c67f31cc89d948a5293bc820348cae1117e2f626ba97e7706819318ac8 tests/test_requests.py
|
| 52 |
-
|
| 53 |
98216c34413fa17aeaaba8aeec41d157609b6b8f6c07cf6250239128cf616bb5 vendor/ZYPHRA_LICENSE
|
| 54 |
5dd0692aae95e02965462827ccdd0fb1fc4f9762552e1cf979c4dc789cc34fc5 vendor/ZYPHRA_NOTICE
|
| 55 |
cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30 vendor/third_party/licenses/fusealign-multilingual-Apache-2.0.txt
|
|
|
|
| 1 |
378f694f97eb755989fc6139455a58bc8c1bca7ae32c7a11be939a42ab27eeda .gitattributes
|
| 2 |
f63fc2a4a5e19102c55cf01aa092aa8982403ef6ef808f4c2c65a9f766ccbf3e .gitignore
|
| 3 |
+
9a449a5fa3b6bd3818edc79255b00aadc6e3960542530cba0feca8ce1a9a1cb8 CHANGELOG.md
|
| 4 |
3381fc95c5558560e1bb62d4cebea34bf6efa4adfbda7f4fe03bbf40bbcc55d3 LICENSE
|
| 5 |
14ff7f6e037dae2f40983b6408a1570d587bbcdecd7a7b061f2d11f94044f812 NOTICE
|
| 6 |
5ea53dcd52ecacb21ea6ae4fb94cc2ed7163fe02300f792a0ed35d09dcc0709c PROVENANCE.md
|
| 7 |
+
7626578745fba5b916ac5a585899d9f9c6a80183a05c11e4b34aa978d171443c README.md
|
| 8 |
+
6079d8fde984301f974b43c1a0604594c5525b05463fdb6d76aa133600172162 VALIDATION.md
|
| 9 |
c27fa4c518c35c000227a0a939c350999db6c4e45e71082ef32f74efb8ff3f92 alignment.py
|
| 10 |
+
d0234e3e0bf5ef377bb3276cc3dca73f8d2a689f55bc44d80a234d2063591989 app.py
|
| 11 |
+
bb3994ae1c3e5b64bda5474cda993ba94aee12776d8495bc6de950cca2cdfd17 assets/boot.html
|
| 12 |
ca28255ebe082f8104b27f52fcf5c05bf899e6edfe8fdaa853eb30adcfdd0e52 assets/generate.js
|
| 13 |
dd23ac0a74f0682218938cf387a037e89724e8bbfbdeb5ff35ecff289ebc24d4 assets/layout.js
|
| 14 |
ecb1d25ae4aa218f69e269519fb68719a2c657ea1c24c0b6780df5f5dcf08259 assets/picker.js
|
| 15 |
98d10bfcd45f9d3dfe3cf1e433a4a2829d253496a90daefd30819a0abd5a4347 assets/player.css
|
| 16 |
+
47e44be992f37a3d94c7eab6d0efb99579f3ba68141045a28c68cb0e4962cdbf assets/player.html
|
| 17 |
+
cd4ed9b35a9d40beb8d91c054310437026e00723828123a5ffe26f681ac4e97b assets/player.js
|
| 18 |
57347c4f3eb6f9aec59f1c8882c66e926e04acb87cc0154573594f063cf5df02 assets/reference.css
|
| 19 |
03b2f8dbea4891f5ceb8990727473184118536720f439ffc69ef7d83d2f9a396 assets/reference.html
|
| 20 |
697bc75149164c4737f1d07d1ba95cffcae9214fee3c6e7ba1210c497783c5f1 assets/reference.js
|
| 21 |
74a71fe3c3cee7795fdd1e80f79b29f22c82c7bd27f2486c1a88578e5de1e821 assets/script.css
|
| 22 |
9519e9e91bc74d23ef72c0e4e8b3c02f0d16fee8143910bff75c1731f163ed44 assets/script.html
|
| 23 |
+
d82a75d9d2e92326d93230d3389815ce9216f30195b74b619098b509b5afc089 assets/script.js
|
| 24 |
b379b615bb1f52b355b60bdde94a9fde4ef732f635a1ed56adbdfb7e279baca7 assets/settings.css
|
| 25 |
+
9d311f9d8222ba9efab8178e5ebe48c75afd20212769ac04af0248550dfea2eb assets/settings.html
|
| 26 |
+
f8f916678698be4980a84d68a2ced620aa0dac039cbf0330bb1d8e64e2d8d65a assets/settings.js
|
| 27 |
249fd190f8b58cf6e1b8f2582ed4afc40ce8ecf43292cbb409ee82f255604465 assets/style.css
|
| 28 |
+
4ce25de7785872fde1655e9c372dc2f6530b7f1e8d8a4d9295f1bf249cc8cfce assets/wave.js
|
| 29 |
c476aa0fc7ed79b77cd6577d6e5efaf75dee8cccb6d5dc5f56fe92334b27d04d audio_files.py
|
| 30 |
+
e02447cc43708cdebc0025104cdade91c19ba88bfbe3b37830fe3b9748d55f64 boot.py
|
| 31 |
+
daaaf0fc864b96719eff2f5684739bca9dc41806ec23d0d0da19ea6be879c826 config.py
|
| 32 |
22cc095df3f023b95e4f7456f1b79144e341a33db6554db88ebe08a98c6b2f78 controller.py
|
| 33 |
6aa124baba94d5961a439a9467325e14aec4306f1e1fbdb3fef9a7a0b9bd511b engine.py
|
| 34 |
e419cf08810b1fb969523c597a03c728d2f697b03a3e5e9a2ef81e63620d4046 examples.json
|
|
|
|
| 44 |
f364eca49b31d8637a504c9644b5c522c6702d33c89df06e229438fb124a11dc tested-dependencies.json
|
| 45 |
e25f3dd0e11ec835e7db001c703825430e5c4745a538efb8d445fe650821cf1b tests/test_alignment.py
|
| 46 |
70802a3c9077443d6e7c8e9d354f799981f96a321cca5b40f5f307fa364b2924 tests/test_audio.py
|
| 47 |
+
545383708509cae09cb17f69f5f7ffdba43a93e72ff70bbe743ef9e0c3ce6bc4 tests/test_boot.py
|
| 48 |
89a62cbb880d9495634fce62a659e531dba74a1efa8bb5db4dbfeb1b8d5bf44b tests/test_contracts.py
|
| 49 |
8133a4f9345f726307b0cac6530b948faa710e9d0405b1021b6d72b006d71ea1 tests/test_examples.py
|
| 50 |
9c2d7b6aad84fa56d891bb7f79bedc8386b5cfca86f80a282c41777d9cd1a61b tests/test_http.py
|
|
|
|
| 52 |
0889b93f4d83986372f5dbc72ad902ebb076bfd730c6ad07f3232b01b5ed1ee9 tests/test_logging.py
|
| 53 |
31bfd349c66c1c31c06a7fc7d6cd1c94257679029bd512ddb4f133bfc4f6ccc8 tests/test_package.py
|
| 54 |
beae97c67f31cc89d948a5293bc820348cae1117e2f626ba97e7706819318ac8 tests/test_requests.py
|
| 55 |
+
9dbb093d9356a2dd2dab243c635629ff5c65e2c0550c87a9a838a9845abbaa3a tests/test_ui_js.py
|
| 56 |
98216c34413fa17aeaaba8aeec41d157609b6b8f6c07cf6250239128cf616bb5 vendor/ZYPHRA_LICENSE
|
| 57 |
5dd0692aae95e02965462827ccdd0fb1fc4f9762552e1cf979c4dc789cc34fc5 vendor/ZYPHRA_NOTICE
|
| 58 |
cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30 vendor/third_party/licenses/fusealign-multilingual-Apache-2.0.txt
|
README.md
CHANGED
|
@@ -15,7 +15,7 @@ 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-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).
|
|
@@ -26,12 +26,18 @@ Centered editable text, a fine audio-reactive line, and one Generate button. The
|
|
| 26 |
|
| 27 |
1. Write up to 600 characters. Choose a language and a preset voice.
|
| 28 |
2. Select **Generate speech**. The native Gradio/ZeroGPU progress and quota notices remain available.
|
| 29 |
-
3. Play, seek
|
| 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:
|
| 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).
|
|
|
|
| 15 |
short_description: A voice, a line, and your words.
|
| 16 |
---
|
| 17 |
|
| 18 |
+
# ZONOS3 Mini · 0.3.2
|
| 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).
|
|
|
|
| 26 |
|
| 27 |
1. Write up to 600 characters. Choose a language and a preset voice.
|
| 28 |
2. Select **Generate speech**. The native Gradio/ZeroGPU progress and quota notices remain available.
|
| 29 |
+
3. The completed WAV plays automatically when **Auto-play result** is enabled (the default). Disable it in Voice settings if preferred. Play, seek and download remain available. A browser that blocks playback shows a prompt to tap Play.
|
| 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: moving crests, smooth local phase deformation 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 |
+
## Loading and playback
|
| 36 |
+
|
| 37 |
+
The HTML entry response includes a compact loading shell before framework JavaScript executes. If initialization takes longer, it offers Reload and Open in a new tab. It disappears after the text, player and settings are initialized. The entry is served with `Cache-Control: no-store`; static hashed assets remain cacheable. `ssr_mode=False` is explicit so local and hosted startup use the same rendering path. This removes the empty SSR shell observed before client initialization; it does not claim to diagnose every network or browser-cache failure.
|
| 38 |
+
|
| 39 |
+
Auto-play is a browser-only preference, remembered locally when storage is available. The Generate gesture prepares one persistent audio element and AudioContext (including a short silent first-use primer). Only a new completed result may start automatically. Failed or cancelled requests never replay the preceding take, and a background tab waits for a manual Play click. Browser and iframe playback policies still apply; a rejected `play()` promise leaves Play and Download usable. No autoplay setting is added to the public synthesis API.
|
| 40 |
+
|
| 41 |
## Deploy to Hugging Face
|
| 42 |
|
| 43 |
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).
|
VALIDATION.md
CHANGED
|
@@ -1,7 +1,18 @@
|
|
| 1 |
-
# Validation · 0.3.
|
| 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.
|
|
|
|
| 1 |
+
# Validation · 0.3.2 · 2026-11-10
|
| 2 |
|
| 3 |
## Current release
|
| 4 |
|
| 5 |
+
- 61 local tests passed, including the unchanged synthesis API, lifecycle and packaging checks. New ASGI tests verify insertion before framework markup, no-store entry headers and untouched API/SSE/media/asset responses. The five boot/UI contracts also passed after the final readiness change. The existing protobuf deprecation warning remains visible.
|
| 6 |
+
- Actual WebKit and Chromium rendering with an iPhone-sized viewport: automatic playback succeeded on two successive results using the same media element. Disabling auto-play prevented playback, and the preference survived reload. A simulated browser-policy rejection left an explicit message and usable manual Play control; manual playback then succeeded after restoring the browser method.
|
| 7 |
+
- Cancelled and invalid requests preserved the previous audio without replaying it. The curve changed during actual WAV playback and settled to the exact flat baseline on pause. No JavaScript page errors were observed. Viewports at 320, 390, 768 and 1440 CSS px had no horizontal overflow.
|
| 8 |
+
- Startup checks in both engines: initial HTML includes the compact 280 px loading shell and no-store header; the shell disappears after the text, player and settings signal readiness. Blocking framework JavaScript kept the shell visible, then exposed Reload and Open in a new tab after the timeout. Mobile loading and playback screenshots were inspected.
|
| 9 |
+
- Browser inference was a CPU fixture with simulated request progress and a real 3.933-second English/Savannah WAV obtained from the deployed v0.3.1 engine. The fixture is not a new GPU inference or a GPU-release test.
|
| 10 |
+
- Models, pinned dependencies, inference parameters, GPU lifecycle and 13-input / 5-output API are unchanged. Auto-play is a local browser preference. Entry loading changes use the public ASGI middleware interface and explicit Gradio client rendering.
|
| 11 |
+
|
| 12 |
+
Remaining limits: physical Safari/iPhone, background-tab media policy across devices, and acoustic review of all language examples. Linux WebKit is not a physical iOS test. The policy-refusal case was simulated; playback success above was actual browser media playback. Earlier GPU-release evidence applies to the unchanged engine.
|
| 13 |
+
|
| 14 |
+
## v0.3.1 evidence
|
| 15 |
+
|
| 16 |
- 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.
|
| 17 |
- 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.
|
| 18 |
- 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.
|
app.py
CHANGED
|
@@ -12,6 +12,8 @@ import gradio as gr
|
|
| 12 |
from config import DEFAULT_TEXT, GRADIO_VERSION, LANGUAGES, MAX_CHARS, MAX_SEED, ROOT, VERSION
|
| 13 |
from controller import Controller, message
|
| 14 |
from logging_setup import configure_logging
|
|
|
|
|
|
|
| 15 |
|
| 16 |
configure_logging()
|
| 17 |
if gr.__version__ != GRADIO_VERSION:
|
|
@@ -159,4 +161,5 @@ demo.queue(max_size=16, default_concurrency_limit=1, api_open=False)
|
|
| 159 |
if __name__ == "__main__":
|
| 160 |
demo.launch(theme=theme, css=(ASSETS / "style.css").read_text(), allowed_paths=[str(controller.outputs.resolve())],
|
| 161 |
blocked_paths=[str((ROOT / "vendor").resolve()), str(controller.jobs.directory.resolve())],
|
| 162 |
-
max_file_size="25mb", show_error=False, max_threads=40, footer_links=[]
|
|
|
|
|
|
| 12 |
from config import DEFAULT_TEXT, GRADIO_VERSION, LANGUAGES, MAX_CHARS, MAX_SEED, ROOT, VERSION
|
| 13 |
from controller import Controller, message
|
| 14 |
from logging_setup import configure_logging
|
| 15 |
+
from starlette.middleware import Middleware
|
| 16 |
+
from boot import EntryShell
|
| 17 |
|
| 18 |
configure_logging()
|
| 19 |
if gr.__version__ != GRADIO_VERSION:
|
|
|
|
| 161 |
if __name__ == "__main__":
|
| 162 |
demo.launch(theme=theme, css=(ASSETS / "style.css").read_text(), allowed_paths=[str(controller.outputs.resolve())],
|
| 163 |
blocked_paths=[str((ROOT / "vendor").resolve()), str(controller.jobs.directory.resolve())],
|
| 164 |
+
max_file_size="25mb", show_error=False, max_threads=40, footer_links=[],
|
| 165 |
+
ssr_mode=False, app_kwargs={"middleware":[Middleware(EntryShell, shell_path=ASSETS / "boot.html")]})
|
assets/boot.html
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<style>
|
| 2 |
+
#zonos-boot{box-sizing:border-box;display:grid;align-content:center;justify-items:center;gap:20px;min-height:280px;padding:36px 20px;background:#000;color:#ddd;font:12px/1.6 ui-monospace,monospace;text-align:center}
|
| 3 |
+
#zonos-boot p{margin:0}#zonos-boot strong{font-weight:400;letter-spacing:.04em}
|
| 4 |
+
#zonos-boot svg{display:block;width:180px;height:32px;fill:none;stroke:#999;stroke-width:1.25}
|
| 5 |
+
#zonos-boot a{color:#ddd;text-underline-offset:4px;display:inline-flex;align-items:center;min-height:44px;margin:0 8px}
|
| 6 |
+
#zonos-boot a:focus-visible{outline:2px solid #eee;outline-offset:4px}
|
| 7 |
+
#zonos-boot [hidden]{display:none!important}
|
| 8 |
+
html[data-zonos-loading] gradio-app{visibility:hidden;height:0!important;overflow:hidden;flex:none!important}
|
| 9 |
+
</style>
|
| 10 |
+
<div id="zonos-boot" role="status" aria-live="polite">
|
| 11 |
+
<strong>ZONOS3-Mini</strong>
|
| 12 |
+
<svg viewBox="0 0 180 32" aria-hidden="true"><path d="M0 16H40C51 16 51 8 60 8S69 24 78 24S87 5 96 5S105 23 114 23S124 16 140 16H180"/></svg>
|
| 13 |
+
<p data-boot-message>Loading the interface…</p>
|
| 14 |
+
<div data-boot-help hidden><a href="" data-boot-reload>Reload</a><a href="" target="_blank" rel="noopener" data-boot-direct>Open in a new tab</a></div>
|
| 15 |
+
<noscript>This application needs JavaScript to display its controls.</noscript>
|
| 16 |
+
</div>
|
| 17 |
+
<script>
|
| 18 |
+
(function(){
|
| 19 |
+
var root=document.documentElement,boot=document.getElementById('zonos-boot');
|
| 20 |
+
root.setAttribute('data-zonos-loading','');
|
| 21 |
+
var reload=boot.querySelector('[data-boot-reload]'),direct=boot.querySelector('[data-boot-direct]');
|
| 22 |
+
var fresh=new URL(location.href);fresh.searchParams.set('reload',Date.now().toString());
|
| 23 |
+
reload.href=fresh.href;direct.href=fresh.href;
|
| 24 |
+
function ready(){
|
| 25 |
+
var player=document.getElementById('speech-player'),settings=document.getElementById('voice-options'),text=document.getElementById('script-input');
|
| 26 |
+
if(!document.getElementById('generate-speech')||!text||text.dataset.ready!=='true'||!player||player.dataset.ready!=='true'||!settings||settings.dataset.ready!=='true')return false;
|
| 27 |
+
root.removeAttribute('data-zonos-loading');boot.remove();observer.disconnect();clearTimeout(timer);return true;
|
| 28 |
+
}
|
| 29 |
+
var observer=new MutationObserver(ready);
|
| 30 |
+
observer.observe(document.body,{childList:true,subtree:true,attributes:true,attributeFilter:['data-ready']});
|
| 31 |
+
var timer=setTimeout(function(){
|
| 32 |
+
if(ready())return;
|
| 33 |
+
// This is a loading fallback, not a fabricated inference error or ETA.
|
| 34 |
+
root.removeAttribute('data-zonos-loading');
|
| 35 |
+
boot.querySelector('[data-boot-message]').textContent='The interface is taking longer to load.';
|
| 36 |
+
boot.querySelector('[data-boot-help]').hidden=false;
|
| 37 |
+
},12000);
|
| 38 |
+
ready();
|
| 39 |
+
})();
|
| 40 |
+
</script>
|
assets/player.html
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 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>
|
| 7 |
<button type="button" data-play aria-label="Play generated speech">Play</button>
|
| 8 |
<span data-time aria-live="off">0:00</span>
|
|
|
|
| 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" playsinline hidden></audio>
|
| 6 |
<div class="playback" hidden>
|
| 7 |
<button type="button" data-play aria-label="Play generated speech">Play</button>
|
| 8 |
<span data-time aria-live="off">0:00</span>
|
assets/player.js
CHANGED
|
@@ -1,120 +1,163 @@
|
|
| 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
|
| 6 |
-
let energy=()=>0,
|
| 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 |
-
|
| 12 |
-
const
|
| 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 |
-
|
| 18 |
-
|
| 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()
|
| 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'
|
|
|
|
| 33 |
}
|
| 34 |
scheduleWave();
|
| 35 |
}
|
| 36 |
const globalListeners=new AbortController();
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
const statusObserver=new MutationObserver(()=>{
|
| 39 |
-
if(!element.isConnected){cleanup();return;}
|
| 40 |
const status=document.getElementById('status-state');
|
| 41 |
-
if(status
|
|
|
|
| 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(){
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
seek.value='0';element.querySelector('[data-time]').textContent='0:00';
|
| 64 |
-
|
| 65 |
-
const
|
| 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 |
-
|
| 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 |
-
|
|
|
|
| 1 |
const host=document.getElementById('speech-player');
|
|
|
|
| 2 |
const reducedMotion=window.matchMedia('(prefers-reduced-motion: reduce)');
|
| 3 |
const motion=new WaveMotion();
|
| 4 |
+
let activityFrame=0,lastFrame=0,active=false;
|
| 5 |
+
let energy=()=>0,playing=()=>false,clock=()=>{};
|
| 6 |
function animate(now) {
|
| 7 |
activityFrame=0;
|
| 8 |
if(!element.isConnected)return;
|
| 9 |
+
const dt=lastFrame?Math.min(.05,(now-lastFrame)/1000):1/60;lastFrame=now;
|
| 10 |
+
const isPlaying=playing();if(isPlaying)clock();
|
| 11 |
+
const target=active ? .32+.055*Math.sin(now/1300) : isPlaying ? energy() : 0;
|
|
|
|
|
|
|
| 12 |
const path=element.querySelector('.wave path');
|
| 13 |
+
if(reducedMotion.matches){motion.level=0;path.setAttribute('d',wavePath(active ? .27 : 0,.6));}
|
| 14 |
+
else {motion.step(target,dt);path.setAttribute('d',wavePath(motion.level,motion.phase));}
|
| 15 |
+
if(isPlaying || (active&&!reducedMotion.matches) || motion.level>0)activityFrame=requestAnimationFrame(animate);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
else lastFrame=0;
|
| 17 |
}
|
| 18 |
+
function scheduleWave(){if(!activityFrame)activityFrame=requestAnimationFrame(animate);}
|
| 19 |
+
function setActivity(busy){
|
| 20 |
active=busy;
|
| 21 |
element.querySelector('.wave').setAttribute('aria-label',busy?'Request activity indicator; not an audio preview':'Voice-reactive visualization');
|
| 22 |
host.dataset.busy=String(busy);
|
| 23 |
+
if(!busy){
|
| 24 |
const generate=document.getElementById('generate-speech');
|
| 25 |
+
if(generate)generate.disabled=document.getElementById('voice-options')?.dataset.clip==='true'&&document.getElementById('reference-clip')?.dataset.busy==='true';
|
| 26 |
+
queueMicrotask(tryAutoplay);
|
| 27 |
}
|
| 28 |
scheduleWave();
|
| 29 |
}
|
| 30 |
const globalListeners=new AbortController();
|
| 31 |
+
const on=(target,name,handler,options={})=>target?.addEventListener(name,handler,{...options,signal:globalListeners.signal});
|
| 32 |
+
// Keep one media element and AudioContext across takes. WebKit grants playback
|
| 33 |
+
// permission per element; replacing it after an asynchronous request loses that.
|
| 34 |
+
const player=element.querySelector('audio'),controls=element.querySelector('.playback');
|
| 35 |
+
const button=element.querySelector('[data-play]'),seek=element.querySelector('[data-seek]');
|
| 36 |
+
const notice=element.querySelector('.audio-notice');
|
| 37 |
+
let context=null,analyser=null,source=null,data={},revision=0,disposed=false;
|
| 38 |
+
let requested=false,autoplayCandidate=false,playPending=false;
|
| 39 |
+
const samples=new Float32Array(2048);
|
| 40 |
+
const minutes=seconds=>`${Math.floor(seconds/60)}:${String(Math.floor(seconds%60)).padStart(2,'0')}`;
|
| 41 |
+
const autoplayEnabled=()=>document.getElementById('autoplay-result')?.checked===true;
|
| 42 |
+
playing=()=>Boolean(data.url)&&!player.paused&&!player.ended;
|
| 43 |
+
energy=()=>{if(!analyser)return 0;analyser.getFloatTimeDomainData(samples);return voiceEnergy(samples);};
|
| 44 |
+
clock=()=>{if(data.url)document.getElementById('script-input')?.dispatchEvent(new CustomEvent('zonos:clock',{detail:{time:player.currentTime||0,playing:playing()}}));};
|
| 45 |
+
function prepareAudio(){
|
| 46 |
+
const AudioContextClass=window.AudioContext||window.webkitAudioContext;
|
| 47 |
+
if(!context&&AudioContextClass){
|
| 48 |
+
try{context=new AudioContextClass();analyser=context.createAnalyser();analyser.fftSize=2048;
|
| 49 |
+
source=context.createMediaElementSource(player);source.connect(analyser);analyser.connect(context.destination);
|
| 50 |
+
}catch{source?.disconnect();analyser?.disconnect();context?.close().catch(()=>{});context=null;analyser=null;source=null;}
|
| 51 |
+
}
|
| 52 |
+
return context?.state==='suspended'?context.resume():Promise.resolve();
|
| 53 |
+
}
|
| 54 |
+
function silentWav(){
|
| 55 |
+
const buffer=new ArrayBuffer(44+1600),view=new DataView(buffer);
|
| 56 |
+
const str=(offset,text)=>{for(let i=0;i<text.length;i++)view.setUint8(offset+i,text.charCodeAt(i));};
|
| 57 |
+
str(0,'RIFF');view.setUint32(4,buffer.byteLength-8,true);str(8,'WAVE');str(12,'fmt ');
|
| 58 |
+
view.setUint32(16,16,true);view.setUint16(20,1,true);view.setUint16(22,1,true);
|
| 59 |
+
view.setUint32(24,8000,true);view.setUint32(28,16000,true);view.setUint16(32,2,true);view.setUint16(34,16,true);
|
| 60 |
+
str(36,'data');view.setUint32(40,1600,true);
|
| 61 |
+
return URL.createObjectURL(new Blob([buffer],{type:'audio/wav'}));
|
| 62 |
+
}
|
| 63 |
+
let primerURL=null;
|
| 64 |
+
function armPlayback(){
|
| 65 |
+
// Called directly in the Generate click, before Gradio's asynchronous request.
|
| 66 |
+
requested=autoplayEnabled();autoplayCandidate=false;
|
| 67 |
+
player.pause();
|
| 68 |
+
if(!requested)return;
|
| 69 |
+
prepareAudio().catch(()=>{});
|
| 70 |
+
if(!data.url){
|
| 71 |
+
primerURL ||= silentWav();player.src=primerURL;
|
| 72 |
+
player.play().catch(()=>{}); // Silent 100 ms permission primer; never a result.
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
function transport(){
|
| 76 |
+
const ended=player.ended||(Number.isFinite(player.duration)&&player.duration>0&&player.currentTime>=player.duration-.01);
|
| 77 |
+
const label=playing()?'Pause':ended?'Replay':'Play';
|
| 78 |
+
button.textContent=label;button.setAttribute('aria-label',label+' generated speech');
|
| 79 |
+
}
|
| 80 |
+
function updateTime(){
|
| 81 |
+
if(!data.url)return;
|
| 82 |
+
clock();transport();element.querySelector('[data-time]').textContent=minutes(player.currentTime||0);
|
| 83 |
+
if(Number.isFinite(player.duration)&&player.duration>0)seek.value=String(Math.round(player.currentTime/player.duration*1000));
|
| 84 |
+
}
|
| 85 |
+
async function startPlayback(automatic=false){
|
| 86 |
+
if(!data.url||playPending||disposed)return;
|
| 87 |
+
const take=revision;playPending=true;
|
| 88 |
+
try{
|
| 89 |
+
// Start both during the gesture for manual playback, without yielding first.
|
| 90 |
+
const ready=prepareAudio();const started=player.play();
|
| 91 |
+
await Promise.all([ready,started]);
|
| 92 |
+
if(disposed||take!==revision)return;
|
| 93 |
+
if(context&&context.state!=='running')throw new Error('Audio context is suspended');
|
| 94 |
+
notice.hidden=true;player.controls=false;player.hidden=true;transport();scheduleWave();
|
| 95 |
+
}catch(error){
|
| 96 |
+
if(disposed||take!==revision||error?.name==='AbortError')return;
|
| 97 |
+
player.pause();transport();
|
| 98 |
+
notice.textContent=automatic?'Auto-play was blocked. Tap Play to listen.':'Tap Play to listen, or download the WAV.';
|
| 99 |
+
notice.hidden=false;
|
| 100 |
+
// Our Play control remains available; expose native controls for media errors.
|
| 101 |
+
if(error?.name!=='NotAllowedError'){player.hidden=false;player.controls=true;}
|
| 102 |
+
}finally{if(take===revision)playPending=false;}
|
| 103 |
+
}
|
| 104 |
+
function tryAutoplay(){
|
| 105 |
+
if(active||!autoplayCandidate)return;
|
| 106 |
+
autoplayCandidate=false;
|
| 107 |
+
if(!requested||!autoplayEnabled())return;
|
| 108 |
+
requested=false;
|
| 109 |
+
if(document.visibilityState==='hidden'){
|
| 110 |
+
notice.textContent='Audio is ready. Tap Play to listen.';notice.hidden=false;return;
|
| 111 |
+
}
|
| 112 |
+
startPlayback(true);
|
| 113 |
+
}
|
| 114 |
+
on(button,'click',()=>{
|
| 115 |
+
requested=false;autoplayCandidate=false;
|
| 116 |
+
if(playing()){player.pause();return;}
|
| 117 |
+
if(player.ended||(player.duration>0&&player.currentTime>=player.duration-.01))player.currentTime=0;
|
| 118 |
+
startPlayback();
|
| 119 |
+
});
|
| 120 |
+
on(document,'click',event=>{
|
| 121 |
+
if(event.target.closest('#generate-speech'))armPlayback();
|
| 122 |
+
if(event.target.closest('#cancel-speech')){requested=false;autoplayCandidate=false;}
|
| 123 |
+
},{capture:true});
|
| 124 |
+
on(host,'zonos:pause',()=>{if(data.url)player.pause();});
|
| 125 |
+
on(host,'zonos:activity',event=>setActivity(event.detail===true));
|
| 126 |
+
for(const event of ['play','playing','pause','ended','seeked'])on(player,event,()=>{updateTime();scheduleWave();});
|
| 127 |
+
on(player,'timeupdate',updateTime);
|
| 128 |
+
on(player,'loadedmetadata',()=>{if(data.url){element.querySelector('[data-duration]').textContent=minutes(player.duration);updateTime();}});
|
| 129 |
+
on(player,'error',()=>{if(data.url){notice.hidden=false;notice.textContent='This audio is no longer available. Generate a new take.';scheduleWave();}});
|
| 130 |
+
on(seek,'input',()=>{if(data.url&&Number.isFinite(player.duration)){player.currentTime=Number(seek.value)/1000*player.duration;updateTime();}});
|
| 131 |
const statusObserver=new MutationObserver(()=>{
|
|
|
|
| 132 |
const status=document.getElementById('status-state');
|
| 133 |
+
if(status?.getAttribute('role')==='alert'){requested=false;autoplayCandidate=false;}
|
| 134 |
+
if(status&&(status.dataset.busy==='true')!==active)setActivity(status.dataset.busy==='true');
|
| 135 |
});
|
| 136 |
+
statusObserver.observe(document.getElementById('request-status')||document.documentElement,{childList:true,subtree:true,attributes:true,attributeFilter:['data-busy']});
|
|
|
|
| 137 |
const removalObserver=new MutationObserver(()=>{if(!element.isConnected)cleanup();});
|
| 138 |
removalObserver.observe(document.documentElement,{childList:true,subtree:true});
|
| 139 |
+
function cleanup(){
|
| 140 |
+
if(disposed)return;disposed=true;player.pause();player.removeAttribute('src');player.load();
|
| 141 |
+
source?.disconnect();analyser?.disconnect();context?.close().catch(()=>{});
|
| 142 |
+
if(primerURL)URL.revokeObjectURL(primerURL);
|
| 143 |
+
cancelAnimationFrame(activityFrame);statusObserver.disconnect();removalObserver.disconnect();globalListeners.abort();
|
| 144 |
+
}
|
| 145 |
+
on(window,'pagehide',event=>{requested=false;autoplayCandidate=false;player.pause();if(!event.persisted)cleanup();});
|
| 146 |
+
on(reducedMotion,'change',scheduleWave);
|
| 147 |
+
function mount(){
|
| 148 |
+
let next;try{next=JSON.parse(props.value||'{}');}catch{return;}
|
| 149 |
+
if(!next.url)return;
|
| 150 |
+
let url;try{url=new URL(next.url,document.baseURI);}catch{return;}
|
| 151 |
+
if(url.origin!==location.origin||!url.pathname.includes('/gradio_api/file='))return;
|
| 152 |
+
if(next.url===data.url)return;
|
| 153 |
+
player.pause();revision++;playPending=false;data=next;
|
| 154 |
+
player.controls=false;player.hidden=true;notice.hidden=true;controls.hidden=false;
|
| 155 |
+
player.src=url.href;seek.value='0';element.querySelector('[data-time]').textContent='0:00';
|
| 156 |
+
element.querySelector('[data-duration]').textContent=minutes(data.duration||0);
|
| 157 |
+
const link=element.querySelector('[data-download]');link.href=url.href;link.download=data.download_name||'zonos3.wav';
|
|
|
|
| 158 |
document.getElementById('script-input')?.dispatchEvent(new CustomEvent('zonos:take',{detail:data}));
|
| 159 |
document.getElementById('voice-options')?.dispatchEvent(new CustomEvent('zonos:seed',{detail:data.seed}));
|
| 160 |
+
autoplayCandidate=requested;transport();scheduleWave();queueMicrotask(tryAutoplay);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
}
|
| 162 |
+
watch('value',mount);mount();
|
| 163 |
+
host.dataset.ready='true';
|
assets/script.js
CHANGED
|
@@ -68,3 +68,5 @@ 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();
|
|
|
|
|
|
|
|
|
| 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();
|
| 71 |
+
|
| 72 |
+
host.dataset.ready='true';
|
assets/settings.html
CHANGED
|
@@ -7,6 +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 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>
|
|
|
|
| 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="autoplay-result">Auto-play result</label><p class="setting-help">Play when your next take is ready.</p></div><input id="autoplay-result" type="checkbox" role="switch" data-setting="autoplay" checked></div>
|
| 11 |
<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>
|
| 12 |
<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>
|
| 13 |
<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>
|
assets/settings.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
| 1 |
const host = document.getElementById('voice-options');
|
| 2 |
// Gradio HTML props are the component state. The public /generate API retains
|
| 3 |
// its original 13 inputs; generate.js takes an atomic snapshot of this state.
|
| 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'};
|
|
@@ -31,6 +32,7 @@ element.addEventListener('input', event => {
|
|
| 31 |
const node = event.target;
|
| 32 |
if (!node.matches('[data-setting]')) return;
|
| 33 |
state[node.dataset.setting] = node.type === 'checkbox' ? node.checked : (node.value === '' ? null : Number(node.value));
|
|
|
|
| 34 |
sync();
|
| 35 |
}, {signal:listeners.signal});
|
| 36 |
element.addEventListener('click', event => {
|
|
@@ -45,5 +47,6 @@ host.addEventListener('zonos:seed', event => { state.seed = event.detail; sync()
|
|
| 45 |
watch('source', () => { state.source = props.source; sync(); sourceChanged(); });
|
| 46 |
watch('value', () => { state = {...defaults, ...JSON.parse(props.value || '{}')}; sync(); });
|
| 47 |
sync();
|
|
|
|
| 48 |
const observer = new MutationObserver(() => { if (!element.isConnected) { listeners.abort(); observer.disconnect(); } });
|
| 49 |
observer.observe(document.documentElement, {childList:true, subtree:true});
|
|
|
|
| 1 |
const host = document.getElementById('voice-options');
|
| 2 |
// Gradio HTML props are the component state. The public /generate API retains
|
| 3 |
// its original 13 inputs; generate.js takes an atomic snapshot of this state.
|
| 4 |
+
let savedAutoplay=true;try{savedAutoplay=localStorage.getItem('zonos.autoplay')!=='false';}catch{}
|
| 5 |
+
const defaults = {autoplay:savedAutoplay,source:'Preset',consent:false,seed:0,randomize:true,normalize:true,rate:0,temperature:.75,top_p:.69,top_k:80,repetition:2.4};
|
| 6 |
let state = {...defaults, ...JSON.parse(props.value || '{}')};
|
| 7 |
let open = false;
|
| 8 |
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'};
|
|
|
|
| 32 |
const node = event.target;
|
| 33 |
if (!node.matches('[data-setting]')) return;
|
| 34 |
state[node.dataset.setting] = node.type === 'checkbox' ? node.checked : (node.value === '' ? null : Number(node.value));
|
| 35 |
+
if(node.dataset.setting==='autoplay'){try{localStorage.setItem('zonos.autoplay',String(state.autoplay));}catch{}}
|
| 36 |
sync();
|
| 37 |
}, {signal:listeners.signal});
|
| 38 |
element.addEventListener('click', event => {
|
|
|
|
| 47 |
watch('source', () => { state.source = props.source; sync(); sourceChanged(); });
|
| 48 |
watch('value', () => { state = {...defaults, ...JSON.parse(props.value || '{}')}; sync(); });
|
| 49 |
sync();
|
| 50 |
+
host.dataset.ready='true';
|
| 51 |
const observer = new MutationObserver(() => { if (!element.isConnected) { listeners.abort(); observer.disconnect(); } });
|
| 52 |
observer.observe(document.documentElement, {childList:true, subtree:true});
|
assets/wave.js
CHANGED
|
@@ -15,7 +15,7 @@ class WaveMotion {
|
|
| 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*(
|
| 19 |
if(target===0 && this.level<.001)this.level=0;
|
| 20 |
return this;
|
| 21 |
}
|
|
@@ -26,8 +26,9 @@ function wavePath(level,phase=0) {
|
|
| 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=.
|
| 30 |
-
const
|
|
|
|
| 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;
|
|
|
|
| 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*(3.2+2.6*this.level))%(Math.PI*20);
|
| 19 |
if(target===0 && this.level<.001)this.level=0;
|
| 20 |
return this;
|
| 21 |
}
|
|
|
|
| 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=.66+.34*(.5+.5*Math.sin(Math.PI*2*.9*t-phase*.4));
|
| 30 |
+
const bend=.8*Math.sin(Math.PI*2*1.2*t-phase*.3);
|
| 31 |
+
const carrier=(Math.sin(Math.PI*2*6.2*t+phase+bend)+.23*Math.sin(Math.PI*2*3.1*t-phase*.7+.7))/1.23;
|
| 32 |
return 70-56*level*fade*broad*carrier;
|
| 33 |
}
|
| 34 |
const derivative=t=>t<=0||t>=1?0:(y(t+.0001)-y(t-.0001))/.0002;
|
boot.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""A small HTML entry shell using the public ASGI middleware interface."""
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import re
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class EntryShell:
|
| 7 |
+
"""Only decorate the document; never buffer API, SSE, media or asset responses."""
|
| 8 |
+
def __init__(self, app, shell_path):
|
| 9 |
+
self.app = app
|
| 10 |
+
self.shell = Path(shell_path).read_bytes()
|
| 11 |
+
|
| 12 |
+
async def __call__(self, scope, receive, send):
|
| 13 |
+
if scope['type'] != 'http' or scope.get('method') != 'GET' or scope.get('path') != '/':
|
| 14 |
+
await self.app(scope, receive, send)
|
| 15 |
+
return
|
| 16 |
+
start = None
|
| 17 |
+
chunks = []
|
| 18 |
+
decorate = False
|
| 19 |
+
|
| 20 |
+
async def intercept(message):
|
| 21 |
+
nonlocal start, decorate
|
| 22 |
+
if message['type'] == 'http.response.start':
|
| 23 |
+
headers = dict(message.get('headers', []))
|
| 24 |
+
decorate = (message['status'] == 200 and
|
| 25 |
+
headers.get(b'content-type', b'').startswith(b'text/html') and
|
| 26 |
+
b'content-encoding' not in headers)
|
| 27 |
+
if decorate:
|
| 28 |
+
start = message
|
| 29 |
+
else:
|
| 30 |
+
await send(message)
|
| 31 |
+
elif message['type'] == 'http.response.body' and decorate:
|
| 32 |
+
chunks.append(message.get('body', b''))
|
| 33 |
+
if not message.get('more_body', False):
|
| 34 |
+
body = re.sub(rb'(<body\b[^>]*>)', lambda match: match[0] + self.shell,
|
| 35 |
+
b''.join(chunks), count=1, flags=re.I)
|
| 36 |
+
headers = [(k, v) for k, v in start['headers']
|
| 37 |
+
if k.lower() not in {b'content-length', b'etag', b'cache-control'}]
|
| 38 |
+
headers += [(b'content-length', str(len(body)).encode()),
|
| 39 |
+
(b'cache-control', b'no-store')]
|
| 40 |
+
await send({**start, 'headers': headers})
|
| 41 |
+
await send({'type': 'http.response.body', 'body': body})
|
| 42 |
+
else:
|
| 43 |
+
await send(message)
|
| 44 |
+
|
| 45 |
+
await self.app(scope, receive, intercept)
|
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.2"
|
| 5 |
ROOT = Path(__file__).resolve().parent
|
| 6 |
MODEL_ID = "Zyphra/ZONOS3-Mini"
|
| 7 |
MODEL_REVISION = "eaac314642440e8f1f497f1505ca376f8e618f2a"
|
tests/test_boot.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
from boot import EntryShell
|
| 3 |
+
from config import ROOT
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def test_entry_shell_is_sent_before_javascript_and_is_not_cached():
|
| 7 |
+
async def app(scope, receive, send):
|
| 8 |
+
await send({'type':'http.response.start','status':200,'headers':[(b'content-type',b'text/html'),(b'content-length',b'31'),(b'etag',b'old')]})
|
| 9 |
+
await send({'type':'http.response.body','body':b'<html><body\n style="margin:0">','more_body':True})
|
| 10 |
+
await send({'type':'http.response.body','body':b'<gradio-app></gradio-app></body></html>'})
|
| 11 |
+
result=[]
|
| 12 |
+
async def send(message):result.append(message)
|
| 13 |
+
asyncio.run(EntryShell(app,ROOT/'assets/boot.html')({'type':'http','method':'GET','path':'/'},None,send))
|
| 14 |
+
headers=dict(result[0]['headers']);body=result[1]['body']
|
| 15 |
+
assert headers[b'cache-control']==b'no-store' and b'etag' not in headers
|
| 16 |
+
assert int(headers[b'content-length'])==len(body)
|
| 17 |
+
assert body.index(b'id="zonos-boot"')<body.index(b'<gradio-app>')
|
| 18 |
+
assert b'Loading the interface' in body and b'Open in a new tab' in body
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def test_api_and_audio_streams_pass_through_without_buffering():
|
| 22 |
+
for path in ['/gradio_api/queue/data','/gradio_api/file=voice.wav','/assets/script.js','/config']:
|
| 23 |
+
sent=[]
|
| 24 |
+
async def app(scope,receive,send):
|
| 25 |
+
for message in [{'type':'http.response.start','status':200,'headers':[]},
|
| 26 |
+
{'type':'http.response.body','body':b'first','more_body':True},
|
| 27 |
+
{'type':'http.response.body','body':b'last'}]:
|
| 28 |
+
await send(message)
|
| 29 |
+
assert sent[-1] is message
|
| 30 |
+
async def send(message):sent.append(message)
|
| 31 |
+
asyncio.run(EntryShell(app,ROOT/'assets/boot.html')({'type':'http','method':'GET','path':path},None,send))
|
| 32 |
+
assert len(sent)==3
|
tests/test_ui_js.py
CHANGED
|
@@ -31,6 +31,10 @@ 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 |
|
|
@@ -66,7 +70,7 @@ const svg = {setAttribute:()=>{},querySelector:()=>path};
|
|
| 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');
|
|
|
|
| 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 |
+
// A quarter second changes the crest positions even at steady amplitude.
|
| 35 |
+
const live=new WaveMotion();live.level=.7;const old=wavePath(live.level,live.phase);
|
| 36 |
+
for(let i=0;i<15;i++)live.step(.7,1/60);
|
| 37 |
+
assert.ok(live.phase>1);assert.notEqual(wavePath(live.level,live.phase),old);
|
| 38 |
'''
|
| 39 |
subprocess.run(['node','-e',script], check=True)
|
| 40 |
|
|
|
|
| 70 |
global.element = {querySelector:()=>svg,isConnected:true};
|
| 71 |
global.document = {getElementById:id=>id==='generate-speech'?generate:hostNode};
|
| 72 |
global.window = {matchMedia:()=>({matches:true})};
|
| 73 |
+
global.cancelAnimationFrame=()=>{};global.requestAnimationFrame=()=>1;global.queueMicrotask=()=>{};global.tryAutoplay=()=>{};
|
| 74 |
''' + source + r'''
|
| 75 |
setActivity(true);assert.equal(hostNode.dataset.busy,'true');
|
| 76 |
setActivity(false);assert.equal(generate.disabled,false);assert.equal(hostNode.dataset.busy,'false');
|