Spaces:
Runtime error
Runtime error
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)
|