File size: 8,585 Bytes
4f5f309
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24e7fa4
 
 
 
 
 
 
 
 
 
4f5f309
 
 
 
 
 
24e7fa4
4f5f309
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
from __future__ import annotations

import re
import time
from pathlib import Path
from typing import Any
import numpy as np
import psutil

from .assets import PromptAssets, default_assets_dir
from .backbone import Backbone
from .depth import Depth
from .codec import MimiDecoder
from .prompt import AudioIn, build_prompt
from .sampling import gumbel
from .control import (
    expected_frames,
    frame_cap,
    min_frames,
    split_text,
    StopPolicy,
    sample_cb0,
)

# Speakable text check: must contain at least one alphanumeric character
ALPHANUM_PATTERN = re.compile(r"[\w\d]", re.UNICODE)


def _softmax_topk(logits: np.ndarray, temp: float = 0.7, top_k: int = 64) -> np.ndarray:
    logits = np.asarray(logits, dtype=np.float32)
    k = min(top_k, logits.shape[-1])
    topk_idx = np.argpartition(logits, -k)[-k:]
    masked = np.full_like(logits, -np.inf)
    masked[topk_idx] = logits[topk_idx] / temp
    max_val = np.max(masked[topk_idx])
    exp_vals = np.exp(masked - max_val)
    exp_vals[masked == -np.inf] = 0.0
    sum_exp = np.sum(exp_vals)
    if sum_exp > 0:
        return exp_vals / sum_exp
    probs = np.zeros_like(logits)
    probs[topk_idx] = 1.0 / len(topk_idx)
    return probs


