File size: 12,345 Bytes
e8ff114
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
"""MayaTTS on MLX: the same engine (prompt, sampler, stop rule, guards, voices, text splitting, streaming Mimi), with
the model run by maya_mac.mlx_model instead of torch MPS.

What makes it faster than the torch engine, per 80 ms frame:
  * the cb0 sampler, the 31-level depth decoder and the feedback embedding are one compiled Metal graph
    (FrameStep.step): no per-op dispatch from Python, no host round trip between the backbone and depth;
  * the frame loop is pipelined: frame t+1 is queued on the GPU before the host reads frame t (stop flag + codes, one
    33-int read), so the host-side work (guards, Mimi hand-off) overlaps the GPU instead of stalling it. A
    speculative frame is thrown away when frame t stops, or recomputed when the loop guard changes the AEOS bias;
  * a voice's reference prefix (the ICL clip) is prefilled once and its KV reused by every later request;
  * optional 8 / 4-bit weights (EngineConfig.backbone_bits / depth_bits).

Sampling draws from the same distributions as the torch engine but with MLX's RNG, so a given seed yields different
(equally likely) audio on the two backends.
"""
from __future__ import annotations

import json
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

import mlx.core as mx
import numpy as np
import torch

from maya_mac import guards as G
from maya_mac import reference as S
from maya_mac.engine import HERE, EngineConfig, MayaTTS
from maya_mac.mlx_model import DTYPES, STOP_REASONS, Backbone, Composer, FrameStep, load_weights
from maya_mac.prompt import DEFAULT_BUILDER, MODES, Voice, VoiceRegistry, load_named_voices
from maya_mac.tts.tokens import N_Q


