File size: 9,115 Bytes
0fdbf3b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb19a58
0fdbf3b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb19a58
0fdbf3b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb19a58
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
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
"""tiny-voca — a 4.5M-parameter English text-to-speech model.

This Space runs the ONNX export of [n4ze3m/tiny-voca](https://huggingface.co/n4ze3m/tiny-voca)
(24 kHz mono, two voices: F2 female, M2 male) with the author's own
[dizione](https://github.com/n4ze3m/dizione) phonemizer: a CMUdict-derived
lexicon, a small neural G2P model for unknown words, and a homograph
classifier ("I read the book you read yesterday").

The utterance pipeline is a port of the author's browser demo
(`web/public/worker.js`): text is split into sentences (without breaking after
titles or initials), long sentences are recursively fitted to the model's
512-token limit at clause boundaries, then word halves, each utterance is
synthesized through the ONNX graph, and the chunks are concatenated.
"""

import json
import math
import os
import re
import sys
import tempfile
from pathlib import Path

import gradio as gr
import numpy as np
import onnxruntime as ort
from huggingface_hub import hf_hub_download, snapshot_download

REPO_ID = "n4ze3m/tiny-voca"
TOKEN_HEADROOM = 12         # the reference demo fits sentences to max_tokens - 12

# ---------------------------------------------------------------- the model

# The phonemizer's built data (lexicon, aligner, G2P weights, homograph model)
# lives in the model repo under phonemizer/; dizione finds it via $DIZIONE_DATA.
_PHONEME_DIR = Path(tempfile.mkdtemp(prefix="dizione-"))
snapshot_download(REPO_ID, allow_patterns=["phonemizer/*"], local_dir=str(_PHONEME_DIR))
os.environ["DIZIONE_DATA"] = str(_PHONEME_DIR / "phonemizer")

sys.path.insert(0, str(Path(__file__).parent))
import dizione  # noqa: E402  (vendored; needs $DIZIONE_DATA set before import)

_CONFIG_PATH = hf_hub_download(REPO_ID, "config.json")
_MODEL_PATH = hf_hub_download(REPO_ID, "onnx/model.onnx")
with open(_CONFIG_PATH, encoding="utf-8") as file:
    CONFIG = json.load(file)

_SESSION = ort.InferenceSession(_MODEL_PATH, providers=["CPUExecutionProvider"])
_MAX_TOKENS = int(CONFIG["model"]["max_tokens"])
SAMPLE_RATE = int(CONFIG["model"]["sample_rate"])
VOICES = tuple(CONFIG["voices"])

dizione.phonemize("Warm up.")   # build the lexicon and G2P tables once, at startup

# ------------------------------------------------ text -> sentences (worker.js)

# Split after . ! ? … (optionally followed by a closing quote or bracket), at
# whitespace, before a new sentence that starts with a capital or a digit.
# Python's re needs fixed-width lookbehinds, so the optional trailing mark
# becomes an alternation of one- and two-character lookbehinds.
_SENTENCE_END = re.compile(
    r'''(?:(?<=[.!?…])|(?<=[.!?…]["')\]]))\s+(?=["'(\[]?[A-Z0-9])''')
# Sentences ending like this are titles, initials or numbers, not sentence ends.
_ABBREVIATION = re.compile(
    r"(?:\b(?:mr|mrs|ms|dr|prof|sr|jr|st|vs|etc|no|inc|ltd|co|e\.g|i\.e|a\.m|p\.m)|\b[A-Z])\.$",
    re.IGNORECASE)
_CLAUSE_END = re.compile(r"(?<=[,;:—])\s+")


def _pieces(text):
    parts, last = [], 0
    for match in _SENTENCE_END.finditer(text):
        parts.append(text[last:match.start()])
        last = match.end()
    parts.append(text[last:])
    return parts


def sentences(text):
    """Sentences, without breaking after titles, initials or inside numbers."""
    text = re.sub(r"\s+", " ", text).strip()
    parts, current = [], ""
    for piece in _pieces(text):
        current = f"{current} {piece}" if current else piece
        if not _ABBREVIATION.search(current):
            parts.append(current)
            current = ""
    if current:
        parts.append(current)
    return [part for part in parts if any(c.isalpha() or c.isdigit() for c in part)]


def fit(sentence):
    """Phonemized utterances of one sentence, each within the token limit:
    cut at clause boundaries, then at word halves, like the reference demo."""
    try:
        result = dizione.phonemize(sentence)
    except ValueError:      # nothing pronounceable in this piece
        return []
    if len(result.ids) <= _MAX_TOKENS - TOKEN_HEADROOM:
        return [result]
    clauses = _CLAUSE_END.split(sentence)
    if len(clauses) > 1:
        return [u for clause in clauses for u in fit(clause)]
    words = sentence.split(" ")
    if len(words) < 2:
        return []           # a single word too long for one utterance: drop it
    half = math.ceil(len(words) / 2)
    return fit(" ".join(words[:half])) + fit(" ".join(words[half:]))