class MayaFast:
    def __init__(
        self,
        assets_dir: str | Path | None = None,
        threads: int | None = None,
    ) -> None:
        if assets_dir is None:
            self.assets_dir = default_assets_dir()
        else:
            self.assets_dir = Path(assets_dir)

        self.threads = threads
        self.assets = PromptAssets(self.assets_dir, verify=True)
        self.shapes = self.assets.shapes
        self.controls = self.assets.controls
        self.sampling_cfg = self.controls.get("sampling", {})

        # Load models. Depth first: its session peaks ~1 GB above steady state
        # during graph optimization, so loading it before the backbone keeps
        # that transient off the combined peak.
        # Graph file names come from the manifest ("graphs"), defaulting to the
        # v1 names (see assets.DEFAULT_GRAPHS).
        self.graphs = self.assets.graphs
        depth_path = self.assets_dir / self.graphs["depth"]
        self.depth = Depth(depth_path, threads=self.threads)

        backbone_path = self.assets_dir / self.graphs["backbone"]
        self.backbone = Backbone(
            backbone_path,
            threads=self.threads,
            config=self.shapes,
        )

        mimi_path = self.assets_dir / self.graphs["mimi"]
        self.mimi = MimiDecoder(mimi_path, threads=self.threads)

        self.audio_in = self.assets.audio_in

    @property
    def voices(self) -> list[str]:
        return sorted(list(self.assets.voices_info.keys()))

    def synthesize(
        self,
        text: str,
        voice: str = "Charvi",
        seed: int = 0,
        chunk_s: float | None = None,
        lang: str = "mr",
    ) -> tuple[np.ndarray, dict[str, Any]]:
        """
        Synthesize text into 24 kHz float32 audio.
        Returns:
            (wav, metrics)
        """
        # Review focus #2: Empty or punctuation-only text: expected ValueError("no speakable text")
        if not text or not ALPHANUM_PATTERN.search(text):
            raise ValueError("no speakable text")

        if voice not in self.assets.voices_info:
            raise KeyError(f"Unknown voice {voice!r}. Available: {self.voices}")

        t_wall_start = time.perf_counter()
        prior = self.controls.get("text_frames_prior", {})
        sampling_cfg = self.sampling_cfg
        q_serve = int(sampling_cfg.get("q_serve", 16))
        temp = float(sampling_cfg.get("temp", 0.7))
        top_k = int(sampling_cfg.get("top_k", 64))
        tau = float(sampling_cfg.get("tau", 0.35))
        hazard = float(sampling_cfg.get("hazard", 0.6))
        aeos_class = int(self.shapes.get("n_codes", 2048))

        # Split text into chunks
        c_s = 12.0 if chunk_s is None else chunk_s
        chunks = split_text(text, lang, prior, chunk_s=c_s)

        rng = np.random.default_rng(seed)

        # Speaker memory for Depth: named voices use a null memory (use_mem False)
        d_mem = int(self.shapes.get("d_mem", 1024))
        mem = np.zeros((1, 1, d_mem), dtype=np.float32)

        gen_codes: list[np.ndarray] = []
        reasons: list[str] = []
        caps: list[int] = []
        max_past_len = 0
        max_chunk_bound = 0

        backbone_time = 0.0
        depth_time = 0.0
        mimi_time = 0.0

        for chunk_text in chunks:
            # Review focus #1: Each piece a fresh KV session
            self.backbone.reset()

            prompt = build_prompt(chunk_text, voice, self.assets, lang=lang)

            ef, er = expected_frames(prior, lang, len(chunk_text))
            cap = frame_cap(chunk_text, lang, ef, er, self.controls)
            min_f = min_frames(ef)
            caps.append(cap)
            chunk_bound = prompt.n_prompt + cap
            if chunk_bound > max_chunk_bound:
                max_chunk_bound = chunk_bound

            # Initial backbone step on prompt
            t0 = time.perf_counter()
            h, cb0_logits = self.backbone.step(prompt.embeds)
            backbone_time += time.perf_counter() - t0

            if self.backbone.past_len > max_past_len:
                max_past_len = self.backbone.past_len

            stop_policy = StopPolicy(min_frames=min_f, tau=tau, hazard=hazard)
            chunk_frames: list[np.ndarray] = []
            reason = "cap"

            for t in range(cap):
                # 1. Softmax with top_k mask over cb0_logits
                p = _softmax_topk(cb0_logits, temp=temp, top_k=top_k)

                # 2. StopPolicy check
                stop_reason = stop_policy.check(t, p, aeos_class=aeos_class)
                if stop_reason is not None:
                    reason = stop_reason
                    break

                # 3. Sample cb0 (aeos zeroed, Gumbel-max over the rest)
                cb0_noise = gumbel(rng, (cb0_logits.shape[-1],))
                cb0 = sample_cb0(p, cb0_noise, aeos_class=aeos_class)

                # 4. Depth frame
                depth_noise = gumbel(rng, (15, int(self.shapes.get("n_codes", 2048))))
                t_d = time.perf_counter()
                frame_codes = self.depth.frame(h, cb0, mem, depth_noise)
                depth_time += time.perf_counter() - t_d

                chunk_frames.append(frame_codes)

                # 5. AudioIn feedback
                fb = self.audio_in.frames(frame_codes[None, :], q_use=q_serve)

                # 6. Next backbone step
                t_b = time.perf_counter()
                h, cb0_logits = self.backbone.step(fb)
                backbone_time += time.perf_counter() - t_b

                if self.backbone.past_len > max_past_len:
                    max_past_len = self.backbone.past_len

            reasons.append(reason)
            # Note on upstream aeos step:
            # Upstream does one self._step(audio_in.aeos(1)) after the loop if reason.startswith("aeos").
            # However, because each chunk resets the KV session and no further steps read from this KV session,
            # running the final aeos step changes nothing returned and wastes compute. We skip it here.

            if chunk_frames:
                # chunk_frames: list of (16,)
                chunk_codes_arr = np.stack(chunk_frames, axis=1)  # (16, F_chunk)
                gen_codes.append(chunk_codes_arr)

        if gen_codes:
            all_codes = np.concatenate(gen_codes, axis=1)  # (16, Total_F)
            total_frames = all_codes.shape[1]
            t_m = time.perf_counter()
            wav = self.mimi.decode(all_codes)
            mimi_time += time.perf_counter() - t_m
        else:
            total_frames = 0
            wav = np.zeros(0, dtype=np.float32)

        wall_s = time.perf_counter() - t_wall_start
        audio_s = len(wav) / 24000.0
        rtf = wall_s / max(1e-6, audio_s)

        # Peak RSS in MB
        process = psutil.Process()
        peak_rss_mb = process.memory_info().rss / (1024 * 1024)

        metrics = {
            "audio_s": audio_s,
            "wall_s": wall_s,
            "rtf": rtf,
            "frames": total_frames,
            "reasons": reasons,
            "caps": caps,
            "backbone_s": backbone_time,
            "depth_s": depth_time,
            "mimi_s": mimi_time,
            "peak_rss_mb": peak_rss_mb,
            "max_past_len": max_past_len,
            "max_chunk_bound": max_chunk_bound,
        }

        return wav, metrics