"""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] Let me think." --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*, word)") 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()