File size: 10,976 Bytes
6d1acb0
 
287541f
6d1acb0
 
 
 
 
 
 
 
 
 
c392a82
9684bdc
 
6d1acb0
c392a82
6d1acb0
 
 
 
 
 
 
 
 
 
 
1b2da16
6d1acb0
 
 
1b2da16
 
6d1acb0
 
1b2da16
6d1acb0
287541f
6d1acb0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
287541f
 
 
 
 
 
 
 
 
 
6d1acb0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1b2da16
6d1acb0
 
 
1b2da16
 
 
6d1acb0
 
255bed9
6d1acb0
 
 
 
 
 
287541f
255bed9
 
1b2da16
 
 
 
255bed9
 
 
 
 
 
 
 
 
 
 
 
1b2da16
6d1acb0
255bed9
 
 
 
287541f
 
6d1acb0
 
 
 
 
 
255bed9
6d1acb0
215ce62
255bed9
 
 
ba8c1f3
215ce62
 
 
6d1acb0
 
 
 
 
 
 
 
9684bdc
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
"""ZONOS3 Mini · a minimal, session-isolated ZeroGPU Space."""
import html
from functools import partial
import json
import os
from pathlib import Path
import tempfile

import spaces  # Before any torch or upstream model imports.
import gradio as gr

from config import DEFAULT_TEXT, GRADIO_VERSION, LANGUAGES, MAX_CHARS, MAX_SEED, ROOT, VERSION
from controller import Controller, message
from logging_setup import configure_logging
from starlette.middleware import Middleware
from boot import EntryShell

configure_logging()
if gr.__version__ != GRADIO_VERSION:
    raise RuntimeError(f"Use Gradio {GRADIO_VERSION}; this version is part of the UI contract.")

CATALOG = json.loads((ROOT / "voices.json").read_text())["voices"]
UI_ONLY = os.environ.get("ZONOS_UI_ONLY") == "1"
if UI_ONLY:
    class PreviewEngine:
        def prepare(self, *args):
            from requests_schema import InputError
            raise InputError("UI preview only: model inference is disabled.")
    engine = PreviewEngine()
    aligner = None
else:
    from engine import load_engine
    engine = load_engine()  # Synchronous startup download / load / CUDA placement.
    from alignment import load_aligner
    aligner = load_aligner()

RUNTIME = Path(os.environ.get("ZONOS_RUNTIME_DIR", str(Path(tempfile.gettempdir()) / "zonos3-mini")))
controller = Controller(engine, RUNTIME, [v["name"] for v in CATALOG], aligner=aligner)
ASSETS = ROOT / "assets"
EXAMPLES = json.loads((ROOT / "examples.json").read_text())
DEFAULT_PICKER = json.dumps({"language":"en_us", "voice":"Savannah", "custom":False})


def picker_markup():
    langs = "".join(f'<option value="{key}">{html.escape(label)}</option>' for key, label in LANGUAGES.items())
    voices = "".join(f'<option value="{html.escape(v["name"], quote=True)}" data-language="{v["language"]}">{html.escape(v["name"])}</option>' for v in CATALOG)
    return ('<div class="pickers"><label>language <select data-language aria-label="Text language">' + langs +
            '</select></label><span aria-hidden="true">·</span><label><span class="sr">Voice</span>' +
            '<select data-voice aria-label="Preset voice">' + voices +
            '</select><span data-custom hidden>Your voice clip</span></label></div>')


PICKER_CSS = """
.pickers { display:flex; justify-content:center; align-items:center; flex-wrap:wrap; gap:13px; color:#bdbdbd; font:12px ui-monospace,monospace; }
.pickers label { display:inline-flex; align-items:center; gap:10px; }
.pickers select { color:#ddd; background:#000; border:0; border-radius:0; min-height:44px; max-width:165px; font:13px ui-monospace,monospace; padding:0 7px; }
.pickers select:focus-visible { outline:2px solid #ddd; outline-offset:3px; }
.pickers option { background:#151515; color:#eee; }
.pickers [hidden] { display:none; }
.sr { position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); }
@media(max-width:400px) { .pickers { gap:6px; } .pickers label { gap:4px; } .pickers select { max-width:128px; font-size:16px; } }
"""


def load_example(language):
    example = EXAMPLES[language]
    return example["text"], json.dumps({"voice":example["voice"], "language":example["language"], "custom":False}), "Preset"


def settings_markup():
    buttons = "".join(
        f'<button type="button" class="example-button" data-example="{html.escape(code, quote=True)}"><span>{html.escape(item["label"])}</span></button>'
        for code, item in EXAMPLES.items()
    )
    return (ASSETS / "settings.html").read_text().replace("<!-- example-buttons -->", buttons)


theme = gr.themes.Base(primary_hue="neutral", secondary_hue="neutral", neutral_hue="neutral",
                       font=["Arial", "sans-serif"], font_mono=["ui-monospace", "monospace"]).set(
    body_background_fill="#000", body_background_fill_dark="#000",
    body_text_color="#eee", body_text_color_dark="#eee",
    block_background_fill="#111", block_background_fill_dark="#111",
    block_border_color="#333", block_border_color_dark="#333",
    input_background_fill="#151515", input_background_fill_dark="#151515",
    background_fill_primary="#111", background_fill_primary_dark="#111",
    background_fill_secondary="#171717", background_fill_secondary_dark="#171717",
    block_label_text_color="#bdbdbd", block_label_text_color_dark="#bdbdbd",
    block_title_text_color="#bdbdbd", block_title_text_color_dark="#bdbdbd",
    block_label_background_fill="#111", block_label_background_fill_dark="#111",
    button_secondary_background_fill="#242424", button_secondary_background_fill_dark="#242424",
    button_secondary_background_fill_hover="#333", button_secondary_background_fill_hover_dark="#333",
    button_secondary_text_color="#eee", button_secondary_text_color_dark="#eee",
    button_secondary_border_color="#444", button_secondary_border_color_dark="#444",
    link_text_color="#ccc", link_text_color_dark="#ccc",
)

