@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tianshu-ai/tianshu",
3
- "version": "0.77.0",
3
+ "version": "0.78.0",
4
4
  "description": "An open AI agent platform with a sidecar browser. Built in public.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/tianshu-ai/tianshu",
@@ -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 → PyTorch CUDA backend"
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 qwen-tts (official PyTorch package)
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
- Both backends expose the same HTTP API so the tianshu server doesn't
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 == "mlx", # true streaming only on MLX
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:-mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16}"
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
- exec "$py" "$SERVER_PY" --port "$PORT" --voice "$VOICE" --model "$MODEL"
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" --model "$MODEL" \
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"