fffiloni commited on
Commit
1b2da16
·
verified ·
1 Parent(s): 1bb0780

v0.3.0: timed two-line reading, smooth waveform and custom voice input

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