File size: 15,478 Bytes
b840c7e
 
 
 
 
 
f58e011
 
 
 
 
 
 
 
 
 
 
 
 
 
b840c7e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8d36590
167fa74
8d36590
4be97a5
8d36590
8c740c9
4be97a5
8c740c9
167fa74
 
8c740c9
 
 
4be97a5
8d36590
 
 
 
 
 
8c740c9
4be97a5
8d36590
 
 
 
 
 
8c740c9
 
 
 
 
 
167fa74
 
8c740c9
 
8d36590
 
 
 
 
 
8c740c9
167fa74
 
8d36590
f672f51
 
 
8d36590
f672f51
8d36590
f672f51
 
8d36590
f672f51
8d36590
 
 
 
f672f51
b840c7e
 
 
 
 
 
 
 
 
 
 
 
 
 
f58e011
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b840c7e
 
8f5b2eb
b840c7e
8f5b2eb
 
b840c7e
 
c5017ba
b840c7e
 
 
 
 
 
8f5b2eb
6775e28
c5017ba
8f5b2eb
6775e28
 
c5017ba
8f5b2eb
 
 
 
c5017ba
 
 
8f5b2eb
 
 
af4112e
8f5b2eb
6775e28
c5017ba
b840c7e
6775e28
f58e011
 
 
 
b840c7e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1e1da0c
b840c7e
f58e011
 
 
 
 
 
 
 
 
 
c5017ba
 
 
f58e011
 
 
c5017ba
f58e011
 
 
 
c5017ba
 
 
f58e011
 
 
c5017ba
f58e011
 
c5017ba
f58e011
 
4be97a5
f58e011
 
b840c7e
f58e011
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b840c7e
f58e011
b840c7e
 
 
1e1da0c
 
 
b840c7e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
import base64
import io
import os
import struct
import wave

# SNAC token constants for Maya1
CODE_START_TOKEN_ID = 128257
CODE_END_TOKEN_ID = 128258
CODE_TOKEN_OFFSET = 128266
SNAC_MIN_ID = 128266
SNAC_MAX_ID = 156937
SNAC_TOKENS_PER_FRAME = 7

SOH_ID = 128259
EOH_ID = 128260
SOA_ID = 128261
BOS_ID = 128000
TEXT_EOT_ID = 128009


