File size: 7,870 Bytes
1894d18
e049a51
 
 
 
aecf044
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e049a51
 
 
1894d18
e049a51
6cae1ea
f109235
1894d18
6cae1ea
 
f109235
1894d18
f109235
 
 
1894d18
 
 
6cae1ea
f109235
 
 
e049a51
f109235
 
6cae1ea
e049a51
 
 
 
 
6cae1ea
 
 
f109235
6cae1ea
 
 
 
f109235
 
e049a51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f109235
e049a51
f109235
 
e049a51
f109235
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e049a51
f109235
 
e049a51
6cae1ea
 
 
 
 
1894d18
 
 
 
6cae1ea
 
 
e049a51
f109235
 
6cae1ea
 
1894d18
 
f109235
 
 
 
 
 
 
e049a51
 
 
 
 
f109235
 
6cae1ea
 
 
e049a51
 
6cae1ea
 
f109235
 
 
6cae1ea
f109235
 
6cae1ea
 
e049a51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6cae1ea
f109235
 
6cae1ea
f109235
6cae1ea
 
 
 
 
 
 
 
 
 
e049a51
f109235
 
6cae1ea
f109235
 
6cae1ea
 
f109235
 
6cae1ea
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
import os

# Must be set before torch's OpenMP/MKL thread pools initialize on first use.
# This is the most reliable way to make sure BLAS/OpenMP actually uses all
# cores instead of a conservative default.
def _detect_cpu_threads() -> int:
    # Respect a value the platform/container already set -- on cgroup-limited
    # containers (like HF Spaces) this is often the *correct* real quota,
    # whereas os.cpu_count() reports the host machine's full core count and
    # will cause thread oversubscription if trusted blindly.
    for var in ("OMP_NUM_THREADS", "MKL_NUM_THREADS"):
        val = os.environ.get(var)
        if val and val.isdigit() and int(val) > 0:
            return int(val)
    # sched_getaffinity reflects CPU-affinity/cgroup restrictions more
    # accurately than os.cpu_count() on Linux; fall back to cpu_count if
    # unavailable (e.g. non-Linux).
    try:
        return len(os.sched_getaffinity(0))
    except AttributeError:
        return os.cpu_count() or 4


_CPU_THREADS = str(_detect_cpu_threads())
os.environ.setdefault("OMP_NUM_THREADS", _CPU_THREADS)
os.environ.setdefault("MKL_NUM_THREADS", _CPU_THREADS)

import tempfile
import time
import traceback
from typing import Optional

import soundfile as sf
import torch
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import FileResponse
from starlette.background import BackgroundTask

from qwen_tts import Qwen3TTSModel

app = FastAPI()

MODEL = None
SUPPORTED_SPEAKERS = []
SUPPORTED_LANGUAGES = []

MODEL_NAME = "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
DEFAULT_SPEAKER = "Ryan"       # English male voice; pick any from SUPPORTED_SPEAKERS
DEFAULT_LANGUAGE = "English"   # or "Auto" to let the model detect it

# bf16 has no native hardware acceleration on most x86 CPUs (it gets
# emulated via upcasting), which is often slower than plain fp32. fp32 is
# the reliable baseline for generic CPU inference.
CPU_DTYPE = torch.float32


