feat: sync full-stack Docker runtime and UI
This commit is contained in:
+29
-16
@@ -4,13 +4,13 @@ LLM_BASE_URL=https://api.openai.com/v1/
|
||||
LLM_API_KEY=sk-your-key
|
||||
|
||||
# Default model for inline completions
|
||||
LLM_MODEL=gpt-4.1-mini
|
||||
LLM_MODEL=Nex-N2-mini-mlx-OptiQ-8bit-MTP
|
||||
|
||||
# Pro-tier model (defaults to LLM_MODEL if unset)
|
||||
PRO_LLM_MODEL=gpt-4.1
|
||||
PRO_LLM_MODEL=Nex-N2-mini-mlx-OptiQ-8bit-MTP
|
||||
|
||||
# Vision model for OCR
|
||||
VLM_MODEL=gpt-4.1-mini
|
||||
VLM_MODEL=Nex-N2-mini-mlx-OptiQ-8bit-MTP
|
||||
|
||||
# API key for the FastAPI app (change in production)
|
||||
API_KEY=your-secret-key-here
|
||||
@@ -56,10 +56,10 @@ JOB_OCR_CONCURRENCY=1
|
||||
JOB_OCR_MAX_QUEUE=8
|
||||
JOB_CONVERT_CONCURRENCY=1
|
||||
JOB_CONVERT_MAX_QUEUE=8
|
||||
JOB_TTS_CONCURRENCY=1
|
||||
JOB_TTS_MAX_QUEUE=4
|
||||
JOB_ASR_CONCURRENCY=1
|
||||
JOB_ASR_MAX_QUEUE=4
|
||||
JOB_TTS_CONCURRENCY=4
|
||||
JOB_TTS_MAX_QUEUE=16
|
||||
JOB_ASR_CONCURRENCY=2
|
||||
JOB_ASR_MAX_QUEUE=8
|
||||
|
||||
# Timeouts (seconds)
|
||||
LLM_COMPLETION_TIMEOUT=600
|
||||
@@ -88,10 +88,12 @@ RISK_MODEL_CIRCUIT_TTL_SECONDS=300
|
||||
RISK_ENFORCE_REDIS_FAIL_CLOSED=false
|
||||
|
||||
# Backend-controlled model policy
|
||||
RISK_COMPLETION_MODEL=gpt-4.1-mini
|
||||
RISK_PRO_MODEL=gpt-4.1
|
||||
RISK_VISION_MODEL=gpt-4.1-mini
|
||||
RISK_WEB_SEARCH_MODEL=gpt-4.1-mini
|
||||
RISK_COMPLETION_MODEL=Nex-N2-mini-mlx-OptiQ-8bit-MTP
|
||||
RISK_PRO_MODEL=Nex-N2-mini-mlx-OptiQ-8bit-MTP
|
||||
RISK_VISION_MODEL=Nex-N2-mini-mlx-OptiQ-8bit-MTP
|
||||
RISK_WEB_SEARCH_MODEL=Nex-N2-mini-mlx-OptiQ-8bit-MTP
|
||||
RISK_SPEECH_TTS_MODEL=Qwen3-TTS-12Hz-1.7B-VoiceDesign-8bit
|
||||
RISK_SPEECH_ASR_MODEL=Qwen3-ASR-0.6B-8bit
|
||||
RISK_COMPLETION_MAX_INPUT_CHARS=24000
|
||||
RISK_COMPLETION_MAX_OUTPUT_TOKENS=768
|
||||
RISK_COMPLETION_TEMPERATURE=0.4
|
||||
@@ -104,6 +106,8 @@ RISK_WEB_SEARCH_TEMPERATURE=0.4
|
||||
RISK_COMPRESS_MAX_INPUT_CHARS=128000
|
||||
RISK_COMPRESS_MAX_OUTPUT_TOKENS=1536
|
||||
RISK_OCR_MAX_INPUT_BYTES=104857600
|
||||
RISK_SPEECH_TTS_MAX_INPUT_CHARS=4096
|
||||
RISK_SPEECH_ASR_MAX_INPUT_BYTES=104857600
|
||||
|
||||
# Web search providers
|
||||
SEARXNG_BASE_URL=http://searxng:8080
|
||||
@@ -120,9 +124,18 @@ RISK_PRO_INPUT_COST_PER_1K=0.003
|
||||
RISK_PRO_OUTPUT_COST_PER_1K=0.012
|
||||
RISK_VISION_INPUT_COST_PER_1K=0.0008
|
||||
RISK_VISION_OUTPUT_COST_PER_1K=0.0024
|
||||
RISK_SPEECH_TTS_INPUT_COST_PER_1K_CHARS=0
|
||||
RISK_SPEECH_TTS_OUTPUT_COST_PER_MINUTE_AUDIO=0
|
||||
RISK_SPEECH_ASR_INPUT_COST_PER_MB=0
|
||||
|
||||
# Legacy fallback: if LLM_BASE_URL is not set, OLLAMA_HOST will be auto-converted to /v1/ path
|
||||
#OLLAMA_HOST=http://localhost:11434
|
||||
|
||||
# TTS/ASR settings (see README for full list)
|
||||
TTS_ASR_DEVICE=auto
|
||||
# Shared speech API settings (uses LLM_BASE_URL + LLM_API_KEY)
|
||||
TTS_MODEL_ID=Qwen3-TTS-12Hz-1.7B-VoiceDesign-8bit
|
||||
TTS_DEFAULT_INSTRUCTIONS=A clear, natural voice speaking Mandarin Chinese.
|
||||
ASR_MODEL_ID=Qwen3-ASR-0.6B-8bit
|
||||
TTS_ASR_MAX_TEXT_CHARS=4096
|
||||
ASR_MAX_AUDIO_BYTES=104857600
|
||||
TTS_ASR_TTS_TIMEOUT_SECONDS=180
|
||||
TTS_ASR_ASR_TIMEOUT_SECONDS=300
|
||||
TTS_ASR_HEALTHCHECK_TIMEOUT_SECONDS=5
|
||||
TTS_ASR_MAX_CONNECTIONS=24
|
||||
TTS_ASR_MAX_KEEPALIVE_CONNECTIONS=12
|
||||
|
||||
+3
-3
@@ -5,9 +5,9 @@
|
||||
## 后端职责
|
||||
|
||||
- 对外提供补全、取消补全、OCR、文档转换和 TTS/ASR 相关接口。
|
||||
- 组织 Prompt,上下文清洗,调用 Ollama 模型。
|
||||
- 组织 Prompt,上下文清洗,调用 OpenAI-compatible 模型接口。
|
||||
- **通过 Redis Streams 异步任务队列处理各类作业(completion/PRO/web_search/compress/OCR/convert/TTS/ASR)。**
|
||||
- 负责 API Key 校验、日志记录和部分启动预热逻辑。
|
||||
- 负责 API Key 校验、日志记录和队列任务路由。
|
||||
|
||||
## 先看哪里
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
- 通过 _register_tts_asr_routes 延迟导入并挂到主应用。
|
||||
- **TTS 请求通过 job_handlers.py tts_handler 处理。**
|
||||
- **ASR 请求通过 job_handlers.py asr_handler 处理。**
|
||||
- **当前实现是 `Qwen3TTSModel + faster-whisper`,不是旧的 edge-tts / macos-say / MLX-only 路线。**
|
||||
- **当前实现统一通过 `LLM_BASE_URL` + `LLM_API_KEY` 调用共享 Speech API,默认模型为 `Qwen3-TTS-12Hz-1.7B-VoiceDesign-8bit` 和 `Qwen3-ASR-0.6B-8bit`。**
|
||||
|
||||
## 开发命令
|
||||
|
||||
|
||||
+6
-4
@@ -6,12 +6,14 @@ ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app/backend
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
|
||||
apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg
|
||||
|
||||
COPY backend/requirements.docker.txt /tmp/requirements.docker.txt
|
||||
RUN pip install --no-cache-dir -r /tmp/requirements.docker.txt
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install -r /tmp/requirements.docker.txt
|
||||
|
||||
COPY backend /app/backend
|
||||
|
||||
|
||||
+33
-2
@@ -76,6 +76,9 @@ class PostgresAuditStore(BaseAuditStore):
|
||||
estimated_cost NUMERIC(18, 8) NOT NULL DEFAULT 0,
|
||||
actual_output_chars INTEGER NOT NULL DEFAULT 0,
|
||||
actual_cost NUMERIC(18, 8) NOT NULL DEFAULT 0,
|
||||
queue_ms INTEGER NOT NULL DEFAULT 0,
|
||||
run_ms INTEGER NOT NULL DEFAULT 0,
|
||||
total_ms INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL,
|
||||
error_code TEXT NOT NULL DEFAULT '',
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
@@ -84,6 +87,15 @@ class PostgresAuditStore(BaseAuditStore):
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"ALTER TABLE llm_call_audit ADD COLUMN IF NOT EXISTS queue_ms INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
cur.execute(
|
||||
"ALTER TABLE llm_call_audit ADD COLUMN IF NOT EXISTS run_ms INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
cur.execute(
|
||||
"ALTER TABLE llm_call_audit ADD COLUMN IF NOT EXISTS total_ms INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS risk_events (
|
||||
@@ -112,6 +124,21 @@ class PostgresAuditStore(BaseAuditStore):
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_api_request_audit_request_id ON api_request_audit (request_id)"
|
||||
)
|
||||
cur.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_api_request_audit_route_created_at ON api_request_audit (route, created_at DESC)"
|
||||
)
|
||||
cur.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_llm_call_audit_request_id ON llm_call_audit (request_id)"
|
||||
)
|
||||
cur.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_llm_call_audit_job_type_started_at ON llm_call_audit (job_type, started_at DESC)"
|
||||
)
|
||||
cur.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_llm_call_audit_model_started_at ON llm_call_audit (model, started_at DESC)"
|
||||
)
|
||||
self._initialized = True
|
||||
|
||||
def record_api_request(self, payload: dict[str, Any]) -> None:
|
||||
@@ -152,9 +179,10 @@ class PostgresAuditStore(BaseAuditStore):
|
||||
INSERT INTO llm_call_audit (
|
||||
request_id, session_hash, ip_hash, job_type, model,
|
||||
estimated_input_tokens, max_output_tokens, estimated_cost,
|
||||
actual_output_chars, actual_cost, status, error_code, metadata_json
|
||||
actual_output_chars, actual_cost, queue_ms, run_ms, total_ms,
|
||||
status, error_code, metadata_json
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb)
|
||||
""",
|
||||
(
|
||||
payload["request_id"],
|
||||
@@ -167,6 +195,9 @@ class PostgresAuditStore(BaseAuditStore):
|
||||
float(payload.get("estimated_cost", 0.0)),
|
||||
int(payload.get("actual_output_chars", 0)),
|
||||
float(payload.get("actual_cost", 0.0)),
|
||||
int(payload.get("queue_ms", 0)),
|
||||
int(payload.get("run_ms", 0)),
|
||||
int(payload.get("total_ms", 0)),
|
||||
payload["status"],
|
||||
payload.get("error_code", ""),
|
||||
json.dumps(metadata, ensure_ascii=False),
|
||||
|
||||
+183
-50
@@ -1,8 +1,12 @@
|
||||
import asyncio
|
||||
import io
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import time
|
||||
import zipfile
|
||||
from contextlib import suppress
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Awaitable
|
||||
@@ -22,24 +26,19 @@ from prompt import (
|
||||
)
|
||||
from risk_config import load_risk_config
|
||||
from risk_control import RiskIdentity, estimate_tokens, get_risk_controller
|
||||
|
||||
try: # pragma: no cover - optional heavy dependency path
|
||||
from tts_asr import generate_asr_response, generate_tts_response
|
||||
except Exception: # pragma: no cover
|
||||
generate_tts_response = None
|
||||
generate_asr_response = None
|
||||
from tts_asr import generate_asr_response, generate_tts_response
|
||||
|
||||
|
||||
IMAGE_MARKDOWN_RE = re.compile(r"!\[[^\]]*]\([^)]+\)")
|
||||
IMAGE_HTML_RE = re.compile(r"<img\b[^>]*>", re.IGNORECASE)
|
||||
ALLOWED_CONVERT_EXTENSIONS = {".txt", ".docx", ".pptx", ".pdf"}
|
||||
SEARXNG_BASE_URL = (os.getenv("SEARXNG_BASE_URL", "http://searxng:8080") or "http://searxng:8080").rstrip("/")
|
||||
SEARXNG_RESULT_LIMIT = max(1, int(os.getenv("SEARXNG_RESULT_LIMIT", "10") or "10"))
|
||||
FIRECRAWL_BASE_URL = (os.getenv("FIRECRAWL_BASE_URL", "http://firecrawl:3002") or "http://firecrawl:3002").rstrip("/")
|
||||
FIRECRAWL_API_KEY = os.getenv("FIRECRAWL_API_KEY", "").strip()
|
||||
WEB_SEARCH_QUERY_COUNT = max(3, min(5, int(os.getenv("WEB_SEARCH_QUERY_COUNT", "4") or "4")))
|
||||
WEB_SEARCH_SELECTED_URL_LIMIT = max(5, min(20, int(os.getenv("WEB_SEARCH_SELECTED_URL_LIMIT", "10") or "10")))
|
||||
WEB_SEARCH_CRAWL_CONCURRENCY = max(1, min(5, int(os.getenv("WEB_SEARCH_CRAWL_CONCURRENCY", "3") or "3")))
|
||||
SEARXNG_BASE_URL = os.getenv("SEARXNG_BASE_URL", "http://searxng:8080").rstrip("/")
|
||||
SEARXNG_RESULT_LIMIT = int(os.getenv("SEARXNG_RESULT_LIMIT", "10") or "10")
|
||||
FIRECRAWL_BASE_URL = os.getenv("FIRECRAWL_BASE_URL", "http://firecrawl:3002").rstrip("/")
|
||||
FIRECRAWL_API_KEY = os.getenv("FIRECRAWL_API_KEY", "").strip() or ""
|
||||
WEB_SEARCH_QUERY_COUNT = int(os.getenv("WEB_SEARCH_QUERY_COUNT", "4") or "4")
|
||||
WEB_SEARCH_SELECTED_URL_LIMIT = int(os.getenv("WEB_SEARCH_SELECTED_URL_LIMIT", "10") or "10")
|
||||
WEB_SEARCH_CRAWL_CONCURRENCY = max(1, min(6, int(os.getenv("WEB_SEARCH_CRAWL_CONCURRENCY", "3") or "3")))
|
||||
WEB_SEARCH_CRAWL_TIMEOUT_SECONDS = max(10, min(90, int(os.getenv("WEB_SEARCH_CRAWL_TIMEOUT_SECONDS", "35") or "35")))
|
||||
_markitdown_instance = None
|
||||
_risk_config = load_risk_config()
|
||||
@@ -82,6 +81,52 @@ def _normalize_multiline_text(value: str) -> str:
|
||||
return (value or "").replace("\r\n", "\n").replace("\r", "\n").strip()
|
||||
|
||||
|
||||
def _looks_like_text(raw_bytes: bytes) -> bool:
|
||||
sample = raw_bytes[:8192]
|
||||
if not sample or b"\x00" in sample:
|
||||
return False
|
||||
try:
|
||||
text = sample.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return False
|
||||
if not text.strip():
|
||||
return False
|
||||
control_count = sum(
|
||||
1
|
||||
for char in text
|
||||
if (ord(char) < 32 and char not in "\t\n\r") or ord(char) == 127
|
||||
)
|
||||
return control_count / max(len(text), 1) < 0.05
|
||||
|
||||
|
||||
def _infer_convert_suffix(raw_bytes: bytes, filename: str) -> str:
|
||||
sample = raw_bytes[:1024 * 1024]
|
||||
if sample.startswith(b"%PDF-"):
|
||||
return ".pdf"
|
||||
if sample.startswith((b"PK\x03\x04", b"PK\x05\x06")):
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(raw_bytes)) as archive:
|
||||
names = set(archive.namelist())
|
||||
if any(name.startswith("ppt/") for name in names):
|
||||
return ".pptx"
|
||||
if any(name.startswith("word/") for name in names):
|
||||
return ".docx"
|
||||
except Exception:
|
||||
pass
|
||||
if _looks_like_text(sample):
|
||||
return ".txt"
|
||||
return ""
|
||||
|
||||
|
||||
def _resolve_url_addresses(url: str) -> list[tuple[Any, ...]]:
|
||||
parsed = urlparse((url or "").strip())
|
||||
host = (parsed.hostname or "").strip().lower()
|
||||
if not host:
|
||||
return []
|
||||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||
return socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
|
||||
|
||||
|
||||
def _is_blocked_public_url(url: str) -> bool:
|
||||
try:
|
||||
parsed = urlparse((url or "").strip())
|
||||
@@ -92,13 +137,26 @@ def _is_blocked_public_url(url: str) -> bool:
|
||||
host = (parsed.hostname or "").strip().lower()
|
||||
if not host:
|
||||
return True
|
||||
if host in {"localhost", "127.0.0.1", "::1"} or host.endswith(".local"):
|
||||
if host in {"localhost", "127.0.0.1", "::1"} or host.endswith((".local", ".localhost")):
|
||||
return True
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
return ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast
|
||||
return not ip.is_global
|
||||
except ValueError:
|
||||
return False
|
||||
pass
|
||||
try:
|
||||
addresses = _resolve_url_addresses(url)
|
||||
except Exception:
|
||||
return True
|
||||
for info in addresses:
|
||||
address = info[4][0]
|
||||
try:
|
||||
ip = ipaddress.ip_address(address)
|
||||
except ValueError:
|
||||
continue
|
||||
if not ip.is_global:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _strip_code_fence(value: str) -> str:
|
||||
@@ -239,7 +297,7 @@ async def _searxng_search(query: str, *, limit: int) -> list[dict[str, Any]]:
|
||||
results: list[dict[str, Any]] = []
|
||||
for item in payload.get("results") or []:
|
||||
url = str(item.get("url") or item.get("link") or "").strip()
|
||||
if not url or _is_blocked_public_url(url):
|
||||
if not url or await asyncio.to_thread(_is_blocked_public_url, url):
|
||||
continue
|
||||
results.append({
|
||||
"title": str(item.get("title") or "").strip(),
|
||||
@@ -347,6 +405,7 @@ async def _exit_llm_execution(
|
||||
status: str,
|
||||
actual_output_text: str = "",
|
||||
error_code: str = "",
|
||||
audit_metadata: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
policy = (risk.get("policy") or {})
|
||||
controller = get_risk_controller(_risk_config)
|
||||
@@ -361,11 +420,35 @@ async def _exit_llm_execution(
|
||||
"vision": _risk_config.vision_output_cost_per_1k,
|
||||
}.get(profile, _risk_config.completion_output_cost_per_1k)
|
||||
actual_output_tokens = estimate_tokens(actual_output_text)
|
||||
actual_cost = round((estimated_input_tokens / 1000.0) * {
|
||||
"completion": _risk_config.completion_input_cost_per_1k,
|
||||
"pro": _risk_config.pro_input_cost_per_1k,
|
||||
"vision": _risk_config.vision_input_cost_per_1k,
|
||||
}.get(profile, _risk_config.completion_input_cost_per_1k) + (actual_output_tokens / 1000.0) * pricing_out, 8)
|
||||
extra_metadata = dict(audit_metadata or {})
|
||||
if profile == "speech_tts":
|
||||
actual_cost = round(
|
||||
(int(extra_metadata.get("text_chars", 0) or 0) / 1000.0) * _risk_config.speech_tts_input_cost_per_1k_chars
|
||||
+ (int(extra_metadata.get("duration_ms", 0) or 0) / 60000.0) * _risk_config.speech_tts_output_cost_per_minute_audio,
|
||||
8,
|
||||
)
|
||||
elif profile == "speech_asr":
|
||||
actual_cost = round(
|
||||
(int(extra_metadata.get("audio_bytes", 0) or 0) / (1024.0 * 1024.0)) * _risk_config.speech_asr_input_cost_per_mb,
|
||||
8,
|
||||
)
|
||||
else:
|
||||
actual_cost = round((estimated_input_tokens / 1000.0) * {
|
||||
"completion": _risk_config.completion_input_cost_per_1k,
|
||||
"pro": _risk_config.pro_input_cost_per_1k,
|
||||
"vision": _risk_config.vision_input_cost_per_1k,
|
||||
}.get(profile, _risk_config.completion_input_cost_per_1k) + (actual_output_tokens / 1000.0) * pricing_out, 8)
|
||||
job_context = payload.get("job_context") or {}
|
||||
now_ms = int(time.time() * 1000)
|
||||
started_at = int(job_context.get("started_at", 0) or 0)
|
||||
created_at = int(job_context.get("created_at", 0) or 0)
|
||||
queue_ms = int(job_context.get("queue_ms", 0) or 0)
|
||||
run_ms = int(job_context.get("run_ms", 0) or 0)
|
||||
total_ms = int(job_context.get("total_ms", 0) or 0)
|
||||
if not run_ms and started_at:
|
||||
run_ms = max(0, now_ms - started_at)
|
||||
if not total_ms:
|
||||
total_ms = max(0, now_ms - created_at) if created_at else run_ms
|
||||
await asyncio.to_thread(
|
||||
store.record_llm_call,
|
||||
{
|
||||
@@ -381,7 +464,10 @@ async def _exit_llm_execution(
|
||||
"actual_cost": actual_cost,
|
||||
"status": status,
|
||||
"error_code": error_code,
|
||||
"metadata": {"profile": profile},
|
||||
"queue_ms": queue_ms,
|
||||
"run_ms": run_ms,
|
||||
"total_ms": total_ms,
|
||||
"metadata": {"profile": profile, **extra_metadata},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -748,14 +834,13 @@ async def ocr_handler(
|
||||
|
||||
if media_type == "video" or is_video_filename(filename, mime_type):
|
||||
asr_text = ""
|
||||
if generate_asr_response is not None:
|
||||
try:
|
||||
await emit("progress", {"phase": "asr", "media_type": media_type})
|
||||
audio_bytes = await asyncio.to_thread(extract_audio_wav_bytes, path)
|
||||
asr_response = await generate_asr_response(audio_bytes, language)
|
||||
asr_text = getattr(asr_response, "text", "") or ""
|
||||
except Exception as exc:
|
||||
asr_text = f"(音频解析失败: {exc})"
|
||||
try:
|
||||
await emit("progress", {"phase": "asr", "media_type": media_type})
|
||||
audio_bytes = await asyncio.to_thread(extract_audio_wav_bytes, path)
|
||||
asr_response = await generate_asr_response(audio_bytes, language)
|
||||
asr_text = getattr(asr_response, "text", "") or ""
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"音频解析失败: {exc}") from exc
|
||||
if ocr_text.strip() or asr_text.strip():
|
||||
text_parts = []
|
||||
if ocr_text.strip():
|
||||
@@ -792,12 +877,15 @@ async def convert_handler(
|
||||
) -> dict[str, Any]:
|
||||
path = payload["input_path"]
|
||||
filename = payload.get("filename", "document")
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
if ext not in ALLOWED_CONVERT_EXTENSIONS:
|
||||
try:
|
||||
temp_ext = os.path.splitext(path)[1].lower()
|
||||
except Exception:
|
||||
temp_ext = ""
|
||||
if temp_ext not in ALLOWED_CONVERT_EXTENSIONS:
|
||||
_safe_unlink(path)
|
||||
raise ValueError("仅支持 txt、docx、pptx、pdf 格式")
|
||||
try:
|
||||
if ext == ".txt":
|
||||
if temp_ext == ".txt":
|
||||
with open(path, "rb") as handle:
|
||||
markdown = _sanitize_converted_markdown(handle.read().decode("utf-8", errors="ignore"))
|
||||
else:
|
||||
@@ -817,19 +905,45 @@ async def tts_handler(
|
||||
emit: Callable[[str, dict[str, Any]], Awaitable[None]],
|
||||
is_cancelled: Callable[[], bool],
|
||||
) -> dict[str, Any]:
|
||||
if generate_tts_response is None:
|
||||
raise RuntimeError("TTS 功能当前不可用")
|
||||
response = await generate_tts_response(
|
||||
text=payload["text"],
|
||||
instruct=payload.get("instruct", ""),
|
||||
speaker=payload.get("speaker", "Vivian"),
|
||||
output_format=payload.get("format", "wav"),
|
||||
)
|
||||
if is_cancelled():
|
||||
raise asyncio.CancelledError()
|
||||
result = response.dict()
|
||||
await emit("result", result)
|
||||
return result
|
||||
text = str(payload.get("text", "") or "").strip()
|
||||
if not text:
|
||||
raise ValueError("TTS 文本为空")
|
||||
identity, risk, lock_keys = await _enter_llm_execution(payload, emit)
|
||||
try:
|
||||
response = await generate_tts_response(
|
||||
text=text,
|
||||
instruct=str(payload.get("instruct", "") or ""),
|
||||
speaker=str(payload.get("speaker", "Vivian") or "Vivian"),
|
||||
output_format=str(payload.get("format", "wav") or "wav"),
|
||||
)
|
||||
if is_cancelled():
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
result = dict(response)
|
||||
await emit("result", result)
|
||||
await _exit_llm_execution(
|
||||
payload,
|
||||
identity,
|
||||
risk,
|
||||
lock_keys,
|
||||
status="completed",
|
||||
audit_metadata={
|
||||
"speaker": result.get("speaker", ""),
|
||||
"format": result.get("format", ""),
|
||||
"duration_ms": int(result.get("duration_ms", 0) or 0),
|
||||
"audio_bytes": int(result.get("audio_bytes", 0) or 0),
|
||||
"text_chars": int(result.get("text_chars", len(text)) or len(text)),
|
||||
"request_ms": int(result.get("request_ms", 0) or 0),
|
||||
"upstream_request_id": result.get("upstream_request_id", ""),
|
||||
},
|
||||
)
|
||||
return result
|
||||
except asyncio.CancelledError:
|
||||
await _exit_llm_execution(payload, identity, risk, lock_keys, status="cancelled", error_code="cancelled")
|
||||
raise
|
||||
except Exception:
|
||||
await _exit_llm_execution(payload, identity, risk, lock_keys, status="failed", error_code="tts_failed")
|
||||
raise
|
||||
|
||||
|
||||
async def asr_handler(
|
||||
@@ -837,17 +951,36 @@ async def asr_handler(
|
||||
emit: Callable[[str, dict[str, Any]], Awaitable[None]],
|
||||
is_cancelled: Callable[[], bool],
|
||||
) -> dict[str, Any]:
|
||||
if generate_asr_response is None:
|
||||
raise RuntimeError("ASR 功能当前不可用")
|
||||
path = payload["input_path"]
|
||||
identity, risk, lock_keys = await _enter_llm_execution(payload, emit)
|
||||
try:
|
||||
with open(path, "rb") as handle:
|
||||
audio_bytes = handle.read()
|
||||
response = await generate_asr_response(audio_bytes, payload.get("language", "zh-CN"))
|
||||
if is_cancelled():
|
||||
raise asyncio.CancelledError()
|
||||
result = response.dict()
|
||||
result = dict(response)
|
||||
await emit("result", result)
|
||||
await _exit_llm_execution(
|
||||
payload,
|
||||
identity,
|
||||
risk,
|
||||
lock_keys,
|
||||
status="completed",
|
||||
actual_output_text=result.get("text", "") or "",
|
||||
audit_metadata={
|
||||
"language": result.get("language", ""),
|
||||
"audio_bytes": int(result.get("audio_bytes", len(audio_bytes)) or len(audio_bytes)),
|
||||
"request_ms": int(result.get("request_ms", 0) or 0),
|
||||
"upstream_request_id": result.get("upstream_request_id", ""),
|
||||
},
|
||||
)
|
||||
return result
|
||||
except asyncio.CancelledError:
|
||||
await _exit_llm_execution(payload, identity, risk, lock_keys, status="cancelled", error_code="cancelled")
|
||||
raise
|
||||
except Exception:
|
||||
await _exit_llm_execution(payload, identity, risk, lock_keys, status="failed", error_code="asr_failed")
|
||||
raise
|
||||
finally:
|
||||
_safe_unlink(path)
|
||||
|
||||
+165
-26
@@ -33,6 +33,14 @@ JOB_TYPES = (
|
||||
"asr",
|
||||
)
|
||||
|
||||
|
||||
def _int_env(name: str, default: int) -> int:
|
||||
try:
|
||||
return max(1, int(os.getenv(name, str(default))))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
DEFAULT_CONCURRENCY = {
|
||||
"completion": 2,
|
||||
"pro_completion": 1,
|
||||
@@ -40,8 +48,8 @@ DEFAULT_CONCURRENCY = {
|
||||
"compress": 1,
|
||||
"ocr": 1,
|
||||
"convert": 1,
|
||||
"tts": 1,
|
||||
"asr": 1,
|
||||
"tts": _int_env("JOB_TTS_CONCURRENCY", 2),
|
||||
"asr": _int_env("JOB_ASR_CONCURRENCY", 1),
|
||||
}
|
||||
|
||||
DEFAULT_QUEUE_SIZE = {
|
||||
@@ -51,8 +59,8 @@ DEFAULT_QUEUE_SIZE = {
|
||||
"compress": 8,
|
||||
"ocr": 8,
|
||||
"convert": 8,
|
||||
"tts": 4,
|
||||
"asr": 4,
|
||||
"tts": _int_env("JOB_TTS_MAX_QUEUE", 8),
|
||||
"asr": _int_env("JOB_ASR_MAX_QUEUE", 8),
|
||||
}
|
||||
|
||||
|
||||
@@ -94,13 +102,6 @@ def _bool_env(name: str, default: bool) -> bool:
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _int_env(name: str, default: int) -> int:
|
||||
try:
|
||||
return max(1, int(os.getenv(name, str(default))))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _float_env(name: str, default: float) -> float:
|
||||
try:
|
||||
return float(os.getenv(name, str(default)))
|
||||
@@ -248,7 +249,7 @@ class InMemoryJobManager(BaseJobManager):
|
||||
async with self.lock:
|
||||
config = _queue_config(job_type)
|
||||
if self.queue_counts[job_type] >= config.max_queue:
|
||||
raise QueueFullError(f"{job_type} queue is full")
|
||||
raise QueueFullError(job_type, config.max_queue)
|
||||
job_id = request_id or str(uuid.uuid4())
|
||||
self.jobs[job_id] = {
|
||||
"job_id": job_id,
|
||||
@@ -261,6 +262,11 @@ class InMemoryJobManager(BaseJobManager):
|
||||
"cancel_requested": False,
|
||||
"created_at": _now_ms(),
|
||||
"updated_at": _now_ms(),
|
||||
"started_at": 0,
|
||||
"completed_at": 0,
|
||||
"queue_ms": 0,
|
||||
"run_ms": 0,
|
||||
"total_ms": 0,
|
||||
}
|
||||
self.event_history[job_id] = []
|
||||
self.queue_counts[job_type] += 1
|
||||
@@ -305,6 +311,12 @@ class InMemoryJobManager(BaseJobManager):
|
||||
"status": job["status"],
|
||||
"result": job["result"],
|
||||
"error": job["error"],
|
||||
"created_at": job.get("created_at", 0),
|
||||
"started_at": job.get("started_at", 0),
|
||||
"completed_at": job.get("completed_at", 0),
|
||||
"queue_ms": job.get("queue_ms", 0),
|
||||
"run_ms": job.get("run_ms", 0),
|
||||
"total_ms": job.get("total_ms", 0),
|
||||
**metrics,
|
||||
}
|
||||
|
||||
@@ -353,7 +365,10 @@ class InMemoryJobManager(BaseJobManager):
|
||||
self.queue_counts[job_type] = max(0, self.queue_counts[job_type] - 1)
|
||||
self.running_counts[job_type] += 1
|
||||
job["status"] = "running"
|
||||
job["updated_at"] = _now_ms()
|
||||
started_at = _now_ms()
|
||||
job["updated_at"] = started_at
|
||||
job["started_at"] = started_at
|
||||
job["queue_ms"] = max(0, started_at - int(job.get("created_at", started_at)))
|
||||
metrics = self._metrics(job_type)
|
||||
await self._publish(job_id, "started", {"job_id": job_id, "type": job_type, "status": "running", **metrics})
|
||||
|
||||
@@ -364,7 +379,14 @@ class InMemoryJobManager(BaseJobManager):
|
||||
def is_cancelled() -> bool:
|
||||
return bool(job.get("cancel_requested"))
|
||||
|
||||
result = await self.handlers[job_type](job["payload"], emit, is_cancelled)
|
||||
job_payload = dict(job["payload"])
|
||||
job_payload["job_context"] = {
|
||||
"job_id": job_id,
|
||||
"created_at": int(job.get("created_at", 0) or 0),
|
||||
"started_at": int(job.get("started_at", 0) or 0),
|
||||
"queue_ms": int(job.get("queue_ms", 0) or 0),
|
||||
}
|
||||
result = await self.handlers[job_type](job_payload, emit, is_cancelled)
|
||||
async with self.lock:
|
||||
if job["cancel_requested"]:
|
||||
job["status"] = "cancelled"
|
||||
@@ -373,13 +395,35 @@ class InMemoryJobManager(BaseJobManager):
|
||||
return
|
||||
job["status"] = "completed"
|
||||
job["result"] = result
|
||||
job["updated_at"] = _now_ms()
|
||||
completed_at = _now_ms()
|
||||
job["updated_at"] = completed_at
|
||||
job["completed_at"] = completed_at
|
||||
job["run_ms"] = max(0, completed_at - int(job.get("started_at", completed_at)))
|
||||
job["total_ms"] = max(0, completed_at - int(job.get("created_at", completed_at)))
|
||||
metrics = self._metrics(job_type)
|
||||
await self._publish(job_id, "done", {"job_id": job_id, "type": job_type, "status": "completed", "result": result, **metrics})
|
||||
await self._publish(
|
||||
job_id,
|
||||
"done",
|
||||
{
|
||||
"job_id": job_id,
|
||||
"type": job_type,
|
||||
"status": "completed",
|
||||
"result": result,
|
||||
"queue_ms": job.get("queue_ms", 0),
|
||||
"run_ms": job.get("run_ms", 0),
|
||||
"total_ms": job.get("total_ms", 0),
|
||||
**metrics,
|
||||
},
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
async with self.lock:
|
||||
job["status"] = "cancelled"
|
||||
job["cancel_requested"] = True
|
||||
completed_at = _now_ms()
|
||||
job["updated_at"] = completed_at
|
||||
job["completed_at"] = completed_at
|
||||
job["run_ms"] = max(0, completed_at - int(job.get("started_at", completed_at)))
|
||||
job["total_ms"] = max(0, completed_at - int(job.get("created_at", completed_at)))
|
||||
metrics = self._metrics(job_type)
|
||||
await self._publish(job_id, "cancelled", {"job_id": job_id, "type": job_type, "status": "cancelled", **metrics})
|
||||
raise
|
||||
@@ -388,9 +432,26 @@ class InMemoryJobManager(BaseJobManager):
|
||||
async with self.lock:
|
||||
job["status"] = "failed"
|
||||
job["error"] = str(exc)
|
||||
job["updated_at"] = _now_ms()
|
||||
completed_at = _now_ms()
|
||||
job["updated_at"] = completed_at
|
||||
job["completed_at"] = completed_at
|
||||
job["run_ms"] = max(0, completed_at - int(job.get("started_at", completed_at)))
|
||||
job["total_ms"] = max(0, completed_at - int(job.get("created_at", completed_at)))
|
||||
metrics = self._metrics(job_type)
|
||||
await self._publish(job_id, "error", {"job_id": job_id, "type": job_type, "status": "failed", "error": str(exc), **metrics})
|
||||
await self._publish(
|
||||
job_id,
|
||||
"error",
|
||||
{
|
||||
"job_id": job_id,
|
||||
"type": job_type,
|
||||
"status": "failed",
|
||||
"error": str(exc),
|
||||
"queue_ms": job.get("queue_ms", 0),
|
||||
"run_ms": job.get("run_ms", 0),
|
||||
"total_ms": job.get("total_ms", 0),
|
||||
**metrics,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
async with self.lock:
|
||||
self.running_counts[job_type] = max(0, self.running_counts[job_type] - 1)
|
||||
@@ -484,7 +545,7 @@ class RedisJobManager(BaseJobManager):
|
||||
config = _queue_config(job_type)
|
||||
metrics = await self._metrics(job_type)
|
||||
if metrics["queued_count"] >= config.max_queue:
|
||||
raise QueueFullError(f"{job_type} queue is full")
|
||||
raise QueueFullError(job_type, config.max_queue)
|
||||
|
||||
job_id = request_id or str(uuid.uuid4())
|
||||
created_at = _now_ms()
|
||||
@@ -496,6 +557,11 @@ class RedisJobManager(BaseJobManager):
|
||||
"error": "",
|
||||
"created_at": created_at,
|
||||
"updated_at": created_at,
|
||||
"started_at": 0,
|
||||
"completed_at": 0,
|
||||
"queue_ms": 0,
|
||||
"run_ms": 0,
|
||||
"total_ms": 0,
|
||||
"cancel_requested": "0",
|
||||
}
|
||||
await self._set_state(job_id, state)
|
||||
@@ -533,6 +599,12 @@ class RedisJobManager(BaseJobManager):
|
||||
"error": error,
|
||||
"result": _json_loads(result, result),
|
||||
"cancel_requested": state.get("cancel_requested") == "1",
|
||||
"created_at": int(state.get("created_at", "0") or 0),
|
||||
"started_at": int(state.get("started_at", "0") or 0),
|
||||
"completed_at": int(state.get("completed_at", "0") or 0),
|
||||
"queue_ms": int(state.get("queue_ms", "0") or 0),
|
||||
"run_ms": int(state.get("run_ms", "0") or 0),
|
||||
"total_ms": int(state.get("total_ms", "0") or 0),
|
||||
**metrics,
|
||||
}
|
||||
|
||||
@@ -621,6 +693,9 @@ class RedisWorker:
|
||||
semaphore: asyncio.Semaphore,
|
||||
) -> None:
|
||||
job_id = fields["job_id"]
|
||||
started_at = 0
|
||||
created_at = 0
|
||||
queue_ms = 0
|
||||
try:
|
||||
state = await self.manager.get_status(job_id)
|
||||
if not state or state["status"] == "cancelled":
|
||||
@@ -629,13 +704,21 @@ class RedisWorker:
|
||||
|
||||
await self.manager.redis.hincrby(self.manager._metrics_key(job_type), "queued_count", -1)
|
||||
await self.manager.redis.hincrby(self.manager._metrics_key(job_type), "running_count", 1)
|
||||
started_at = _now_ms()
|
||||
created_at = int(state.get("created_at", 0) or 0)
|
||||
queue_ms = max(0, started_at - created_at)
|
||||
await self.manager._set_state(job_id, {
|
||||
"job_id": job_id,
|
||||
"request_id": state["request_id"],
|
||||
"type": job_type,
|
||||
"status": "running",
|
||||
"updated_at": _now_ms(),
|
||||
"created_at": state.get("created_at", _now_ms()),
|
||||
"updated_at": started_at,
|
||||
"created_at": created_at or started_at,
|
||||
"started_at": started_at,
|
||||
"completed_at": 0,
|
||||
"queue_ms": queue_ms,
|
||||
"run_ms": 0,
|
||||
"total_ms": 0,
|
||||
"cancel_requested": "1" if state.get("cancel_requested") else "0",
|
||||
"error": "",
|
||||
})
|
||||
@@ -643,6 +726,12 @@ class RedisWorker:
|
||||
await self.manager._emit_event(job_id, "started", {"job_id": job_id, "type": job_type, "status": "running", **metrics})
|
||||
|
||||
payload = _json_loads(fields["payload"], {})
|
||||
payload["job_context"] = {
|
||||
"job_id": job_id,
|
||||
"created_at": created_at,
|
||||
"started_at": started_at,
|
||||
"queue_ms": queue_ms,
|
||||
}
|
||||
|
||||
async def emit(event: str, data: dict[str, Any]) -> None:
|
||||
live_state = await self.manager.get_status(job_id) or {"status": "running"}
|
||||
@@ -656,6 +745,7 @@ class RedisWorker:
|
||||
result = await self.manager.handlers[job_type](payload, emit, is_cancelled)
|
||||
current = await self.manager.get_status(job_id)
|
||||
if current and current["status"] == "cancelled":
|
||||
await self.manager.redis.xack(queue_key, group, message_id)
|
||||
return
|
||||
|
||||
await self.manager._set_state(job_id, {
|
||||
@@ -664,16 +754,46 @@ class RedisWorker:
|
||||
"type": job_type,
|
||||
"status": "completed",
|
||||
"updated_at": _now_ms(),
|
||||
"created_at": state.get("created_at", _now_ms()),
|
||||
"created_at": created_at or started_at,
|
||||
"started_at": started_at,
|
||||
"completed_at": _now_ms(),
|
||||
"queue_ms": queue_ms,
|
||||
"run_ms": max(0, _now_ms() - started_at),
|
||||
"total_ms": max(0, _now_ms() - (created_at or started_at)),
|
||||
"cancel_requested": "0",
|
||||
"error": "",
|
||||
"result": _json_dumps(result),
|
||||
})
|
||||
metrics = await self.manager._metrics(job_type)
|
||||
await self.manager._emit_event(job_id, "done", {"job_id": job_id, "type": job_type, "status": "completed", "result": result, **metrics})
|
||||
final_state = await self.manager.get_status(job_id) or {}
|
||||
await self.manager._emit_event(
|
||||
job_id,
|
||||
"done",
|
||||
{
|
||||
"job_id": job_id,
|
||||
"type": job_type,
|
||||
"status": "completed",
|
||||
"result": result,
|
||||
"queue_ms": final_state.get("queue_ms", queue_ms),
|
||||
"run_ms": final_state.get("run_ms", 0),
|
||||
"total_ms": final_state.get("total_ms", 0),
|
||||
**metrics,
|
||||
},
|
||||
)
|
||||
await self.manager.redis.xack(queue_key, group, message_id)
|
||||
except asyncio.CancelledError:
|
||||
await self.manager.redis.hset(self.manager._state_key(job_id), mapping={"status": "cancelled", "cancel_requested": "1", "updated_at": _now_ms()})
|
||||
cancelled_at = _now_ms()
|
||||
await self.manager.redis.hset(
|
||||
self.manager._state_key(job_id),
|
||||
mapping={
|
||||
"status": "cancelled",
|
||||
"cancel_requested": "1",
|
||||
"updated_at": cancelled_at,
|
||||
"completed_at": cancelled_at,
|
||||
"run_ms": max(0, cancelled_at - started_at),
|
||||
"total_ms": max(0, cancelled_at - (created_at or started_at)),
|
||||
},
|
||||
)
|
||||
metrics = await self.manager._metrics(job_type)
|
||||
await self.manager._emit_event(job_id, "cancelled", {"job_id": job_id, "type": job_type, "status": "cancelled", **metrics})
|
||||
await self.manager.redis.xack(queue_key, group, message_id)
|
||||
@@ -688,12 +808,31 @@ class RedisWorker:
|
||||
"type": job_type,
|
||||
"status": "failed",
|
||||
"updated_at": _now_ms(),
|
||||
"created_at": state.get("created_at", _now_ms()) if state else _now_ms(),
|
||||
"created_at": created_at or (_now_ms() if state else _now_ms()),
|
||||
"started_at": started_at,
|
||||
"completed_at": _now_ms(),
|
||||
"queue_ms": queue_ms,
|
||||
"run_ms": max(0, _now_ms() - started_at),
|
||||
"total_ms": max(0, _now_ms() - (created_at or started_at)),
|
||||
"cancel_requested": "0",
|
||||
"error": str(exc),
|
||||
})
|
||||
metrics = await self.manager._metrics(job_type)
|
||||
await self.manager._emit_event(job_id, "error", {"job_id": job_id, "type": job_type, "status": "failed", "error": str(exc), **metrics})
|
||||
final_state = await self.manager.get_status(job_id) or {}
|
||||
await self.manager._emit_event(
|
||||
job_id,
|
||||
"error",
|
||||
{
|
||||
"job_id": job_id,
|
||||
"type": job_type,
|
||||
"status": "failed",
|
||||
"error": str(exc),
|
||||
"queue_ms": final_state.get("queue_ms", queue_ms),
|
||||
"run_ms": final_state.get("run_ms", 0),
|
||||
"total_ms": final_state.get("total_ms", 0),
|
||||
**metrics,
|
||||
},
|
||||
)
|
||||
await self.manager.redis.xack(queue_key, group, message_id)
|
||||
finally:
|
||||
self.running_tasks.pop(job_id, None)
|
||||
|
||||
+5
-11
@@ -22,20 +22,14 @@ LLM_API_KEY = os.getenv('LLM_API_KEY', 'ollama')
|
||||
# Auth headers for upstream LLM service (OpenAI-compatible Bearer token)
|
||||
LLM_HEADERS = {'Authorization': f'Bearer {LLM_API_KEY}'}
|
||||
|
||||
# Model names (backward compat: fall back to OLLAMA_MODEL if LLM_MODEL not set)
|
||||
_raw_model = os.getenv('LLM_MODEL') or os.getenv('OLLAMA_MODEL', 'gpt-oss:20b')
|
||||
LLM_MODEL = _raw_model.strip() if _raw_model else 'gpt-oss:20b'
|
||||
# Model names
|
||||
DEFAULT_LLM_MODEL = 'Nex-N2-mini-mlx-OptiQ-8bit-MTP'
|
||||
_raw_model = os.getenv('LLM_MODEL', DEFAULT_LLM_MODEL)
|
||||
LLM_MODEL = _raw_model.strip() if _raw_model else DEFAULT_LLM_MODEL
|
||||
PRO_LLM_MODEL = os.getenv('PRO_LLM_MODEL', LLM_MODEL)
|
||||
|
||||
# VLM for OCR (vision models)
|
||||
VLM_MODEL = os.getenv('VLM_MODEL', 'qwen3-vl:30b')
|
||||
|
||||
# Fallback for legacy OLLAMA_HOST env var (auto-convert to /v1/ path)
|
||||
_legacy_host = os.getenv('OLLAMA_HOST')
|
||||
if _legacy_host and not os.getenv('LLM_BASE_URL'):
|
||||
base = _legacy_host.rstrip('/')
|
||||
if '/v1' not in base:
|
||||
LLM_BASE_URL = f"{base}/v1/"
|
||||
VLM_MODEL = os.getenv('VLM_MODEL', DEFAULT_LLM_MODEL)
|
||||
|
||||
# Normalize trailing slash for base URL
|
||||
LLM_BASE_URL = LLM_BASE_URL.rstrip('/') + '/'
|
||||
|
||||
@@ -77,4 +77,24 @@ def resolve_llm_policy(job_type: str, request_payload: dict[str, Any], config: R
|
||||
temperature=0.0,
|
||||
thinking=None,
|
||||
)
|
||||
if job_type == "tts":
|
||||
return LLMPolicy(
|
||||
job_type=job_type,
|
||||
model=config.speech_tts_model,
|
||||
profile="speech_tts",
|
||||
max_input_chars=config.speech_tts_max_input_chars,
|
||||
max_output_tokens=0,
|
||||
temperature=0.0,
|
||||
thinking=None,
|
||||
)
|
||||
if job_type == "asr":
|
||||
return LLMPolicy(
|
||||
job_type=job_type,
|
||||
model=config.speech_asr_model,
|
||||
profile="speech_asr",
|
||||
max_input_chars=config.speech_asr_max_input_bytes,
|
||||
max_output_tokens=0,
|
||||
temperature=0.0,
|
||||
thinking=None,
|
||||
)
|
||||
raise ValueError(f"unsupported llm policy job type: {job_type}")
|
||||
|
||||
+94
-48
@@ -19,6 +19,7 @@ from docs_store import get_document_store
|
||||
from geoip import get_ip_location_text
|
||||
from job_handlers import (
|
||||
_sanitize_converted_markdown,
|
||||
_infer_convert_suffix,
|
||||
sanitize_inline_completion_content,
|
||||
ALLOWED_CONVERT_EXTENSIONS,
|
||||
asr_handler,
|
||||
@@ -296,10 +297,6 @@ def _register_handlers() -> None:
|
||||
manager.register_handler("tts", tts_handler)
|
||||
manager.register_handler("asr", asr_handler)
|
||||
_handlers_registered = True
|
||||
|
||||
# 打印注册信息便于调试
|
||||
registered = list(getattr(manager, "handlers", {}).keys())
|
||||
logger.info("handlers registered: %s", registered)
|
||||
|
||||
|
||||
def _sse(event: str, data: dict) -> str:
|
||||
@@ -400,6 +397,29 @@ def _estimate_completion_chars(req: CompletionRequest | ProCompletionRequest | W
|
||||
return len(req.prefix or "") + len(req.suffix or "") + len(getattr(req, "instruction", "") or "")
|
||||
|
||||
|
||||
def _estimate_job_cost(policy, raw_size: int, estimated_input_tokens: int) -> float:
|
||||
if policy.profile == "speech_tts":
|
||||
return round((raw_size / 1000.0) * config.speech_tts_input_cost_per_1k_chars, 8)
|
||||
if policy.profile == "speech_asr":
|
||||
return round((raw_size / (1024.0 * 1024.0)) * config.speech_asr_input_cost_per_mb, 8)
|
||||
|
||||
pricing_in = {
|
||||
"completion": config.completion_input_cost_per_1k,
|
||||
"pro": config.pro_input_cost_per_1k,
|
||||
"vision": config.vision_input_cost_per_1k,
|
||||
}[policy.profile]
|
||||
pricing_out = {
|
||||
"completion": config.completion_output_cost_per_1k,
|
||||
"pro": config.pro_output_cost_per_1k,
|
||||
"vision": config.vision_output_cost_per_1k,
|
||||
}[policy.profile]
|
||||
return round(
|
||||
(estimated_input_tokens / 1000.0) * pricing_in
|
||||
+ (policy.max_output_tokens / 1000.0) * pricing_out,
|
||||
8,
|
||||
)
|
||||
|
||||
|
||||
async def _prepare_llm_payload(
|
||||
request: Request,
|
||||
*,
|
||||
@@ -423,21 +443,7 @@ async def _prepare_llm_payload(
|
||||
)
|
||||
raise HTTPException(status_code=400, detail=f"输入过长,超过限制 {policy.max_input_chars}")
|
||||
estimated_input_tokens = estimate_tokens(token_source_text if token_source_text is not None else json.dumps(request_body, ensure_ascii=False))
|
||||
pricing_in = {
|
||||
"completion": config.completion_input_cost_per_1k,
|
||||
"pro": config.pro_input_cost_per_1k,
|
||||
"vision": config.vision_input_cost_per_1k,
|
||||
}[policy.profile]
|
||||
pricing_out = {
|
||||
"completion": config.completion_output_cost_per_1k,
|
||||
"pro": config.pro_output_cost_per_1k,
|
||||
"vision": config.vision_output_cost_per_1k,
|
||||
}[policy.profile]
|
||||
estimated_cost = round(
|
||||
(estimated_input_tokens / 1000.0) * pricing_in
|
||||
+ (policy.max_output_tokens / 1000.0) * pricing_out,
|
||||
8,
|
||||
)
|
||||
estimated_cost = _estimate_job_cost(policy, raw_size, estimated_input_tokens)
|
||||
controller = get_risk_controller(config)
|
||||
llm_decision = await controller.check_llm(identity, scope=policy.model, estimated_cost=estimated_cost)
|
||||
if not llm_decision.allowed:
|
||||
@@ -675,7 +681,13 @@ async def convert_to_markdown(request: Request, req: ConvertRequest, auth: dict
|
||||
file_bytes = base64.b64decode(req.file)
|
||||
except Exception as exc:
|
||||
return JSONResponse({"error": str(exc)}, status_code=500)
|
||||
input_path = persist_temp_input(file_bytes, ext or ".bin")
|
||||
temp_suffix = _infer_convert_suffix(file_bytes, req.filename)
|
||||
if not temp_suffix:
|
||||
return JSONResponse({"error": "仅支持 txt、docx、pptx、pdf 格式"}, status_code=500)
|
||||
ext = os.path.splitext(req.filename)[1].lower()
|
||||
if ext != temp_suffix:
|
||||
return JSONResponse({"error": "仅支持 txt、docx、pptx、pdf 格式"}, status_code=500)
|
||||
input_path = persist_temp_input(file_bytes, temp_suffix)
|
||||
try:
|
||||
job_id = await _queue_job("convert", {
|
||||
"request_id": request_id,
|
||||
@@ -742,32 +754,72 @@ async def get_compress_status(task_id: str, auth: dict = Security(_authorize_req
|
||||
@app.post("/v1/tts-asr/tts")
|
||||
async def queue_tts(req: TTSJobRequest, request: Request, auth: dict = Security(_authorize_request)):
|
||||
del auth
|
||||
request_id = _request_id(request)
|
||||
job_id = await _queue_job("tts", {
|
||||
"request_id": request_id,
|
||||
"text": req.text,
|
||||
"instruct": req.instruct,
|
||||
"speaker": req.speaker,
|
||||
"format": req.format,
|
||||
}, request_id)
|
||||
body = {
|
||||
"text_chars": len((req.text or "").strip()),
|
||||
"speaker": req.speaker or "Vivian",
|
||||
"format": req.format or "wav",
|
||||
}
|
||||
try:
|
||||
identity, payload = await _prepare_llm_payload(
|
||||
request,
|
||||
job_type="tts",
|
||||
request_body=body,
|
||||
raw_size=len((req.text or "").strip()),
|
||||
token_source_text=req.text or "",
|
||||
extra_payload={
|
||||
"text": req.text,
|
||||
"instruct": req.instruct,
|
||||
"speaker": req.speaker,
|
||||
"format": req.format,
|
||||
},
|
||||
)
|
||||
job_id = await _queue_job("tts", payload, identity.request_id)
|
||||
except RiskRejected as exc:
|
||||
return _risk_json_response(_request_identity(request), exc.decision)
|
||||
except QueueFullError as exc:
|
||||
return JSONResponse({"error": str(exc), "request_id": _request_id(request)}, status_code=429)
|
||||
except JobSystemError as exc:
|
||||
return JSONResponse({"error": str(exc), "request_id": _request_id(request)}, status_code=503)
|
||||
return await _stream_job(job_id)
|
||||
|
||||
|
||||
@app.post("/v1/tts-asr/asr")
|
||||
async def queue_asr(req: ASRJobRequest, request: Request, auth: dict = Security(_authorize_request)):
|
||||
del auth
|
||||
request_id = _request_id(request)
|
||||
try:
|
||||
audio_bytes = base64.b64decode(req.audio_base64)
|
||||
except Exception as exc:
|
||||
return JSONResponse({"error": str(exc)}, status_code=500)
|
||||
return JSONResponse({"error": str(exc)}, status_code=400)
|
||||
input_path = persist_temp_input(audio_bytes, ".wav")
|
||||
try:
|
||||
job_id = await _queue_job("asr", {
|
||||
"request_id": request_id,
|
||||
"input_path": input_path,
|
||||
"language": req.language or "zh-CN",
|
||||
}, request_id)
|
||||
identity, payload = await _prepare_llm_payload(
|
||||
request,
|
||||
job_type="asr",
|
||||
request_body={
|
||||
"audio_bytes": len(audio_bytes),
|
||||
"language": req.language or "zh-CN",
|
||||
},
|
||||
raw_size=len(audio_bytes),
|
||||
token_source_text=f"audio-bytes:{len(audio_bytes)} language:{req.language or 'zh-CN'}",
|
||||
extra_payload={
|
||||
"input_path": input_path,
|
||||
"language": req.language or "zh-CN",
|
||||
"audio_bytes": len(audio_bytes),
|
||||
},
|
||||
)
|
||||
job_id = await _queue_job("asr", payload, identity.request_id)
|
||||
except RiskRejected as exc:
|
||||
if os.path.exists(input_path):
|
||||
os.unlink(input_path)
|
||||
return _risk_json_response(_request_identity(request), exc.decision)
|
||||
except QueueFullError as exc:
|
||||
if os.path.exists(input_path):
|
||||
os.unlink(input_path)
|
||||
return JSONResponse({"error": str(exc), "request_id": _request_id(request)}, status_code=429)
|
||||
except JobSystemError as exc:
|
||||
if os.path.exists(input_path):
|
||||
os.unlink(input_path)
|
||||
return JSONResponse({"error": str(exc), "request_id": _request_id(request)}, status_code=503)
|
||||
except Exception:
|
||||
if os.path.exists(input_path):
|
||||
os.unlink(input_path)
|
||||
@@ -943,20 +995,11 @@ async def download_docs_blob(request: Request, node_id: str, auth: dict = Securi
|
||||
return Response(content=payload.content, media_type=payload.mime_type, headers=headers)
|
||||
|
||||
|
||||
def _register_tts_asr_routes():
|
||||
try:
|
||||
from tts_asr import register_tts_asr_routes
|
||||
except ModuleNotFoundError as exc:
|
||||
logger.warning("Skipping TTS/ASR route registration because a dependency is missing: %s", exc)
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.warning("Skipping TTS/ASR route registration because import failed: %s", exc)
|
||||
return
|
||||
def _register_tts_asr_routes() -> None:
|
||||
from tts_asr import LLM_BASE_URL, register_tts_asr_routes as _register_fn
|
||||
|
||||
try:
|
||||
register_tts_asr_routes(app, include_generation_routes=False)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to register TTS/ASR routes: %s", exc)
|
||||
logger.info("TTS/ASR routes registered with shared LLM speech backend")
|
||||
_register_fn(app)
|
||||
|
||||
|
||||
_register_tts_asr_routes()
|
||||
@@ -964,10 +1007,13 @@ _register_tts_asr_routes()
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def _shutdown_job_manager(): # pragma: no cover
|
||||
from tts_asr import close_speech_client
|
||||
|
||||
manager = get_job_manager()
|
||||
close = getattr(manager, "close", None)
|
||||
if close is not None:
|
||||
await close()
|
||||
await close_speech_client()
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
@@ -8,10 +8,3 @@ python-multipart>=0.0.9
|
||||
python-dotenv>=1.0.0
|
||||
markitdown>=0.1.1
|
||||
geoip2>=4.8.0
|
||||
numpy>=1.26.0
|
||||
torch>=2.2.0
|
||||
soundfile>=0.12.1
|
||||
scipy>=1.13.0
|
||||
qwen-tts
|
||||
modelscope>=1.18.0
|
||||
faster-whisper>=1.1.0
|
||||
|
||||
@@ -6,18 +6,8 @@ redis>=5.0.0
|
||||
psycopg[binary]>=3.2.0
|
||||
python-multipart>=0.0.9
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
numpy>=1.23.0
|
||||
soundfile>=0.10.3
|
||||
torch>=1.12.0
|
||||
torchaudio>=1.12.0
|
||||
transformers>=4.25.0
|
||||
whisper>=1.0.0
|
||||
qwen-tts>=0.0.0
|
||||
modelscope>=1.20.0
|
||||
|
||||
# MLX-based ASR (Apple Silicon only)
|
||||
mlx-audio>=0.4.3
|
||||
markitdown>=0.1.1
|
||||
geoip2>=4.8.0
|
||||
|
||||
# testing
|
||||
pytest>=7.0.0
|
||||
|
||||
+18
-4
@@ -75,18 +75,25 @@ class RiskConfig:
|
||||
pro_max_output_tokens: int
|
||||
pro_temperature: float
|
||||
web_search_model: str
|
||||
speech_tts_model: str
|
||||
speech_asr_model: str
|
||||
web_search_max_input_chars: int
|
||||
web_search_max_output_tokens: int
|
||||
web_search_temperature: float
|
||||
compress_max_input_chars: int
|
||||
compress_max_output_tokens: int
|
||||
ocr_max_input_bytes: int
|
||||
speech_tts_max_input_chars: int
|
||||
speech_asr_max_input_bytes: int
|
||||
completion_input_cost_per_1k: float
|
||||
completion_output_cost_per_1k: float
|
||||
pro_input_cost_per_1k: float
|
||||
pro_output_cost_per_1k: float
|
||||
vision_input_cost_per_1k: float
|
||||
vision_output_cost_per_1k: float
|
||||
speech_tts_input_cost_per_1k_chars: float
|
||||
speech_tts_output_cost_per_minute_audio: float
|
||||
speech_asr_input_cost_per_mb: float
|
||||
|
||||
|
||||
def load_risk_config() -> RiskConfig:
|
||||
@@ -123,26 +130,33 @@ def load_risk_config() -> RiskConfig:
|
||||
model_circuit_breaker_failures=_int_env("RISK_MODEL_CIRCUIT_FAILURES", 8),
|
||||
model_circuit_ttl_seconds=_int_env("RISK_MODEL_CIRCUIT_TTL_SECONDS", 300),
|
||||
enforce_redis_fail_closed=_bool_env("RISK_ENFORCE_REDIS_FAIL_CLOSED", False),
|
||||
completion_model=_str_env("RISK_COMPLETION_MODEL", os.getenv("LLM_MODEL", "gpt-4.1-mini")),
|
||||
pro_model=_str_env("RISK_PRO_MODEL", os.getenv("PRO_LLM_MODEL", os.getenv("LLM_MODEL", "gpt-4.1"))),
|
||||
vision_model=_str_env("RISK_VISION_MODEL", os.getenv("VLM_MODEL", "gpt-4.1-mini")),
|
||||
completion_model=_str_env("RISK_COMPLETION_MODEL", os.getenv("LLM_MODEL", "Nex-N2-mini-mlx-OptiQ-8bit-MTP")),
|
||||
pro_model=_str_env("RISK_PRO_MODEL", os.getenv("PRO_LLM_MODEL", os.getenv("LLM_MODEL", "Nex-N2-mini-mlx-OptiQ-8bit-MTP"))),
|
||||
vision_model=_str_env("RISK_VISION_MODEL", os.getenv("VLM_MODEL", "Nex-N2-mini-mlx-OptiQ-8bit-MTP")),
|
||||
completion_max_input_chars=_int_env("RISK_COMPLETION_MAX_INPUT_CHARS", 24000),
|
||||
completion_max_output_tokens=_int_env("RISK_COMPLETION_MAX_OUTPUT_TOKENS", 768),
|
||||
completion_temperature=_float_env("RISK_COMPLETION_TEMPERATURE", 0.4),
|
||||
pro_max_input_chars=_int_env("RISK_PRO_MAX_INPUT_CHARS", 48000),
|
||||
pro_max_output_tokens=_int_env("RISK_PRO_MAX_OUTPUT_TOKENS", 2048),
|
||||
pro_temperature=_float_env("RISK_PRO_TEMPERATURE", 0.6),
|
||||
web_search_model=_str_env("RISK_WEB_SEARCH_MODEL", os.getenv("LLM_MODEL", "gpt-4.1-mini")),
|
||||
web_search_model=_str_env("RISK_WEB_SEARCH_MODEL", os.getenv("LLM_MODEL", "Nex-N2-mini-mlx-OptiQ-8bit-MTP")),
|
||||
speech_tts_model=_str_env("RISK_SPEECH_TTS_MODEL", "Qwen3-TTS-12Hz-1.7B-VoiceDesign-8bit"),
|
||||
speech_asr_model=_str_env("RISK_SPEECH_ASR_MODEL", "Qwen3-ASR-0.6B-8bit"),
|
||||
web_search_max_input_chars=_int_env("RISK_WEB_SEARCH_MAX_INPUT_CHARS", 128000),
|
||||
web_search_max_output_tokens=_int_env("RISK_WEB_SEARCH_MAX_OUTPUT_TOKENS", 4096),
|
||||
web_search_temperature=_float_env("RISK_WEB_SEARCH_TEMPERATURE", 0.4),
|
||||
compress_max_input_chars=_int_env("RISK_COMPRESS_MAX_INPUT_CHARS", 128000),
|
||||
compress_max_output_tokens=_int_env("RISK_COMPRESS_MAX_OUTPUT_TOKENS", 1536),
|
||||
ocr_max_input_bytes=_int_env("RISK_OCR_MAX_INPUT_BYTES", 100 * 1024 * 1024),
|
||||
speech_tts_max_input_chars=_int_env("RISK_SPEECH_TTS_MAX_INPUT_CHARS", 4096),
|
||||
speech_asr_max_input_bytes=_int_env("RISK_SPEECH_ASR_MAX_INPUT_BYTES", 100 * 1024 * 1024),
|
||||
completion_input_cost_per_1k=_float_env("RISK_COMPLETION_INPUT_COST_PER_1K", 0.0004),
|
||||
completion_output_cost_per_1k=_float_env("RISK_COMPLETION_OUTPUT_COST_PER_1K", 0.0016),
|
||||
pro_input_cost_per_1k=_float_env("RISK_PRO_INPUT_COST_PER_1K", 0.003),
|
||||
pro_output_cost_per_1k=_float_env("RISK_PRO_OUTPUT_COST_PER_1K", 0.012),
|
||||
vision_input_cost_per_1k=_float_env("RISK_VISION_INPUT_COST_PER_1K", 0.0008),
|
||||
vision_output_cost_per_1k=_float_env("RISK_VISION_OUTPUT_COST_PER_1K", 0.0024),
|
||||
speech_tts_input_cost_per_1k_chars=_float_env("RISK_SPEECH_TTS_INPUT_COST_PER_1K_CHARS", 0.0),
|
||||
speech_tts_output_cost_per_minute_audio=_float_env("RISK_SPEECH_TTS_OUTPUT_COST_PER_MINUTE_AUDIO", 0.0),
|
||||
speech_asr_input_cost_per_mb=_float_env("RISK_SPEECH_ASR_INPUT_COST_PER_MB", 0.0),
|
||||
)
|
||||
|
||||
@@ -1,453 +0,0 @@
|
||||
# TTS/ASR 测试指南
|
||||
|
||||
本文档提供完整的测试脚本使用说明,包括单元测试、集成测试和macOS环境模拟测试。
|
||||
|
||||
## 测试脚本概览
|
||||
|
||||
| 脚本 | 位置 | 用途 | 需要后端服务 |
|
||||
|------|------|------|--------------|
|
||||
| `test_tts_asr_unit.py` | `backend/tests/` | 单元测试(设备检测、模型选择、音频处理) | 否 |
|
||||
| `test_tts_asr_integration.py` | `backend/tests/` | 集成测试(API端点、完整流程) | 是 |
|
||||
| `simulate_macos.py` | `backend/tests/` | macOS环境模拟(在非Mac环境测试) | 否 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 单元测试(推荐首先运行)
|
||||
|
||||
单元测试不需要实际运行模型或后端服务,测试代码逻辑:
|
||||
|
||||
```bash
|
||||
# 使用pytest运行(推荐)
|
||||
pytest backend/tests/test_tts_asr_unit.py -v
|
||||
|
||||
# 直接运行
|
||||
python backend/tests/test_tts_asr_unit.py
|
||||
|
||||
# 运行特定测试类
|
||||
pytest backend/tests/test_tts_asr_unit.py::TestAppleSiliconDetection -v
|
||||
|
||||
# 运行特定测试方法
|
||||
pytest backend/tests/test_tts_asr_unit.py::TestAppleSiliconDetection::test_is_apple_silicon_on_darwin_arm64 -v
|
||||
```
|
||||
|
||||
### 2. macOS环境模拟测试
|
||||
|
||||
在非macOS环境下模拟Apple Silicon环境:
|
||||
|
||||
```bash
|
||||
# 运行完整模拟测试套件
|
||||
python backend/tests/simulate_macos.py --full-simulation
|
||||
|
||||
# 仅模拟Apple Silicon环境并进入交互模式
|
||||
python backend/tests/simulate_macos.py --apple-silicon
|
||||
|
||||
# 模拟特定设备
|
||||
python backend/tests/simulate_macos.py --device mps
|
||||
python backend/tests/simulate_macos.py --device cuda
|
||||
|
||||
# 运行特定测试
|
||||
python backend/tests/simulate_macos.py --test device # 设备检测
|
||||
python backend/tests/simulate_macos.py --test memory # 内存管理
|
||||
python backend/tests/simulate_macos.py --test model # 模型选择
|
||||
python backend/tests/simulate_macos.py --test audio # 音频处理
|
||||
python backend/tests/simulate_macos.py --test env # 环境变量
|
||||
```
|
||||
|
||||
### 3. 集成测试
|
||||
|
||||
集成测试需要运行后端服务:
|
||||
|
||||
```bash
|
||||
# 1. 启动后端服务(终端1)
|
||||
python backend/main.py
|
||||
|
||||
# 2. 运行集成测试(终端2)
|
||||
# 运行所有测试
|
||||
python backend/tests/test_tts_asr_integration.py
|
||||
|
||||
# 运行特定测试
|
||||
python backend/tests/test_tts_asr_integration.py --test config # 配置端点
|
||||
python backend/tests/test_tts_asr_integration.py --test status # 状态端点
|
||||
python backend/tests/test_tts_asr_integration.py --test warmup # 预热测试
|
||||
python backend/tests/test_tts_asr_integration.py --test tts # TTS测试
|
||||
python backend/tests/test_tts_asr_integration.py --test asr # ASR测试
|
||||
python backend/tests/test_tts_asr_integration.py --test perf # 性能测试
|
||||
|
||||
# 自定义API地址
|
||||
python backend/tests/test_tts_asr_integration.py --url http://localhost:8001 --key your-api-key
|
||||
```
|
||||
|
||||
## 详细测试说明
|
||||
|
||||
### 单元测试详解
|
||||
|
||||
#### TestAppleSiliconDetection
|
||||
|
||||
测试Apple Silicon检测功能:
|
||||
|
||||
- `test_is_apple_silicon_on_darwin_arm64`: 在Darwin/arm64环境检测
|
||||
- `test_is_apple_silicon_on_windows`: 在Windows环境不应检测到
|
||||
- `test_is_apple_silicon_on_linux`: 在Linux环境不应检测到
|
||||
|
||||
#### TestEnvironmentVariables
|
||||
|
||||
测试环境变量解析:
|
||||
|
||||
- `test_default_environment_values`: 验证默认值
|
||||
- `test_custom_environment_values`: 验证自定义值
|
||||
|
||||
#### TestModelSizeSelection
|
||||
|
||||
测试模型大小选择:
|
||||
|
||||
- `test_whisper_model_sizes_mapping`: 模型大小映射验证
|
||||
- `test_recommended_model_size_explicit`: 显式指定大小
|
||||
- `test_invalid_model_size_falls_back`: 无效大小回退
|
||||
|
||||
#### TestAudioValidation
|
||||
|
||||
测试音频验证:
|
||||
|
||||
- `test_validate_empty_audio`: 空音频验证
|
||||
- `test_validate_valid_wav_header`: 有效WAV头验证
|
||||
- `test_validate_invalid_audio`: 无效音频验证
|
||||
|
||||
#### TestAudioResampling
|
||||
|
||||
测试音频重采样:
|
||||
|
||||
- `test_resample_same_rate`: 相同采样率
|
||||
- `test_resample_different_rate`: 不同采样率重采样
|
||||
- `test_resample_downsample`: 下采样
|
||||
|
||||
#### TestDeviceCapabilities
|
||||
|
||||
测试设备能力检测:
|
||||
|
||||
- `test_device_capabilities_dataclass`: 数据类验证
|
||||
- `test_device_capabilities_with_mps`: MPS设备能力
|
||||
|
||||
#### TestModelCacheCheck
|
||||
|
||||
测试模型缓存检查:
|
||||
|
||||
- `test_cache_check_non_offline_mode`: 非离线模式
|
||||
- `test_cache_check_offline_mode_missing`: 离线模式缺失模型
|
||||
|
||||
#### TestRequestResponseModels
|
||||
|
||||
测试API模型:
|
||||
|
||||
- `test_tts_request_model`: TTS请求模型
|
||||
- `test_asr_request_model`: ASR请求模型
|
||||
- `test_model_status_model`: 状态模型
|
||||
|
||||
### 集成测试详解
|
||||
|
||||
#### TTSASRIntegrationTest
|
||||
|
||||
主要集成测试:
|
||||
|
||||
- `test_01_config_endpoint`: 配置端点测试
|
||||
- `test_02_status_endpoint`: 状态端点测试
|
||||
- `test_03_warmup_endpoint`: 预热端点测试
|
||||
- `test_04_tts_endpoint_basic`: TTS基本功能测试
|
||||
- `test_05_asr_endpoint_basic`: ASR基本功能测试
|
||||
- `test_06_api_key_validation`: API密钥验证测试
|
||||
- `test_07_tts_long_text`: TTS长文本测试
|
||||
|
||||
#### PerformanceTest
|
||||
|
||||
性能测试:
|
||||
|
||||
- `test_tts_latency`: TTS延迟测试
|
||||
|
||||
### macOS模拟测试详解
|
||||
|
||||
#### MacOSSimulator类
|
||||
|
||||
提供以下模拟功能:
|
||||
|
||||
- `simulate_apple_silicon()`: 模拟Darwin/arm64环境
|
||||
- `simulate_mps_device()`: 模拟MPS设备可用
|
||||
- `simulate_cuda_device()`: 模拟CUDA设备可用
|
||||
- `cleanup()`: 清理模拟环境
|
||||
|
||||
#### 独立测试函数
|
||||
|
||||
- `test_device_detection_on_apple_silicon()`: Apple Silicon设备检测
|
||||
- `test_memory_management()`: 内存管理测试
|
||||
- `test_model_size_selection()`: 模型大小选择测试
|
||||
- `test_audio_processing()`: 音频处理测试
|
||||
- `test_environment_variables()`: 环境变量测试
|
||||
|
||||
## 测试覆盖率
|
||||
|
||||
### 单元测试覆盖的功能
|
||||
|
||||
- [x] Apple Silicon检测逻辑
|
||||
- [x] 环境变量解析和默认值
|
||||
- [x] 模型大小选择和推荐
|
||||
- [x] 音频数据验证
|
||||
- [x] 音频重采样(多回退方案)
|
||||
- [x] 设备能力检测数据结构
|
||||
- [x] 模型缓存检查
|
||||
- [x] API请求/响应模型
|
||||
|
||||
### 集成测试覆盖的功能
|
||||
|
||||
- [x] 配置端点(`/v1/tts-asr/config`)
|
||||
- [x] 状态端点(`/v1/tts-asr/status`)
|
||||
- [x] 预热端点(`/v1/tts-asr/warmup`)
|
||||
- [x] TTS端点(`/v1/tts-asr/tts`)
|
||||
- [x] ASR端点(`/v1/tts-asr/asr`)
|
||||
- [x] API密钥验证
|
||||
- [x] 长文本处理
|
||||
- [x] 性能基准测试
|
||||
|
||||
### macOS模拟测试覆盖的场景
|
||||
|
||||
- [x] Apple Silicon环境模拟
|
||||
- [x] MPS设备模拟
|
||||
- [x] CUDA设备模拟
|
||||
- [x] 系统内存模拟
|
||||
- [x] 完整环境变量测试
|
||||
|
||||
## 常见测试场景
|
||||
|
||||
### 场景1: 开发时快速验证
|
||||
|
||||
```bash
|
||||
# 快速单元测试
|
||||
pytest backend/tests/test_tts_asr_unit.py -v --tb=short
|
||||
|
||||
# macOS模拟(完整)
|
||||
python backend/tests/simulate_macos.py --full-simulation
|
||||
```
|
||||
|
||||
### 场景2: 验证特定配置
|
||||
|
||||
```bash
|
||||
# 设置环境变量后测试
|
||||
export TTS_ASR_MODEL_SIZE=small
|
||||
export TTS_ASR_QUANTIZE=true
|
||||
|
||||
# 运行测试
|
||||
python backend/tests/simulate_macos.py --test model
|
||||
```
|
||||
|
||||
### 场景3: API功能验证
|
||||
|
||||
```bash
|
||||
# 启动服务
|
||||
python backend/main.py
|
||||
|
||||
# 测试配置端点
|
||||
python backend/tests/test_tts_asr_integration.py --test config
|
||||
|
||||
# 测试TTS功能
|
||||
python backend/tests/test_tts_asr_integration.py --test tts
|
||||
|
||||
# 测试ASR功能
|
||||
python backend/tests/test_tts_asr_integration.py --test asr
|
||||
```
|
||||
|
||||
### 场景4: 性能基准测试
|
||||
|
||||
```bash
|
||||
# 启动服务
|
||||
python backend/main.py
|
||||
|
||||
# 运行性能测试
|
||||
python backend/tests/test_tts_asr_integration.py --test perf
|
||||
```
|
||||
|
||||
## 测试输出解读
|
||||
|
||||
### 成功示例
|
||||
|
||||
```
|
||||
test_is_apple_silicon_on_darwin_arm64 ... ok
|
||||
test_is_apple_silicon_on_windows ... ok
|
||||
test_is_apple_silicon_on_linux ... ok
|
||||
|
||||
----------------------------------------------------------------------
|
||||
Ran 3 tests in 0.005s
|
||||
|
||||
OK
|
||||
```
|
||||
|
||||
### 失败示例
|
||||
|
||||
```
|
||||
test_device_detection_on_apple_silicon ... FAIL
|
||||
|
||||
======================================================================
|
||||
FAIL: test_device_detection_on_apple_silicon
|
||||
----------------------------------------------------------------------
|
||||
Traceback (most recent call last):
|
||||
File "test_tts_asr_unit.py", line 45, in test_is_apple_silicon_on_darwin_arm64
|
||||
self.assertTrue(_is_apple_silicon())
|
||||
AssertionError: False is not true
|
||||
|
||||
----------------------------------------------------------------------
|
||||
Ran 1 tests in 0.002s
|
||||
|
||||
FAILED (failures=1)
|
||||
```
|
||||
|
||||
## 持续集成配置
|
||||
|
||||
### GitHub Actions示例
|
||||
|
||||
```yaml
|
||||
name: TTS/ASR Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
unit-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -r backend/requirements.txt
|
||||
pip install pytest
|
||||
- name: Run unit tests
|
||||
run: pytest backend/tests/test_tts_asr_unit.py -v
|
||||
- name: Run macOS simulation
|
||||
run: python backend/tests/simulate_macos.py --full-simulation
|
||||
```
|
||||
|
||||
### pytest配置
|
||||
|
||||
创建 `pytest.ini`:
|
||||
|
||||
```ini
|
||||
[pytest]
|
||||
testpaths = backend/tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
addopts = -v --tb=short
|
||||
```
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 问题1: 导入错误
|
||||
|
||||
```
|
||||
ModuleNotFoundError: No module named 'backend'
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
|
||||
```bash
|
||||
# 确保在项目根目录运行
|
||||
cd /path/to/llm-in-text
|
||||
|
||||
# 或设置PYTHONPATH
|
||||
export PYTHONPATH="${PYTHONPATH}:$(pwd)"
|
||||
```
|
||||
|
||||
### 问题2: 后端服务连接失败
|
||||
|
||||
```
|
||||
✗ 无法连接到服务: [Errno 111] Connection refused
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
|
||||
```bash
|
||||
# 确保后端服务正在运行
|
||||
python backend/main.py
|
||||
|
||||
# 检查端口
|
||||
lsof -i :8001
|
||||
|
||||
# 或使用自定义URL
|
||||
python backend/tests/test_tts_asr_integration.py --url http://localhost:8001
|
||||
```
|
||||
|
||||
### 问题3: 模型未加载
|
||||
|
||||
```
|
||||
⚠ TTS失败(可能是模型未加载)
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
|
||||
这是预期行为,表示模型需要时间下载。可以:
|
||||
|
||||
1. 等待模型下载完成
|
||||
2. 使用预热端点: `POST /v1/tts-asr/warmup`
|
||||
3. 启用离线模式(如果模型已下载)
|
||||
|
||||
### 问题4: 测试超时
|
||||
|
||||
```
|
||||
httpx.ReadTimeout: timed out
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
|
||||
```bash
|
||||
# 增加超时时间
|
||||
export TEST_TIMEOUT=300.0
|
||||
|
||||
# 或在测试脚本中修改
|
||||
TEST_TIMEOUT = 300.0 # 5分钟
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **开发时**: 频繁运行单元测试
|
||||
```bash
|
||||
pytest backend/tests/test_tts_asr_unit.py -v --tb=short
|
||||
```
|
||||
|
||||
2. **提交前**: 运行完整测试套件
|
||||
```bash
|
||||
pytest backend/tests/test_tts_asr_unit.py -v
|
||||
python backend/tests/simulate_macos.py --full-simulation
|
||||
```
|
||||
|
||||
3. **部署前**: 运行集成测试
|
||||
```bash
|
||||
python backend/tests/test_tts_asr_integration.py
|
||||
```
|
||||
|
||||
4. **调试时**: 使用详细输出
|
||||
```bash
|
||||
pytest backend/tests/test_tts_asr_unit.py -v -s --tb=long
|
||||
```
|
||||
|
||||
## 测试报告
|
||||
|
||||
生成测试覆盖率报告:
|
||||
|
||||
```bash
|
||||
# 安装coverage
|
||||
pip install pytest-cov
|
||||
|
||||
# 运行并生成报告
|
||||
pytest backend/tests/test_tts_asr_unit.py --cov=backend.tts_asr --cov-report=html
|
||||
|
||||
# 查看报告
|
||||
open htmlcov/index.html
|
||||
```
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [TTS/ASR修复说明](./TTS_ASR_MACOS_FIX.md)
|
||||
- [环境变量配置](../README.md#ttsasr环境变量配置)
|
||||
- [API文档](../README.md#api接口)
|
||||
|
||||
---
|
||||
|
||||
**更新日期**: 2026-04-06
|
||||
**维护者**: 项目开发团队
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Lightweight benchmark for TTS/ASR queueing and API throughput.
|
||||
|
||||
This benchmark uses the FastAPI app with a mocked upstream speech API so it
|
||||
measures this project's queueing, request handling, and SSE delivery cost
|
||||
without requiring a real external model endpoint.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import statistics
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(BACKEND_DIR) not in __import__("sys").path:
|
||||
__import__("sys").path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
import main # noqa: E402
|
||||
import tts_asr # noqa: E402
|
||||
from job_system import reset_job_manager # noqa: E402
|
||||
|
||||
|
||||
def _wav_bytes(duration_ms: int = 320) -> bytes:
|
||||
sample_rate = 16000
|
||||
frames = max(1, int(sample_rate * duration_ms / 1000))
|
||||
data = b"".join((i % 32768).to_bytes(2, "little", signed=False) for i in range(frames))
|
||||
data_size = len(data)
|
||||
return (
|
||||
b"RIFF" + (36 + data_size).to_bytes(4, "little")
|
||||
+ b"WAVE"
|
||||
+ b"fmt " + (16).to_bytes(4, "little")
|
||||
+ (1).to_bytes(2, "little")
|
||||
+ (1).to_bytes(2, "little")
|
||||
+ sample_rate.to_bytes(4, "little")
|
||||
+ sample_rate.to_bytes(4, "little")
|
||||
+ (2).to_bytes(2, "little")
|
||||
+ (16).to_bytes(2, "little")
|
||||
+ b"data" + data_size.to_bytes(4, "little")
|
||||
+ data
|
||||
)
|
||||
|
||||
|
||||
def _parse_sse_done(text: str) -> dict:
|
||||
for chunk in reversed([item for item in text.split("\n\n") if item.strip()]):
|
||||
event = ""
|
||||
data = ""
|
||||
for line in chunk.splitlines():
|
||||
if line.startswith("event:"):
|
||||
event = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("data:"):
|
||||
data = line.split(":", 1)[1].strip()
|
||||
if event == "done" and data:
|
||||
payload = json.loads(data)
|
||||
result = dict(payload.get("result") or {})
|
||||
for key in ("queue_ms", "run_ms", "total_ms", "queued_count", "running_count", "busy_level", "busy_ratio"):
|
||||
if key in payload:
|
||||
result[key] = payload[key]
|
||||
return result
|
||||
raise RuntimeError("done event not found")
|
||||
|
||||
|
||||
def _percentile(values: list[float], q: float) -> float:
|
||||
if not values:
|
||||
return 0.0
|
||||
if len(values) == 1:
|
||||
return values[0]
|
||||
index = (len(values) - 1) * q
|
||||
lower = int(index)
|
||||
upper = min(lower + 1, len(values) - 1)
|
||||
if lower == upper:
|
||||
return values[lower]
|
||||
weight = index - lower
|
||||
return values[lower] * (1 - weight) + values[upper] * weight
|
||||
|
||||
|
||||
async def _build_mock_client(tts_delay_ms: int, asr_delay_ms: int) -> httpx.AsyncClient:
|
||||
async def transport(request: httpx.Request):
|
||||
if request.url.path.endswith("/audio/speech"):
|
||||
await asyncio.sleep(tts_delay_ms / 1000.0)
|
||||
return httpx.Response(200, content=_wav_bytes(420), headers={"x-request-id": "bench-tts"}, request=request)
|
||||
await asyncio.sleep(asr_delay_ms / 1000.0)
|
||||
return httpx.Response(200, json={"text": "benchmark transcript", "language": "zh"}, headers={"x-request-id": "bench-asr"}, request=request)
|
||||
|
||||
return httpx.AsyncClient(
|
||||
base_url="https://benchmark.example/v1/",
|
||||
transport=httpx.MockTransport(transport),
|
||||
)
|
||||
|
||||
|
||||
async def _run_case(case_name: str, concurrency: int, request_count: int, audio_b64: str | None = None) -> dict:
|
||||
results: list[dict] = []
|
||||
latencies: list[float] = []
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=main.app),
|
||||
base_url="http://testserver",
|
||||
timeout=120.0,
|
||||
) as client:
|
||||
semaphore = asyncio.Semaphore(concurrency)
|
||||
|
||||
async def fire(index: int) -> None:
|
||||
async with semaphore:
|
||||
started = time.perf_counter()
|
||||
if case_name == "tts":
|
||||
response = await client.post(
|
||||
"/v1/tts-asr/tts",
|
||||
json={"text": f"第 {index} 条基准文本", "speaker": "Vivian", "format": "wav"},
|
||||
)
|
||||
else:
|
||||
response = await client.post(
|
||||
"/v1/tts-asr/asr",
|
||||
json={"audio_base64": audio_b64, "language": "zh-CN"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = _parse_sse_done(response.text)
|
||||
latencies.append((time.perf_counter() - started) * 1000.0)
|
||||
results.append(payload)
|
||||
|
||||
await asyncio.gather(*(fire(index) for index in range(request_count)))
|
||||
|
||||
queue_values = sorted(float(item.get("queue_ms", 0) or 0) for item in results)
|
||||
run_values = sorted(float(item.get("run_ms", 0) or 0) for item in results)
|
||||
total_values = sorted(float(item.get("total_ms", 0) or 0) for item in results)
|
||||
latency_values = sorted(latencies)
|
||||
elapsed_sum_ms = sum(latency_values)
|
||||
return {
|
||||
"case": case_name,
|
||||
"requests": request_count,
|
||||
"concurrency": concurrency,
|
||||
"avg_latency_ms": round(statistics.fmean(latency_values), 2),
|
||||
"p95_latency_ms": round(_percentile(latency_values, 0.95), 2),
|
||||
"avg_queue_ms": round(statistics.fmean(queue_values), 2),
|
||||
"p95_queue_ms": round(_percentile(queue_values, 0.95), 2),
|
||||
"avg_run_ms": round(statistics.fmean(run_values), 2),
|
||||
"p95_run_ms": round(_percentile(run_values, 0.95), 2),
|
||||
"avg_total_ms": round(statistics.fmean(total_values), 2),
|
||||
"p95_total_ms": round(_percentile(total_values, 0.95), 2),
|
||||
"throughput_rps_estimate": round((request_count * 1000.0) / max(latency_values[-1], elapsed_sum_ms / max(request_count, 1)), 2),
|
||||
}
|
||||
|
||||
|
||||
async def main_async(args) -> None:
|
||||
os.environ["JOB_BACKEND"] = "memory"
|
||||
os.environ["JOB_TTS_CONCURRENCY"] = str(args.tts_workers)
|
||||
os.environ["JOB_TTS_MAX_QUEUE"] = str(max(args.tts_requests, args.tts_workers))
|
||||
os.environ["JOB_ASR_CONCURRENCY"] = str(args.asr_workers)
|
||||
os.environ["JOB_ASR_MAX_QUEUE"] = str(max(args.asr_requests, args.asr_workers))
|
||||
reset_job_manager()
|
||||
|
||||
mock_client = await _build_mock_client(args.tts_delay_ms, args.asr_delay_ms)
|
||||
tts_asr._httpx_client = mock_client
|
||||
try:
|
||||
audio_b64 = base64.b64encode(_wav_bytes(args.audio_duration_ms)).decode("utf-8")
|
||||
tts_stats = await _run_case("tts", args.tts_concurrency, args.tts_requests)
|
||||
asr_stats = await _run_case("asr", args.asr_concurrency, args.asr_requests, audio_b64=audio_b64)
|
||||
finally:
|
||||
await mock_client.aclose()
|
||||
tts_asr._httpx_client = None
|
||||
reset_job_manager()
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"benchmark_date": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"assumptions": {
|
||||
"upstream_tts_delay_ms": args.tts_delay_ms,
|
||||
"upstream_asr_delay_ms": args.asr_delay_ms,
|
||||
"job_backend": "memory",
|
||||
},
|
||||
"tts": tts_stats,
|
||||
"asr": asr_stats,
|
||||
"recommended_defaults": {
|
||||
"JOB_TTS_CONCURRENCY": args.tts_workers,
|
||||
"JOB_TTS_MAX_QUEUE": max(16, args.tts_workers * 4),
|
||||
"JOB_ASR_CONCURRENCY": args.asr_workers,
|
||||
"JOB_ASR_MAX_QUEUE": max(8, args.asr_workers * 4),
|
||||
"TTS_ASR_MAX_CONNECTIONS": max(24, (args.tts_workers + args.asr_workers) * 4),
|
||||
"TTS_ASR_MAX_KEEPALIVE_CONNECTIONS": max(12, (args.tts_workers + args.asr_workers) * 2),
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--tts-delay-ms", type=int, default=120)
|
||||
parser.add_argument("--asr-delay-ms", type=int, default=280)
|
||||
parser.add_argument("--tts-workers", type=int, default=4)
|
||||
parser.add_argument("--asr-workers", type=int, default=2)
|
||||
parser.add_argument("--tts-concurrency", type=int, default=8)
|
||||
parser.add_argument("--asr-concurrency", type=int, default=4)
|
||||
parser.add_argument("--tts-requests", type=int, default=32)
|
||||
parser.add_argument("--asr-requests", type=int, default=16)
|
||||
parser.add_argument("--audio-duration-ms", type=int, default=320)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main_async(parse_args()))
|
||||
@@ -1,188 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
快速验证脚本
|
||||
验证TTS/ASR模块修复是否正确应用
|
||||
|
||||
运行方式:
|
||||
python backend/tests/quick_verify.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 设置控制台编码
|
||||
if sys.platform == 'win32':
|
||||
import io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
|
||||
|
||||
# 确保可以导入backend模块
|
||||
script_path = Path(__file__).resolve()
|
||||
project_root = script_path.parent.parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
print(f"项目根目录: {project_root}")
|
||||
print(f"脚本路径: {script_path}")
|
||||
|
||||
|
||||
def check_file_exists(filepath: str, description: str) -> bool:
|
||||
"""检查文件是否存在"""
|
||||
full_path = project_root / filepath
|
||||
exists = full_path.exists()
|
||||
status = "[OK]" if exists else "[FAIL]"
|
||||
print(f"{status} {description}: {filepath} (完整路径: {full_path})")
|
||||
return exists
|
||||
|
||||
|
||||
def check_function_exists(module_name: str, function_name: str) -> bool:
|
||||
"""检查函数是否存在"""
|
||||
try:
|
||||
module = __import__(module_name, fromlist=[function_name])
|
||||
exists = hasattr(module, function_name)
|
||||
status = "[OK]" if exists else "[FAIL]"
|
||||
print(f"{status} 函数存在: {module_name}.{function_name}")
|
||||
return exists
|
||||
except Exception as e:
|
||||
print(f"[FAIL] 导入失败: {module_name} - {e}")
|
||||
return False
|
||||
|
||||
|
||||
def check_environment_variable(var_name: str, expected_default: str) -> bool:
|
||||
"""检查环境变量默认值"""
|
||||
try:
|
||||
# 清除可能存在的环境变量
|
||||
original_value = os.environ.get(var_name)
|
||||
if var_name in os.environ:
|
||||
del os.environ[var_name]
|
||||
|
||||
# 重新导入模块
|
||||
if 'backend.tts_asr' in sys.modules:
|
||||
del sys.modules['backend.tts_asr']
|
||||
|
||||
from backend.tts_asr import (
|
||||
TTS_ASR_DEVICE, TTS_ASR_MODEL_SIZE, TTS_ASR_QUANTIZE,
|
||||
TTS_ASR_OFFLINE_MODE, TTS_ASR_WARMUP, TTS_ASR_WARMUP_TIMEOUT,
|
||||
TTS_ASR_IDLE_TIMEOUT, TTS_ASR_MPS_MEMORY_LIMIT_MB
|
||||
)
|
||||
|
||||
var_map = {
|
||||
'TTS_ASR_DEVICE': TTS_ASR_DEVICE,
|
||||
'TTS_ASR_MODEL_SIZE': TTS_ASR_MODEL_SIZE,
|
||||
'TTS_ASR_QUANTIZE': TTS_ASR_QUANTIZE,
|
||||
'TTS_ASR_OFFLINE_MODE': TTS_ASR_OFFLINE_MODE,
|
||||
'TTS_ASR_WARMUP': TTS_ASR_WARMUP,
|
||||
'TTS_ASR_WARMUP_TIMEOUT': TTS_ASR_WARMUP_TIMEOUT,
|
||||
'TTS_ASR_IDLE_TIMEOUT': TTS_ASR_IDLE_TIMEOUT,
|
||||
'TTS_ASR_MPS_MEMORY_LIMIT_MB': TTS_ASR_MPS_MEMORY_LIMIT_MB,
|
||||
}
|
||||
|
||||
actual_value = var_map.get(var_name)
|
||||
if var_name == 'TTS_ASR_MODEL_SIZE':
|
||||
expected = 'auto'
|
||||
elif var_name == 'TTS_ASR_QUANTIZE':
|
||||
expected = False
|
||||
elif var_name == 'TTS_ASR_OFFLINE_MODE':
|
||||
expected = False
|
||||
elif var_name == 'TTS_ASR_WARMUP':
|
||||
expected = True
|
||||
elif var_name == 'TTS_ASR_WARMUP_TIMEOUT':
|
||||
expected = 120
|
||||
elif var_name == 'TTS_ASR_IDLE_TIMEOUT':
|
||||
expected = 0
|
||||
elif var_name == 'TTS_ASR_MPS_MEMORY_LIMIT_MB':
|
||||
expected = 8192
|
||||
else:
|
||||
expected = expected_default
|
||||
|
||||
matches = actual_value == expected
|
||||
status = "[OK]" if matches else "[FAIL]"
|
||||
print(f"{status} 环境变量默认值: {var_name} = {actual_value} (预期: {expected})")
|
||||
return matches
|
||||
|
||||
except Exception as e:
|
||||
print(f"[FAIL] 检查环境变量失败: {var_name} - {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
print("="*70)
|
||||
print("TTS/ASR模块快速验证")
|
||||
print("="*70)
|
||||
|
||||
checks = []
|
||||
|
||||
# 1. 检查文件
|
||||
print("\n[1] 文件检查")
|
||||
print("-"*70)
|
||||
checks.append(check_file_exists("backend/tts_asr.py", "主模块文件"))
|
||||
checks.append(check_file_exists("backend/tests/test_tts_asr_unit.py", "单元测试"))
|
||||
checks.append(check_file_exists("backend/tests/test_tts_asr_integration.py", "集成测试"))
|
||||
checks.append(check_file_exists("backend/tests/simulate_macos.py", "macOS模拟工具"))
|
||||
checks.append(check_file_exists("backend/tests/TESTING_GUIDE.md", "测试指南"))
|
||||
checks.append(check_file_exists("backend/TTS_ASR_MACOS_FIX.md", "修复文档"))
|
||||
|
||||
# 2. 检查核心函数
|
||||
print("\n[2] 核心函数检查")
|
||||
print("-"*70)
|
||||
checks.append(check_function_exists("backend.tts_asr", "_is_apple_silicon"))
|
||||
checks.append(check_function_exists("backend.tts_asr", "_detect_device_capabilities"))
|
||||
checks.append(check_function_exists("backend.tts_asr", "_get_recommended_model_size"))
|
||||
checks.append(check_function_exists("backend.tts_asr", "_validate_audio_data"))
|
||||
checks.append(check_function_exists("backend.tts_asr", "_resample_audio_robust"))
|
||||
checks.append(check_function_exists("backend.tts_asr", "_check_model_cached"))
|
||||
|
||||
# 3. 检查数据类
|
||||
print("\n[3] 数据类检查")
|
||||
print("-"*70)
|
||||
checks.append(check_function_exists("backend.tts_asr", "DeviceCapabilities"))
|
||||
checks.append(check_function_exists("backend.tts_asr", "ModelStatus"))
|
||||
|
||||
# 4. 检查环境变量
|
||||
print("\n[4] 环境变量默认值检查")
|
||||
print("-"*70)
|
||||
checks.append(check_environment_variable("TTS_ASR_DEVICE", "auto"))
|
||||
checks.append(check_environment_variable("TTS_ASR_MODEL_SIZE", "auto"))
|
||||
checks.append(check_environment_variable("TTS_ASR_QUANTIZE", "false"))
|
||||
checks.append(check_environment_variable("TTS_ASR_OFFLINE_MODE", "false"))
|
||||
|
||||
# 5. 检查常量
|
||||
print("\n[5] 常量检查")
|
||||
print("-"*70)
|
||||
try:
|
||||
from backend.tts_asr import WHISPER_MODEL_SIZES, APPLE_SILICON_DEFAULT_SIZE
|
||||
expected_sizes = ['tiny', 'base', 'small', 'medium', 'large', 'turbo']
|
||||
sizes_match = list(WHISPER_MODEL_SIZES.keys()) == expected_sizes
|
||||
status = "[OK]" if sizes_match else "[FAIL]"
|
||||
print(f"{status} WHISPER_MODEL_SIZES: {list(WHISPER_MODEL_SIZES.keys())}")
|
||||
checks.append(sizes_match)
|
||||
|
||||
size_match = APPLE_SILICON_DEFAULT_SIZE == 'small'
|
||||
status = "[OK]" if size_match else "[FAIL]"
|
||||
print(f"{status} APPLE_SILICON_DEFAULT_SIZE: {APPLE_SILICON_DEFAULT_SIZE}")
|
||||
checks.append(size_match)
|
||||
except Exception as e:
|
||||
print(f"[FAIL] 常量检查失败: {e}")
|
||||
checks.extend([False, False])
|
||||
|
||||
# 汇总结果
|
||||
print("\n" + "="*70)
|
||||
print("验证结果")
|
||||
print("="*70)
|
||||
|
||||
total = len(checks)
|
||||
passed = sum(checks)
|
||||
|
||||
print(f"通过: {passed}/{total}")
|
||||
|
||||
if all(checks):
|
||||
print("\n[SUCCESS] 所有验证通过!TTS/ASR模块修复已正确应用。")
|
||||
return 0
|
||||
else:
|
||||
print("\n[FAILED] 部分验证失败,请检查上述错误。")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
+47
-170
@@ -1,16 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
TTS/ASR测试运行器
|
||||
便捷地运行各种测试组合
|
||||
"""Speech test runner for the current API-based TTS/ASR stack."""
|
||||
|
||||
运行方式:
|
||||
python backend/tests/run_tests.py --help
|
||||
python backend/tests/run_tests.py unit
|
||||
python backend/tests/run_tests.py integration
|
||||
python backend/tests/run_tests.py simulate
|
||||
python backend/tests/run_tests.py all
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
@@ -19,186 +10,72 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run_command(cmd: list, cwd: str = None) -> int:
|
||||
"""运行命令并返回退出码"""
|
||||
def run_command(cmd: list[str], cwd: str | None = None) -> int:
|
||||
print(f"\n执行: {' '.join(cmd)}")
|
||||
print("-" * 70)
|
||||
result = subprocess.run(cmd, cwd=cwd)
|
||||
return result.returncode
|
||||
return subprocess.run(cmd, cwd=cwd).returncode
|
||||
|
||||
|
||||
def run_unit_tests(verbose: bool = False) -> int:
|
||||
"""运行单元测试"""
|
||||
print("\n" + "="*70)
|
||||
print("运行单元测试")
|
||||
print("="*70)
|
||||
|
||||
cmd = ['pytest', 'backend/tests/test_tts_asr_unit.py']
|
||||
cmd = ["pytest", "backend/tests/test_tts_asr.py"]
|
||||
if verbose:
|
||||
cmd.append('-v')
|
||||
|
||||
cmd.append("-v")
|
||||
return run_command(cmd)
|
||||
|
||||
|
||||
def run_integration_tests(test_type: str = None, url: str = None, key: str = None) -> int:
|
||||
"""运行集成测试"""
|
||||
print("\n" + "="*70)
|
||||
print("运行集成测试")
|
||||
print("="*70)
|
||||
|
||||
cmd = ['python', 'backend/tests/test_tts_asr_integration.py']
|
||||
|
||||
if test_type:
|
||||
cmd.extend(['--test', test_type])
|
||||
|
||||
if url:
|
||||
cmd.extend(['--url', url])
|
||||
|
||||
if key:
|
||||
cmd.extend(['--key', key])
|
||||
|
||||
def run_benchmark(extra_args: list[str] | None = None) -> int:
|
||||
cmd = ["python", "backend/tests/benchmark_tts_asr.py"]
|
||||
if extra_args:
|
||||
cmd.extend(extra_args)
|
||||
return run_command(cmd)
|
||||
|
||||
|
||||
def run_simulation(test_type: str = None) -> int:
|
||||
"""运行macOS模拟测试"""
|
||||
print("\n" + "="*70)
|
||||
print("运行macOS环境模拟测试")
|
||||
print("="*70)
|
||||
|
||||
if test_type == 'full':
|
||||
cmd = ['python', 'backend/tests/simulate_macos.py', '--full-simulation']
|
||||
elif test_type:
|
||||
cmd = ['python', 'backend/tests/simulate_macos.py', '--test', test_type]
|
||||
else:
|
||||
cmd = ['python', 'backend/tests/simulate_macos.py', '--full-simulation']
|
||||
|
||||
return run_command(cmd)
|
||||
def run_all(verbose: bool = False) -> int:
|
||||
results = [
|
||||
("单元测试", run_unit_tests(verbose=verbose)),
|
||||
("基准测试", run_benchmark()),
|
||||
]
|
||||
|
||||
|
||||
def run_all_tests(url: str = None, key: str = None) -> int:
|
||||
"""运行所有测试"""
|
||||
print("\n" + "="*70)
|
||||
print("运行完整测试套件")
|
||||
print("="*70)
|
||||
|
||||
results = []
|
||||
|
||||
# 1. 单元测试
|
||||
print("\n[1/3] 单元测试")
|
||||
results.append(("单元测试", run_unit_tests(verbose=True)))
|
||||
|
||||
# 2. macOS模拟测试
|
||||
print("\n[2/3] macOS模拟测试")
|
||||
results.append(("macOS模拟", run_simulation(test_type='full')))
|
||||
|
||||
# 3. 集成测试(如果服务可用)
|
||||
print("\n[3/3] 集成测试")
|
||||
print("注意: 集成测试需要后端服务运行中")
|
||||
response = input("是否继续运行集成测试? [y/N]: ")
|
||||
|
||||
if response.lower() == 'y':
|
||||
results.append(("集成测试", run_integration_tests(url=url, key=key)))
|
||||
else:
|
||||
print("跳过集成测试")
|
||||
results.append(("集成测试", 0))
|
||||
|
||||
# 汇总结果
|
||||
print("\n" + "="*70)
|
||||
print("\n" + "=" * 70)
|
||||
print("测试结果汇总")
|
||||
print("="*70)
|
||||
|
||||
total_passed = 0
|
||||
print("=" * 70)
|
||||
passed = 0
|
||||
for name, code in results:
|
||||
status = "✓ 通过" if code == 0 else "✗ 失败"
|
||||
print(f"{name}: {status}")
|
||||
if code == 0:
|
||||
total_passed += 1
|
||||
|
||||
print("\n" + "-"*70)
|
||||
print(f"总计: {total_passed}/{len(results)} 测试套件通过")
|
||||
print("="*70)
|
||||
|
||||
return 0 if all(code == 0 for _, code in results) else 1
|
||||
ok = code == 0
|
||||
passed += int(ok)
|
||||
print(f"{name}: {'✓ 通过' if ok else '✗ 失败'}")
|
||||
print("-" * 70)
|
||||
print(f"总计: {passed}/{len(results)} 通过")
|
||||
return 0 if passed == len(results) else 1
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='TTS/ASR测试运行器',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
示例:
|
||||
# 运行单元测试
|
||||
python backend/tests/run_tests.py unit
|
||||
|
||||
# 运行集成测试
|
||||
python backend/tests/run_tests.py integration
|
||||
|
||||
# 运行macOS模拟测试
|
||||
python backend/tests/run_tests.py simulate
|
||||
|
||||
# 运行所有测试
|
||||
python backend/tests/run_tests.py all
|
||||
|
||||
# 运行特定集成测试
|
||||
python backend/tests/run_tests.py integration --test config
|
||||
|
||||
# 运行特定模拟测试
|
||||
python backend/tests/run_tests.py simulate --test device
|
||||
"""
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest='command', help='测试类型')
|
||||
|
||||
# 单元测试
|
||||
unit_parser = subparsers.add_parser('unit', help='运行单元测试')
|
||||
unit_parser.add_argument('-v', '--verbose', action='store_true', help='详细输出')
|
||||
|
||||
# 集成测试
|
||||
integration_parser = subparsers.add_parser('integration', help='运行集成测试')
|
||||
integration_parser.add_argument('--test', choices=[
|
||||
'config', 'status', 'warmup', 'tts', 'asr', 'perf'
|
||||
], help='运行特定测试')
|
||||
integration_parser.add_argument('--url', default='http://localhost:8001', help='API URL')
|
||||
integration_parser.add_argument('--key', default='your-secret-key-here', help='API密钥')
|
||||
|
||||
# macOS模拟测试
|
||||
simulate_parser = subparsers.add_parser('simulate', help='运行macOS模拟测试')
|
||||
simulate_parser.add_argument('--test', choices=[
|
||||
'device', 'memory', 'model', 'audio', 'env', 'full'
|
||||
], help='运行特定测试')
|
||||
|
||||
# 所有测试
|
||||
all_parser = subparsers.add_parser('all', help='运行所有测试')
|
||||
all_parser.add_argument('--url', default='http://localhost:8001', help='API URL')
|
||||
all_parser.add_argument('--key', default='your-secret-key-here', help='API密钥')
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="当前 API 化 TTS/ASR 测试运行器")
|
||||
subparsers = parser.add_subparsers(dest="command", help="测试类型")
|
||||
|
||||
unit_parser = subparsers.add_parser("unit", help="运行当前 TTS/ASR 单元测试")
|
||||
unit_parser.add_argument("-v", "--verbose", action="store_true", help="详细输出")
|
||||
|
||||
benchmark_parser = subparsers.add_parser("benchmark", help="运行当前 TTS/ASR benchmark")
|
||||
benchmark_parser.add_argument("benchmark_args", nargs="*", help="透传给 benchmark_tts_asr.py")
|
||||
|
||||
all_parser = subparsers.add_parser("all", help="运行当前 TTS/ASR 单元测试和 benchmark")
|
||||
all_parser.add_argument("-v", "--verbose", action="store_true", help="详细输出")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 确保在项目根目录
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
os.chdir(project_root)
|
||||
|
||||
if args.command == 'unit':
|
||||
|
||||
if args.command == "unit":
|
||||
return run_unit_tests(verbose=args.verbose)
|
||||
|
||||
elif args.command == 'integration':
|
||||
return run_integration_tests(
|
||||
test_type=args.test,
|
||||
url=args.url,
|
||||
key=args.key
|
||||
)
|
||||
|
||||
elif args.command == 'simulate':
|
||||
return run_simulation(test_type=args.test)
|
||||
|
||||
elif args.command == 'all':
|
||||
return run_all_tests(url=args.url, key=args.key)
|
||||
|
||||
else:
|
||||
parser.print_help()
|
||||
return 0
|
||||
if args.command == "benchmark":
|
||||
return run_benchmark(extra_args=args.benchmark_args)
|
||||
if args.command == "all":
|
||||
return run_all(verbose=args.verbose)
|
||||
|
||||
parser.print_help()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
||||
@@ -1,504 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
macOS环境模拟测试工具
|
||||
在非macOS环境下模拟Apple Silicon环境进行测试
|
||||
|
||||
运行方式:
|
||||
python backend/tests/simulate_macos.py --help
|
||||
python backend/tests/simulate_macos.py --device mps
|
||||
python backend/tests/simulate_macos.py --apple-silicon
|
||||
python backend/tests/simulate_macos.py --full-simulation
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
import numpy as np
|
||||
|
||||
|
||||
class MacOSSimulator:
|
||||
"""macOS环境模拟器"""
|
||||
|
||||
def __init__(self):
|
||||
self.original_platform_system = platform.system
|
||||
self.original_platform_machine = platform.machine
|
||||
self.patches = []
|
||||
|
||||
def simulate_apple_silicon(self):
|
||||
"""模拟Apple Silicon环境"""
|
||||
print("\n" + "="*70)
|
||||
print("模拟 Apple Silicon 环境")
|
||||
print("="*70)
|
||||
|
||||
# 模拟Darwin系统和arm64架构
|
||||
self.patches.append(patch('platform.system', return_value='Darwin'))
|
||||
self.patches.append(patch('platform.machine', return_value='arm64'))
|
||||
|
||||
for p in self.patches:
|
||||
p.start()
|
||||
|
||||
print("✓ 平台: Darwin (macOS)")
|
||||
print("✓ 架构: arm64 (Apple Silicon)")
|
||||
|
||||
def simulate_mps_device(self):
|
||||
"""模拟MPS设备可用"""
|
||||
print("\n" + "="*70)
|
||||
print("模拟 MPS 设备")
|
||||
print("="*70)
|
||||
|
||||
# 创建模拟的torch.backends.mps
|
||||
mock_mps = type('MockMPS', (), {
|
||||
'is_available': lambda: True,
|
||||
'is_built': lambda: True,
|
||||
'empty_cache': lambda: None
|
||||
})()
|
||||
|
||||
mock_backends = type('MockBackends', (), {
|
||||
'mps': mock_mps
|
||||
})()
|
||||
|
||||
# 模拟torch模块
|
||||
mock_torch = type('MockTorch', (), {
|
||||
'backends': mock_backends,
|
||||
'mps': mock_mps,
|
||||
'randn': lambda *args, **kwargs: np.random.randn(*args),
|
||||
'mm': lambda a, b: np.dot(a, b),
|
||||
'empty_cache': lambda: None
|
||||
})()
|
||||
|
||||
self.patches.append(patch('torch', mock_torch))
|
||||
self.patches.append(patch('torch.backends.mps.is_available', return_value=True))
|
||||
self.patches.append(patch('torch.backends.mps.is_built', return_value=True))
|
||||
|
||||
for p in self.patches[-3:]:
|
||||
p.start()
|
||||
|
||||
print("✓ MPS 可用: True")
|
||||
print("✓ MPS 已编译: True")
|
||||
|
||||
def simulate_cuda_device(self):
|
||||
"""模拟CUDA设备可用"""
|
||||
print("\n" + "="*70)
|
||||
print("模拟 CUDA 设备")
|
||||
print("="*70)
|
||||
|
||||
mock_cuda = type('MockCUDA', (), {
|
||||
'is_available': lambda: True,
|
||||
'device_count': lambda: 1,
|
||||
'get_device_properties': lambda n: type('Props', (), {'total_memory': 8*1024*1024*1024})(),
|
||||
'empty_cache': lambda: None
|
||||
})()
|
||||
|
||||
self.patches.append(patch('torch.cuda', mock_cuda))
|
||||
self.patches.append(patch('torch.cuda.is_available', return_value=True))
|
||||
|
||||
for p in self.patches[-2:]:
|
||||
p.start()
|
||||
|
||||
print("✓ CUDA 可用: True")
|
||||
print("✓ GPU 数量: 1")
|
||||
print("✓ 显存: 8 GB")
|
||||
|
||||
def cleanup(self):
|
||||
"""清理所有补丁"""
|
||||
for p in self.patches:
|
||||
p.stop()
|
||||
self.patches.clear()
|
||||
print("\n✓ 已清理模拟环境")
|
||||
|
||||
|
||||
def test_device_detection_on_apple_silicon():
|
||||
"""测试Apple Silicon设备检测"""
|
||||
print("\n测试1: Apple Silicon 设备检测")
|
||||
print("-"*70)
|
||||
|
||||
simulator = MacOSSimulator()
|
||||
try:
|
||||
simulator.simulate_apple_silicon()
|
||||
simulator.simulate_mps_device()
|
||||
|
||||
# 设置环境变量
|
||||
os.environ['TTS_ASR_DEVICE'] = 'auto'
|
||||
os.environ['TTS_ASR_MODEL_SIZE'] = 'auto'
|
||||
|
||||
# 重新导入模块以应用模拟
|
||||
if 'backend.tts_asr' in sys.modules:
|
||||
del sys.modules['backend.tts_asr']
|
||||
|
||||
from backend.tts_asr import (
|
||||
_is_apple_silicon,
|
||||
_detect_device_capabilities,
|
||||
_get_recommended_model_size
|
||||
)
|
||||
|
||||
# 测试Apple Silicon检测
|
||||
assert _is_apple_silicon(), "应该检测到Apple Silicon"
|
||||
print("✓ Apple Silicon 检测: 通过")
|
||||
|
||||
# 测试设备能力检测
|
||||
caps = _detect_device_capabilities()
|
||||
print(f"✓ 设备: {caps.device}")
|
||||
print(f"✓ MPS 可用: {caps.mps_available}")
|
||||
print(f"✓ 推荐模型大小: {caps.recommended_model_size}")
|
||||
|
||||
# 测试模型大小推荐
|
||||
recommended_size = _get_recommended_model_size()
|
||||
assert recommended_size in ['small', 'tiny', 'base'], \
|
||||
f"Apple Silicon应推荐小模型,但推荐了 {recommended_size}"
|
||||
print(f"✓ 推荐模型大小: {recommended_size}")
|
||||
|
||||
print("\n✓ 测试通过")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n✗ 测试失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
finally:
|
||||
simulator.cleanup()
|
||||
|
||||
|
||||
def test_memory_management():
|
||||
"""测试内存管理"""
|
||||
print("\n测试2: 内存管理")
|
||||
print("-"*70)
|
||||
|
||||
simulator = MacOSSimulator()
|
||||
try:
|
||||
simulator.simulate_apple_silicon()
|
||||
simulator.simulate_mps_device()
|
||||
|
||||
# 模拟系统内存
|
||||
import psutil
|
||||
original_virtual_memory = psutil.virtual_memory
|
||||
|
||||
def mock_virtual_memory():
|
||||
mock_mem = type('MockMemory', (), {
|
||||
'total': 16 * 1024 * 1024 * 1024 # 16GB
|
||||
})()
|
||||
return mock_mem
|
||||
|
||||
self.patches.append(patch('psutil.virtual_memory', mock_virtual_memory))
|
||||
|
||||
from backend.tts_asr import _get_system_memory_mb, TTS_ASR_MPS_MEMORY_LIMIT_MB
|
||||
|
||||
mem_mb = _get_system_memory_mb()
|
||||
print(f"✓ 系统内存: {mem_mb} MB")
|
||||
|
||||
# 计算预期的MPS内存限制(60%)
|
||||
expected_limit = int(mem_mb * 0.6)
|
||||
print(f"✓ 预期MPS限制: {expected_limit} MB (60%)")
|
||||
print(f"✓ 配置MPS限制: {TTS_ASR_MPS_MEMORY_LIMIT_MB} MB")
|
||||
|
||||
print("\n✓ 测试通过")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n✗ 测试失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
finally:
|
||||
simulator.cleanup()
|
||||
|
||||
|
||||
def test_model_size_selection():
|
||||
"""测试模型大小选择"""
|
||||
print("\n测试3: 模型大小选择")
|
||||
print("-"*70)
|
||||
|
||||
test_cases = [
|
||||
('auto', 'Apple Silicon默认'),
|
||||
('tiny', '最小模型'),
|
||||
('small', '推荐模型'),
|
||||
('medium', '中等模型'),
|
||||
('large', '大模型'),
|
||||
('turbo', 'turbo模型'),
|
||||
]
|
||||
|
||||
from backend.tts_asr import WHISPER_MODEL_SIZES, _get_recommended_model_size
|
||||
|
||||
for size, desc in test_cases:
|
||||
os.environ['TTS_ASR_MODEL_SIZE'] = size
|
||||
|
||||
# 重新加载模块
|
||||
if 'backend.tts_asr' in sys.modules:
|
||||
del sys.modules['backend.tts_asr']
|
||||
|
||||
from backend.tts_asr import _get_recommended_model_size
|
||||
|
||||
if size == 'auto':
|
||||
# 自动选择
|
||||
recommended = _get_recommended_model_size()
|
||||
print(f"✓ {desc}: {recommended}")
|
||||
else:
|
||||
# 显式选择
|
||||
os.environ['TTS_ASR_MODEL_SIZE'] = size
|
||||
result = _get_recommended_model_size()
|
||||
assert result == size, f"应该返回 {size},但返回了 {result}"
|
||||
print(f"✓ {desc}: {size} -> {WHISPER_MODEL_SIZES[size]}")
|
||||
|
||||
print("\n✓ 测试通过")
|
||||
return True
|
||||
|
||||
|
||||
def test_audio_processing():
|
||||
"""测试音频处理"""
|
||||
print("\n测试4: 音频处理")
|
||||
print("-"*70)
|
||||
|
||||
from backend.tts_asr import (
|
||||
_validate_audio_data,
|
||||
_resample_audio_robust
|
||||
)
|
||||
|
||||
# 测试音频验证
|
||||
test_cases = [
|
||||
(b'', False, "空数据"),
|
||||
(b'short', False, "太短"),
|
||||
(b'RIFF' + b'\x00' * 40, True, "有效WAV头"),
|
||||
]
|
||||
|
||||
for data, expected, desc in test_cases:
|
||||
result = _validate_audio_data(data)
|
||||
assert result == expected, f"{desc}: 预期 {expected},得到 {result}"
|
||||
print(f"✓ 音频验证 ({desc}): {'通过' if result == expected else '失败'}")
|
||||
|
||||
# 测试重采样
|
||||
audio_16k = np.sin(np.linspace(0, 2*np.pi, 16000)).astype(np.float32)
|
||||
|
||||
# 16k -> 48k
|
||||
audio_48k = _resample_audio_robust(audio_16k, 16000, 48000)
|
||||
assert len(audio_48k) == 48000, f"48kHz音频长度错误: {len(audio_48k)}"
|
||||
print(f"✓ 重采样 (16k -> 48k): 长度 {len(audio_16k)} -> {len(audio_48k)}")
|
||||
|
||||
# 48k -> 16k
|
||||
audio_back = _resample_audio_robust(audio_48k, 48000, 16000)
|
||||
assert len(audio_back) == 16000, f"16kHz音频长度错误: {len(audio_back)}"
|
||||
print(f"✓ 重采样 (48k -> 16k): 长度 {len(audio_48k)} -> {len(audio_back)}")
|
||||
|
||||
print("\n✓ 测试通过")
|
||||
return True
|
||||
|
||||
|
||||
def test_environment_variables():
|
||||
"""测试环境变量"""
|
||||
print("\n测试5: 环境变量配置")
|
||||
print("-"*70)
|
||||
|
||||
# 清理环境变量
|
||||
env_vars = [
|
||||
'TTS_ASR_DEVICE', 'TTS_ASR_MODEL_SIZE', 'TTS_ASR_QUANTIZE',
|
||||
'TTS_ASR_OFFLINE_MODE', 'TTS_ASR_WARMUP', 'TTS_ASR_WARMUP_TIMEOUT',
|
||||
'TTS_ASR_IDLE_TIMEOUT', 'TTS_ASR_MPS_MEMORY_LIMIT_MB'
|
||||
]
|
||||
|
||||
original_values = {}
|
||||
for var in env_vars:
|
||||
original_values[var] = os.environ.get(var)
|
||||
if var in os.environ:
|
||||
del os.environ[var]
|
||||
|
||||
try:
|
||||
# 测试默认值
|
||||
from backend.tts_asr import (
|
||||
TTS_ASR_DEVICE, TTS_ASR_MODEL_SIZE, TTS_ASR_QUANTIZE,
|
||||
TTS_ASR_OFFLINE_MODE, TTS_ASR_WARMUP, TTS_ASR_WARMUP_TIMEOUT,
|
||||
TTS_ASR_IDLE_TIMEOUT, TTS_ASR_MPS_MEMORY_LIMIT_MB
|
||||
)
|
||||
|
||||
defaults = {
|
||||
'TTS_ASR_DEVICE': 'auto',
|
||||
'TTS_ASR_MODEL_SIZE': 'auto',
|
||||
'TTS_ASR_QUANTIZE': False,
|
||||
'TTS_ASR_OFFLINE_MODE': False,
|
||||
'TTS_ASR_WARMUP': True,
|
||||
'TTS_ASR_WARMUP_TIMEOUT': 120,
|
||||
'TTS_ASR_IDLE_TIMEOUT': 0,
|
||||
'TTS_ASR_MPS_MEMORY_LIMIT_MB': 8192,
|
||||
}
|
||||
|
||||
for var, expected in defaults.items():
|
||||
actual = locals()[var]
|
||||
assert actual == expected, f"{var}: 预期 {expected},得到 {actual}"
|
||||
print(f"✓ {var} = {actual}")
|
||||
|
||||
# 测试自定义值
|
||||
print("\n自定义配置测试:")
|
||||
os.environ['TTS_ASR_MODEL_SIZE'] = 'small'
|
||||
os.environ['TTS_ASR_QUANTIZE'] = 'true'
|
||||
os.environ['TTS_ASR_OFFLINE_MODE'] = 'true'
|
||||
os.environ['TTS_ASR_MPS_MEMORY_LIMIT_MB'] = '4096'
|
||||
|
||||
# 重新加载
|
||||
if 'backend.tts_asr' in sys.modules:
|
||||
del sys.modules['backend.tts_asr']
|
||||
|
||||
from backend.tts_asr import (
|
||||
TTS_ASR_MODEL_SIZE, TTS_ASR_QUANTIZE,
|
||||
TTS_ASR_OFFLINE_MODE, TTS_ASR_MPS_MEMORY_LIMIT_MB
|
||||
)
|
||||
|
||||
assert TTS_ASR_MODEL_SIZE == 'small'
|
||||
assert TTS_ASR_QUANTIZE == True
|
||||
assert TTS_ASR_OFFLINE_MODE == True
|
||||
assert TTS_ASR_MPS_MEMORY_LIMIT_MB == 4096
|
||||
|
||||
print(f"✓ TTS_ASR_MODEL_SIZE = {TTS_ASR_MODEL_SIZE}")
|
||||
print(f"✓ TTS_ASR_QUANTIZE = {TTS_ASR_QUANTIZE}")
|
||||
print(f"✓ TTS_ASR_OFFLINE_MODE = {TTS_ASR_OFFLINE_MODE}")
|
||||
print(f"✓ TTS_ASR_MPS_MEMORY_LIMIT_MB = {TTS_ASR_MPS_MEMORY_LIMIT_MB}")
|
||||
|
||||
print("\n✓ 测试通过")
|
||||
return True
|
||||
|
||||
finally:
|
||||
# 恢复原始值
|
||||
for var, value in original_values.items():
|
||||
if value is not None:
|
||||
os.environ[var] = value
|
||||
elif var in os.environ:
|
||||
del os.environ[var]
|
||||
|
||||
|
||||
def run_full_simulation():
|
||||
"""运行完整模拟测试"""
|
||||
print("\n" + "="*70)
|
||||
print("完整macOS环境模拟测试")
|
||||
print("="*70)
|
||||
|
||||
results = []
|
||||
|
||||
# 运行所有测试
|
||||
results.append(("设备检测", test_device_detection_on_apple_silicon()))
|
||||
results.append(("内存管理", test_memory_management()))
|
||||
results.append(("模型选择", test_model_size_selection()))
|
||||
results.append(("音频处理", test_audio_processing()))
|
||||
results.append(("环境变量", test_environment_variables()))
|
||||
|
||||
# 汇总结果
|
||||
print("\n" + "="*70)
|
||||
print("测试结果汇总")
|
||||
print("="*70)
|
||||
|
||||
for name, passed in results:
|
||||
status = "✓ 通过" if passed else "✗ 失败"
|
||||
print(f"{name}: {status}")
|
||||
|
||||
total = len(results)
|
||||
passed = sum(1 for _, p in results if p)
|
||||
|
||||
print("\n" + "-"*70)
|
||||
print(f"总计: {passed}/{total} 测试通过")
|
||||
print("="*70)
|
||||
|
||||
return all(p for _, p in results)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='macOS环境模拟测试工具',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
示例:
|
||||
# 运行完整模拟测试
|
||||
python backend/tests/simulate_macos.py --full-simulation
|
||||
|
||||
# 仅模拟Apple Silicon环境
|
||||
python backend/tests/simulate_macos.py --apple-silicon
|
||||
|
||||
# 仅模拟MPS设备
|
||||
python backend/tests/simulate_macos.py --device mps
|
||||
|
||||
# 仅模拟CUDA设备
|
||||
python backend/tests/simulate_macos.py --device cuda
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--full-simulation',
|
||||
action='store_true',
|
||||
help='运行完整模拟测试'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--apple-silicon',
|
||||
action='store_true',
|
||||
help='模拟Apple Silicon环境'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--device',
|
||||
choices=['mps', 'cuda'],
|
||||
help='模拟特定设备'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--test',
|
||||
choices=['device', 'memory', 'model', 'audio', 'env'],
|
||||
help='运行特定测试'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 确保可以导入backend模块
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
|
||||
if args.full_simulation:
|
||||
success = run_full_simulation()
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
if args.apple_silicon:
|
||||
simulator = MacOSSimulator()
|
||||
try:
|
||||
simulator.simulate_apple_silicon()
|
||||
simulator.simulate_mps_device()
|
||||
|
||||
print("\n环境已模拟,按Ctrl+D退出")
|
||||
print("在Python环境中可以使用:")
|
||||
print(" from backend.tts_asr import _is_apple_silicon")
|
||||
print(" print(_is_apple_silicon()) # 应该返回 True")
|
||||
|
||||
# 进入交互模式
|
||||
import code
|
||||
code.interact(local=locals())
|
||||
finally:
|
||||
simulator.cleanup()
|
||||
|
||||
if args.device:
|
||||
simulator = MacOSSimulator()
|
||||
try:
|
||||
if args.device == 'mps':
|
||||
simulator.simulate_mps_device()
|
||||
elif args.device == 'cuda':
|
||||
simulator.simulate_cuda_device()
|
||||
|
||||
print("\n设备已模拟")
|
||||
import code
|
||||
code.interact(local=locals())
|
||||
finally:
|
||||
simulator.cleanup()
|
||||
|
||||
if args.test:
|
||||
test_func = {
|
||||
'device': test_device_detection_on_apple_silicon,
|
||||
'memory': test_memory_management,
|
||||
'model': test_model_size_selection,
|
||||
'audio': test_audio_processing,
|
||||
'env': test_environment_variables,
|
||||
}
|
||||
|
||||
success = test_func[args.test]()
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
# 默认运行完整测试
|
||||
if not any([args.full_simulation, args.apple_silicon, args.device, args.test]):
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Regression tests for PostgreSQL audit persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(BACKEND_DIR) not in __import__("sys").path:
|
||||
__import__("sys").path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
import audit_store # noqa: E402
|
||||
from audit_store import PostgresAuditStore # noqa: E402
|
||||
|
||||
|
||||
class _RecordingCursor:
|
||||
def __init__(self) -> None:
|
||||
self.query = ""
|
||||
self.params = ()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def execute(self, query: str, params=()) -> None:
|
||||
self.query = query
|
||||
self.params = params or ()
|
||||
assert query.count("%s") == len(self.params)
|
||||
|
||||
|
||||
class _RecordingConnection:
|
||||
def __init__(self, cursor: _RecordingCursor) -> None:
|
||||
self._cursor = cursor
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def cursor(self) -> _RecordingCursor:
|
||||
return self._cursor
|
||||
|
||||
|
||||
def test_record_llm_call_keeps_columns_placeholders_and_params_aligned(monkeypatch):
|
||||
cursor = _RecordingCursor()
|
||||
monkeypatch.setattr(audit_store, "psycopg", object())
|
||||
store = PostgresAuditStore("postgresql://unused")
|
||||
store._initialized = True
|
||||
monkeypatch.setattr(store, "_connect", lambda: _RecordingConnection(cursor))
|
||||
|
||||
store.record_llm_call({
|
||||
"request_id": "request-1",
|
||||
"session_hash": "session",
|
||||
"ip_hash": "ip",
|
||||
"job_type": "ocr",
|
||||
"model": "vision-model",
|
||||
"estimated_input_tokens": 12,
|
||||
"max_output_tokens": 256,
|
||||
"estimated_cost": 0.01,
|
||||
"actual_output_chars": 42,
|
||||
"actual_cost": 0.02,
|
||||
"queue_ms": 10,
|
||||
"run_ms": 20,
|
||||
"total_ms": 30,
|
||||
"status": "completed",
|
||||
"error_code": "",
|
||||
"metadata": {"source": "test"},
|
||||
})
|
||||
|
||||
assert "INSERT INTO llm_call_audit" in cursor.query
|
||||
assert cursor.query.count("%s") == 16
|
||||
assert len(cursor.params) == 16
|
||||
@@ -90,6 +90,72 @@ def test_cancel_endpoint_cancels_running_task(monkeypatch):
|
||||
assert "event: cancelled" in response_box["body"]
|
||||
|
||||
|
||||
class FakeRedis:
|
||||
def __init__(self):
|
||||
self.acks = []
|
||||
|
||||
async def xack(self, *args):
|
||||
self.acks.append(args)
|
||||
|
||||
async def hincrby(self, key, field, amount):
|
||||
return 0
|
||||
|
||||
|
||||
class FakeManager:
|
||||
def __init__(self):
|
||||
self.redis = FakeRedis()
|
||||
self.statuses = {}
|
||||
|
||||
async def get_status(self, job_id):
|
||||
return self.statuses.get(job_id)
|
||||
|
||||
async def _set_state(self, job_id, state):
|
||||
self.statuses[job_id] = state
|
||||
|
||||
async def _metrics(self, job_type):
|
||||
return {"queued_count": 0, "running_count": 0}
|
||||
|
||||
async def _emit_event(self, job_id, event, data):
|
||||
self.statuses[job_id]["event"] = event
|
||||
|
||||
def _metrics_key(self, job_type):
|
||||
return f"metrics:{job_type}"
|
||||
|
||||
def _state_key(self, job_id):
|
||||
return f"state:{job_id}"
|
||||
|
||||
|
||||
async def _run_cancelled_after_handler(manager, job_type):
|
||||
worker = job_system.RedisWorker(manager)
|
||||
await worker._run_message(
|
||||
job_type,
|
||||
"queue",
|
||||
"group",
|
||||
"msg-1",
|
||||
{"job_id": "job-1"},
|
||||
asyncio.Semaphore(1),
|
||||
)
|
||||
|
||||
|
||||
def test_redis_worker_acks_when_handler_returns_cancelled_state():
|
||||
async def handler(payload, emit, is_cancelled):
|
||||
return {"ok": True}
|
||||
|
||||
async def coro():
|
||||
manager = FakeManager()
|
||||
manager.handlers = {"completion": handler}
|
||||
manager.statuses["job-1"] = {
|
||||
"request_id": "req-1",
|
||||
"type": "completion",
|
||||
"status": "running",
|
||||
"created_at": 1,
|
||||
}
|
||||
await _run_cancelled_after_handler(manager, "completion")
|
||||
assert manager.redis.acks == [("queue", "group", "msg-1")]
|
||||
|
||||
asyncio.run(coro())
|
||||
|
||||
|
||||
def test_cancel_not_found():
|
||||
with TestClient(main.app) as client:
|
||||
response = client.post(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import base64
|
||||
import asyncio
|
||||
import base64
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
@@ -209,6 +210,16 @@ def test_post_convert_unsupported_extension_returns_500():
|
||||
assert "仅支持" in resp.json()["error"]
|
||||
|
||||
|
||||
def test_post_convert_rejects_mismatched_content_suffix():
|
||||
content = base64.b64encode(b"%PDF-1.4\n%%EOF").decode()
|
||||
with TestClient(main.app) as client:
|
||||
resp = client.post("/v1/convert", headers=HEADERS, json={
|
||||
"file": content, "filename": "sample.txt",
|
||||
})
|
||||
assert resp.status_code == 500
|
||||
assert "仅支持" in resp.json()["error"]
|
||||
|
||||
|
||||
def test_docs_nodes_crud_round_trip():
|
||||
with TestClient(main.app) as client:
|
||||
folder_resp = client.post("/v1/docs/folders", headers=HEADERS, json={
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
"""Tests for the shared LLM speech adapter and speech job handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(BACKEND_DIR) not in __import__("sys").path:
|
||||
__import__("sys").path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
import job_handlers # noqa: E402
|
||||
import tts_asr # noqa: E402
|
||||
from audit_store import BaseAuditStore # noqa: E402
|
||||
|
||||
|
||||
def _wav_bytes(duration_ms: int = 100) -> bytes:
|
||||
sample_rate = 16000
|
||||
frames = max(1, int(sample_rate * duration_ms / 1000))
|
||||
data = b"".join((i % 32768).to_bytes(2, "little", signed=False) for i in range(frames))
|
||||
data_size = len(data)
|
||||
return (
|
||||
b"RIFF" + (36 + data_size).to_bytes(4, "little")
|
||||
+ b"WAVE"
|
||||
+ b"fmt " + (16).to_bytes(4, "little")
|
||||
+ (1).to_bytes(2, "little")
|
||||
+ (1).to_bytes(2, "little")
|
||||
+ sample_rate.to_bytes(4, "little")
|
||||
+ sample_rate.to_bytes(4, "little")
|
||||
+ (2).to_bytes(2, "little")
|
||||
+ (16).to_bytes(2, "little")
|
||||
+ b"data" + data_size.to_bytes(4, "little")
|
||||
+ data
|
||||
)
|
||||
|
||||
|
||||
def _run_async(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
class _CaptureAuditStore(BaseAuditStore):
|
||||
def __init__(self) -> None:
|
||||
self.llm_calls: list[dict] = []
|
||||
|
||||
def record_llm_call(self, payload: dict) -> None:
|
||||
self.llm_calls.append(payload)
|
||||
|
||||
|
||||
def test_tts_calls_shared_llm_speech_endpoint(monkeypatch):
|
||||
captured: dict[str, object] = {}
|
||||
monkeypatch.setattr(tts_asr, "LLM_API_KEY", "test-api-key")
|
||||
monkeypatch.setattr(tts_asr, "TTS_MODEL_ID", "Qwen3-TTS-12Hz-1.7B-VoiceDesign-8bit")
|
||||
|
||||
def transport(request: httpx.Request):
|
||||
captured["url"] = str(request.url)
|
||||
captured["headers"] = dict(request.headers)
|
||||
captured["json"] = json.loads(request.read().decode("utf-8"))
|
||||
return httpx.Response(200, content=b"speech-ok", headers={"x-request-id": "tts-req-1"})
|
||||
|
||||
async def run():
|
||||
client = httpx.AsyncClient(
|
||||
base_url="https://speech.example/v1",
|
||||
transport=httpx.MockTransport(transport),
|
||||
)
|
||||
try:
|
||||
tts_asr._httpx_client = client
|
||||
return await tts_asr.generate_tts_response(
|
||||
"你好世界",
|
||||
instruct="A warm Mandarin voice.",
|
||||
speaker="Vivian",
|
||||
output_format="wav",
|
||||
)
|
||||
finally:
|
||||
await client.aclose()
|
||||
tts_asr._httpx_client = None
|
||||
|
||||
result = _run_async(run())
|
||||
|
||||
assert result["format"] == "wav"
|
||||
assert result["speaker"] == "Vivian"
|
||||
assert result["model"] == "Qwen3-TTS-12Hz-1.7B-VoiceDesign-8bit"
|
||||
assert result["upstream_request_id"] == "tts-req-1"
|
||||
assert base64.b64decode(result["audio_base64"]) == b"speech-ok"
|
||||
assert captured["url"] == "https://speech.example/v1/audio/speech"
|
||||
assert captured["headers"]["authorization"] == "Bearer test-api-key"
|
||||
payload = captured["json"]
|
||||
assert payload["model"] == "Qwen3-TTS-12Hz-1.7B-VoiceDesign-8bit"
|
||||
assert payload["voice"] == "Vivian"
|
||||
assert payload["input"] == "你好世界"
|
||||
assert payload["instructions"] == "A warm Mandarin voice."
|
||||
assert "instruction" not in payload
|
||||
|
||||
|
||||
def test_tts_uses_nonempty_default_instructions(monkeypatch):
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def transport(request: httpx.Request):
|
||||
captured["json"] = json.loads(request.read().decode("utf-8"))
|
||||
return httpx.Response(200, content=b"speech-ok")
|
||||
|
||||
async def run():
|
||||
client = httpx.AsyncClient(
|
||||
base_url="https://speech.example/v1",
|
||||
transport=httpx.MockTransport(transport),
|
||||
)
|
||||
try:
|
||||
tts_asr._httpx_client = client
|
||||
return await tts_asr.generate_tts_response("你好世界")
|
||||
finally:
|
||||
await client.aclose()
|
||||
tts_asr._httpx_client = None
|
||||
|
||||
_run_async(run())
|
||||
|
||||
payload = captured["json"]
|
||||
assert payload["instructions"] == tts_asr.DEFAULT_TTS_INSTRUCTIONS
|
||||
assert payload["instructions"].strip()
|
||||
|
||||
|
||||
def test_asr_calls_shared_llm_transcriptions_endpoint(monkeypatch):
|
||||
captured: dict[str, object] = {}
|
||||
monkeypatch.setattr(tts_asr, "LLM_API_KEY", "test-api-key")
|
||||
monkeypatch.setattr(tts_asr, "ASR_MODEL_ID", "Qwen3-ASR-0.6B-8bit")
|
||||
|
||||
def transport(request: httpx.Request):
|
||||
captured["url"] = str(request.url)
|
||||
captured["headers"] = dict(request.headers)
|
||||
captured["content"] = request.read()
|
||||
return httpx.Response(200, json={"text": "hello world", "language": "zh"}, headers={"x-request-id": "asr-req-1"})
|
||||
|
||||
async def run():
|
||||
client = httpx.AsyncClient(
|
||||
base_url="https://speech.example/v1",
|
||||
transport=httpx.MockTransport(transport),
|
||||
)
|
||||
try:
|
||||
tts_asr._httpx_client = client
|
||||
return await tts_asr.generate_asr_response(_wav_bytes(), language="zh-CN")
|
||||
finally:
|
||||
await client.aclose()
|
||||
tts_asr._httpx_client = None
|
||||
|
||||
result = _run_async(run())
|
||||
|
||||
assert result["text"] == "hello world"
|
||||
assert result["language"] == "zh"
|
||||
assert result["model"] == "Qwen3-ASR-0.6B-8bit"
|
||||
assert result["upstream_request_id"] == "asr-req-1"
|
||||
assert captured["url"] == "https://speech.example/v1/audio/transcriptions"
|
||||
assert captured["headers"]["authorization"] == "Bearer test-api-key"
|
||||
content = captured["content"]
|
||||
assert b'name="model"' in content
|
||||
assert b"Qwen3-ASR-0.6B-8bit" in content
|
||||
assert b'name="language"' in content
|
||||
assert b"zh" in content
|
||||
|
||||
|
||||
def test_invalid_tts_text_returns_http_exception():
|
||||
with pytest.raises(tts_asr.HTTPException) as exc:
|
||||
_run_async(tts_asr._call_tts_api("", speaker="Vivian"))
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
def test_invalid_asr_audio_returns_http_exception():
|
||||
with pytest.raises(tts_asr.HTTPException) as exc:
|
||||
_run_async(tts_asr._call_asr_api(b"", language="zh-CN"))
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
def test_status_config_routes(monkeypatch):
|
||||
app = FastAPI()
|
||||
app.include_router(tts_asr.meta_router)
|
||||
monkeypatch.setattr(tts_asr, "LLM_BASE_URL", "https://speech.example/v1")
|
||||
monkeypatch.setattr(tts_asr, "LLM_API_KEY", "")
|
||||
monkeypatch.setattr(tts_asr, "TTS_MODEL_ID", "Qwen3-TTS-12Hz-1.7B-VoiceDesign-8bit")
|
||||
monkeypatch.setattr(tts_asr, "ASR_MODEL_ID", "Qwen3-ASR-0.6B-8bit")
|
||||
|
||||
with TestClient(app) as client:
|
||||
status = client.get("/status")
|
||||
config = client.get("/config")
|
||||
|
||||
assert status.status_code == 200
|
||||
assert config.status_code == 200
|
||||
assert status.json()["llm_url"] == "https://speech.example/v1"
|
||||
assert status.json()["tts_model"] == "Qwen3-TTS-12Hz-1.7B-VoiceDesign-8bit"
|
||||
assert status.json()["asr_model"] == "Qwen3-ASR-0.6B-8bit"
|
||||
assert status.json()["status"]["api_key_configured"] is False
|
||||
assert status.json()["status"]["max_connections"] == tts_asr.SPEECH_MAX_CONNECTIONS
|
||||
|
||||
|
||||
def test_tts_concurrent_requests_respect_connection_limit(monkeypatch):
|
||||
monkeypatch.setattr(tts_asr, "SPEECH_MAX_CONNECTIONS", 4)
|
||||
monkeypatch.setattr(tts_asr, "SPEECH_MAX_KEEPALIVE_CONNECTIONS", 1)
|
||||
|
||||
class LimitedClient:
|
||||
def __init__(self):
|
||||
self.semaphore = asyncio.Semaphore(4)
|
||||
self.active = 0
|
||||
self.max_active = 0
|
||||
|
||||
async def post(self, url: str, **kwargs):
|
||||
async with self.semaphore:
|
||||
self.active += 1
|
||||
self.max_active = max(self.max_active, self.active)
|
||||
await asyncio.sleep(0.01)
|
||||
self.active -= 1
|
||||
return httpx.Response(200, content=b"speech-ok", request=httpx.Request("POST", f"https://speech.example{url}"))
|
||||
|
||||
async def run():
|
||||
client = LimitedClient()
|
||||
|
||||
async def get_client():
|
||||
return client
|
||||
|
||||
monkeypatch.setattr(tts_asr, "_get_speech_client", get_client)
|
||||
await asyncio.gather(*(tts_asr.generate_tts_response(f"文本 {index}") for index in range(20)))
|
||||
return client
|
||||
|
||||
client = _run_async(run())
|
||||
assert client.max_active <= 4
|
||||
|
||||
|
||||
def test_tts_asr_handlers_record_audit(monkeypatch):
|
||||
audit_store = _CaptureAuditStore()
|
||||
|
||||
async def fake_tts(*args, **kwargs):
|
||||
return {
|
||||
"audio_base64": base64.b64encode(b"ok").decode("utf-8"),
|
||||
"format": "wav",
|
||||
"duration_ms": 1200,
|
||||
"audio_bytes": 2,
|
||||
"text_chars": 2,
|
||||
"speaker": "Vivian",
|
||||
"model": "Qwen3-TTS-12Hz-1.7B-VoiceDesign-8bit",
|
||||
"request_ms": 45,
|
||||
"upstream_request_id": "tts-upstream",
|
||||
}
|
||||
|
||||
async def fake_asr(*args, **kwargs):
|
||||
return {
|
||||
"text": "hello world",
|
||||
"language": "zh",
|
||||
"audio_bytes": len(_wav_bytes()),
|
||||
"model": "Qwen3-ASR-0.6B-8bit",
|
||||
"request_ms": 80,
|
||||
"upstream_request_id": "asr-upstream",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(job_handlers, "generate_tts_response", fake_tts)
|
||||
monkeypatch.setattr(job_handlers, "generate_asr_response", fake_asr)
|
||||
monkeypatch.setattr(job_handlers, "get_audit_store", lambda *_args, **_kwargs: audit_store)
|
||||
|
||||
base_payload = {
|
||||
"request_id": "req-1",
|
||||
"risk": {
|
||||
"request_id": "req-1",
|
||||
"session_hash": "session",
|
||||
"ip_hash": "ip",
|
||||
"estimated_input_tokens": 12,
|
||||
"estimated_cost": 0.0,
|
||||
"policy": {
|
||||
"job_type": "tts",
|
||||
"model": "Qwen3-TTS-12Hz-1.7B-VoiceDesign-8bit",
|
||||
"profile": "speech_tts",
|
||||
"max_output_tokens": 0,
|
||||
},
|
||||
},
|
||||
"job_context": {
|
||||
"created_at": 1000,
|
||||
"started_at": 1200,
|
||||
"queue_ms": 200,
|
||||
},
|
||||
}
|
||||
|
||||
async def run():
|
||||
events = []
|
||||
|
||||
async def emit(event: str, data: dict):
|
||||
events.append((event, data))
|
||||
|
||||
tts_payload = {
|
||||
**base_payload,
|
||||
"text": "你好",
|
||||
"speaker": "Vivian",
|
||||
"format": "wav",
|
||||
}
|
||||
await job_handlers.tts_handler(tts_payload, emit, lambda: False)
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as handle:
|
||||
handle.write(_wav_bytes())
|
||||
audio_path = handle.name
|
||||
|
||||
try:
|
||||
asr_payload = {
|
||||
**base_payload,
|
||||
"risk": {
|
||||
**base_payload["risk"],
|
||||
"policy": {
|
||||
"job_type": "asr",
|
||||
"model": "Qwen3-ASR-0.6B-8bit",
|
||||
"profile": "speech_asr",
|
||||
"max_output_tokens": 0,
|
||||
},
|
||||
},
|
||||
"input_path": audio_path,
|
||||
"language": "zh-CN",
|
||||
}
|
||||
await job_handlers.asr_handler(asr_payload, emit, lambda: False)
|
||||
finally:
|
||||
job_handlers._safe_unlink(audio_path)
|
||||
|
||||
return events
|
||||
|
||||
events = _run_async(run())
|
||||
|
||||
assert any(event == "result" for event, _data in events)
|
||||
assert len(audit_store.llm_calls) == 2
|
||||
tts_audit = audit_store.llm_calls[0]
|
||||
asr_audit = audit_store.llm_calls[1]
|
||||
assert tts_audit["job_type"] == "tts"
|
||||
assert tts_audit["queue_ms"] == 200
|
||||
assert tts_audit["metadata"]["duration_ms"] == 1200
|
||||
assert tts_audit["metadata"]["upstream_request_id"] == "tts-upstream"
|
||||
assert asr_audit["job_type"] == "asr"
|
||||
assert asr_audit["metadata"]["language"] == "zh"
|
||||
assert asr_audit["metadata"]["upstream_request_id"] == "asr-upstream"
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import importlib
|
||||
import socket
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
@@ -44,12 +45,26 @@ def _payload():
|
||||
}
|
||||
|
||||
|
||||
def test_is_blocked_public_url():
|
||||
def test_is_blocked_public_url(monkeypatch):
|
||||
def fake_getaddrinfo(host, port, type=0, flags=0): # noqa: ARG001
|
||||
del host, flags
|
||||
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", port, 0, 0))]
|
||||
|
||||
monkeypatch.setattr(job_handlers.socket, "getaddrinfo", fake_getaddrinfo)
|
||||
assert job_handlers._is_blocked_public_url("http://127.0.0.1/test") is True
|
||||
assert job_handlers._is_blocked_public_url("file:///tmp/test") is True
|
||||
assert job_handlers._is_blocked_public_url("https://example.com/docs") is False
|
||||
|
||||
|
||||
def test_is_blocked_public_url_resolves_private_hostname(monkeypatch):
|
||||
def fake_getaddrinfo(host, port, type=0, flags=0): # noqa: ARG001
|
||||
del host, flags
|
||||
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", port, 0, 0))]
|
||||
|
||||
monkeypatch.setattr(job_handlers.socket, "getaddrinfo", fake_getaddrinfo)
|
||||
assert job_handlers._is_blocked_public_url("https://private.example.com/docs") is True
|
||||
|
||||
|
||||
def test_web_search_route_returns_done(monkeypatch):
|
||||
async def fake_call_ollama(prompt, system_prompt=None, tag="", **kwargs): # noqa: ARG001
|
||||
if tag.endswith("-webq"):
|
||||
|
||||
+297
-254
@@ -1,174 +1,133 @@
|
||||
"""OpenAI-compatible TTS/ASR adapter bound to the shared LLM API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from typing import Optional
|
||||
|
||||
os.environ.setdefault("HF_ENDPOINT", "https://hf-mirror.com")
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import numpy as np # type: ignore
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.debug("numpy import failed: %s", exc)
|
||||
np = None # type: ignore
|
||||
|
||||
try:
|
||||
import torch # type: ignore
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.debug("torch import failed: %s", exc)
|
||||
torch = None # type: ignore
|
||||
def _int_env(name: str, default: int) -> int:
|
||||
try:
|
||||
return max(1, int(os.getenv(name, str(default))))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
try:
|
||||
from qwen_tts import Qwen3TTSModel # type: ignore
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.debug("qwen_tts import failed: %s", exc)
|
||||
Qwen3TTSModel = None # type: ignore
|
||||
|
||||
try:
|
||||
from faster_whisper import WhisperModel # type: ignore
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.debug("faster_whisper import failed: %s", exc)
|
||||
WhisperModel = None # type: ignore
|
||||
|
||||
try:
|
||||
from modelscope import snapshot_download # type: ignore
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.debug("modelscope import failed: %s", exc)
|
||||
snapshot_download = None # type: ignore
|
||||
|
||||
meta_router = APIRouter()
|
||||
generation_router = APIRouter()
|
||||
|
||||
MODEL_ID_HF = "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign"
|
||||
MODEL_ID_MS = "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign"
|
||||
ASR_MODEL_ID = os.getenv("ASR_MODEL_ID", "small")
|
||||
ASR_COMPUTE_TYPE = os.getenv("ASR_COMPUTE_TYPE", "int8")
|
||||
LLM_BASE_URL = (os.getenv("LLM_BASE_URL", "https://api.openai.com/v1/") or "").strip().rstrip("/")
|
||||
LLM_API_KEY = (os.getenv("LLM_API_KEY", "") or "").strip()
|
||||
|
||||
_tts_model: Optional["Qwen3TTSModel"] = None
|
||||
_asr_model: Optional["WhisperModel"] = None
|
||||
DEFAULT_TTS_MODEL_ID = "Qwen3-TTS-12Hz-1.7B-VoiceDesign-8bit"
|
||||
DEFAULT_ASR_MODEL_ID = "Qwen3-ASR-0.6B-8bit"
|
||||
DEFAULT_TTS_INSTRUCTIONS = (
|
||||
os.getenv("TTS_DEFAULT_INSTRUCTIONS", "A clear, natural voice speaking Mandarin Chinese.")
|
||||
or "A clear, natural voice speaking Mandarin Chinese."
|
||||
).strip()
|
||||
|
||||
TTS_MODEL_ID = (os.getenv("TTS_MODEL_ID", DEFAULT_TTS_MODEL_ID) or DEFAULT_TTS_MODEL_ID).strip()
|
||||
ASR_MODEL_ID = (os.getenv("ASR_MODEL_ID", DEFAULT_ASR_MODEL_ID) or DEFAULT_ASR_MODEL_ID).strip()
|
||||
|
||||
TTS_MAX_TEXT_CHARS = _int_env("TTS_ASR_MAX_TEXT_CHARS", 4096)
|
||||
ASR_MAX_AUDIO_BYTES = _int_env("ASR_MAX_AUDIO_BYTES", 100 * 1024 * 1024)
|
||||
TTS_TIMEOUT_SECONDS = _int_env("TTS_ASR_TTS_TIMEOUT_SECONDS", 180)
|
||||
ASR_TIMEOUT_SECONDS = _int_env("TTS_ASR_ASR_TIMEOUT_SECONDS", 300)
|
||||
HEALTHCHECK_TIMEOUT_SECONDS = _int_env("TTS_ASR_HEALTHCHECK_TIMEOUT_SECONDS", 5)
|
||||
SPEECH_MAX_CONNECTIONS = _int_env("TTS_ASR_MAX_CONNECTIONS", 16)
|
||||
SPEECH_MAX_KEEPALIVE_CONNECTIONS = _int_env("TTS_ASR_MAX_KEEPALIVE_CONNECTIONS", 8)
|
||||
|
||||
_httpx_client: Optional[httpx.AsyncClient] = None
|
||||
_httpx_client_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def _get_device_map() -> str:
|
||||
if torch is None:
|
||||
return "cpu"
|
||||
if torch.cuda.is_available():
|
||||
return "cuda"
|
||||
try:
|
||||
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
return "mps"
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.debug("MPS check failed: %s", exc)
|
||||
return "cpu"
|
||||
|
||||
|
||||
def _download_tts_model_from_modelscope() -> Optional[str]:
|
||||
if snapshot_download is None:
|
||||
def _read_uint16(data: bytes, offset: int) -> Optional[int]:
|
||||
if len(data) < offset + 2:
|
||||
return None
|
||||
cache_dir = os.path.join(os.path.dirname(__file__), "models")
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
try:
|
||||
return snapshot_download(MODEL_ID_MS, cache_dir=cache_dir, revision="master")
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.warning("ModelScope TTS download failed: %s", exc)
|
||||
return int.from_bytes(data[offset : offset + 2], "little", signed=False)
|
||||
|
||||
|
||||
def _read_uint32(data: bytes, offset: int) -> Optional[int]:
|
||||
if len(data) < offset + 4:
|
||||
return None
|
||||
return int.from_bytes(data[offset : offset + 4], "little", signed=False)
|
||||
|
||||
|
||||
def _ensure_tts_model() -> "Qwen3TTSModel":
|
||||
global _tts_model
|
||||
if _tts_model is not None:
|
||||
return _tts_model
|
||||
if np is None or torch is None or Qwen3TTSModel is None:
|
||||
raise RuntimeError("TTS 依赖未安装完整")
|
||||
def _parse_wav_duration_ms(audio_bytes: bytes) -> int:
|
||||
if len(audio_bytes) < 44 or audio_bytes[:4] != b"RIFF" or audio_bytes[8:12] != b"WAVE":
|
||||
return 0
|
||||
|
||||
device_map = _get_device_map()
|
||||
dtype = torch.float16 if device_map != "cpu" else torch.float32
|
||||
data_size = 0
|
||||
byte_rate = 0
|
||||
offset = 12
|
||||
|
||||
model_path = _download_tts_model_from_modelscope()
|
||||
last_error = None
|
||||
while offset + 8 <= len(audio_bytes):
|
||||
chunk_id = audio_bytes[offset : offset + 4]
|
||||
chunk_size = _read_uint32(audio_bytes, offset + 4)
|
||||
if chunk_size is None:
|
||||
break
|
||||
chunk_start = offset + 8
|
||||
chunk_end = min(chunk_start + chunk_size, len(audio_bytes))
|
||||
|
||||
for candidate in [model_path, MODEL_ID_HF]:
|
||||
if not candidate:
|
||||
continue
|
||||
try:
|
||||
_tts_model = Qwen3TTSModel.from_pretrained( # type: ignore
|
||||
candidate,
|
||||
device_map=device_map,
|
||||
dtype=dtype,
|
||||
)
|
||||
return _tts_model
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
logger.warning("TTS model load failed from %s: %s", candidate, exc)
|
||||
if chunk_id == b"fmt ":
|
||||
audio_format = _read_uint16(audio_bytes, chunk_start)
|
||||
channels = _read_uint16(audio_bytes, chunk_start + 2)
|
||||
sample_rate = _read_uint32(audio_bytes, chunk_start + 4)
|
||||
bits_per_sample = _read_uint16(audio_bytes, chunk_start + 14)
|
||||
if audio_format == 1 and channels and sample_rate and bits_per_sample:
|
||||
byte_rate = int(sample_rate * channels * bits_per_sample // 8)
|
||||
|
||||
raise RuntimeError(f"TTS 模型加载失败: {last_error}") from last_error
|
||||
if chunk_id == b"data":
|
||||
data_size = chunk_size
|
||||
offset = chunk_end + (chunk_end - chunk_start) % 2
|
||||
|
||||
if data_size and byte_rate:
|
||||
return max(0, int(data_size * 1000 / byte_rate))
|
||||
return 0
|
||||
|
||||
|
||||
def _ensure_asr_model() -> "WhisperModel":
|
||||
global _asr_model
|
||||
if _asr_model is not None:
|
||||
return _asr_model
|
||||
if WhisperModel is None:
|
||||
raise RuntimeError("faster-whisper 未安装")
|
||||
|
||||
device = "cuda" if _get_device_map() == "cuda" else "cpu"
|
||||
compute_type = ASR_COMPUTE_TYPE if device == "cpu" else "float16"
|
||||
_asr_model = WhisperModel(ASR_MODEL_ID, device=device, compute_type=compute_type)
|
||||
return _asr_model
|
||||
def _duration_from_audio_bytes(audio_bytes: bytes) -> int:
|
||||
return _parse_wav_duration_ms(audio_bytes)
|
||||
|
||||
|
||||
async def _warmup_tts():
|
||||
await asyncio.to_thread(_ensure_tts_model)
|
||||
def _audio_bytes_to_base64(audio_bytes: bytes) -> str:
|
||||
return base64.b64encode(audio_bytes).decode("utf-8")
|
||||
|
||||
|
||||
async def _warmup_asr():
|
||||
await asyncio.to_thread(_ensure_asr_model)
|
||||
def _normalize_tts_text(text: str) -> str:
|
||||
value = (text or "").strip()
|
||||
if not value:
|
||||
raise HTTPException(status_code=400, detail="TTS 文本为空")
|
||||
if len(value) > TTS_MAX_TEXT_CHARS:
|
||||
raise HTTPException(status_code=400, detail=f"TTS 文本过长,超过限制 {TTS_MAX_TEXT_CHARS} 个字符")
|
||||
return value
|
||||
|
||||
|
||||
class TTSRequest(BaseModel):
|
||||
text: str
|
||||
instruct: str = ""
|
||||
speaker: str = "Vivian"
|
||||
format: str = "wav"
|
||||
def _normalize_output_format(output_format: str) -> str:
|
||||
value = (output_format or "wav").strip().lower()
|
||||
if value not in {"wav", "mp3"}:
|
||||
raise HTTPException(status_code=400, detail="不支持的 TTS 输出格式")
|
||||
return value
|
||||
|
||||
|
||||
class TTSResponse(BaseModel):
|
||||
audio_base64: str
|
||||
format: str
|
||||
duration_ms: int
|
||||
|
||||
|
||||
class ASRRequest(BaseModel):
|
||||
audio_base64: str
|
||||
language: Optional[str] = "zh-CN"
|
||||
|
||||
|
||||
class ASRResponse(BaseModel):
|
||||
text: str
|
||||
language: Optional[str] = None
|
||||
|
||||
|
||||
class ModelStatus(BaseModel):
|
||||
tts_loaded: bool
|
||||
asr_loaded: bool = False
|
||||
device: str
|
||||
|
||||
|
||||
def _normalize_language(language: Optional[str]) -> Optional[str]:
|
||||
def _normalize_asr_language(language: Optional[str]) -> Optional[str]:
|
||||
if not language:
|
||||
return None
|
||||
value = language.strip().lower()
|
||||
if value in {"auto", ""}:
|
||||
value = str(language).strip().lower()
|
||||
if value in {"", "auto"}:
|
||||
return None
|
||||
mapping = {
|
||||
"zh-cn": "zh",
|
||||
"zh-hans": "zh",
|
||||
"zh-tw": "zh",
|
||||
"en-us": "en",
|
||||
"ja-jp": "ja",
|
||||
"ko-kr": "ko",
|
||||
@@ -176,38 +135,138 @@ def _normalize_language(language: Optional[str]) -> Optional[str]:
|
||||
return mapping.get(value, value.split("-")[0])
|
||||
|
||||
|
||||
@meta_router.get("/status", response_model=ModelStatus)
|
||||
async def get_status():
|
||||
return ModelStatus(
|
||||
tts_loaded=_tts_model is not None,
|
||||
asr_loaded=_asr_model is not None,
|
||||
device=_get_device_map(),
|
||||
)
|
||||
def _speech_headers() -> dict[str, str]:
|
||||
headers = {"Accept": "*/*"}
|
||||
if LLM_API_KEY:
|
||||
headers["Authorization"] = f"Bearer {LLM_API_KEY}"
|
||||
headers["X-API-Key"] = LLM_API_KEY
|
||||
return headers
|
||||
|
||||
|
||||
@meta_router.get("/config")
|
||||
async def get_config():
|
||||
def _raise_http_error(response: httpx.Response, operation: str) -> None:
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
body = (exc.response.text or "").strip()[:1000]
|
||||
detail = f"{operation} 请求失败 HTTP {exc.response.status_code}"
|
||||
if body:
|
||||
detail = f"{detail}: {body}"
|
||||
raise HTTPException(status_code=exc.response.status_code, detail=detail) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=f"{operation} 请求失败: {exc}") from exc
|
||||
|
||||
|
||||
def _tts_timeout() -> httpx.Timeout:
|
||||
return httpx.Timeout(TTS_TIMEOUT_SECONDS, connect=5.0)
|
||||
|
||||
|
||||
def _asr_timeout() -> httpx.Timeout:
|
||||
return httpx.Timeout(ASR_TIMEOUT_SECONDS, connect=5.0)
|
||||
|
||||
|
||||
def _extract_upstream_request_id(response: httpx.Response) -> str:
|
||||
for header_name in ("x-request-id", "request-id", "openai-request-id"):
|
||||
value = (response.headers.get(header_name) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
async def _get_speech_client() -> httpx.AsyncClient:
|
||||
global _httpx_client
|
||||
|
||||
if _httpx_client is None or getattr(_httpx_client, "is_closed", False):
|
||||
limits = httpx.Limits(
|
||||
max_connections=SPEECH_MAX_CONNECTIONS,
|
||||
max_keepalive_connections=max(1, SPEECH_MAX_KEEPALIVE_CONNECTIONS),
|
||||
)
|
||||
async with _httpx_client_lock:
|
||||
if _httpx_client is None or getattr(_httpx_client, "is_closed", False):
|
||||
_httpx_client = httpx.AsyncClient(
|
||||
base_url=LLM_BASE_URL,
|
||||
timeout=_tts_timeout(),
|
||||
headers=_speech_headers(),
|
||||
follow_redirects=True,
|
||||
limits=limits,
|
||||
)
|
||||
return _httpx_client
|
||||
|
||||
|
||||
async def close_speech_client() -> None:
|
||||
global _httpx_client
|
||||
if _httpx_client is not None and not getattr(_httpx_client, "is_closed", False):
|
||||
await _httpx_client.aclose()
|
||||
_httpx_client = None
|
||||
|
||||
|
||||
async def _call_tts_api(text: str, instruct: str = "", speaker: str = "Vivian", output_format: str = "wav") -> dict[str, Any]:
|
||||
normalized_text = _normalize_tts_text(text)
|
||||
normalized_format = _normalize_output_format(output_format)
|
||||
client = await _get_speech_client()
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"model": TTS_MODEL_ID,
|
||||
"input": normalized_text,
|
||||
"response_format": normalized_format,
|
||||
"voice": speaker or "Vivian",
|
||||
}
|
||||
payload["instructions"] = (instruct or "").strip() or DEFAULT_TTS_INSTRUCTIONS
|
||||
|
||||
started_at = time.perf_counter()
|
||||
response = await client.post("audio/speech", json=payload, timeout=_tts_timeout(), headers=_speech_headers())
|
||||
elapsed_ms = int((time.perf_counter() - started_at) * 1000)
|
||||
_raise_http_error(response, "TTS")
|
||||
audio_bytes = response.content
|
||||
if not audio_bytes:
|
||||
raise HTTPException(status_code=502, detail="TTS API 返回音频为空")
|
||||
return {
|
||||
"model": {
|
||||
"tts": MODEL_ID_MS,
|
||||
"asr": ASR_MODEL_ID,
|
||||
},
|
||||
"device": _get_device_map(),
|
||||
"status": {
|
||||
"tts_loaded": _tts_model is not None,
|
||||
"asr_loaded": _asr_model is not None,
|
||||
}
|
||||
"audio_bytes": audio_bytes,
|
||||
"request_ms": elapsed_ms,
|
||||
"upstream_request_id": _extract_upstream_request_id(response),
|
||||
}
|
||||
|
||||
|
||||
@meta_router.post("/warmup")
|
||||
async def warmup_models():
|
||||
await _warmup_tts()
|
||||
await _warmup_asr()
|
||||
async def _call_asr_api(audio_bytes: bytes, language: Optional[str] = "zh-CN") -> dict[str, Any]:
|
||||
if not audio_bytes:
|
||||
raise HTTPException(status_code=400, detail="ASR 音频内容为空")
|
||||
if len(audio_bytes) > ASR_MAX_AUDIO_BYTES:
|
||||
raise HTTPException(status_code=400, detail=f"ASR 音频过大,超过限制 {ASR_MAX_AUDIO_BYTES} 字节")
|
||||
|
||||
normalized_language = _normalize_asr_language(language)
|
||||
client = await _get_speech_client()
|
||||
files = {"file": ("audio.wav", audio_bytes, "audio/wav")}
|
||||
data = {"model": ASR_MODEL_ID}
|
||||
if normalized_language:
|
||||
data["language"] = normalized_language
|
||||
|
||||
started_at = time.perf_counter()
|
||||
response = await client.post(
|
||||
"audio/transcriptions",
|
||||
files=files,
|
||||
data=data,
|
||||
timeout=_asr_timeout(),
|
||||
headers=_speech_headers(),
|
||||
)
|
||||
elapsed_ms = int((time.perf_counter() - started_at) * 1000)
|
||||
_raise_http_error(response, "ASR")
|
||||
try:
|
||||
result = response.json()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=502, detail="ASR API 返回非 JSON 数据") from exc
|
||||
|
||||
if not isinstance(result, dict):
|
||||
raise HTTPException(status_code=502, detail="ASR API 返回结构异常")
|
||||
|
||||
text = str(result.get("text", "") or "").strip()
|
||||
if not text:
|
||||
raise HTTPException(status_code=422, detail="ASR API 返回结果为空")
|
||||
|
||||
detected_language = result.get("language") or normalized_language or "auto"
|
||||
return {
|
||||
"tts_warmup": _tts_model is not None,
|
||||
"asr_warmup": _asr_model is not None,
|
||||
"device": _get_device_map(),
|
||||
"text": text,
|
||||
"language": str(detected_language),
|
||||
"request_ms": elapsed_ms,
|
||||
"upstream_request_id": _extract_upstream_request_id(response),
|
||||
}
|
||||
|
||||
|
||||
@@ -216,113 +275,97 @@ async def generate_tts_response(
|
||||
instruct: str = "",
|
||||
speaker: str = "Vivian",
|
||||
output_format: str = "wav",
|
||||
) -> TTSResponse:
|
||||
del speaker
|
||||
del output_format
|
||||
if np is None:
|
||||
raise HTTPException(status_code=501, detail="numpy 未安装,TTS 功能不可用")
|
||||
|
||||
try:
|
||||
model = _ensure_tts_model()
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc))
|
||||
|
||||
try:
|
||||
wavs, sample_rate = await asyncio.to_thread(
|
||||
model.generate_voice_design, # type: ignore
|
||||
text=text,
|
||||
language="Chinese",
|
||||
instruct=instruct or "",
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("TTS inference failed")
|
||||
raise HTTPException(status_code=500, detail=f"TTS 推理失败: {exc}")
|
||||
|
||||
wav_data = wavs[0] if isinstance(wavs, (list, tuple)) else wavs
|
||||
if hasattr(wav_data, "cpu"):
|
||||
wav_data = wav_data.cpu().numpy()
|
||||
wav_data = np.asarray(wav_data, dtype=np.float32)
|
||||
|
||||
tmp_path = None
|
||||
try:
|
||||
import soundfile as sf # type: ignore
|
||||
|
||||
fd, tmp_path = tempfile.mkstemp(suffix=".wav")
|
||||
os.close(fd)
|
||||
sf.write(tmp_path, wav_data, sample_rate)
|
||||
with open(tmp_path, "rb") as handle:
|
||||
audio_bytes = handle.read()
|
||||
except Exception as exc:
|
||||
logger.exception("TTS audio encode failed")
|
||||
raise HTTPException(status_code=500, detail=f"音频编码失败: {exc}")
|
||||
finally:
|
||||
if tmp_path and os.path.exists(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
|
||||
duration_ms = int(len(wav_data) / sample_rate * 1000) if sample_rate > 0 else 0
|
||||
return TTSResponse(
|
||||
audio_base64=base64.b64encode(audio_bytes).decode("utf-8"),
|
||||
format="wav",
|
||||
duration_ms=duration_ms,
|
||||
) -> dict[str, Any]:
|
||||
result = await _call_tts_api(
|
||||
text=text,
|
||||
instruct=instruct or "",
|
||||
speaker=speaker or "Vivian",
|
||||
output_format=output_format or "wav",
|
||||
)
|
||||
audio_bytes = bytes(result["audio_bytes"])
|
||||
return {
|
||||
"audio_base64": _audio_bytes_to_base64(audio_bytes),
|
||||
"format": _normalize_output_format(output_format or "wav"),
|
||||
"duration_ms": _duration_from_audio_bytes(audio_bytes),
|
||||
"audio_bytes": len(audio_bytes),
|
||||
"text_chars": len(_normalize_tts_text(text)),
|
||||
"speaker": speaker or "Vivian",
|
||||
"model": TTS_MODEL_ID,
|
||||
"request_ms": int(result.get("request_ms", 0) or 0),
|
||||
"upstream_request_id": str(result.get("upstream_request_id", "") or ""),
|
||||
}
|
||||
|
||||
|
||||
async def generate_asr_response(audio_bytes: bytes, language: Optional[str] = "zh-CN") -> ASRResponse:
|
||||
if not audio_bytes:
|
||||
raise HTTPException(status_code=400, detail="音频内容为空")
|
||||
|
||||
try:
|
||||
model = _ensure_asr_model()
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"ASR 模型加载失败: {exc}")
|
||||
|
||||
normalized_language = _normalize_language(language)
|
||||
tmp_path = None
|
||||
try:
|
||||
fd, tmp_path = tempfile.mkstemp(suffix=".wav")
|
||||
os.close(fd)
|
||||
with open(tmp_path, "wb") as handle:
|
||||
handle.write(audio_bytes)
|
||||
|
||||
segments, info = await asyncio.to_thread(
|
||||
model.transcribe,
|
||||
tmp_path,
|
||||
language=normalized_language,
|
||||
vad_filter=True,
|
||||
beam_size=5,
|
||||
)
|
||||
text = "".join(segment.text for segment in segments).strip()
|
||||
if not text:
|
||||
raise RuntimeError("ASR 返回结果为空")
|
||||
detected_language = getattr(info, "language", normalized_language or "unknown")
|
||||
return ASRResponse(text=text, language=str(detected_language))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("ASR inference failed")
|
||||
raise HTTPException(status_code=500, detail=f"ASR 推理失败: {exc}")
|
||||
finally:
|
||||
if tmp_path and os.path.exists(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
async def generate_asr_response(audio_bytes: bytes, language: Optional[str] = "zh-CN") -> dict[str, Any]:
|
||||
result = await _call_asr_api(bytes(audio_bytes or b""), language or "zh-CN")
|
||||
return {
|
||||
"text": str(result["text"]),
|
||||
"language": str(result["language"]),
|
||||
"audio_bytes": len(audio_bytes or b""),
|
||||
"model": ASR_MODEL_ID,
|
||||
"request_ms": int(result.get("request_ms", 0) or 0),
|
||||
"upstream_request_id": str(result.get("upstream_request_id", "") or ""),
|
||||
}
|
||||
|
||||
|
||||
@generation_router.post("/tts", response_model=TTSResponse)
|
||||
async def tts_endpoint(req: TTSRequest):
|
||||
return await generate_tts_response(
|
||||
text=req.text,
|
||||
instruct=req.instruct or "",
|
||||
speaker=req.speaker,
|
||||
output_format=req.format,
|
||||
)
|
||||
class TTSResponse(BaseModel):
|
||||
audio_base64: str = ""
|
||||
format: str = "wav"
|
||||
duration_ms: int = 0
|
||||
audio_bytes: int = 0
|
||||
text_chars: int = 0
|
||||
speaker: str = "Vivian"
|
||||
model: str = TTS_MODEL_ID
|
||||
request_ms: int = 0
|
||||
upstream_request_id: str = ""
|
||||
|
||||
|
||||
@generation_router.post("/asr", response_model=ASRResponse)
|
||||
async def asr_endpoint(req: ASRRequest):
|
||||
audio_bytes = base64.b64decode(req.audio_base64)
|
||||
return await generate_asr_response(audio_bytes, req.language if req.language else None)
|
||||
class ASRResponse(BaseModel):
|
||||
text: str = ""
|
||||
language: Optional[str] = None
|
||||
audio_bytes: int = 0
|
||||
model: str = ASR_MODEL_ID
|
||||
request_ms: int = 0
|
||||
upstream_request_id: str = ""
|
||||
|
||||
|
||||
def register_tts_asr_routes(app, include_generation_routes: bool = True):
|
||||
class ModelStatus(BaseModel):
|
||||
llm_url: str
|
||||
tts_model: str
|
||||
asr_model: str
|
||||
status: dict[str, Any]
|
||||
|
||||
|
||||
def _status_payload() -> dict[str, Any]:
|
||||
return {
|
||||
"llm_url": LLM_BASE_URL or "",
|
||||
"tts_model": TTS_MODEL_ID,
|
||||
"asr_model": ASR_MODEL_ID,
|
||||
"status": {
|
||||
"api_configured": bool(LLM_BASE_URL),
|
||||
"api_key_configured": bool(LLM_API_KEY),
|
||||
"tts_model": TTS_MODEL_ID,
|
||||
"asr_model": ASR_MODEL_ID,
|
||||
"tts_timeout_seconds": TTS_TIMEOUT_SECONDS,
|
||||
"asr_timeout_seconds": ASR_TIMEOUT_SECONDS,
|
||||
"healthcheck_timeout_seconds": HEALTHCHECK_TIMEOUT_SECONDS,
|
||||
"max_connections": SPEECH_MAX_CONNECTIONS,
|
||||
"keepalive_connections": max(1, SPEECH_MAX_KEEPALIVE_CONNECTIONS),
|
||||
"max_tts_text_chars": TTS_MAX_TEXT_CHARS,
|
||||
"max_asr_audio_bytes": ASR_MAX_AUDIO_BYTES,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@meta_router.get("/status", response_model=ModelStatus)
|
||||
async def get_status():
|
||||
return _status_payload()
|
||||
|
||||
|
||||
@meta_router.get("/config")
|
||||
async def get_config():
|
||||
return _status_payload()
|
||||
|
||||
|
||||
def register_tts_asr_routes(app) -> None:
|
||||
app.include_router(meta_router, prefix="/v1/tts-asr")
|
||||
if include_generation_routes:
|
||||
app.include_router(generation_router, prefix="/v1/tts-asr")
|
||||
|
||||
Reference in New Issue
Block a user