File size: 4,799 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
"""The synthesis request: one JSON object, documented by request.schema.json (shipped inside this package and at the
model repo root). normalize_request() validates a request against a loaded model and fills in defaults; it raises
ValueError with a precise message instead of silently ignoring a misspelt field, tag or control.
"""
import json
from importlib import resources

from .config import CONTROL_DIMS, CONTROL_RANGE

DEFAULTS = dict(pace=1.0, guidance=1.0, flow=True, seed=0, markup=False)
FIELDS = {"text", "voice", "ref_wav", "prompt", "tag", "ctrl", "pace", "guidance", "word_shift", "markup", "flow",
          "seed", "prompt_mapping", "$schema"}
WORD_SHIFT_KEYS = {"pitch": (-12.0, 12.0), "energy": (-20.0, 20.0), "dur": (0.25, 4.0)}


def schema():
    """The JSON Schema (draft 2020-12) of a request, as a dict."""
    return json.loads(resources.files(__package__).joinpath("request.schema.json").read_text())


def _num(name, v, lo, hi):
    if isinstance(v, bool) or not isinstance(v, (int, float)):
        raise ValueError(f"{name} must be a number, got {v!r}")
    if not lo <= float(v) <= hi:
        raise ValueError(f"{name}={v} is outside [{lo}, {hi}]")
    return float(v)


def normalize_request(req, tts=None):
    """-> a new dict with defaults filled in. tts (a SteerTTS) enables voice / tag checks."""
    if not isinstance(req, dict):
        raise ValueError("a request is a JSON object")
    unknown = set(req) - FIELDS
    if unknown:
        raise ValueError(f"unknown request field(s) {sorted(unknown)}; see request.schema.json")
    out = {k: v for k, v in req.items() if k != "$schema"}
    text = out.get("text")
    if not isinstance(text, str) or not text.strip():
        raise ValueError("text must be a non-empty string")
    if len(text) > 5000:
        raise ValueError("text is longer than 5000 characters; split it (or stream it sentence by sentence)")
    if out.get("ref_wav") is not None:
        if out.get("voice"):
            raise ValueError("give either voice (a built-in voice) or ref_wav (clone a reference clip), not both")
    else:
        out.pop("ref_wav", None)
        out["voice"] = out.get("voice") or (tts.default_voice if tts is not None else None)
        if tts is not None and out["voice"] not in tts.spk_ref:
            raise ValueError(f"unknown voice {out['voice']!r}; list them with tts.voices / `steertts voices`")
    if out.get("prompt") is not None and not isinstance(out["prompt"], str):
        raise ValueError("prompt must be a string")
    if out.get("tag") is not None:
        if not isinstance(out["tag"], str):
            raise ValueError("tag must be a string")
        if tts is not None and out["tag"] not in tts.tag2i:
            raise ValueError(f"unknown tag {out['tag']!r}; known: {', '.join(tts.tags)}")
    ctrl = out.get("ctrl") or {}
    if not isinstance(ctrl, dict):
        raise ValueError("ctrl must be an object {control: z}")
    for k in ctrl:
        if k not in CONTROL_DIMS:
            raise ValueError(f"unknown control {k!r}; known: {', '.join(CONTROL_DIMS)}")
    out["ctrl"] = {k: _num(f"ctrl.{k}", v, *CONTROL_RANGE) for k, v in ctrl.items()}
    defaults = dict(DEFAULTS)
    if tts is not None:  # the loaded model's own defaults (SteerTTS(guidance=..., flow=...))
        defaults.update(guidance=tts.guidance, flow=bool(tts.use_flow))
    for k, v in defaults.items():
        if out.get(k) is None:
            out[k] = v
    out["pace"] = _num("pace", out["pace"], 0.25, 4.0)
    out["guidance"] = _num("guidance", out["guidance"], 0.0, 4.0)
    for k in ("flow", "markup"):
        if not isinstance(out[k], bool):
            raise ValueError(f"{k} must be true or false")
    if isinstance(out["seed"], bool) or not isinstance(out["seed"], int) or out["seed"] < 0:
        raise ValueError("seed must be a non-negative integer")
    ws = out.get("word_shift")
    if ws:
        if not isinstance(ws, dict):
            raise ValueError("word_shift must be an object {word_index: {pitch, energy, dur}}")
        if out["markup"]:
            raise ValueError("word_shift and markup cannot be combined (markup re-indexes words per segment)")
        norm = {}
        for w, sh in ws.items():
            try:
                wi = int(w)
            except (TypeError, ValueError):
                raise ValueError(f"word_shift key {w!r} is not a word index") from None
            if wi < 0 or not isinstance(sh, dict) or set(sh) - set(WORD_SHIFT_KEYS):
                raise ValueError(f"word_shift[{w!r}] must be a non-negative index -> {{pitch, energy, dur}}")
            norm[str(wi)] = {k: _num(f"word_shift.{w}.{k}", v, *WORD_SHIFT_KEYS[k]) for k, v in sh.items()}
        out["word_shift"] = norm
    else:
        out.pop("word_shift", None)
    return out