File size: 11,525 Bytes
4f78128
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
209
210
211
"""Thin adapter over immutable public packages; no teacher or research weights."""
import hashlib
import importlib
import importlib.util
import io
import json
import os
from pathlib import Path
import sys
import threading
import time
import uuid

from catalog import MODELS

LOCK = threading.RLock()
EVENT_REVISION = "7b6c0cb1ce98d8330dc96cc2685bdb71b1016837"
EVENT_MANIFEST = "55ecf645c7aa2b28d2112a0d25e000f48a723e19638c8f5ba160fdc5e76f4045"
EVENT_BANK_MANIFEST = "46e01c1bc394ce60785652a021e53a4ebd9fa0e7d0b7a8508ea87dc1ef3d3395"
EVENT_BANK_RUNTIME = "f163b56415fb84f5dcd135fdb85157fa16445c352fc2fac897f8b854d1a79604"


class NativeCapture:
    """Keep the exact native chunks passed to the existing enhancer."""
    def __init__(self, enhancer):
        self.enhancer = enhancer
        self.chunks = []

    def enhance(self, wave, sample_rate):
        self.chunks.append(wave.detach().cpu().numpy().copy())
        return self.enhancer.enhance(wave, sample_rate)


def module(path, name):
    spec = importlib.util.spec_from_file_location(name, path)
    value = importlib.util.module_from_spec(spec)
    sys.modules[name] = value
    spec.loader.exec_module(value)
    return value


def verify_files(root, manifest, nested=False):
    root = Path(root).resolve()
    data = json.loads((root / manifest).read_text())
    for name, value in data["files"].items():
        path = root / name
        expected = value["sha256"] if isinstance(value, dict) else value
        if not path.resolve().is_relative_to(root) or path.is_symlink() or not path.is_file():
            raise ValueError("Release file is missing or linked: " + name)
        with path.open("rb") as stream:
            if hashlib.file_digest(stream, "sha256").hexdigest() != expected:
                raise ValueError("Release integrity check failed: " + name)
    return data