class EndpointHandler:
    def __init__(self, path=""):
        # allow overriding device and dtype via environment variables for local CPU testing
        # `MAYA_DEVICE`: 'cpu' or 'auto' (default 'auto')
        # `MAYA_TORCH_DTYPE`: 'bf16' or 'fp32' (default 'bf16')
        # `MAYA_USE_FAKE`: if '1', use a tiny fake pipeline for smoke testing (no HF downloads)
        device_override = os.getenv("MAYA_DEVICE", "auto")
        dtype_override = os.getenv("MAYA_TORCH_DTYPE", "bf16")
        use_fake = os.getenv("MAYA_USE_FAKE", "0") == "1"

        # map string to torch dtype will be set after importing torch

        if use_fake:
            # Minimal fake components for quick local smoke tests. Generates a short sine tone.
            self.model = None
            self.tokenizer = None
            # keep device as plain string for fake mode
            self.device = "cpu"
            self.snac = None
            # no external libs required for fake mode
            self.sf = None
            return

        # import heavy inference dependencies lazily so fake-mode doesn't require them
        try:
            import soundfile as sf
            import torch
            from snac import SNAC
            from transformers import AutoModelForCausalLM, AutoTokenizer
        except Exception as e:
            raise RuntimeError(
                f"Failed to import inference dependencies: {e}.\n"
                "Install the packages listed in `inference_endpoints/requirements.txt`."
            )

        # map string to torch dtype
        torch_dtype = torch.bfloat16 if dtype_override == "bf16" else torch.float32

        # load Maya1 text-to-voice model
        # force CPU device_map when requested to avoid trying to use GPUs
        device_map_arg = "auto"
        if device_override == "cpu":
            device_map_arg = "cpu"

        # Allow loading from HuggingFace Hub via environment variable
        # MAYA_MODEL_ID: HuggingFace model ID (e.g., "maya-research/maya1" or similar Maya model)
        model_id = os.getenv("MAYA_MODEL_ID", "")

        # Determine model path: use env var if set, otherwise local path
        local_path = path if path else "/repository"

        # Check if trying to load from self (circular reference)
        # Only block if it's exactly "binhqd/maya1" - other repos ending in /maya1 are fine
        if model_id == "binhqd/maya1":
            print(f"⚠️ MAYA_MODEL_ID is set to {model_id} which is this repository!")
            print("This creates a circular reference. Checking local files instead...")
            model_id = ""  # Force local loading

        if model_id:
            # Load from HuggingFace Hub
            model_path = model_id
            print(f"Loading Maya1 model from HuggingFace Hub: {model_id}")
        else:
            # Load from local repository
            model_path = local_path

            # Check if this is a valid model directory
            config_path = os.path.join(model_path, "config.json")
            if not os.path.exists(config_path):
                raise RuntimeError(
                    f"❌ Model configuration not found at: {config_path}\n\n"
                    f"The repository appears to be missing model weights and configuration.\n\n"
                    f"To fix this, you have 3 options:\n\n"
                    f"1. 🧪 TESTING MODE (Quick Start):\n"
                    f"   Set: MAYA_USE_FAKE=1\n"
                    f"   This generates test audio (sine wave) without requiring model files.\n\n"
                    f"2. 🌐 USE EXISTING MODEL FROM HUGGINGFACE:\n"
                    f"   Set: MAYA_MODEL_ID=<actual-model-id>\n"
                    f"   Example: MAYA_MODEL_ID=maya-research/maya1\n"
                    f"   (Must be a valid HuggingFace model repository with weights)\n\n"
                    f"3. 📦 UPLOAD YOUR MODEL FILES:\n"
                    f"   Upload these files to your repository (binhqd/maya1):\n"
                    f"   - config.json\n"
                    f"   - model.safetensors (or pytorch_model.bin)\n"
                    f"   - tokenizer.json\n"
                    f"   - tokenizer_config.json\n"
                    f"   - generation_config.json (optional)\n\n"
                    f"Current path: {model_path}\n"
                    f"Files found: {os.listdir(model_path) if os.path.exists(model_path) else 'path does not exist'}\n\n"
                    f"⚠️ Note: MAYA_MODEL_ID=binhqd/maya1 won't work (this repository)\n"
                    f"   because it doesn't contain model weights.\n"
                )

        try:
            self.model = AutoModelForCausalLM.from_pretrained(
                model_path, torch_dtype=torch_dtype, device_map=device_map_arg
            )
            self.tokenizer = AutoTokenizer.from_pretrained(model_path)
        except Exception as e:
            raise RuntimeError(
                f"Failed to load Maya1 model from {model_path}.\n"
                f"Error: {e}\n\n"
                f"Please ensure:\n"
                f"1. The model repository contains valid model files, OR\n"
                f"2. Set MAYA_MODEL_ID to a valid HuggingFace model ID, OR\n"
                f"3. Set MAYA_USE_FAKE=1 for testing without a real model"
            )

        # determine device from model parameters (safer than using `model.device`)
        try:
            self.device = next(self.model.parameters()).device
        except StopIteration:
            # fallback to CPU if model has no parameters
            self.device = torch.device("cpu")

        # load SNAC model (audio decoder) 24 kHz
        self.snac = (
            SNAC.from_pretrained("hubertsiuzdak/snac_24khz").eval().to(self.device)
        )
        self.sf = sf

    def build_prompt(self, description: str, text: str) -> str:
        """Build formatted prompt for Maya1."""
        soh_token = self.tokenizer.decode([SOH_ID])
        eoh_token = self.tokenizer.decode([EOH_ID])
        soa_token = self.tokenizer.decode([SOA_ID])
        sos_token = self.tokenizer.decode([CODE_START_TOKEN_ID])
        eot_token = self.tokenizer.decode([TEXT_EOT_ID])
        bos_token = self.tokenizer.bos_token
        
        formatted_text = f'<description="{description}"> {text}'
        
        prompt = (
            soh_token + bos_token + formatted_text + eot_token +
            eoh_token + soa_token + sos_token
        )
        
        return prompt

    def extract_snac_codes(self, token_ids: list) -> list:
        """Extract SNAC codes from generated tokens."""
        try:
            eos_idx = token_ids.index(CODE_END_TOKEN_ID)
        except ValueError:
            eos_idx = len(token_ids)
        
        snac_codes = [
            token_id for token_id in token_ids[:eos_idx]
            if SNAC_MIN_ID <= token_id <= SNAC_MAX_ID
        ]
        
        return snac_codes

    def unpack_snac_from_7(self, snac_tokens: list) -> list:
        """Unpack 7-token SNAC frames to 3 hierarchical levels."""
        if snac_tokens and snac_tokens[-1] == CODE_END_TOKEN_ID:
            snac_tokens = snac_tokens[:-1]
        
        frames = len(snac_tokens) // SNAC_TOKENS_PER_FRAME
        snac_tokens = snac_tokens[:frames * SNAC_TOKENS_PER_FRAME]
        
        if frames == 0:
            return [[], [], []]
        
        l1, l2, l3 = [], [], []
        
        for i in range(frames):
            slots = snac_tokens[i*7:(i+1)*7]
            l1.append((slots[0] - CODE_TOKEN_OFFSET) % 4096)
            l2.extend([
                (slots[1] - CODE_TOKEN_OFFSET) % 4096,
                (slots[4] - CODE_TOKEN_OFFSET) % 4096,
            ])
            l3.extend([
                (slots[2] - CODE_TOKEN_OFFSET) % 4096,
                (slots[3] - CODE_TOKEN_OFFSET) % 4096,
                (slots[5] - CODE_TOKEN_OFFSET) % 4096,
                (slots[6] - CODE_TOKEN_OFFSET) % 4096,
            ])
        
        return [l1, l2, l3]

    def __call__(self, data):
        """
        HF Endpoints format:
        {
          "inputs": "text to speak" or {"text": "...", "description": "..."},
          "description": "... optional voice description ...",
          "generation_args": { optional dict for text generation params }
        }

        Returns dict with base64 audio:
        {
          "audio_base64": "<base64-encoded WAV data>",
          "sampling_rate": 24000
        }
        """
        # Extract inputs (HF always provides this key)
        inputs = data.get("inputs", "")

        # Get additional parameters from top level
        description = data.get("description", "")
        generation_args = data.get("generation_args", {})

        # Parse inputs
        if isinstance(inputs, dict):
            # inputs is a dict with text and description
            text = inputs.get("text", "")
            description = inputs.get(
                "description", description
            )  # override if in inputs
        elif isinstance(inputs, str):
            # inputs is just the text string
            text = inputs
        else:
            # Try to convert to string
            text = str(inputs) if inputs else ""

        if not text:
            return {"error": f"No text provided. Received: {data}"}
        
        # Use default description if not provided
        if not description:
            description = "Realistic male voice in the 30s age with american accent. Normal pitch, warm timbre, conversational pacing."

        # If running in fake mode (quick smoke test), synthesize a sine tone
        if getattr(self, "snac", None) is None and getattr(self, "model", None) is None:
            # generate a 1-second 24kHz sine wave and write a 16-bit WAV using stdlib
            sr = 24000
            duration = 1.0
            n_samples = int(sr * duration)
            freq = 220.0
            # generate samples without numpy
            waveform = [
                int(
                    0.1
                    * 32767
                    * __import__("math").sin(
                        2 * __import__("math").pi * freq * (i / sr)
                    )
                )
                for i in range(n_samples)
            ]

            buf = io.BytesIO()
            with wave.open(buf, "wb") as wf:
                wf.setnchannels(1)
                wf.setsampwidth(2)  # 16-bit
                wf.setframerate(sr)
                # pack samples as little-endian signed 16-bit
                frames = struct.pack("<" + ("h" * len(waveform)), *waveform)
                wf.writeframes(frames)
            wav_bytes = buf.getvalue()
            b64 = base64.b64encode(wav_bytes).decode("utf-8")

            return [{"blob": b64, "content-type": "audio/wav"}]

        # Build properly formatted prompt for Maya1
        prompt = self.build_prompt(description, text)

        # Tokenize
        import torch
        tokenizer_inputs = self.tokenizer(prompt, return_tensors="pt")
        if torch.cuda.is_available():
            tokenizer_inputs = {k: v.to(self.device) for k, v in tokenizer_inputs.items()}
        else:
            tokenizer_inputs = tokenizer_inputs.to(self.device)

        # Set default generation args if not provided
        default_gen_args = {
            "max_new_tokens": 2048,
            "min_new_tokens": 28,
            "temperature": 0.4,
            "top_p": 0.9,
            "repetition_penalty": 1.1,
            "do_sample": True,
            "eos_token_id": CODE_END_TOKEN_ID,
            "pad_token_id": self.tokenizer.pad_token_id,
        }
        default_gen_args.update(generation_args)

        # Generate tokens
        with torch.inference_mode():
            outputs = self.model.generate(**tokenizer_inputs, **default_gen_args)

        # Extract generated tokens (everything after the input prompt)
        generated_ids = outputs[0, tokenizer_inputs["input_ids"].shape[1]:].tolist()

        # Extract SNAC audio tokens
        snac_tokens = self.extract_snac_codes(generated_ids)
        
        if len(snac_tokens) < 7:
            return {"error": f"Not enough SNAC tokens generated: {len(snac_tokens)}"}

        # Unpack SNAC tokens to 3 hierarchical levels
        levels = self.unpack_snac_from_7(snac_tokens)
        
        # Convert to tensors
        codes_tensor = [
            torch.tensor(level, dtype=torch.long, device=self.device).unsqueeze(0)
            for level in levels
        ]

        # Generate final audio with SNAC decoder
        with torch.inference_mode():
            z_q = self.snac.quantizer.from_codes(codes_tensor)
            audio = self.snac.decoder(z_q)[0, 0].cpu().numpy()

        # Trim warmup samples (first 2048 samples)
        if len(audio) > 2048:
            audio = audio[2048:]

        # Save audio to WAV
        buf = io.BytesIO()
        self.sf.write(buf, audio, 24000, format="WAV")
        wav_bytes = buf.getvalue()
        b64 = base64.b64encode(wav_bytes).decode("utf-8")

        # Return in HuggingFace Inference Endpoints format
        # The endpoint expects a list with blob data
        return [{"blob": b64, "content-type": "audio/wav"}]