with gr.Blocks(title="ZONOS3 Mini", fill_width=True, delete_cache=(3600, 86400), analytics_enabled=False) as demo:
    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")
    if UI_ONLY:
        gr.HTML('<p id="preview-warning">UI preview · inference disabled</p>')
    text = gr.HTML(value=DEFAULT_TEXT, html_template=(ASSETS / "script.html").read_text(),
                   css_template=(ASSETS / "script.css").read_text(),
                   js_on_load=(ASSETS / "script.js").read_text(), elem_id="script-input")
    player = gr.HTML(value="{}", html_template=(ASSETS / "player.html").read_text(),
                     css_template=(ASSETS / "player.css").read_text(),
                     js_on_load=(ASSETS / "wave.js").read_text() + (ASSETS / "player.js").read_text(), elem_id="speech-player")
    picker = gr.HTML(value=DEFAULT_PICKER, html_template=picker_markup(), css_template=PICKER_CSS,
                     js_on_load=(ASSETS / "picker.js").read_text(), elem_id="voice-picker")
    with gr.Row(elem_id="action-row"):
        generate = gr.Button("Generate speech", variant="primary", elem_id="generate-speech")
        cancel = gr.Button("Cancel", visible=False, elem_id="cancel-speech")
    status = gr.HTML(message(""), elem_id="request-status")
    with gr.HTML(value="{}", source="Preset", html_template=settings_markup(),
                 css_template=(ASSETS / "settings.css").read_text(),
                 js_on_load=(ASSETS / "settings.js").read_text(), elem_id="voice-options") as settings:
        reference = gr.HTML(value="null", html_template=(ASSETS / "reference.html").read_text(),
                            css_template=(ASSETS / "reference.css").read_text(),
                            js_on_load=(ASSETS / "reference.js").read_text(), elem_id="reference-clip")
    clip = gr.Audio(label="Reference voice", type="filepath", format="wav", visible=False)
    # Stable API wire components. The HTML UI passes its state atomically via the
    # documented event JS preprocessor; API callers continue using the same inputs.
    source = gr.Radio(["Preset", "Voice clip"], value="Preset", label="Voice source", visible=False)
    consent = gr.Checkbox(value=False, label="I have permission to use this voice.", visible=False)
    seed = gr.Number(value=0, precision=0, minimum=0, maximum=MAX_SEED, label="Seed", visible=False)
    randomize = gr.Checkbox(value=True, label="New seed for each take", visible=False)
    normalize = gr.Checkbox(value=True, label="Normalize numbers and abbreviations", visible=False)
    rate = gr.Slider(0, 30, value=0, step=.5, label="Speaking rate · UTF-8 bytes/second", visible=False)
    temperature = gr.Slider(.1, 1.5, value=.75, step=.05, label="Temperature", visible=False)
    top_p = gr.Slider(.1, 1, value=.69, step=.01, label="Top-p", visible=False)
    top_k = gr.Slider(1, 400, value=80, step=1, label="Top-k", visible=False)
    repetition = gr.Slider(1, 4, value=2.4, step=.1, label="Repetition penalty", visible=False)
    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")

    def example(language):
        script, voice, _ = load_example(language)
        return script, voice, gr.HTML(source="Preset")

    for code in EXAMPLES:
        getattr(settings, "example_" + code)(partial(example, code), outputs=[text, picker, settings], queue=False, api_visibility="private")
    event = generate.click(
        controller.generate,
        [text, picker, source, clip, consent, normalize, seed, randomize, temperature, top_p, top_k, repetition, rate],
        [player, status, generate, cancel, seed],
        api_name="generate", concurrency_limit=8, concurrency_id="speech_requests", trigger_mode="once",
        show_progress="minimal", show_progress_on=[status],
        js=(ASSETS / "generate.js").read_text(),
    )
    finish_ui = """() => {
        const status = document.getElementById('status-state');
        if (status) status.dataset.busy = 'false';
        document.getElementById('speech-player')?.dispatchEvent(new CustomEvent('zonos:activity', {detail:false}));
        document.getElementById('generate-speech').disabled = document.getElementById('voice-options')?.dataset.clip === 'true' && document.getElementById('reference-clip')?.dataset.busy === 'true';
    }"""
    event.then(fn=None, js=finish_ui, api_visibility="private")
    event.failure(fn=None, js=finish_ui, api_visibility="private")
    cancel.click(controller.cancel, outputs=[status, cancel], queue=False, api_name="cancel", show_progress="hidden")
    demo.unload(controller.on_disconnect)

demo.queue(max_size=16, default_concurrency_limit=1, api_open=False)

if __name__ == "__main__":
    demo.launch(theme=theme, css=(ASSETS / "style.css").read_text(), allowed_paths=[str(controller.outputs.resolve())],
                blocked_paths=[str((ROOT / "vendor").resolve()), str(controller.jobs.directory.resolve())],
                max_file_size="25mb", show_error=False, max_threads=40, footer_links=[],
                ssr_mode=False, app_kwargs={"middleware":[Middleware(EntryShell, shell_path=ASSETS / "boot.html")]})