@tianshu-ai/tianshu 0.77.0 → 0.78.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -68,12 +68,13 @@ if [ "$OS" = "Darwin" ] && [ "$ARCH" = "arm64" ]; then
|
|
|
68
68
|
MODEL_ID="mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16"
|
|
69
69
|
info "Apple Silicon detected → MLX backend"
|
|
70
70
|
elif [ "$OS" = "Linux" ]; then
|
|
71
|
-
BACKEND="pytorch
|
|
71
|
+
BACKEND="faster" # default; install step falls back to pytorch if needed
|
|
72
72
|
MODEL_ID="Qwen/Qwen3-TTS-12Hz-1.7B-Base"
|
|
73
73
|
# Check for CUDA
|
|
74
74
|
if command -v nvidia-smi &>/dev/null; then
|
|
75
|
-
info "Linux + NVIDIA GPU detected →
|
|
75
|
+
info "Linux + NVIDIA GPU detected → faster-qwen3-tts (CUDA Graph) preferred"
|
|
76
76
|
else
|
|
77
|
+
BACKEND="pytorch"
|
|
77
78
|
warn "Linux without NVIDIA GPU → PyTorch CPU backend (slow, not recommended)"
|
|
78
79
|
fi
|
|
79
80
|
else
|
|
@@ -103,8 +104,29 @@ if [ "$BACKEND" = "mlx" ]; then
|
|
|
103
104
|
fastapi uvicorn python-multipart \
|
|
104
105
|
-q 2>&1 | tail -3
|
|
105
106
|
info "MLX dependencies installed"
|
|
107
|
+
elif [ "$BACKEND" = "faster" ]; then
|
|
108
|
+
# Prefer faster-qwen3-tts (CUDA Graph acceleration, true streaming)
|
|
109
|
+
if "$PIP" install \
|
|
110
|
+
faster-qwen3-tts soundfile numpy \
|
|
111
|
+
fastapi uvicorn python-multipart \
|
|
112
|
+
-q 2>&1 | tail -3; then
|
|
113
|
+
info "faster-qwen3-tts dependencies installed (CUDA Graph enabled)"
|
|
114
|
+
else
|
|
115
|
+
warn "faster-qwen3-tts install failed, falling back to qwen-tts"
|
|
116
|
+
BACKEND="pytorch"
|
|
117
|
+
"$PIP" install \
|
|
118
|
+
qwen-tts soundfile numpy \
|
|
119
|
+
fastapi uvicorn python-multipart \
|
|
120
|
+
-q 2>&1 | tail -3
|
|
121
|
+
# Try to install flash-attn for better performance (optional)
|
|
122
|
+
echo "Installing FlashAttention 2 (optional, may take a few minutes) ..."
|
|
123
|
+
MAX_JOBS=4 "$PIP" install flash-attn --no-build-isolation -q 2>&1 | tail -3 || {
|
|
124
|
+
warn "FlashAttention 2 install failed (non-fatal). Using default attention."
|
|
125
|
+
}
|
|
126
|
+
info "PyTorch dependencies installed (fallback)"
|
|
127
|
+
fi
|
|
106
128
|
else
|
|
107
|
-
# PyTorch backend
|
|
129
|
+
# PyTorch backend (CPU or explicit)
|
|
108
130
|
"$PIP" install \
|
|
109
131
|
qwen-tts soundfile numpy \
|
|
110
132
|
fastapi uvicorn python-multipart \
|
|
@@ -134,6 +156,13 @@ for r in model.generate(text='test', verbose=False):
|
|
|
134
156
|
mx.clear_cache()
|
|
135
157
|
print('Model verified.')
|
|
136
158
|
" 2>&1 | grep -v 'Warning\|warning\|Fetching\|transformers\]'
|
|
159
|
+
elif [ "$BACKEND" = "faster" ]; then
|
|
160
|
+
"$PY" -c "
|
|
161
|
+
from faster_qwen3_tts import FasterQwen3TTS
|
|
162
|
+
print('Loading faster-qwen3-tts model...')
|
|
163
|
+
model = FasterQwen3TTS.from_pretrained('$MODEL_ID')
|
|
164
|
+
print('Model downloaded and loaded (faster backend).')
|
|
165
|
+
" 2>&1 | grep -v 'Warning\|warning\|Fetching'
|
|
137
166
|
else
|
|
138
167
|
"$PY" -c "
|
|
139
168
|
import torch
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Qwen3-TTS FastAPI server — cross-platform (MLX on Apple Silicon, PyTorch on Linux/CUDA).
|
|
2
|
+
"""Qwen3-TTS FastAPI server — cross-platform (MLX on Apple Silicon, faster-qwen3-tts / PyTorch on Linux/CUDA).
|
|
3
3
|
|
|
4
4
|
Auto-detects the runtime:
|
|
5
5
|
- Apple Silicon (macOS arm64): uses mlx_audio for streaming generation
|
|
6
|
-
- Linux / CUDA: uses
|
|
6
|
+
- Linux / CUDA: uses faster-qwen3-tts (CUDA Graph, true streaming, 5-6x faster)
|
|
7
|
+
- Linux / CUDA fallback: uses qwen-tts (official PyTorch package)
|
|
7
8
|
- CPU fallback: uses qwen-tts with float32
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
All backends expose the same HTTP API so the tianshu server doesn't
|
|
10
11
|
need to know which one is running.
|
|
11
12
|
|
|
12
13
|
Supports two model variants:
|
|
@@ -39,7 +40,7 @@ import sys
|
|
|
39
40
|
import time
|
|
40
41
|
|
|
41
42
|
import numpy as np
|
|
42
|
-
from fastapi import FastAPI, Form
|
|
43
|
+
from fastapi import FastAPI, Form, Request
|
|
43
44
|
from fastapi.responses import StreamingResponse
|
|
44
45
|
from fastapi.middleware.cors import CORSMiddleware
|
|
45
46
|
import uvicorn
|
|
@@ -58,7 +59,7 @@ app.add_middleware(
|
|
|
58
59
|
|
|
59
60
|
# ─── Globals set in __main__ ─────────────────────────────────
|
|
60
61
|
|
|
61
|
-
backend = "" # "mlx" or "pytorch"
|
|
62
|
+
backend = "" # "mlx", "faster", or "pytorch"
|
|
62
63
|
model = None # pre-loaded model instance (backend-specific)
|
|
63
64
|
model_type = "" # "custom_voice" or "base"
|
|
64
65
|
default_voice = "vivian"
|
|
@@ -360,6 +361,142 @@ def warmup_pytorch():
|
|
|
360
361
|
log.warning("PyTorch warmup failed (non-fatal): %s", e)
|
|
361
362
|
|
|
362
363
|
|
|
364
|
+
# ─── Backend: faster-qwen3-tts (Linux / CUDA Graph) ──────────
|
|
365
|
+
|
|
366
|
+
def load_model_faster(mid: str):
|
|
367
|
+
global model, model_type, sample_rate
|
|
368
|
+
from faster_qwen3_tts import FasterQwen3TTS
|
|
369
|
+
|
|
370
|
+
log.info("Loading faster-qwen3-tts model: %s", mid)
|
|
371
|
+
model = FasterQwen3TTS.from_pretrained(mid)
|
|
372
|
+
sample_rate = 24000 # Qwen3-TTS always 24kHz
|
|
373
|
+
|
|
374
|
+
# Detect model type from class name (same heuristic as pytorch)
|
|
375
|
+
cls_name = type(model).__name__.lower()
|
|
376
|
+
if "customvoice" in cls_name:
|
|
377
|
+
model_type = "custom_voice"
|
|
378
|
+
else:
|
|
379
|
+
model_type = "base"
|
|
380
|
+
log.info("faster-qwen3-tts model loaded: type=%s", model_type)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
async def generate_pcm_faster(
|
|
384
|
+
tts_text: str, voice: str, ref_audio: str | None, request=None,
|
|
385
|
+
):
|
|
386
|
+
"""Stream PCM chunks from faster-qwen3-tts with true CUDA Graph streaming.
|
|
387
|
+
|
|
388
|
+
Uses generate_voice_clone_streaming() for real streaming output.
|
|
389
|
+
CustomVoice models fall back to non-streaming generate_custom_voice()
|
|
390
|
+
since it has no streaming variant.
|
|
391
|
+
"""
|
|
392
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
393
|
+
import queue as _queue
|
|
394
|
+
|
|
395
|
+
t0 = time.time()
|
|
396
|
+
first_chunk_time = None
|
|
397
|
+
total_bytes = 0
|
|
398
|
+
chunk_count = 0
|
|
399
|
+
|
|
400
|
+
if ref_audio:
|
|
401
|
+
# True streaming path — voice cloning with ref audio
|
|
402
|
+
pcm_queue: _queue.Queue = _queue.Queue(maxsize=8)
|
|
403
|
+
cancelled = False
|
|
404
|
+
|
|
405
|
+
def _produce():
|
|
406
|
+
try:
|
|
407
|
+
for audio_chunk, sr, timing in model.generate_voice_clone_streaming(
|
|
408
|
+
text=tts_text,
|
|
409
|
+
language="Auto",
|
|
410
|
+
ref_audio=ref_audio,
|
|
411
|
+
ref_text="",
|
|
412
|
+
chunk_size=4, # ~333ms per chunk
|
|
413
|
+
):
|
|
414
|
+
if cancelled:
|
|
415
|
+
log.info("faster generate cancelled by client")
|
|
416
|
+
break
|
|
417
|
+
pcm = (audio_chunk * 32767).clip(-32768, 32767).astype(np.int16).tobytes()
|
|
418
|
+
pcm_queue.put(pcm)
|
|
419
|
+
pcm_queue.put(None)
|
|
420
|
+
except Exception as e:
|
|
421
|
+
pcm_queue.put(e)
|
|
422
|
+
|
|
423
|
+
loop = asyncio.get_event_loop()
|
|
424
|
+
_executor = ThreadPoolExecutor(max_workers=1)
|
|
425
|
+
loop.run_in_executor(_executor, _produce)
|
|
426
|
+
|
|
427
|
+
try:
|
|
428
|
+
while True:
|
|
429
|
+
while pcm_queue.empty():
|
|
430
|
+
# Check if client disconnected
|
|
431
|
+
if request and await request.is_disconnected():
|
|
432
|
+
cancelled = True
|
|
433
|
+
log.info("Client disconnected, cancelling faster generate")
|
|
434
|
+
return
|
|
435
|
+
await asyncio.sleep(0.02)
|
|
436
|
+
item = pcm_queue.get_nowait()
|
|
437
|
+
if item is None:
|
|
438
|
+
break
|
|
439
|
+
if isinstance(item, Exception):
|
|
440
|
+
log.error("faster generate failed: %s", item)
|
|
441
|
+
return
|
|
442
|
+
if first_chunk_time is None:
|
|
443
|
+
first_chunk_time = time.time() - t0
|
|
444
|
+
total_bytes += len(item)
|
|
445
|
+
chunk_count += 1
|
|
446
|
+
yield item
|
|
447
|
+
finally:
|
|
448
|
+
cancelled = True
|
|
449
|
+
_executor.shutdown(wait=False)
|
|
450
|
+
|
|
451
|
+
elif model_type == "custom_voice":
|
|
452
|
+
# Non-streaming path — CustomVoice has no streaming API
|
|
453
|
+
try:
|
|
454
|
+
audio_list, sr = model.generate_custom_voice(
|
|
455
|
+
text=tts_text,
|
|
456
|
+
speaker=voice.capitalize(),
|
|
457
|
+
language="Auto",
|
|
458
|
+
)
|
|
459
|
+
audio_np = np.array(audio_list[0], dtype=np.float32).flatten()
|
|
460
|
+
pcm = (audio_np * 32767).clip(-32768, 32767).astype(np.int16).tobytes()
|
|
461
|
+
first_chunk_time = time.time() - t0
|
|
462
|
+
total_bytes = len(pcm)
|
|
463
|
+
|
|
464
|
+
chunk_size = sample_rate # ~0.5s chunks
|
|
465
|
+
for i in range(0, len(pcm), chunk_size):
|
|
466
|
+
chunk_count += 1
|
|
467
|
+
yield pcm[i:i + chunk_size]
|
|
468
|
+
await asyncio.sleep(0)
|
|
469
|
+
except Exception as e:
|
|
470
|
+
log.error("faster generate_custom_voice failed: %s", e)
|
|
471
|
+
return
|
|
472
|
+
|
|
473
|
+
else:
|
|
474
|
+
# Base model without ref audio — use default voice's ref audio
|
|
475
|
+
_, fallback_ref = _resolve_voice(default_voice)
|
|
476
|
+
if fallback_ref:
|
|
477
|
+
async for chunk in generate_pcm_faster(tts_text, voice, fallback_ref, request):
|
|
478
|
+
yield chunk
|
|
479
|
+
return
|
|
480
|
+
else:
|
|
481
|
+
log.error("No ref audio and not a custom_voice model (faster backend)")
|
|
482
|
+
return
|
|
483
|
+
|
|
484
|
+
wall = time.time() - t0
|
|
485
|
+
audio_dur = total_bytes / (sample_rate * 2) if total_bytes else 0
|
|
486
|
+
log.info(
|
|
487
|
+
"faster streamed: %.1fs audio in %.3fs (first=%.3fs, %d chunks, RTF=%.3f, voice=%s)",
|
|
488
|
+
audio_dur, wall, first_chunk_time or 0, chunk_count,
|
|
489
|
+
wall / audio_dur if audio_dur > 0 else 0, voice,
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def warmup_faster():
|
|
494
|
+
"""Warm up faster-qwen3-tts to capture CUDA graphs."""
|
|
495
|
+
try:
|
|
496
|
+
model.warmup(prefill_len=100)
|
|
497
|
+
log.info("faster-qwen3-tts warmup done (CUDA graphs captured)")
|
|
498
|
+
except Exception as e:
|
|
499
|
+
log.warning("faster-qwen3-tts warmup failed (non-fatal): %s", e)
|
|
363
500
|
|
|
364
501
|
|
|
365
502
|
# ─── API endpoints ───────────────────────────────────────────
|
|
@@ -367,6 +504,7 @@ def warmup_pytorch():
|
|
|
367
504
|
@app.get("/inference_sft")
|
|
368
505
|
@app.post("/inference_sft")
|
|
369
506
|
async def inference_sft(
|
|
507
|
+
request: Request,
|
|
370
508
|
tts_text: str = Form(),
|
|
371
509
|
spk_id: str = Form(default=""),
|
|
372
510
|
):
|
|
@@ -379,6 +517,8 @@ async def inference_sft(
|
|
|
379
517
|
|
|
380
518
|
if backend == "mlx":
|
|
381
519
|
gen = generate_pcm_mlx(tts_text, voice, ref_audio)
|
|
520
|
+
elif backend == "faster":
|
|
521
|
+
gen = generate_pcm_faster(tts_text, voice, ref_audio, request)
|
|
382
522
|
else:
|
|
383
523
|
gen = generate_pcm_pytorch(tts_text, voice, ref_audio)
|
|
384
524
|
|
|
@@ -396,7 +536,7 @@ async def health():
|
|
|
396
536
|
"preset_voices": PRESET_VOICES if model_type == "custom_voice" else [],
|
|
397
537
|
"custom_voices": list(custom_voices.keys()),
|
|
398
538
|
"sample_rate": sample_rate,
|
|
399
|
-
"streaming": backend
|
|
539
|
+
"streaming": backend in ("mlx", "faster"), # true streaming on MLX and faster
|
|
400
540
|
}
|
|
401
541
|
|
|
402
542
|
|
|
@@ -404,6 +544,7 @@ async def health():
|
|
|
404
544
|
|
|
405
545
|
DEFAULT_MODELS = {
|
|
406
546
|
"mlx": "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16",
|
|
547
|
+
"faster": "Qwen/Qwen3-TTS-12Hz-1.7B-Base",
|
|
407
548
|
"pytorch": "Qwen/Qwen3-TTS-12Hz-1.7B-Base",
|
|
408
549
|
}
|
|
409
550
|
|
|
@@ -415,13 +556,20 @@ def detect_backend() -> str:
|
|
|
415
556
|
return "mlx"
|
|
416
557
|
except ImportError:
|
|
417
558
|
pass
|
|
559
|
+
# Linux / CUDA: prefer faster-qwen3-tts for CUDA Graph acceleration
|
|
560
|
+
try:
|
|
561
|
+
import faster_qwen3_tts # noqa: F401
|
|
562
|
+
log.info("faster-qwen3-tts available → using 'faster' backend")
|
|
563
|
+
return "faster"
|
|
564
|
+
except ImportError:
|
|
565
|
+
pass
|
|
418
566
|
# Fallback to PyTorch
|
|
419
567
|
try:
|
|
420
568
|
import torch # noqa: F401
|
|
421
569
|
return "pytorch"
|
|
422
570
|
except ImportError:
|
|
423
571
|
pass
|
|
424
|
-
log.error("No backend available. Install mlx-audio (Mac) or qwen-tts (Linux).")
|
|
572
|
+
log.error("No backend available. Install mlx-audio (Mac) or faster-qwen3-tts / qwen-tts (Linux).")
|
|
425
573
|
sys.exit(1)
|
|
426
574
|
|
|
427
575
|
|
|
@@ -435,7 +583,7 @@ if __name__ == "__main__":
|
|
|
435
583
|
parser.add_argument("--model", type=str, default="",
|
|
436
584
|
help="Model id (HuggingFace). Auto-selected per backend if empty.")
|
|
437
585
|
parser.add_argument("--backend", type=str, default="auto",
|
|
438
|
-
choices=["auto", "mlx", "pytorch"],
|
|
586
|
+
choices=["auto", "mlx", "faster", "pytorch"],
|
|
439
587
|
help="Force a specific backend (default: auto-detect)")
|
|
440
588
|
args = parser.parse_args()
|
|
441
589
|
default_voice = args.voice
|
|
@@ -458,6 +606,8 @@ if __name__ == "__main__":
|
|
|
458
606
|
# Load model
|
|
459
607
|
if backend == "mlx":
|
|
460
608
|
load_model_mlx(model_id)
|
|
609
|
+
elif backend == "faster":
|
|
610
|
+
load_model_faster(model_id)
|
|
461
611
|
else:
|
|
462
612
|
load_model_pytorch(model_id)
|
|
463
613
|
|
|
@@ -468,6 +618,8 @@ if __name__ == "__main__":
|
|
|
468
618
|
log.info("Warming up...")
|
|
469
619
|
if backend == "mlx":
|
|
470
620
|
warmup_mlx()
|
|
621
|
+
elif backend == "faster":
|
|
622
|
+
warmup_faster()
|
|
471
623
|
else:
|
|
472
624
|
warmup_pytorch()
|
|
473
625
|
log.info("Ready. Default voice: %s, backend: %s", default_voice, backend)
|
|
@@ -20,7 +20,7 @@ SERVER_PY="$SCRIPT_DIR/server.py"
|
|
|
20
20
|
VENV_DIR="${TIANSHU_TTS_VENV:-$HOME/.tianshu/qwen-tts-venv}"
|
|
21
21
|
PORT="${TIANSHU_TTS_PORT:-50000}"
|
|
22
22
|
VOICE="${TIANSHU_TTS_VOICE:-yujie}"
|
|
23
|
-
MODEL="${TIANSHU_TTS_MODEL:-
|
|
23
|
+
MODEL="${TIANSHU_TTS_MODEL:-}"
|
|
24
24
|
LOG_FILE="${TIANSHU_TTS_LOG:-/tmp/qwen3-tts-server.log}"
|
|
25
25
|
PID_FILE="/tmp/qwen3-tts-server.pid"
|
|
26
26
|
|
|
@@ -113,7 +113,9 @@ cmd_start_foreground() {
|
|
|
113
113
|
echo " Voice: $VOICE"
|
|
114
114
|
echo " Port: $PORT"
|
|
115
115
|
echo ""
|
|
116
|
-
|
|
116
|
+
local model_args=()
|
|
117
|
+
[ -n "$MODEL" ] && model_args+=(--model "$MODEL")
|
|
118
|
+
exec "$py" "$SERVER_PY" --port "$PORT" --voice "$VOICE" "${model_args[@]+${model_args[@]}}"
|
|
117
119
|
}
|
|
118
120
|
|
|
119
121
|
cmd_start_background() {
|
|
@@ -134,8 +136,10 @@ cmd_start_background() {
|
|
|
134
136
|
echo " Port: $PORT"
|
|
135
137
|
echo " Log: $LOG_FILE"
|
|
136
138
|
|
|
139
|
+
local model_args=()
|
|
140
|
+
[ -n "$MODEL" ] && model_args+=(--model "$MODEL")
|
|
137
141
|
nohup "$py" "$SERVER_PY" \
|
|
138
|
-
--port "$PORT" --voice "$VOICE"
|
|
142
|
+
--port "$PORT" --voice "$VOICE" "${model_args[@]+${model_args[@]}}" \
|
|
139
143
|
> "$LOG_FILE" 2>&1 &
|
|
140
144
|
local pid=$!
|
|
141
145
|
echo "$pid" > "$PID_FILE"
|