Text-to-Speech
Safetensors
English
steertts
steer-tts
tts
speech-synthesis
english
controllable-tts
steerable-tts
prosody-control
expressive-tts
style-control
inline-markup
word-emphasis
zero-shot-voice-cloning
voice-cloning
interpretable
non-autoregressive
fastpitch
mamba
mamba-3
state-space-model
hybrid-attention
flow-matching
vocos
small-model
quantized
int8
on-device
streaming
non-commercial
Eval Results (legacy)
File size: 8,257 Bytes
5bf57f8 | 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 | """steertts command line.
steertts say "Hello there." -o hello.wav # default voice, default style
steertts say "I can't believe it." --voice voice_1688 --tag anger --ctrl loud=1.5,f0_med=1 -o angry.wav
steertts say "Keep it down." --prompt "whispering and slow" --show-request -o soft.wav
steertts say "Well, *that* was fast. [pause 400ms] <slow>Let me think.</slow>" --markup -o m.wav
steertts say "Now in your voice." --ref-wav me.wav -o clone.wav
steertts say --request req.json -o out.wav # a JSON request (request.schema.json)
steertts stream "First sentence. Second sentence." -o s.wav # prints per-chunk timing
steertts play "First sentence. Second sentence." --voice voice_1688 # speak through the speakers, streaming
steertts say "Hello there." --play # play instead of (or with -o, as well as) saving
steertts voices [--gender female] | steertts tags | steertts schema | steertts prompt "soft and sad"
steertts measure out.wav --text "..." --voice voice_1688 # read the 12 controls back (z)
The model directory comes from --model, else $STEERTTS_MODEL, else the current directory if it holds config.json,
else WhissleAI/steer-tts from Hugging Face. Playback (--play / play) uses sounddevice, ffplay or aplay.
"""
import argparse
import json
import os
import sys
import time
def _model_dir(a):
if a.model:
return a.model
if os.environ.get("STEERTTS_MODEL"):
return os.environ["STEERTTS_MODEL"]
if os.path.exists("config.json") and os.path.exists("voices.json"):
return "."
print("steertts: no local model; using WhissleAI/steer-tts from Hugging Face (--model DIR to change)", file=sys.stderr)
return "WhissleAI/steer-tts"
def _ctrl(s):
out = {}
for kv in filter(None, (s or "").split(",")):
k, _, v = kv.partition("=")
out[k.strip()] = float(v)
return out
def _load(a, **kw):
from . import SteerTTS
t0 = time.time()
tts = SteerTTS.from_pretrained(_model_dir(a), device=a.device, flow=not a.no_flow, precision=a.precision, **kw)
print(f"loaded {tts} in {time.time() - t0:.1f}s", file=sys.stderr)
return tts
def _request(a):
req = json.load(open(a.request)) if a.request else {}
for k, v in dict(text=a.text, voice=a.voice, ref_wav=a.ref_wav, prompt=a.prompt, tag=a.tag,
pace=a.pace, guidance=a.guidance, seed=a.seed).items():
if v is not None:
req[k] = v
if a.ctrl:
req["ctrl"] = {**req.get("ctrl", {}), **_ctrl(a.ctrl)}
if getattr(a, "markup", False):
req["markup"] = True
if a.no_flow:
req["flow"] = False
return req
def main(argv=None):
ap = argparse.ArgumentParser(prog="steertts", description="SteerTTS: steerable English TTS")
ap.add_argument("--model", help="model directory or HF repo id (default: $STEERTTS_MODEL or .)")
ap.add_argument("--device", help="cpu | cuda (default: cuda if available)")
ap.add_argument("--precision", choices=["fp32", "int8"], default="fp32",
help="int8: the 4x smaller weight files in int8/ (see the model card for the measured differences)")
ap.add_argument("--no-flow", action="store_true", help="skip the flow-matching refiner")
sub = ap.add_subparsers(dest="cmd", required=True)
for name in ("say", "stream", "play"):
p = sub.add_parser(name)
p.add_argument("text", nargs="?")
p.add_argument("--request", help="JSON request file (fields as in request.schema.json)")
p.add_argument("--voice")
p.add_argument("--ref-wav", dest="ref_wav", help="clone this reference clip (consent required)")
p.add_argument("--prompt", help="free-text direction, e.g. 'soft and sad'")
p.add_argument("--tag")
p.add_argument("--ctrl", help="comma list dim=z, e.g. rate=1.5,f0_med=-1")
p.add_argument("--pace", type=float)
p.add_argument("--guidance", type=float)
p.add_argument("--seed", type=int)
p.add_argument("--markup", action="store_true")
p.add_argument("--show-request", action="store_true", help="print the resolved request JSON")
p.add_argument("--play", action="store_true", help="play through the speakers as chunks are rendered")
p.add_argument("-o", "--out", help="output wav (default out.wav unless playing)")
p = sub.add_parser("voices")
p.add_argument("--gender", choices=["female", "male"])
p.add_argument("--json", action="store_true")
sub.add_parser("tags")
sub.add_parser("schema")
p = sub.add_parser("prompt")
p.add_argument("prompt")
p = sub.add_parser("measure")
p.add_argument("wav")
p.add_argument("--text")
p.add_argument("--voice")
a = ap.parse_args(argv)
if a.cmd == "schema":
from .request import schema
print(json.dumps(schema(), indent=1))
return
if a.cmd in ("voices", "tags"):
from .pipeline import resolve_model_dir
d = resolve_model_dir(_model_dir(a))
if a.cmd == "tags":
print(" ".join(json.load(open(d / "control_stats.json"))["tags"]))
return
vs = json.load(open(d / "voices.json"))["voices"]
vs = [v for v in vs if not a.gender or v.get("gender") == a.gender]
if a.json:
print(json.dumps(vs, indent=1))
else:
for v in vs:
print(f"{v['name']} {v['description']}")
return
if a.cmd == "measure":
from .audio import load_wav
from .measure import measure, measure_z, n_phones
from .pipeline import resolve_model_dir
y, _ = load_wav(a.wav)
if a.voice:
st = json.load(open(resolve_model_dir(_model_dir(a)) / "control_stats.json"))
z = measure_z(y, a.text, st["spk_mean"][a.voice], st["pooled_sd"])
print(json.dumps({k: round(v, 2) for k, v in z.items()}))
else:
from .config import CONTROL_DIMS
raw = measure(y, n_phones=n_phones(a.text) if a.text else None, want_mel=False)["controls"]
print(json.dumps({k: round(float(v), 3) for k, v in zip(CONTROL_DIMS, raw)}))
return
tts = _load(a)
if a.cmd == "prompt":
print(json.dumps(tts.map_prompt(a.prompt), indent=1))
return
req = _request(a)
if not req.get("text"):
sys.exit("steertts: no text (positional argument or --request)")
req = tts.resolve(req)
if getattr(a, "show_request", False) or "prompt_mapping" in req:
print(json.dumps(req, indent=1), file=sys.stderr)
import numpy as np
play = a.play or a.cmd == "play"
out = a.out or (None if play else "out.wav")
player = None
if play:
from .player import Player
player = Player(tts.sample_rate)
print(f"playing via {player.name}", file=sys.stderr)
if a.cmd == "say":
t0 = time.time()
wav = tts.synthesize(req)
dt = time.time() - t0
if player:
player.play(wav)
else:
if req.get("word_shift"):
sys.exit("steertts: word_shift is not supported when streaming; use inline markup (*word*, <high>word</high>)")
t0, chunks = time.time(), []
try:
for i, c in enumerate(tts.stream(request=req)):
chunks.append(c.audio)
if player:
player.play(c.audio)
print(f"chunk {i}: {len(c.audio) / tts.sample_rate:.2f}s audio at +{(time.time() - t0) * 1000:.0f} ms "
f"({c.segment.text or ('pause %d ms' % c.segment.pause_ms)!r})", file=sys.stderr)
except KeyboardInterrupt:
print("stopped", file=sys.stderr)
wav = np.concatenate(chunks) if chunks else np.zeros(1, np.float32)
dt = time.time() - t0
if out:
tts.save(wav, out)
print(f"{out or 'played'}: {len(wav) / tts.sample_rate:.2f}s audio rendered in {dt:.2f}s", file=sys.stderr)
if player:
player.close()
if player.err:
sys.exit(f"steertts: playback failed: {player.err}")
if __name__ == "__main__":
main()
|