def _synthesize(phones, voice, speed, pitch_shift, pitch_range):
    """One utterance through the ONNX graph -> float32 [samples] at 24 kHz."""
    count = len(phones.ids)
    audio, _durations = _SESSION.run(None, {
        "tokens": np.array([phones.ids], dtype=np.int64),
        "stress": np.array([phones.stress], dtype=np.int64),
        "voice": np.array([voice], dtype=np.int64),
        "speed": np.array([speed], dtype=np.float32),
        "pitch_shift": np.array([pitch_shift], dtype=np.float32),
        "pitch_range": np.array([pitch_range], dtype=np.float32),
    })
    return audio[0]


# ------------------------------------------------------------- the demo

CSS = """
#col-container { max-width: 880px; margin: 0 auto; }
.dark .gradio-container { color: var(--body-text-color); }
"""


def speak(text, voice="F2", speed=1.0, pitch_shift=0.0, pitch_range=1.0):
    """Synthesize speech from English text.

    Args:
        text: English text to speak. Long text is split into sentences; each
            sentence into utterances that fit the model's 512-token limit.
        voice: "F2" (female) or "M2" (male).
        speed: speaking rate, 0.7 (slow) to 1.4 (fast).
        pitch_shift: added to log F0; 0.058 is about one semitone.
        pitch_range: scale of pitch movement around the utterance mean
            (1 = as predicted, <1 flatter, >1 more lively).
    """
    if not isinstance(text, str) or not text.strip():
        raise gr.Error("Enter some text to speak.")
    chunks, transcript = [], []
    for sentence in sentences(text):
        for phones in fit(sentence):
            chunks.append(_synthesize(phones, VOICES.index(voice), speed, pitch_shift, pitch_range))
            transcript.append(str(phones))
    if not chunks:
        raise gr.Error("That text has nothing to pronounce — try some English words.")
    audio = np.concatenate(chunks)
    if not np.isfinite(audio).all():
        raise gr.Error("The model produced non-finite audio; try different text or settings.")
    audio = np.clip(audio, -1.0, 1.0)
    return (SAMPLE_RATE, audio.astype(np.float32)), " ".join(transcript)


with gr.Blocks() as demo:
    with gr.Column(elem_id="col-container"):
        gr.Markdown(
            "# 🗣️ tiny-voca\n"
            "A 4.5M-parameter English text-to-speech model — two voices, 24 kHz audio, "
            "~70× real-time on CPU. Weights: [n4ze3m/tiny-voca](https://huggingface.co/n4ze3m/tiny-voca) "
            "(Apache-2.0) · phonemized by the author's "
            "[dizione](https://github.com/n4ze3m/dizione) dictionary + neural G2P."
        )
        with gr.Row():
            text = gr.Textbox(
                label="Text",
                placeholder="Type anything in English — try a homograph sentence…",
                lines=4,
                autofocus=True,
                scale=4,
            )
            run = gr.Button("Speak", variant="primary", scale=1)
        audio = gr.Audio(label="Speech", type="numpy")
        phones = gr.Textbox(label="Phonemes (dizione)", interactive=False, lines=2)
        with gr.Row():
            voice = gr.Radio(choices=list(VOICES), value="F2", label="Voice")
            speed = gr.Slider(minimum=0.7, maximum=1.4, value=1.0, step=0.05, label="Speed")
        with gr.Accordion("Pitch controls", open=False):
            pitch_shift = gr.Slider(minimum=-0.174, maximum=0.174, value=0.0, step=0.058,
                                    label="Pitch shift", info="0.058 ≈ one semitone; 0 = as trained")
            pitch_range = gr.Slider(minimum=0.5, maximum=1.5, value=1.0, step=0.1,
                                    label="Pitch range", info="Scale of pitch movement around the utterance mean")
        gr.Examples(
            examples=[
                ["Hello! This is tiny voca, a small text to speech model running entirely in your browser."],
                ["The meeting moved to 3:30 pm on March 14th, so we have about 45 minutes left. It costs $12.99."],
                ["Did you really think I would forget your birthday? Of course not!"],
                ["She had never seen the sea before, and for a long while she stood at the edge of the water without saying a word."],
                ["I read the book you read yesterday. They live near the live music hall."],
            ],
            inputs=[text],
            outputs=[audio, phones],
            fn=speak,
            cache_examples=True,
            cache_mode="lazy",
        )
    run.click(speak, inputs=[text, voice, speed, pitch_shift, pitch_range],
              outputs=[audio, phones], api_name="speak")

demo.launch(theme=gr.themes.Citrus(), css=CSS, mcp_server=True)