File size: 8,180 Bytes
845ded6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""LFM3-Audio-2B as an optional second engine of the Kokoro Space (for A/B tests).

Off unless the Space variable LFM3_ENABLED=1. When on, the routes below sit
beside the Kokoro ones with the same contract (a client only changes the path):

  POST /lfm3/tts/stream   {"text", "voice", "sample_rate"}  -> raw PCM16 mono
  POST /lfm3/tts/bench    {"text", "voice"}                 -> seconds per piece
  GET  /lfm3/tts/health

The model (about 8 GB in float32 on CPU) loads on the first request, not at
start-up, so Kokoro boots and answers exactly as before; transformers is not
imported until then. One LFM3 synthesis at a time. "speed" is ignored (LFM3 has
no speed control; X-Speed-Ignored says so). Voices: "female", "male" or a
Kokoro id such as "af_heart" (see pieces.parse_voice).

LiquidAI/LFM3-Audio-2B is under the LFM Open License v1.0: commercial use only
below US$10M annual revenue.
"""
import asyncio
import os
import threading
import time
from typing import Callable

import numpy as np
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse, StreamingResponse

from pieces import parse_voice, system_prompt

MODEL_ID = os.getenv("LFM3_MODEL_ID", "LiquidAI/LFM3-Audio-2B")
MAX_NEW_TOKENS = int(os.getenv("LFM3_MAX_NEW_TOKENS", "256"))  # audio frames per piece
SAMPLE_RATE = 24000  # LFM3 codec output


def enabled() -> bool:
    return os.getenv("LFM3_ENABLED", "").strip().lower() in ("1", "true", "yes")


class Lfm3Engine:
    """Lazy, thread-safe LFM3 text-to-speech."""

    def __init__(self, threads: int) -> None:
        self.threads = threads
        self.device = "cpu"
        self.dtype = "float32"
        self._model = None
        self._processor = None
        self._codec = None
        self._load_lock = threading.Lock()
        self._synth_lock = threading.Lock()

    @property
    def loaded(self) -> bool:
        return self._model is not None

    def _load(self) -> None:
        with self._load_lock:
            if self._model is not None:
                return
            import torch
            from transformers import AutoModelForMultimodalLM, AutoProcessor

            started = time.perf_counter()
            self.device = "cuda" if torch.cuda.is_available() else "cpu"
            dtype = torch.bfloat16 if self.device == "cuda" else torch.float32
            self.dtype = str(dtype).replace("torch.", "")
            model = AutoModelForMultimodalLM.from_pretrained(
                MODEL_ID, dtype=dtype, device_map=self.device, trust_remote_code=True
            ).eval()
            processor = AutoProcessor.from_pretrained(MODEL_ID, trust_remote_code=True)
            self._codec = processor.audio_tokenizer.to(device=self.device).eval()
            self._processor = processor
            self._model = model  # set last: `loaded` means everything is ready
            print(f"[LFM3] loaded {MODEL_ID} on {self.device} ({self.dtype}) in {time.perf_counter() - started:.1f}s")

    def synth(self, text: str, language: str, gender: str) -> np.ndarray:
        """Float32 mono audio at 24 kHz for one piece of text."""
        import torch

        self._load()
        conversation = [
            {"role": "system", "content": system_prompt(language, gender)},
            {"role": "user", "content": text},
            {"role": "assistant", "content": "<|audio_start|>"},
        ]
        with self._synth_lock, torch.no_grad():
            inputs = self._processor.apply_chat_template(
                conversation,
                continue_final_message=True,
                tokenize=True,
                return_dict=True,
                return_tensors="pt",
            ).to(self.device)
            outputs = self._model.generate(
                **inputs,
                max_new_tokens=MAX_NEW_TOKENS,
                audio_temperature=1.0,
                audio_top_k=128,
                audio_repetition_penalty=1.1,
                audio_repetition_window=4096,
                return_dict_in_generate=True,
            )
            waveform = self._codec.decode(
                outputs.audio_codes[:1, :, : outputs.audio_lengths[0]]
            ).audio_values[0]
        return waveform.float().cpu().numpy().reshape(-1)


def build_router(
    *,
    engine: Lfm3Engine,
    check_key: Callable[[Request], None],
    split: Callable[[str], list],
    to_pcm16: Callable[[np.ndarray, int], bytes],
    max_chars: int,
) -> APIRouter:
    """Routes that share the Kokoro Space's API-key check, text splitting and
    PCM conversion."""
    router = APIRouter(prefix="/lfm3/tts")

    async def read_body(request: Request) -> dict:
        try:
            body = await request.json()
        except Exception:
            raise HTTPException(status_code=400, detail="json body required")
        if not isinstance(body, dict):
            raise HTTPException(status_code=400, detail="json object required")
        return body

    def voice_or_400(body: dict):
        parsed = parse_voice(str(body.get("voice") or "female"))
        if parsed is None:
            raise HTTPException(
                status_code=400, detail="unknown voice: use female, male or an a?_/b?_ Kokoro id"
            )
        return parsed

    @router.get("/health")
    def health():
        return {"ok": True, "engine": "lfm3", "model": MODEL_ID, "loaded": engine.loaded,
                "device": engine.device, "dtype": engine.dtype, "threads": engine.threads}

    @router.post("/stream")
    async def stream(request: Request):
        check_key(request)
        body = await read_body(request)
        text = str(body.get("text") or "").strip()[:max_chars]
        language, gender = voice_or_400(body)
        try:
            rate = int(body.get("sample_rate") or 16000)
        except (TypeError, ValueError):
            raise HTTPException(status_code=400, detail="invalid sample_rate")
        if rate not in (16000, 24000):
            raise HTTPException(status_code=400, detail="sample_rate must be 16000 or 24000")
        if not text:
            return JSONResponse({"detail": "empty text"}, status_code=400)

        pieces = split(text)

        def generate():
            started = time.perf_counter()
            for i, piece in enumerate(pieces):
                pcm = to_pcm16(engine.synth(piece, language, gender), rate)
                if i == 0:
                    print(f"[LFM3 stream] first piece in {time.perf_counter() - started:.2f}s ({len(piece)} chars)")
                yield pcm

        return StreamingResponse(
            generate(),  # sync generator: Starlette runs it in a worker thread
            media_type="application/octet-stream",
            headers={"X-Sample-Rate": str(rate), "X-Pieces": str(len(pieces)),
                     "X-Speed-Ignored": "1", "Cache-Control": "no-store"},
        )

    @router.post("/bench")
    async def bench(request: Request):
        """Seconds spent synthesizing against seconds of audio, per piece
        (rtf below 1 is faster than real time). The first call also loads the
        model, so run it twice and read the second."""
        check_key(request)
        body = await read_body(request)
        text = str(body.get("text") or "").strip()[:max_chars]
        language, gender = voice_or_400(body)
        if not text:
            return JSONResponse({"detail": "empty text"}, status_code=400)

        def run():
            rows = []
            for piece in split(text):
                started = time.perf_counter()
                audio = engine.synth(piece, language, gender)
                took = time.perf_counter() - started
                seconds = audio.size / SAMPLE_RATE
                rows.append({"chars": len(piece), "synth_seconds": round(took, 3),
                             "audio_seconds": round(seconds, 3),
                             "rtf": round(took / seconds, 3) if seconds else None})
            return rows

        rows = await asyncio.to_thread(run)
        return {"device": engine.device, "threads": engine.threads, "pieces": rows,
                "first_piece_seconds": rows[0]["synth_seconds"] if rows else None}

    return router