class MayaMLX(MayaTTS):
    def __init__(self, model_dir, mimi_dir, ecapa_dir=None, device=None, config: EngineConfig = None, log=print):
        from transformers import AutoTokenizer, MimiModel
        from maya_mac.codec import StreamingMimi
        from maya_mac.tts.data.packing_v2 import PackerV2
        from maya_mac.tts.tokens import extend_tokenizer

        self.device = "mlx"
        self.cfg = cfg = config or EngineConfig()
        self.log = log
        t0 = time.perf_counter()
        dtype = DTYPES[cfg.backbone_dtype]
        self.dtype = dtype
        bb = Path(model_dir) / "backbone"
        # MLX streams are thread-local: every MLX call (load, compile, voices, frames) runs on this one thread, so the
        # engine can be built on one thread and driven from another (the server loads on one and serves on another)
        self._mlx = ThreadPoolExecutor(1, thread_name_prefix="maya-mlx")
        self._mlx_thread = None

        def build():
            self._mlx_thread = threading.current_thread()
            w = load_weights(model_dir)
            self.backbone = Backbone(w, json.loads((bb / "config.json").read_text()), dtype=dtype,
                                     bits=cfg.backbone_bits, capacity=cfg.capacity)
            self.step = FrameStep(w, dtype=DTYPES[cfg.depth_dtype], bits=cfg.depth_bits, depth_temp=cfg.depth_temp,
                                  depth_top_k=cfg.depth_top_k, rep_win=cfg.cb0_rep_win, fb_dtype=dtype)
            self.compose = Composer(w, dtype=dtype)
        self._on_mlx(build)
        tokenizer, token_ids = extend_tokenizer(AutoTokenizer.from_pretrained(str(bb)))
        self.packer = PackerV2(tokenizer, token_ids, max_tokens=8192)
        self.prior = S.load_prior(str(HERE / "configs/text_frames_prior.json"))
        self.dur = G.DurationPrior()
        self.mimi = MimiModel.from_pretrained(str(mimi_dir)).to(cfg.codec_device).eval()
        self.codec = StreamingMimi(self.mimi)
        self._codec_pool = ThreadPoolExecutor(1, thread_name_prefix="maya-mimi")
        self.ecapa_dir = ecapa_dir
        self._ecapa = None
        self._ecapa_device = "cpu"
        self.registry = VoiceRegistry()
        voices_json = Path(model_dir) / "voices" / "voices.json"
        if voices_json.is_file():
            load_named_voices(self.registry, str(voices_json))
        if cfg.warmup:      # compile the frame graph and the Metal kernels at load, not on the first request
            list(self.stream("Hello, this is a warmup.", "en", max_frames=6, seed=1))
        self.load_s = time.perf_counter() - t0
        self._seed_rng = np.random.default_rng(cfg.seed)
        self.last_info = None

    def _on_mlx(self, fn, *args):
        if threading.current_thread() is self._mlx_thread:
            return fn(*args)
        return self._mlx.submit(fn, *args).result()

    def close(self):
        super().close()
        self._mlx.shutdown(wait=True)

    # ------------------------------------------------------------------ voices
    def prepare_voice(self, voice: Voice):
        """Pack the voice's reference prefix and compute its depth cross-attention constants (once per voice)."""
        return self._on_mlx(self._prepare_voice, voice)

    def _prepare_voice(self, voice):
        with voice.lock:
            if voice.mem is not None:
                return voice
            _, use_mem = MODES[voice.mode]
            builder = voice.builder or DEFAULT_BUILDER
            voice.prefix = builder.prefix(self.packer, voice)
            voice.spk_refs = (np.asarray(voice.ecapa, np.float32).reshape(1, 192) if voice.ecapa is not None
                              else np.zeros((1, 192), np.float32))
            voice.mem = self.step.cross(voice.spk_refs if use_mem else None)
            mx.eval(voice.mem)
            voice.ts = int(voice.prefix["tokens"].shape[0]) if voice.prefix is not None else 0
            voice.kv = voice.emb = None
            return voice

    def _embed(self, w):
        if self._ecapa is None:
            if self.ecapa_dir is None:
                raise ValueError("ecapa_dir is required for reference-audio voices")
            from maya_mac.engine import Ecapa
            self._ecapa = Ecapa(self.ecapa_dir, self._ecapa_device)
        return self._ecapa(w)

    # ------------------------------------------------------------------ synthesis
    def _plan(self, text, lang, voice, max_frames):
        from maya_mac.tts.data.packing_v2 import collate_v2
        from maya_mac.tts.model.load_s3 import truncate_prompt_batch
        cfg, g = self.cfg, self.cfg.guards
        builder = voice.builder or DEFAULT_BUILDER
        ex = builder.example(self.packer, voice, text, lang)
        # reference.prompt_from_example, composed by MLX
        b = collate_v2([ex], q_choices=(N_Q,), q_rng=np.random.default_rng(0))
        kind = b["kind"][0]
        a = (kind == 1).nonzero().flatten()
        a = a[a >= int(ex.get("target_start", 0))]
        assert a.numel(), "prompt has no target audio position"
        prompt = self.compose(truncate_prompt_batch(b, int(a[0]), require_target_span=self.compose.anchor is not None))
        ef, er = S.expected_frames(self.prior, lang, len(text))
        loose = min(cfg.hard_cap, int(2.0 * (ef + er) + 40))
        cap = loose
        if g.enabled:
            cap = min(cap, max(int(ef * 0.5) + 8, self.dur.cap_frames(text, lang, g.dur_k, g.dur_margin_s)))
        if max_frames:
            cap = min(cap, max(1, int(max_frames)))
        minimum = max(4, int(0.5 * ef))
        suspend = G.suspend_windows_v2(text, lang, self.dur) if (g.enabled and g.loop and g.aware_v2) else ()
        return prompt, cap, loose, minimum, suspend

    def _prefill(self, prompt, voice):
        """Run the prompt through the backbone, reusing the voice's cached reference-prefix KV when the prompt starts
        with exactly that prefix."""
        bb, ts = self.backbone, voice.ts
        bb.reset()
        if ts and ts < prompt.shape[1]:
            head = prompt[:, :ts]
            if voice.kv is not None and mx.array_equal(head, voice.emb).item():
                bb.restore(voice.kv)
                return bb(prompt[:, ts:])
            bb(head)
            voice.kv, voice.emb = bb.snapshot(ts), head
            mx.eval(voice.kv, voice.emb)   # materialise now, so later in-place cache writes don't have to copy the cache
            return bb(prompt[:, ts:])
        return bb(prompt)

    def _frames(self, text, lang, voice, sampling, max_frames, info):
        """Yield the (32,) code tensor of each frame of one text piece; info receives the stop record. Each frame is
        produced on the MLX thread."""
        gen = self._frames_mlx(text, lang, voice, sampling, max_frames, info)
        try:
            while True:
                try:
                    codes = self._on_mlx(next, gen)
                except StopIteration:
                    return
                yield codes
        finally:
            self._on_mlx(gen.close)

    def _frames_mlx(self, text, lang, voice, sampling, max_frames, info):
        cfg, g = self.cfg, self.cfg.guards
        prompt, cap, loose, minimum, suspend = self._plan(text, lang, voice, max_frames)
        seed = sampling.get("seed")
        seed = int(seed) if seed is not None else int(self._seed_rng.integers(1, 2 ** 31 - 1))
        if prompt.shape[1] + cap + 1 > cfg.capacity:
            raise ValueError(f"prompt of {prompt.shape[1]} tokens + {cap} frames exceeds KV capacity {cfg.capacity}")
        rp = sampling.get("repetition_penalty")
        knobs = (mx.array(float(sampling.get("temperature") or cfg.cb0_temp)),
                 mx.array(min(int(sampling.get("top_k") or cfg.cb0_top_k), 2049)),
                 mx.array(float(sampling.get("top_p") or cfg.cb0_top_p)),
                 mx.array(float(rp if rp is not None else cfg.cb0_rep)))
        cross = voice.mem
        keys = iter(mx.random.split(mx.random.key(seed), cap + 1))
        backbone = self.backbone

        def issue(h, hazard, hist, frame, bias, key):
            """Queue one frame on the GPU; nothing is read back here."""
            out, fb, hazard, hist = self.step.step(h, cross, hist, hazard, mx.array(frame >= minimum),
                                                   mx.array(bias, mx.float32), *knobs, key)
            h = backbone(fb[None, None])
            mx.async_eval(out, h, hazard, hist)
            return out, h, hazard, hist, key

        h = self._prefill(prompt, voice)
        hist0 = mx.full((max(1, cfg.cb0_rep_win),), -1, mx.int32)
        cur = issue(h, mx.array(0.0), hist0, 0, 0.0, next(keys))
        cb0_hist = np.full(g.hist, -1, np.int64)
        frames, reason, boost_at, n_boost, bias = 0, "cap", None, 0, 0.0
        while True:
            nxt = issue(*cur[1:4], frames + 1, bias, next(keys)) if frames + 1 < cap else None
            out = np.array(cur[0])                      # waits for this frame only; the next one is already queued
            stop = int(out[0])
            if stop:
                reason = STOP_REASONS[stop]
                break
            codes = torch.from_numpy(out[1:].astype(np.int64))
            yield codes
            frames += 1
            cb0_hist[:-1] = cb0_hist[1:]
            cb0_hist[-1] = int(out[1])
            if frames >= cap:
                reason = "max_frames" if max_frames and frames >= max_frames else (
                    "cap_guard" if g.enabled and cap < loose else "cap")
                break
            new_bias = bias
            if (g.enabled and g.loop and frames >= g.start and frames % g.every == 0
                    and not any(a <= frames <= b for a, b in suspend)):
                hit = G.loop_detect_batch(cb0_hist[None], np.array([frames]), g)[0]
                if hit is not None and g.mode == "boost":
                    if boost_at is None:
                        boost_at, n_boost = frames, n_boost + 1
                        new_bias = g.boost
                    if frames - boost_at >= g.backstop:
                        reason = "loop_backstop"
                        break
                elif hit is not None:
                    reason = "loop_" + hit.split(":")[0]
                    break
                elif boost_at is not None:
                    new_bias, boost_at = 0.0, None
            if new_bias != bias:      # the queued frame used the old AEOS bias: redo it (it rewrites the same KV slot)
                bias = new_bias
                nxt = issue(*cur[1:4], frames, bias, nxt[4])
            cur = nxt
        info.update({"reason": reason, "frames": frames, "cap": cap, "n_boost": n_boost})