@app.on_event("startup")
def startup_event():
    global MODEL, SUPPORTED_SPEAKERS, SUPPORTED_LANGUAGES
    try:
        print("=" * 80)
        print("Starting application...")
        print(f"PyTorch version: {torch.__version__}")
        cuda_available = torch.cuda.is_available()
        print(f"CUDA available: {cuda_available}")

        if not cuda_available:
            n_threads = int(_CPU_THREADS)
            # Intra-op parallelism (parallelizes ops like matmul/conv internally).
            torch.set_num_threads(n_threads)
            # Inter-op parallelism (runs independent ops concurrently). Can
            # only be set once, before any parallel work starts, so this
            # must stay early and wrapped defensively.
            try:
                torch.set_num_interop_threads(max(1, n_threads // 2))
            except RuntimeError as e:
                print(f"Could not set interop threads (already initialized): {e}")
            # Denormal floats are handled by a slow FP path on most CPUs;
            # flushing them to zero avoids random latency spikes during
            # generation. Negligible effect on audio quality.
            torch.set_flush_denormal(True)
            print(f"No GPU detected. CPU threads: {n_threads} "
                  f"(OMP_NUM_THREADS={os.environ.get('OMP_NUM_THREADS')})")

        print(f"Loading Qwen3-TTS model: {MODEL_NAME}")
        load_start = time.perf_counter()

        device_map = "cuda:0" if cuda_available else "cpu"
        dtype = torch.bfloat16 if cuda_available else CPU_DTYPE
        load_kwargs = dict(device_map=device_map, dtype=dtype)

        if cuda_available:
            try:
                MODEL = Qwen3TTSModel.from_pretrained(
                    MODEL_NAME,
                    attn_implementation="flash_attention_2",
                    **load_kwargs,
                )
            except Exception as flash_err:
                print(f"flash_attention_2 unavailable ({flash_err}); "
                      f"falling back to default attention implementation.")
                MODEL = Qwen3TTSModel.from_pretrained(MODEL_NAME, **load_kwargs)
        else:
            MODEL = Qwen3TTSModel.from_pretrained(MODEL_NAME, **load_kwargs)

        SUPPORTED_SPEAKERS = MODEL.get_supported_speakers()
        SUPPORTED_LANGUAGES = MODEL.get_supported_languages()

        load_elapsed = time.perf_counter() - load_start
        print(f"Supported speakers: {SUPPORTED_SPEAKERS}")
        print(f"Supported languages: {SUPPORTED_LANGUAGES}")
        print(f"Model loaded successfully in {load_elapsed:.1f}s.")
        print("=" * 80)
    except Exception as exc:
        print(traceback.format_exc())
        MODEL = None
        print(f"Startup failed: {exc}")


@app.get("/")
def health():
    return {
        "status": "running",
        "model_loaded": MODEL is not None,
        "cpu_threads": _CPU_THREADS,
        "supported_speakers": SUPPORTED_SPEAKERS,
        "supported_languages": SUPPORTED_LANGUAGES,
    }


@app.get("/tts")
def generate(
    text: str,
    speaker: str = Query(DEFAULT_SPEAKER, description="Voice to use, e.g. Ryan, Vivian, Aiden"),
    language: str = Query(DEFAULT_LANGUAGE, description="Target language, or 'Auto' to detect"),
    instruct: Optional[str] = Query(
        None, description="Optional natural-language style instruction, e.g. 'Speak happily'"
    ),
    max_new_tokens: Optional[int] = Query(
        None,
        description="Optional cap on generated audio tokens. Lower = faster on CPU, "
                    "but can truncate longer sentences. Omit to use the model default.",
    ),
):
    output_file = None
    try:
        print("=" * 80)
        print(f"Incoming text: {text}")
        print(f"speaker={speaker} language={language} instruct={instruct!r} "
              f"max_new_tokens={max_new_tokens}")

        if MODEL is None:
            raise HTTPException(status_code=500, detail="Model was not loaded.")

        if SUPPORTED_SPEAKERS and speaker not in SUPPORTED_SPEAKERS:
            raise HTTPException(
                status_code=400,
                detail=f"Unsupported speaker '{speaker}'. Choose from: {SUPPORTED_SPEAKERS}",
            )

        generate_kwargs = {}
        if max_new_tokens is not None:
            generate_kwargs["max_new_tokens"] = max_new_tokens

        gen_start = time.perf_counter()
        # inference_mode disables autograd bookkeeping entirely (faster and
        # lower memory than no_grad) -- pure inference, no training ever
        # happens on this path.
        with torch.inference_mode():
            wavs, sample_rate = MODEL.generate_custom_voice(
                text=text,
                language=language,
                speaker=speaker,
                instruct=instruct or "",
                **generate_kwargs,
            )
        gen_elapsed = time.perf_counter() - gen_start
        print(f"Generation took {gen_elapsed:.2f}s")

        output_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
        sf.write(output_file, wavs[0], sample_rate)

        size = os.path.getsize(output_file)
        print(f"Output file: {output_file}")
        print(f"File size: {size} bytes")
        if size == 0:
            raise RuntimeError("Generated WAV file is empty.")
        print("=" * 80)

        return FileResponse(
            output_file,
            media_type="audio/wav",
            filename="speech.wav",
            headers={"X-Generation-Seconds": f"{gen_elapsed:.2f}"},
            # Clean up the temp file once the response has been sent.
            background=BackgroundTask(lambda: os.remove(output_file) if os.path.exists(output_file) else None),
        )
    except HTTPException:
        raise
    except Exception:
        print(traceback.format_exc())
        if output_file and os.path.exists(output_file):
            os.remove(output_file)
        raise