class Studio:
    def __init__(self, cache, output, device="cpu"):
        from huggingface_hub import snapshot_download
        import torch
        self.torch = torch
        self.device = device
        self.output = Path(output)
        self.output.mkdir(parents=True, exist_ok=True)
        self.paths = {}
        for key, spec in MODELS.items():
            patterns = ["core/**"] if key == "mini" else None
            path = snapshot_download(spec.repo, revision=spec.revision, local_dir=Path(cache) / key,
                allow_patterns=patterns, token=False, max_workers=4)
            self.paths[key] = Path(path)
        mini = self.paths["mini"] / "core"
        micro = self.paths["micro"]
        self.mini_cli = module(mini / "synthesize.py", "_demo_mini_cli")
        self.mini_cli.verify(mini)
        self.micro_cli = module(micro / "synthesize.py", "_demo_micro_cli")
        self.micro_cli.verify_candidate(micro)
        verify_files(self.paths["atom"], "manifest.json")
        # The two neural packages intentionally share these exact source bytes.
        a = json.loads((mini / "assembly-manifest.json").read_text())["files"]
        b = json.loads((micro / "release-manifest.json").read_text())["files"]
        for name, digest in a.items():
            other = name.replace("baseline/models/mini/code/", "models/micro/code/")
            if name.startswith("baseline/models/mini/code/") and other in b and digest != b[other]:
                raise ValueError("Model packages have incompatible shared modules.")
        # Micro enforces module origins; load it first, then reuse the verified
        # byte-identical common modules when constructing Mini.
        self.micro_runtime = self.micro_cli.load_runtime(micro, "micro", device)
        self.base_cli, self.mini_runtime, _ = self.mini_cli.load_selected("mini", device,
            profile="pitch", runtime="canonical", root=mini)
        self.mini_runtime[2]._load_pitch()
        selected = module(mini / "selected_mini.py", "_demo_mini_selected")
        self.enhancer = selected.Native48Enhancer(mini, device)
        self.core, self.frontend = self.mini_runtime[:2]
        self.numeric = self.base_cli.load_numeric(mini / "baseline")
        atom_path = self.paths["atom"]
        self.atom_code = module(atom_path / "atom_runtime_20261024.py", "_demo_atom")
        self.atom = self.atom_code.AtomRuntime(atom_path / "export.pt", atom_path / "vocab.json",
            device=device, g2p=self.frontend, dsp_backend="torch")
        event_snapshot = snapshot_download(MODELS["mini"].repo, revision=EVENT_REVISION,
            local_dir=Path(cache) / "events", allow_patterns=["extras/events/component/**"], token=False, max_workers=4)
        events = Path(event_snapshot) / "extras/events/component"
        self.event_code = module(events / "sdk.py", "_demo_events")
        base_events = self.event_code.ApprovedEvents(self._event_speech, events, EVENT_MANIFEST)
        base_events._load()
        bank = Path(__file__).resolve().parent / "event_bank"
        if hashlib.sha256((bank / "runtime.py").read_bytes()).hexdigest() != EVENT_BANK_RUNTIME:
            raise ValueError("Event bank runtime integrity check failed.")
        self.event_bank_code = module(bank / "runtime.py", "_demo_multivoice_events")
        # Owner-requested beta rollout; preserve listening-approval metadata rather than inventing approvals.
        self.event_adapter = self.event_bank_code.MultiVoiceEvents(base_events, bank, EVENT_BANK_MANIFEST,
            allow_unreviewed=True)
        if not all(p["source_approved"] for p in self.event_adapter.manifest["profiles"]):
            raise ValueError("The event bank contains a source that has not been approved.")
        for voice in self.event_bank_code.VOICES:
            for tag in self.event_bank_code.TAGS:
                if not (voice == "cassidy" and tag in ("sigh", "gasp", "cough", "laugh")) and not self.event_adapter.profiles.get((voice, tag)):
                    raise ValueError("Incomplete event bank: " + voice + "/" + tag)
        torch.set_num_threads(2)

    def prepare(self, request):
        with LOCK:
            if request["events"]:
                self.event_bank_code.parse(request["text"])
                return request
            prepared = self.base_cli.prepare_text(request["text"], self.numeric, "conservative")
            # Preflight the same offline frontend before requesting shared GPU time.
            for chunk in prepared["chunks"]:
                self.frontend(chunk)
            return dict(request, prepared=prepared)

    def _speech(self, text, voice, seed, enhanced=False, prepared=None):
        import soundfile as sf
        prepared = prepared or self.base_cli.prepare_text(text, self.numeric, "conservative")
        raw, metadata = self.mini_cli.render(prepared, voice, self.mini_runtime,
            self.enhancer if enhanced else None, seed, "FLOAT")
        audio, rate = sf.read(io.BytesIO(raw), dtype="float32")
        return audio, rate, metadata

    def _event_speech(self, text, voice="cassidy", seed=42, enhanced=False):
        audio, rate, metadata = self._speech(text, voice, seed, enhanced)
        return dict(audio=self.torch.from_numpy(audio), sample_rate=rate, metadata=metadata)

    def _save_take(self, audio, rate, key, request, begin, **extra):
        import numpy as np
        import soundfile as sf
        if audio.ndim != 1 or not audio.size or not np.isfinite(audio).all() or np.abs(audio).max() >= 1:
            raise ValueError("The model returned unsafe audio. Please try a shorter passage.")
        for old in self.output.glob("inflect-*.wav"):
            if old.is_file() and not old.is_symlink() and time.time() - old.stat().st_mtime > 3600:
                old.unlink(missing_ok=True)
        path = self.output / f"inflect-{key}-{request['voice']}-{uuid.uuid4().hex}.wav"
        sf.write(path, audio, rate, subtype="PCM_16")
        return dict(path=str(path), model=key, voice=request["voice"], rate=rate, seconds=len(audio)/rate,
            compute_seconds=time.perf_counter()-begin, seed=request["seed"], characters=len(request["text"]), **extra)

    def generate_quality_pair(self, request):
        import numpy as np
        import soundfile as sf
        if request["model"] != "mini" or request["events"]:
            raise ValueError("24/48 kHz comparison uses Mini with plain text.")
        begin = time.perf_counter()
        with LOCK, self.torch.inference_mode():
            capture = NativeCapture(self.enhancer)
            raw, _ = self.mini_cli.render(request["prepared"], request["voice"], self.mini_runtime,
                capture, request["seed"], "FLOAT")
            enhanced, rate = sf.read(io.BytesIO(raw), dtype="float32")
            chunks = []
            for chunk in capture.chunks:
                if chunks:
                    chunks.append(np.zeros(2880, dtype=np.float32))
                chunks.append(chunk)
            if not chunks:
                raise ValueError("The native performance could not be captured.")
            native = np.concatenate(chunks)
            if rate != 48000 or len(enhanced) != 2 * len(native):
                raise ValueError("The comparison did not preserve the native performance length.")
            return [self._save_take(native, 24000, "mini", request, begin, variant="native", shared_performance=True),
                self._save_take(enhanced, 48000, "mini", request, begin, variant="enhanced", shared_performance=True)]

    def generate(self, request, key=None):
        import numpy as np
        import soundfile as sf
        key = key or request["model"]
        begin = time.perf_counter()
        with LOCK, self.torch.inference_mode():
            if key == "mini":
                if request["events"]:
                    result = self.event_adapter.synthesize(request["text"], voice=request["voice"],
                        seed=request["seed"], events=True, sample_rate=24000)
                    if request["enhanced"]:
                        count = max(1, (result["audio"].numel() + 479999) // 480000)
                        chunks = result["audio"].tensor_split(count)
                        result["audio"] = self.torch.cat([self.enhancer.enhance(chunk, 24000)[0] for chunk in chunks])
                        result["sample_rate"] = 48000
                    audio, rate = result["audio"].detach().cpu().numpy(), result["sample_rate"]
                else:
                    audio, rate, _ = self._speech(request["text"], request["voice"], request["seed"],
                        request["enhanced"], request["prepared"])
            elif key == "micro":
                raw, _ = self.micro_cli.render_prepared(request["prepared"], request["voice"],
                    self.micro_runtime, request["seed"])
                audio, rate = sf.read(io.BytesIO(raw), dtype="float32")
            else:
                pieces = []
                for i, chunk in enumerate(request["prepared"]["chunks"]):
                    result = self.atom.synthesize_text(chunk, request["voice"], seed=(request["seed"] + i) % 2**32)
                    if pieces:
                        pieces.append(np.zeros(2880, dtype=np.float32))
                    pieces.append(result["audio"].detach().cpu().numpy())
                audio, rate = np.concatenate(pieces), 24000
            return self._save_take(audio, rate, key, request, begin)