# Module-level convenience functions for hosting platforms (Hugging Face Endpoints)
# The platform typically expects top-level `init` and `predict` (or `run`) callables
# so we provide thin wrappers around the EndpointHandler class.
_HANDLER = None


def init(model_id: str = None):
    """Initialize the module-level handler.

    model_id: optional path or model identifier to pass to EndpointHandler.
    If omitted the handler will look for environment variables `HF_MODEL_ID` or
    `MODEL_ID` and otherwise instantiate with a blank path (which may be
    appropriate when the model files are bundled with the repo).
    """
    global _HANDLER
    if _HANDLER is not None:
        return

    model_path = model_id or os.getenv("HF_MODEL_ID") or os.getenv("MODEL_ID") or ""
    _HANDLER = EndpointHandler(path=model_path)


def predict(payload):
    """Predict/predict wrapper for hosted endpoints.

    Accepts a dict (recommended) or a JSON-ish payload. If a list is passed,
    the first element is used. If a plain string is provided it is treated as
    the `text` field.
    Returns the same dict structure produced by EndpointHandler.__call__.
    """
    global _HANDLER
    if _HANDLER is None:
        init()

    data = payload
    # handle list payloads (common in some platform wrappers)
    if isinstance(payload, (list, tuple)) and len(payload) > 0:
        data = payload[0]

    # allow raw JSON string -> dict
    if isinstance(data, str):
        try:
            import json

            data = json.loads(data)
        except Exception:
            # treat plain string as the text to synthesize
            data = {"text": data}

    if not isinstance(data, dict):
        # best-effort normalization
        data = {"text": str(data)}

    try:
        return _HANDLER(data)
    except Exception as e:
        return {"error": str(e)}


def run(payload):
    """Alias for predict for platforms that expect `run` entrypoint."""
    return predict(payload)