K100_AI单卡全离线部署同声传译系统

AI2周前发布 beixibaobao
12 0 0

一、引言

在全球化交流日益频繁的当下,同声传译已成为国际会议、跨国商务洽谈、学术交流等场景中不可或缺的沟通桥梁。然而,传统同声传译高度依赖专业译员的人力投入,成本高昂且难以规模化覆盖;而现有的云端AI同传方案则受制于网络带宽、数据隐私与延迟波动等问题,在涉密会议、偏远地区或网络受限环境中往往难以落地。如何在保证翻译质量的前提下,实现低延迟、高隐私、全离线的同声传译系统,成为当前AI语音技术领域亟待攻克的关键命题。

本文用K100_AI单卡在全离线环境里完成同声传译系统的部署,用到的ASR模型是Qwen3-ASR-1.7B,该模型作为语音识别旗舰模型,支持52种语言与方言的语种识别与语音识别,并原生支持流式与离线两种推理模式。用到的TTS模型是Qwen3-TTS-12Hz-1.7B-VoiceDesign,该模型创新性地支持通过自然语言描述直接设计音色——无需参考音频、无需预设说话人,即可生成符合场景需求的目标语种语音。用到的文本翻译模式是Qwen3.5-9B。

二、方案设计

系统采用ASR-翻译-TTS三阶段流水线架构,全部运行于单张海光K100_AI算力卡,实现全离线同声传译。语音识别选用Qwen3-ASR-1.7B,支持流式推理与多语种识别,保证低延迟;语音合成选用Qwen3-TTS-12Hz-1.7B-VoiceDesign,支持自然语言描述直接设计音色,无需参考音频。翻译模块采用Qwen3.5-9B模型,三者通过流水线并行调度。

运行时,音频经VAD切分后送入ASR流式识别,文本增量送入翻译模块,译文再实时驱动TTS合成目标语种语音。支持中英日韩法德等多语种切换,VoiceDesign允许用户动态调整语音风格参数。全部推理离线完成,数据不出本地,满足高安全等级部署需求,单卡即可支撑完整同传服务。

、实施方法及代码

3.1硬件环境

本方案的硬件平台为一台H3C服务器,配置如下:

组件

规格

GPU

8×海光DCU 64GB K100_AI

只需占用一张K100_AI显卡

3.2软件栈

本方案的软件栈基于Docker容器化技术构建,使用经过海光DCU适配的vLLM推理镜像:

镜像一(Qwen3-ASR-1.7B):
harbor.sourcefind.cn:5443/dcu/admin/base/custom:vllm0.15.1-ubuntu22.04-dtk26.04-0130-py3.10-20260220

该镜像基于vLLM 0.15.1推理框架、DTK 26.04,Python 3.10环境,单卡部署Qwen3-ASR-1.7B,200ms即可完成语音识别。

镜像二(Qwen3.5-9B):

42.228.13.241:5000/jenkins/model_test_env/vllm:0.21.0-ubuntu22.04-dtk2604-py3.10-20260702-0235

该镜像包含vLLM 0.21推理框架、DTK 26.04、Python 3.10环境,单卡部署Qwen3.5-9B(和ASR、TTS同张单卡部署),平均每秒生成31token。

镜像Qwen3-TTS-12Hz-1.7B-VoiceDesign):

harbor.sourcefind.cn:5443/dcu/admin/base/custom:vllm018-ubuntu22.04-dtk26.04-nemotron-20260422

该镜像基于vLLM0.18.1和vLLM-omni 0.18.0推理框架、DTK 26.04,Python 3.10环境,单卡部署Qwen3-TTS-12Hz-1.7B-VoiceDesign,平均语音合成延迟低于500ms。

软件项目:

1、Qwen3-ASR-1.7B环境部署参照https://developer.sourcefind.cn/codes/modelzoo/qwen3-asr_pytorch里面操作步骤,项目下载链接为:https://developer.sourcefind.cn/codes/modelzoo/qwen3-asr_pytorch/-/archive/main/qwen3-asr_pytorch-main.zip。

2、Qwen3-TTS-12Hz-1.7B-VoiceDesign环境部署参照https://developer.sourcefind.cn/codes/modelzoo/qwen3-tts_pytorch里面操作步骤,项目下载链接为:https://developer.sourcefind.cn/codes/modelzoo/qwen3-tts_pytorch/-/archive/master/qwen3-tts_pytorch-master.zip。

3、同声传译自编程序代码如下:

#!/usr/bin/env python3
"""
以太天枢 · 同声传译独立模块(TTS 流式/非流式无缝播放版)
后端:HTTP 获取 TTS PCM 块(流式或一次性)→ 封装为 WAV 片段 → WebSocket 推送
前端:Web Audio API 解码并精确调度,实现无缝连续播放
修改:流式 TTS 不再封装为 WAV,直接传输原始 PCM;前端同步创建 AudioBuffer。
新增:VAD 静音持续时间前端可调;TTS 播放时是否静音麦克风可选项。
"""
import eventlet
import sys
if 'gunicorn' not in sys.modules:
    eventlet.monkey_patch()
import json
import os
import io
import wave
import time
import base64
import struct
import threading
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor
import numpy as np
import requests
from flask import Flask, render_template_string, request
from flask_socketio import SocketIO, emit
from scipy import signal
HISTORY_FILE = "history.json"
history_lock = threading.Lock()
def load_server_history():
    if not os.path.exists(HISTORY_FILE):
        return []
    try:
        with open(HISTORY_FILE, 'r', encoding='utf-8') as f:
            return json.load(f)
    except Exception:
        return []
def append_server_history(record):
    with history_lock:
        history = load_server_history()
        history.append(record)
        # 保留最近 500 条
        if len(history) > 500:
            history = history[-500:]
        with open(HISTORY_FILE, 'w', encoding='utf-8') as f:
            json.dump(history, f, ensure_ascii=False, indent=2)
# ========== 配置处理 ==========
CONFIG_FILE = "config.json"
DEFAULT_CONFIG = {
    "asr_api_url": "http://192.168.222.65:8084/v1/audio/transcriptions",
    "asr_model": "Qwen3-ASR-1.7B",
    "asr_api_key": "PassWord@123456",
    "asr_sample_rate": 16000,
    "asr_frame_duration": 30,
    "asr_vad_mode": 1,
    "asr_silence_thresh": 15,
    "ai_api_url": "http://192.168.222.65:8085/v1/chat/completions",
    "ai_model": "Qwen3.5-9B",
    "ai_api_key": "PassWord@123456",
    "tts_api_url": "http://192.168.222.65:8086/v1/audio/speech",
    "tts_model": "Qwen3-TTS-12Hz-1.7B-VoiceDesign",
    "tts_api_key": "PassWord@123456",
    "tts_sample_rate": 24000,
    "tts_stream_mode": False,
    "vad_energy_threshold": 0.04,
    "vad_silence_duration": 0.5,        # 默认静音持续0.5秒切分
    "vad_max_speech_sec": 15,
    "noise_words": [
        "ok.", "Yeah.", "hmm", "uh", "um", "ah", "oh", "mhm",
        "嗯。", "啊", "哦"
    ]
}
def load_config():
    if os.path.exists(CONFIG_FILE):
        try:
            with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
                cfg = json.load(f)
            for k, v in DEFAULT_CONFIG.items():
                cfg.setdefault(k, v)
            return cfg
        except Exception:
            return DEFAULT_CONFIG.copy()
    else:
        return DEFAULT_CONFIG.copy()
config = load_config()
ASR_API_URL = config["asr_api_url"]
ASR_MODEL = config["asr_model"]
ASR_API_KEY = config["asr_api_key"]
ASR_SAMPLE_RATE = config["asr_sample_rate"]
ASR_FRAME_DURATION = config["asr_frame_duration"]
ASR_VAD_MODE = config["asr_vad_mode"]
ASR_SILENCE_THRESH = config["asr_silence_thresh"]
AI_API_URL = config["ai_api_url"]
AI_MODEL = config["ai_model"]
AI_API_KEY = config["ai_api_key"]
TTS_API_URL = config["tts_api_url"]
TTS_MODEL = config["tts_model"]
TTS_API_KEY = config["tts_api_key"]
TTS_SAMPLE_RATE = config.get("tts_sample_rate", 24000)
ENERGY_THRESHOLD = config.get("vad_energy_threshold", 0.04)
SILENCE_DURATION_SEC = config.get("vad_silence_duration", 0.5)   # 默认全局值,但会被每个会话覆盖
MAX_SPEECH_SEC = config.get("vad_max_speech_sec", 15)
NOISE_WORDS = set(config.get("noise_words", []))
executor = ThreadPoolExecutor(max_workers=10)
asr_session = requests.Session()
if ASR_API_KEY:
    asr_session.headers['Authorization'] = f'Bearer {ASR_API_KEY}'
app = Flask(__name__)
app.secret_key = os.urandom(24).hex()
socketio = SocketIO(app, async_mode='eventlet', cors_allowed_origins="*",
                    ping_timeout=60, ping_interval=25,
                    max_http_buffer_size=10 * 1024 * 1024)
# 会话结构新增 vad_silence_duration,用于动态调整 VAD 静音阈值
sessions = defaultdict(lambda: {
    'vad': {
        'voiced_frames': b'',
        'silence_duration': 0.0,
        'speaking': False,
        'speech_duration': 0.0,
        'energy_threshold': ENERGY_THRESHOLD
    },
    'language_pair': {
        'source': 'auto',
        'target': 'zh'
    },
    'enable_tts': False,
    'tts_stream_mode': config.get("tts_stream_mode", False),
    'voice_instructions': '请用年轻女性的嗓音,自然、亲切地朗读以下内容。',
    'stop_event': threading.Event(),
    'lock': threading.Lock(),
    'vad_silence_duration': SILENCE_DURATION_SEC   # 可动态修改
})
def is_noise_text(text):
    t = text.strip().lower().rstrip('.')
    return t in NOISE_WORDS
def transcribe_audio(wav_bytes):
    try:
        resp = asr_session.post(
            ASR_API_URL,
            files={'file': ('audio.wav', io.BytesIO(wav_bytes), 'audio/wav')},
            data={'model': ASR_MODEL, 'prompt': '解析为简体中文'},
            timeout=10
        )
        resp.raise_for_status()
        result = resp.json()
        return result.get('text', '').strip()
    except Exception as e:
        print(f"ASR 请求失败: {e}")
        return ""
def translate_text(text, source_lang, target_lang):
    if not text:
        return ""
    system_prompt = (
        f"你是一个专业翻译引擎。只将用户输入的文本从{source_lang}翻译成{target_lang},"
        "只输出译文,不要添加任何解释或额外内容。"
    )
    if source_lang == 'auto':
        system_prompt = (
            "你是一个专业翻译引擎。自动检测用户输入的语言,并将其翻译成"
            f"{target_lang}。只输出译文,不要添加任何解释或额外内容。"
        )
    messages = [
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": text}
    ]
    headers = {
        "Authorization": f"Bearer {AI_API_KEY}",
        "Content-Type": "application/json"
    }
    payload = {
        "model": AI_MODEL,
        "messages": messages,
        "max_tokens": 1024,
        "temperature": 0.3,
        "stream": False,
        "chat_template_kwargs": {"enable_thinking": False}
    }
    try:
        resp = requests.post(AI_API_URL, headers=headers, json=payload, timeout=200)
        resp.raise_for_status()
        data = resp.json()
        return data['choices'][0]['message']['content'].strip()
    except Exception as e:
        print(f"翻译请求失败: {e}")
        return f"[翻译错误: {e}]"
def synthesize_speech(sid, text, instructions, stream_mode=False):
    """
    合成语音并发送给客户端。
    - 非流式:一次性获取完整 PCM,封装为 WAV 后发送(兼容性更好)。
    - 流式:直接发送原始 PCM 块(16-bit 单声道小端序),避免 WAV 头导致的边界问题。
    """
    headers = {
        "Authorization": f"Bearer {TTS_API_KEY}",
        "Content-Type": "application/json"
    }
    payload = {
        "model": TTS_MODEL,
        "input": text,
        "instructions": instructions,
        "task_type": "VoiceDesign",
        "response_format": "pcm",
        "stream": stream_mode,
        "sample_rate": TTS_SAMPLE_RATE
    }
    try:
        resp = requests.post(
            TTS_API_URL,
            headers=headers,
            json=payload,
            stream=True,
            timeout=30
        )
        if resp.status_code != 200:
            print(f"TTS 请求失败 [{resp.status_code}]: {resp.text[:100]}")
            return
        socketio.emit('tts_stream_start', {'sample_rate': TTS_SAMPLE_RATE}, room=sid)
        if stream_mode:
            # 流式:直接发送 raw PCM (16-bit little-endian)
            for chunk in resp.iter_content(chunk_size=4096):
                if not chunk:
                    break
                # 确保 chunk 长度为偶数(2字节一个样本)
                if len(chunk) % 2 != 0:
                    chunk = chunk[:-1]  # 丢弃最后一个不完整字节
                if not chunk:
                    continue
                audio_b64 = base64.b64encode(chunk).decode('utf-8')
                socketio.emit('tts_audio_chunk', {'audio': audio_b64}, room=sid)
        else:
            # 非流式:仍然使用 WAV 封装(一次性发送,保证兼容)
            full_pcm = resp.content
            if full_pcm:
                wav_io = io.BytesIO()
                with wave.open(wav_io, 'wb') as wf:
                    wf.setnchannels(1)
                    wf.setsampwidth(2)
                    wf.setframerate(TTS_SAMPLE_RATE)
                    wf.writeframes(full_pcm)
                wav_bytes = wav_io.getvalue()
                audio_b64 = base64.b64encode(wav_bytes).decode('utf-8')
                socketio.emit('tts_audio_chunk', {'audio': audio_b64}, room=sid)
        socketio.emit('tts_stream_end', {}, room=sid)
    except Exception as e:
        print(f"TTS 调用异常: {e}")
        socketio.emit('tts_stream_end', {}, room=sid)
def process_audio_segment(sid, pcm_bytes):
    if not pcm_bytes:
        return
    wav_io = io.BytesIO()
    with wave.open(wav_io, 'wb') as wf:
        wf.setnchannels(1)
        wf.setsampwidth(2)
        wf.setframerate(ASR_SAMPLE_RATE)
        wf.writeframes(pcm_bytes)
    wav_bytes = wav_io.getvalue()
    text = transcribe_audio(wav_bytes)
    if not text or is_noise_text(text):
        return
    socketio.emit('asr_text', {'text': text}, room=sid)
    session = sessions[sid]
    lang = session['language_pair']
    translated = translate_text(text, lang['source'], lang['target'])
    if translated:
        socketio.emit('translated_text', {'text': translated}, room=sid)
        if session['enable_tts'] and translated:
            synthesize_speech(sid, translated, session['voice_instructions'],
                              stream_mode=session['tts_stream_mode'])
        # 新增:保存到服务端文件
        executor.submit(append_server_history, {
            "time": time.strftime("%Y-%m-%d %H:%M:%S"),
            "original": text,
            "translated": translated
        })
# ========== WebSocket 事件 ==========
@socketio.on('connect')
def on_connect():
    emit('connected', {'sid': request.sid})
@socketio.on('disconnect')
def on_disconnect():
    sessions.pop(request.sid, None)
@socketio.on('clear_audio_state')
def on_clear_audio_state():
    sid = request.sid
    sessions[sid]['vad'] = {
        'voiced_frames': b'',
        'silence_duration': 0.0,
        'speaking': False,
        'speech_duration': 0.0,
        'energy_threshold': ENERGY_THRESHOLD
    }
    # 注意:不清除 vad_silence_duration
@socketio.on('set_language')
def on_set_language(data):
    sid = request.sid
    sessions[sid]['language_pair'] = {
        'source': data.get('source', 'auto'),
        'target': data.get('target', 'zh')
    }
@socketio.on('set_tts')
def on_set_tts(data):
    sid = request.sid
    sessions[sid]['enable_tts'] = data.get('enable', False)
    sessions[sid]['tts_stream_mode'] = data.get('stream_mode', False)
    instructions = data.get('voice_instructions')
    if instructions:
        sessions[sid]['voice_instructions'] = instructions
# 新增:动态调整 VAD 静音持续时间(秒)
@socketio.on('set_vad_silence_duration')
def on_set_vad_silence_duration(data):
    sid = request.sid
    duration = data.get('duration', 0.5)
    # 限制范围 0.2 ~ 2.5
    duration = max(0.2, min(2.5, duration))
    sessions[sid]['vad_silence_duration'] = duration
    emit('log_message', {'message': f'✅ VAD 静音阈值已设为 {duration:.1f} 秒'}, room=sid)
@socketio.on('calibrate_noise_result')
def on_calibrate_noise_result(data):
    sid = request.sid
    rms = data.get('rms', 0.01)
    new_threshold = max(rms * 1.5, 0.01)
    sessions[sid]['vad']['energy_threshold'] = new_threshold
    emit('log_message', {'message': f'✅ 校准完成:底噪RMS {rms:.4f},语音阈值设为 {new_threshold:.4f}'}, room=sid)
@socketio.on('audio_chunk')
def on_audio_chunk(data):
    sid = request.sid
    session = sessions[sid]
    vad_state = session['vad']
    threshold = vad_state['energy_threshold']
    raw_bytes = bytes(data) if isinstance(data, bytearray) else data
    if len(raw_bytes) < 4:
        return
    original_rate = struct.unpack_from('<I', raw_bytes, 0)[0]
    sample_bytes = raw_bytes[4:]
    if len(sample_bytes) == 0:
        return
    float_samples = np.frombuffer(sample_bytes, dtype=np.float32).copy()
    if float_samples.size == 0:
        return
    if original_rate != ASR_SAMPLE_RATE:
        resampled = signal.resample_poly(float_samples, ASR_SAMPLE_RATE, original_rate)
        resampled = np.clip(resampled, -1.0, 1.0)
    else:
        resampled = float_samples
    rms = np.sqrt(np.mean(resampled ** 2))
    frame_duration = len(resampled) / ASR_SAMPLE_RATE
    int_samples = (resampled * 32767).astype(np.int16)
    pcm_frame = int_samples.tobytes()
    # 使用会话独立的 vad_silence_duration
    silence_dur_thresh = session['vad_silence_duration']
    if rms >= threshold:
        vad_state['voiced_frames'] += pcm_frame
        vad_state['silence_duration'] = 0.0
        vad_state['speech_duration'] += frame_duration
        if not vad_state['speaking']:
            vad_state['speaking'] = True
    else:
        if vad_state['speaking']:
            vad_state['voiced_frames'] += pcm_frame
            vad_state['silence_duration'] += frame_duration
            if (vad_state['silence_duration'] >= silence_dur_thresh or
                    vad_state['speech_duration'] >= MAX_SPEECH_SEC):
                segment_bytes = vad_state['voiced_frames']
                vad_state['voiced_frames'] = b''
                vad_state['silence_duration'] = 0.0
                vad_state['speaking'] = False
                vad_state['speech_duration'] = 0.0
                if segment_bytes:
                    executor.submit(process_audio_segment, sid, segment_bytes)
@app.route('/')
def index():
    return render_template_string(INTERPRETER_HTML)
# ========== HTML 模板(增加 VAD 滑块 和 TTS 麦克风静音选项) ==========
INTERPRETER_HTML = '''
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
    <meta name="apple-mobile-web-app-capable" content="yes">
    <meta name="apple-mobile-web-app-status-bar-style" content="default">
    <title>以太天枢 · 同声传译</title>
    <style>
        :root {
            --bg: #e8f1f5;
            --panel: rgba(255, 255, 255, 0.92);
            --border: rgba(60, 120, 180, 0.4);
            --text: #2c3e50;
            --accent: #1e90ff;
            --accent2: #6c5ce7;
            --input-bg: rgba(240, 248, 255, 0.85);
            --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', 'Microsoft YaHei', sans-serif;
            --sidebar-width: 280px;
        }
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            background: var(--bg);
            font-family: var(--font);
            color: var(--text);
            height: 100vh;
            height: -webkit-fill-available;
            display: flex;
            flex-direction: column;
            overflow: hidden;
            user-select: none;
            -webkit-tap-highlight-color: transparent;
            position: fixed;
            width: 100%;
        }
        canvas#particles { position: fixed; top: 0; left: 0; z-index: 0; pointer-events: none; }
        .top-bar {
            position: relative; z-index: 3;
            display: flex; align-items: center; justify-content: space-between;
            padding: 12px 20px;
            background: rgba(255, 255, 255, 0.85);
            border-bottom: 1px solid var(--border);
            backdrop-filter: blur(12px);
            -webkit-backdrop-filter: blur(12px);
            flex-shrink: 0;
        }
        .top-bar .logo {
            font-size: 1.2em; font-weight: 700;
            background: linear-gradient(135deg, var(--accent), var(--accent2));
            -webkit-background-clip: text;
            -webkit-text-fill-color: transparent;
            background-clip: text;
        }
        .top-bar .status {
            font-size: 0.85em; color: #4a6a8a;
            display: flex; align-items: center; gap: 6px;
        }
        .top-bar .menu-btn {
            display: none;
            background: none;
            border: 1px solid var(--border);
            border-radius: 8px;
            width: 40px; height: 40px;
            color: var(--text);
            font-size: 1.4em;
            cursor: pointer;
            align-items: center; justify-content: center;
            transition: all 0.2s;
        }
        .top-bar .menu-btn:active { background: rgba(30,144,255,0.1); }
        .main-area {
            position: relative; z-index: 1;
            display: flex;
            flex: 1;
            min-height: 0;
            overflow: hidden;
        }
        .sidebar {
            width: var(--sidebar-width);
            min-width: 240px;
            background: rgba(255, 255, 255, 0.8);
            border-right: 1px solid var(--border);
            padding: 16px;
            display: flex;
            flex-direction: column;
            gap: 10px;
            backdrop-filter: blur(8px);
            -webkit-backdrop-filter: blur(8px);
            overflow-y: auto;
            min-height: 0;
            transition: transform 0.3s ease, opacity 0.3s ease;
        }
        .sidebar.hidden-mobile {
            display: flex;
        }
        .sidebar .section-title {
            font-size: 0.7em; text-transform: uppercase; letter-spacing: 2px;
            color: #4a6a8a;
            margin-top: 8px;
            margin-bottom: 2px;
        }
        .sidebar label {
            font-size: 0.85em;
            color: #4a6a8a;
        }
        .sidebar select, .sidebar textarea {
            width: 100%;
            background: var(--input-bg);
            border: 1px solid var(--border);
            border-radius: 10px;
            color: var(--text);
            padding: 10px 12px;
            font-size: 1em;
            font-family: var(--font);
            appearance: none;
            -webkit-appearance: none;
            background-image: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2210%22%20height%3D%226%22%20viewBox%3D%220%200%2010%206%22%3E%3Cpath%20fill%3D%22%234a6a8a%22%20d%3D%22M0%200l5%206%205-6z%22%2F%3E%3C%2Fsvg%3E");
            background-repeat: no-repeat;
            background-position: right 12px center;
            background-size: 10px 6px;
        }
        .sidebar textarea {
            height: 60px;
            resize: vertical;
            background-image: none;
        }
        .toggle-row {
            display: flex; align-items: center; justify-content: space-between;
            font-size: 0.9em;
            padding: 2px 0;
        }
        .toggle-switch { position: relative; width: 48px; height: 28px; }
        .toggle-switch input { opacity: 0; width: 0; height: 0; }
        .toggle-slider {
            position: absolute; cursor: pointer;
            top: 0; left: 0; right: 0; bottom: 0;
            background: rgba(200, 210, 220, 0.8);
            border-radius: 28px;
            transition: 0.3s;
            border: 1px solid var(--border);
        }
        .toggle-slider:before {
            content: ''; position: absolute;
            height: 22px; width: 22px; left: 2px; bottom: 2px;
            background: #fff; border-radius: 50%;
            transition: 0.3s;
            box-shadow: 0 1px 4px rgba(0,0,0,0.2);
        }
        input:checked + .toggle-slider {
            background: rgba(30,144,255,0.3);
            border-color: var(--accent);
        }
        input:checked + .toggle-slider:before {
            transform: translateX(20px);
            background: var(--accent);
            box-shadow: 0 0 12px var(--accent);
        }
        .btn {
            padding: 10px 14px;
            border: 1px solid var(--border);
            border-radius: 12px;
            background: transparent;
            cursor: pointer;
            font-size: 0.95em;
            font-weight: 600;
            transition: all 0.2s;
            font-family: var(--font);
            display: flex;
            align-items: center;
            justify-content: center;
            gap: 6px;
            touch-action: manipulation;
        }
        .btn:active { transform: scale(0.97); }
        .btn:hover { border-color: var(--accent); color: var(--accent); }
        .btn.primary {
            background: linear-gradient(135deg, #1e90ff, #00bfff);
            color: #fff;
            border-color: #1e90ff;
        }
        .btn.primary:hover { box-shadow: 0 4px 20px rgba(30,144,255,0.5); transform: translateY(-1px); }
        .btn.primary:active { transform: scale(0.97); }
        .btn.recording {
            background: rgba(231,76,60,0.15);
            color: #e74c3c;
            border-color: #e74c3c;
            animation: pulse 1s infinite;
        }
        @keyframes pulse {
            0%,100% { box-shadow: 0 0 8px rgba(231,76,60,0.3); }
            50% { box-shadow: 0 0 22px rgba(231,76,60,0.6); }
        }
        .btn.calibrate {
            color: #3498db;
            border-color: #3498db;
        }
        .btn.history-btn {
            color: #8e44ad;
            border-color: #8e44ad;
            margin-top: 6px;
        }
        .slider-row {
            display: flex;
            flex-direction: column;
            gap: 4px;
            margin-bottom: 8px;
        }
        .slider-row input {
            width: 100%;
            cursor: pointer;
        }
        .slider-value {
            font-size: 0.8em;
            color: var(--accent);
            text-align: right;
        }
        .result-area {
            flex: 1;
            overflow-y: auto;
            min-height: 0;
            padding: 16px;
            background: var(--panel);
            font-size: 1em;
            line-height: 1.8;
            -webkit-overflow-scrolling: touch;
            overscroll-behavior: contain;
        }
        .msg-row {
            margin-bottom: 10px;
            word-break: break-word;
            padding: 8px 12px;
            background: rgba(255,255,255,0.7);
            border-radius: 10px;
            box-shadow: 0 1px 4px rgba(0,0,0,0.04);
        }
        .msg-row .label { font-weight: 600; margin-right: 6px; display: inline-block; }
        .asr-label { color: #1e90ff; }
        .trans-label { color: #27ae60; }
        .history-label { color: #8e44ad; }
        .log-row {
            color: #4a6a8a;
            font-size: 0.85em;
            padding: 6px 12px;
            margin-bottom: 4px;
        }
        /* 移动端适配 */
        @media (max-width: 768px) {
            .top-bar .menu-btn { display: flex; }
            .main-area { flex-direction: column; }
            .sidebar {
                position: fixed; top: 0; left: 0;
                width: 100%; height: 100vh; height: -webkit-fill-available;
                z-index: 10; border-right: none;
                border-bottom: 1px solid var(--border);
                transform: translateY(-100%); opacity: 0; pointer-events: none;
                padding: 20px; gap: 14px; overflow-y: auto; min-height: 0;
                background: rgba(255,255,255,0.95);
                backdrop-filter: blur(20px);
                -webkit-backdrop-filter: blur(20px);
            }
            .sidebar.open {
                transform: translateY(0); opacity: 1; pointer-events: auto;
            }
            .sidebar .section-title { font-size: 0.75em; }
            .sidebar select, .sidebar textarea { font-size: 1.1em; padding: 12px 14px; }
            .btn { font-size: 1.05em; padding: 12px 16px; }
            .result-area { font-size: 1.05em; padding: 20px; min-height: 0; touch-action: pan-y; }
            .top-bar .logo { font-size: 1em; }
        }
        @media (max-width: 400px) {
            .top-bar { padding: 10px 15px; }
            .sidebar { padding: 15px; }
            .btn { padding: 10px 14px; }
            .result-area { padding: 15px; }
        }
    </style>
</head>
<body>
    <canvas id="particles"></canvas>
    <div class="top-bar">
        <span class="logo">⚡ 以太天枢 · 同声传译</span>
        <span class="status" id="connectionStatus">
            <span style="font-size:0.9em;">🟡</span> 未连接
        </span>
        <button class="menu-btn" id="menuToggle" aria-label="设置" title="设置">⚙️</button>
    </div>
    <div class="main-area">
        <div class="sidebar" id="sidebar">
            <div class="section-title">🌐 语言设置</div>
            <label for="sourceLang">源语言</label>
            <select id="sourceLang">
                <option value="auto" selected>自动检测</option>
                <option value="中文">中文</option>
                <option value="英文">英文</option>
                <option value="日文">日文</option>
                <option value="韩文">韩文</option>
                <option value="法文">法文</option>
                <option value="德文">德文</option>
            </select>
            <label for="targetLang">目标语言</label>
            <select id="targetLang">
                <option value="中文" selected>中文</option>
                <option value="英文">英文</option>
                <option value="日文">日文</option>
                <option value="韩文">韩文</option>
                <option value="法文">法文</option>
                <option value="德文">德文</option>
            </select>
            <div class="section-title">🔊 语音合成 (TTS)</div>
            <div class="toggle-row">
                <span>启用 TTS</span>
                <label class="toggle-switch">
                    <input type="checkbox" id="enableTts" onchange="updateTts()">
                    <span class="toggle-slider"></span>
                </label>
            </div>
            <div class="toggle-row">
                <span>流式播放</span>
                <label class="toggle-switch">
                    <input type="checkbox" id="ttsStreamMode" onchange="updateTts()">
                    <span class="toggle-slider"></span>
                </label>
            </div>
            <!-- 新增:TTS 播放时是否静音麦克风 -->
            <div class="toggle-row">
                <span>🔇 TTS 播放时静音麦克风</span>
                <label class="toggle-switch">
                    <input type="checkbox" id="muteMicDuringTts" checked>
                    <span class="toggle-slider"></span>
                </label>
            </div>
            <label for="voiceStyle">语音风格</label>
            <select id="voiceStyle" onchange="updateTts()">
                <option value="请用年轻女性的嗓音,自然、亲切、略带活泼地朗读以下内容。" selected>年轻女性 - 自然活泼</option>
                <option value="请用中年男性的嗓音,稳重、沉稳、专业的男中音朗读以下内容。">中年男性 - 稳重专业</option>
                <option value="请用少女的嗓音,活泼、可爱、元气满满地朗读以下内容。">少女 - 活泼可爱</option>
                <option value="请用温柔细腻的女性嗓音,柔和、抒情、富有情感地朗读以下内容。">温柔女性 - 抒情</option>
                <option value="请用成熟男性的嗓音,严肃、正式、权威地朗读以下内容。">成熟男性 - 权威</option>
                <option value="请用年轻男性的嗓音,轻松、愉快、幽默地朗读以下内容。">年轻男性 - 幽默</option>
                <option value="请用激情澎湃的嗓音,激昂、热血、富有感染力的朗读以下内容。">激情 - 热血</option>
            </select>
            <div class="section-title">🎤 VAD 静音持续时间</div>
            <div class="slider-row">
                <input type="range" id="vadSilenceSlider" min="0.2" max="2.5" step="0.05" value="0.5">
                <div class="slider-value" id="vadSilenceValue">0.5 秒</div>
            </div>
            <div class="section-title">🎤 控制</div>
            <button class="btn primary" id="btnStart" onclick="toggleRecording()">🎙 开始实时传译</button>
            <button class="btn calibrate" onclick="calibrateNoise()">🎚 噪音校准</button>
            <button class="btn" onclick="clearScreen()">🧹 清屏</button>
            <div class="section-title">📜 历史记录</div>
            <button class="btn history-btn" onclick="loadHistory()">📂 加载历史记录</button>
            <button class="btn history-btn" onclick="clearHistory()">🗑 清除历史记录</button>
        </div>
        <div class="result-area" id="resultArea">
            <div class="log-row">🚀 准备就绪,请点击“开始实时传译”</div>
        </div>
    </div>
    <script src="static/socket.io.min.js"></script>
    <script>
        // 侧边栏交互
        const sidebar = document.getElementById('sidebar');
        const menuToggle = document.getElementById('menuToggle');
        menuToggle.addEventListener('click', () => sidebar.classList.toggle('open'));
        sidebar.addEventListener('click', (e) => { if (e.target === sidebar) sidebar.classList.remove('open'); });
        window.addEventListener('resize', () => { if (window.innerWidth > 768) sidebar.classList.remove('open'); });
        // 粒子背景
        const canvas = document.getElementById('particles');
        const pctx = canvas.getContext('2d');
        let particles = [];
        function resizeCanvas() { canvas.width = window.innerWidth; canvas.height = window.innerHeight; }
        resizeCanvas();
        window.addEventListener('resize', resizeCanvas);
        const isMobile = window.innerWidth <= 768;
        const particleCount = isMobile ? 25 : 60;
        for (let i = 0; i < particleCount; i++) {
            particles.push({
                x: Math.random() * canvas.width,
                y: Math.random() * canvas.height,
                r: Math.random() * 1.5 + 0.3,
                vx: (Math.random() - 0.5) * 0.2,
                vy: (Math.random() - 0.5) * 0.2,
                alpha: Math.random() * 0.3 + 0.1
            });
        }
        function animateParticles() {
            pctx.clearRect(0, 0, canvas.width, canvas.height);
            particles.forEach(p => {
                p.x += p.vx; p.y += p.vy;
                if (p.x < 0) p.x = canvas.width;
                if (p.x > canvas.width) p.x = 0;
                if (p.y < 0) p.y = canvas.height;
                if (p.y > canvas.height) p.y = 0;
                pctx.beginPath();
                pctx.arc(p.x, p.y, p.r, 0, Math.PI*2);
                pctx.fillStyle = `rgba(30,144,255,${p.alpha})`;
                pctx.fill();
            });
            requestAnimationFrame(animateParticles);
        }
        animateParticles();
        // ---------- 全局变量 ----------
        let socket;
        let isRecording = false;
        let audioContext = null;
        let mediaStream = null;
        let scriptProcessor = null;
        let calibrationInProgress = false;
        let micMuted = false;
        let lastAsrText = null;
        // TTS 播放相关
        let ttsSampleRate = 24000;
        let ttsPlaybackContext = null;
        let ttsQueue = [];
        let ttsNextStartTime = 0;
        let ttsStreamActive = false;
        let ttsScheduledSources = [];
        let ttsStreamEndReceived = false;
        // 新增:记录 TTS 是否因为静音选项而主动静音了麦克风
        let ttsMutedByOption = false;
        // 历史记录存储
        let historyRecords = [];
        function loadHistoryFromStorage() {
            try {
                const stored = localStorage.getItem('interpreter_history');
                if (stored) historyRecords = JSON.parse(stored);
                else historyRecords = [];
            } catch (e) { historyRecords = []; }
        }
        loadHistoryFromStorage();
        function saveHistoryToStorage() {
            try { localStorage.setItem('interpreter_history', JSON.stringify(historyRecords)); } catch(e) {}
        }
        function addHistoryRecord(original, translation) {
            const record = { time: new Date().toISOString(), original, translation };
            historyRecords.push(record);
            if (historyRecords.length > 200) historyRecords = historyRecords.slice(-200);
            saveHistoryToStorage();
        }
        function loadHistory() {
            const area = document.getElementById('resultArea');
            if (historyRecords.length === 0) { appendLog('📭 没有历史记录'); return; }
            appendLog('📜 以下为历史记录:');
            historyRecords.forEach((rec, idx) => {
                const timeStr = new Date(rec.time).toLocaleString();
                const row = document.createElement('div');
                row.className = 'msg-row';
                row.innerHTML = `
                    <div style="font-size:0.8em; color:#7f8c8d; margin-bottom:4px;">#${idx+1} ${timeStr}</div>
                    <span class="label asr-label">🎤 原文:</span> ${escapeHtml(rec.original)}<br>
                    <span class="label trans-label">🌐 译文:</span> ${escapeHtml(rec.translation)}`;
                area.appendChild(row);
                area.scrollTop = area.scrollHeight;
            });
            appendLog('✅ 历史记录加载完毕');
        }
        function clearHistory() {
            if (confirm('确定要清除所有历史记录吗?')) {
                historyRecords = [];
                saveHistoryToStorage();
                appendLog('🗑 历史记录已清除');
            }
        }
        // ---------- TTS 音频播放 ----------
        function initTtsAudioContext() {
            if (!ttsPlaybackContext) {
                ttsPlaybackContext = new (window.AudioContext || window.webkitAudioContext)();
            }
            if (ttsPlaybackContext.state === 'suspended') {
                ttsPlaybackContext.resume();
            }
        }
        function clearTtsQueue() {
            for (let src of ttsScheduledSources) {
                try { src.stop(); } catch(e) {}
            }
            ttsScheduledSources = [];
            ttsQueue = [];
            ttsNextStartTime = 0;
            ttsStreamActive = false;
            ttsStreamEndReceived = false;
            // 如果是因为静音选项而静音,恢复麦克风
            if (ttsMutedByOption) {
                micMuted = false;
                ttsMutedByOption = false;
                appendLog('🎤 麦克风已恢复(TTS 中断)');
            }
        }
        function checkTtsCompletion() {
            if (!ttsPlaybackContext) return;
            if (ttsStreamEndReceived && 
                ttsQueue.length === 0 && 
                ttsScheduledSources.length === 0) {
                if (ttsMutedByOption) {
                    micMuted = false;
                    ttsMutedByOption = false;
                    appendLog('✅ TTS 播放完毕,麦克风已恢复');
                }
                ttsStreamEndReceived = false;
            }
        }
        function scheduleNextBuffer() {
            if (ttsQueue.length === 0) {
                checkTtsCompletion();
                return;
            }
            if (!ttsPlaybackContext) return;
            const now = ttsPlaybackContext.currentTime;
            if (ttsNextStartTime === 0) {
                ttsNextStartTime = Math.max(now, now + 0.05);
            }
            while (ttsQueue.length > 0) {
                const item = ttsQueue.shift();
                const buffer = item.buffer;
                const source = ttsPlaybackContext.createBufferSource();
                source.buffer = buffer;
                source.connect(ttsPlaybackContext.destination);
                source.onended = () => {
                    const idx = ttsScheduledSources.indexOf(source);
                    if (idx > -1) ttsScheduledSources.splice(idx, 1);
                    checkTtsCompletion();
                };
                source.start(ttsNextStartTime);
                ttsScheduledSources.push(source);
                ttsNextStartTime += buffer.duration;
            }
        }
        function handleTtsChunk(base64Pcm) {
            initTtsAudioContext();
            const byteString = atob(base64Pcm);
            const len = byteString.length;
            const numSamples = Math.floor(len / 2);
            if (numSamples === 0) return;
            const bytes = new Uint8Array(len);
            for (let i = 0; i < len; i++) bytes[i] = byteString.charCodeAt(i);
            const int16View = new Int16Array(bytes.buffer);
            const buffer = ttsPlaybackContext.createBuffer(1, numSamples, ttsSampleRate);
            const channelData = buffer.getChannelData(0);
            for (let i = 0; i < numSamples; i++) {
                channelData[i] = int16View[i] / 32768.0;
            }
            ttsQueue.push({ buffer: buffer });
            scheduleNextBuffer();
        }
        // ---------- VAD 滑块控制 ----------
        const vadSlider = document.getElementById('vadSilenceSlider');
        const vadValue = document.getElementById('vadSilenceValue');
        function updateVadSilence() {
            const val = parseFloat(vadSlider.value);
            vadValue.innerText = val.toFixed(2) + ' 秒';
            if (socket && socket.connected) {
                socket.emit('set_vad_silence_duration', { duration: val });
            }
        }
        vadSlider.addEventListener('input', updateVadSilence);
        // 连接后发送初始值
        setTimeout(() => updateVadSilence(), 1000);
        // ---------- WebSocket 连接 ----------
        function connectSocket() {
            socket = io();
            socket.on('connect', () => {
                document.getElementById('connectionStatus').innerHTML = '<span style="font-size:0.9em;">🟢</span> 已连接';
                // 同步 VAD 静音阈值
                updateVadSilence();
            });
            socket.on('disconnect', () => {
                document.getElementById('connectionStatus').innerHTML = '<span style="font-size:0.9em;">🔴</span> 未连接';
                if (isRecording) stopRecording();
                clearTtsQueue();
            });
            socket.on('asr_text', (data) => {
                lastAsrText = data.text;
                appendMessage('🎤 原文', data.text, 'asr-label');
            });
            socket.on('translated_text', (data) => {
                appendMessage('🌐 译文', data.text, 'trans-label');
                if (lastAsrText) {
                    addHistoryRecord(lastAsrText, data.text);
                }
            });
            socket.on('tts_stream_start', (data) => {
                ttsSampleRate = data.sample_rate || 24000;
                clearTtsQueue();
                ttsStreamActive = true;
                ttsStreamEndReceived = false;
                // 根据用户选项决定是否静音麦克风
                const shouldMute = document.getElementById('muteMicDuringTts').checked;
                if (shouldMute && !micMuted) {
                    micMuted = true;
                    ttsMutedByOption = true;
                    appendLog('🔇 TTS 播放开始,麦克风已静音');
                } else if (!shouldMute) {
                    appendLog('🔊 TTS 播放开始,麦克风保持开启');
                }
            });
            socket.on('tts_audio_chunk', (data) => {
                handleTtsChunk(data.audio);
            });
            socket.on('tts_stream_end', () => {
                ttsStreamActive = false;
                ttsStreamEndReceived = true;
                checkTtsCompletion();
            });
            socket.on('log_message', (data) => appendLog(data.message));
        }
        connectSocket();
        // ---------- 显示辅助函数 ----------
        function appendMessage(label, text, labelClass) {
            const area = document.getElementById('resultArea');
            const row = document.createElement('div');
            row.className = 'msg-row';
            row.innerHTML = `<span class="label ${labelClass}">${label}:</span> ${escapeHtml(text)}`;
            area.appendChild(row);
            area.scrollTop = area.scrollHeight;
        }
        function appendLog(text) {
            const area = document.getElementById('resultArea');
            const row = document.createElement('div');
            row.className = 'log-row';
            row.textContent = text;
            area.appendChild(row);
            area.scrollTop = area.scrollHeight;
        }
        function escapeHtml(str) {
            const div = document.createElement('div');
            div.appendChild(document.createTextNode(str));
            return div.innerHTML;
        }
        // ---------- 语言与 TTS 设置 ----------
        function sendLanguageConfig() {
            if (!socket || !socket.connected) return;
            socket.emit('set_language', {
                source: document.getElementById('sourceLang').value,
                target: document.getElementById('targetLang').value
            });
        }
        document.getElementById('sourceLang').addEventListener('change', sendLanguageConfig);
        document.getElementById('targetLang').addEventListener('change', sendLanguageConfig);
        function updateTts() {
            if (!socket || !socket.connected) return;
            const enable = document.getElementById('enableTts').checked;
            const streamMode = document.getElementById('ttsStreamMode').checked;
            const style = document.getElementById('voiceStyle').value;
            socket.emit('set_tts', { enable, stream_mode: streamMode, voice_instructions: style });
        }
        setTimeout(updateTts, 500);
        // ---------- 录音控制 ----------
        async function toggleRecording() {
            if (isRecording) {
                await stopRecording();
            } else {
                await startRecording();
            }
        }
        async function startRecording() {
            if (!socket || !socket.connected) { appendLog('⚠️ 未连接服务器'); return; }
            sendLanguageConfig();
            updateTts();
            socket.emit('clear_audio_state');
            clearTtsQueue();
            micMuted = false;
            ttsMutedByOption = false; // 重置标志
            lastAsrText = null;
            try {
                mediaStream = await navigator.mediaDevices.getUserMedia({
                    audio: { channelCount: 1, echoCancellation: false, noiseSuppression: false, autoGainControl: false }
                });
                audioContext = new (window.AudioContext || window.webkitAudioContext)();
                if (audioContext.state === 'suspended') await audioContext.resume();
                const source = audioContext.createMediaStreamSource(mediaStream);
                scriptProcessor = audioContext.createScriptProcessor(4096, 1, 1);
                scriptProcessor.onaudioprocess = (e) => {
                    if (!isRecording || micMuted || !socket || !socket.connected) return;
                    const inputBuffer = e.inputBuffer;
                    const channelData = inputBuffer.getChannelData(0);
                    const sampleRate = audioContext.sampleRate;
                    const header = new ArrayBuffer(4);
                    new DataView(header).setUint32(0, sampleRate, true);
                    const payload = new Float32Array(channelData);
                    const combined = new Uint8Array(header.byteLength + payload.byteLength);
                    combined.set(new Uint8Array(header), 0);
                    combined.set(new Uint8Array(payload.buffer), header.byteLength);
                    socket.emit('audio_chunk', combined.buffer);
                };
                source.connect(scriptProcessor);
                scriptProcessor.connect(audioContext.destination);
                isRecording = true;
                document.getElementById('btnStart').textContent = '⏹ 停止传译';
                document.getElementById('btnStart').classList.add('recording');
                appendLog('🎙 实时语音传译已开启');
            } catch (err) {
                appendLog('⚠️ 无法访问麦克风: ' + err.message);
                isRecording = false;
            }
        }
        async function stopRecording() {
            if (!isRecording) return;
            isRecording = false;
            micMuted = false;
            ttsMutedByOption = false;
            if (scriptProcessor) {
                scriptProcessor.disconnect();
                scriptProcessor.onaudioprocess = null;
                scriptProcessor = null;
            }
            if (audioContext) {
                audioContext.close().catch(console.error);
                audioContext = null;
            }
            if (mediaStream) {
                mediaStream.getTracks().forEach(t => t.stop());
                mediaStream = null;
            }
            document.getElementById('btnStart').textContent = '🎙 开始实时传译';
            document.getElementById('btnStart').classList.remove('recording');
            appendLog('🎙 实时语音传译已停止');
            socket.emit('clear_audio_state');
            clearTtsQueue();
        }
        // ---------- 噪音校准 ----------
        async function calibrateNoise() {
            if (calibrationInProgress) return;
            if (isRecording) await stopRecording();
            calibrationInProgress = true;
            appendLog('🎤 开始底噪校准,请保持安静 3 秒...');
            let stream;
            try {
                stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1 } });
            } catch (e) {
                appendLog('❌ 校准失败:无法访问麦克风');
                calibrationInProgress = false;
                return;
            }
            const actx = new (window.AudioContext || window.webkitAudioContext)();
            if (actx.state === 'suspended') await actx.resume();
            const source = actx.createMediaStreamSource(stream);
            const gainNode = actx.createGain();
            gainNode.gain.value = 0;
            source.connect(gainNode);
            gainNode.connect(actx.destination);
            const scriptNode = actx.createScriptProcessor(4096, 1, 1);
            const samples = [];
            let duration = 0;
            scriptNode.onaudioprocess = (e) => {
                if (!calibrationInProgress) return;
                const data = e.inputBuffer.getChannelData(0);
                samples.push(new Float32Array(data));
                duration += data.length / actx.sampleRate;
                if (duration >= 3.0) {
                    scriptNode.disconnect();
                    gainNode.disconnect();
                    source.disconnect();
                    actx.close();
                    stream.getTracks().forEach(t => t.stop());
                    const totalLen = samples.reduce((sum, arr) => sum + arr.length, 0);
                    const merged = new Float32Array(totalLen);
                    let offset = 0;
                    for (const arr of samples) { merged.set(arr, offset); offset += arr.length; }
                    let sumSq = 0;
                    for (let i = 0; i < merged.length; i++) sumSq += merged[i] * merged[i];
                    const rms = Math.sqrt(sumSq / merged.length);
                    if (socket && socket.connected) {
                        socket.emit('calibrate_noise_result', { rms: rms });
                    } else {
                        appendLog('❌ 未连接服务器');
                    }
                    calibrationInProgress = false;
                }
            };
            source.connect(scriptNode);
            scriptNode.connect(gainNode);
        }
        function clearScreen() {
            document.getElementById('resultArea').innerHTML = '';
        }
    </script>
</body>
</html>
'''
print("=" * 50)
print("🌐 以太天枢 · 同声传译 服务启动中...")
print("   访问地址: http://localhost:5001   gunicorn -k eventlet -w 1 同声传译:app --bind 0.0.0.0:5001")
print("   ASR 模型:", ASR_MODEL)
print("   翻译模型:", AI_MODEL)
print("   TTS 模型:", TTS_MODEL, "(默认流式关闭,播放完毕自动恢复麦克风)")
print("   新增: VAD 静音持续时间可调 (0.2~2.5秒)")
print("   新增: TTS 播放时麦克风静音选项")
print("=" * 50)
if __name__ == '__main__':
    socketio.run(app, host='0.0.0.0', port=5001, debug=False)

3.3模型下载与准备

1、Qwen3-ASR-1.7B语音识别模型):

下载链接:https://modelscope.cn/models/Qwen/Qwen3-ASR-1.7B/files

2、Qwen3.5-9B语言翻译模型):

下载链接:https://modelscope.cn/models/Qwen/Qwen3.5-9B/files

3、Qwen3-TTS-12Hz-1.7B-VoiceDesign(语音合成模型)

下载链接:

https://modelscope.cn/models/Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign/files

3.4 模型启动参数

1、Qwen3-ASR-1.7B启动参数:

cat Qwen3-ASR-1.7B.sh
HIP_VISIBLE_DEVICES=6 vllm serve '/home/models/Qwen3-ASR-1.7B' 
  --trust-remote-code 
  --gpu-memory-utilization 0.3 
  --limit-mm-per-prompt '{"audio": 1}' 
  --port 8084 
  --served-model-name Qwen3-ASR-1.7B 
  --api-key PassWord@123456

2、Qwen3.5-9B启动参数:

cat Qwen3.5-9B.sh
#!/bin/bash
export VLLM_SPEC_DECODE_EAGER=1
export VLLM_MLA_DISABLE=0
export VLLM_USE_FLASH_MLA=1
export VLLM_RPC_TIMEOUT=1800000
export HIP_VISIBLE_DEVICES=6
export ALLREDUCE_STREAM_WITH_COMPUTE=1
# 海光CPU绑定核,通过hy-smi --showtopo参考numa节点
export VLLM_NUMA_BIND=1
export VLLM_RANK0_NUMA=0
export VLLM_RANK1_NUMA=0
export VLLM_RANK2_NUMA=0
export VLLM_RANK3_NUMA=0
export VLLM_RANK4_NUMA=0
export VLLM_RANK5_NUMA=0
export VLLM_RANK6_NUMA=0
export VLLM_RANK7_NUMA=0
export NCCL_MAX_NCHANNELS=16
export NCCL_MIN_NCHANNELS=16
vllm serve "/home/models/Qwen3.5-9B" 
    --gpu-memory-utilization 0.4 
    --port 8085 
    --max-model-len 32768 
    --max-num-seqs 32 
    --served-model-name Qwen3.5-9B 
    --tensor-parallel-size 1 
    --enable-auto-tool-choice 
    --tool-call-parser qwen3_xml 
    --trust-remote-code 
    --enable-prefix-caching 
    --enable-chunked-prefill 
    --api-key PassWord@123456

3、Qwen3-TTS-12Hz-1.7B-VoiceDesign启动参数:

cat Qwen3-TTS-12Hz-1.7B-VoiceDesign.sh
HIP_VISIBLE_DEVICES=6 vllm-omni serve /home/models/Qwen3-TTS-12Hz-1.7B-VoiceDesign 
    --stage-configs-path qwen3-tts_pytorch-master/qwen3_tts.yaml 
    --gpu-memory-utilization 0.1 
    --omni 
    --port 8086 
    --trust-remote-code 
    --enforce-eager 
    --served-model-name Qwen3-TTS-12Hz-1.7B-VoiceDesign
cat qwen3-tts_pytorch-master/qwen3_tts.yaml 
async_chunk: true
stage_args:
  - stage_id: 0
    stage_type: llm
    is_comprehension: true
    runtime:
      devices: "0"
    engine_args:
      model_stage: qwen3_tts
      max_num_seqs: 10
      model_arch: Qwen3TTSTalkerForConditionalGeneration
      worker_type: ar
      scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
      enforce_eager: false
      trust_remote_code: true
      async_scheduling: true
      enable_prefix_caching: false
      engine_output_type: latent
      gpu_memory_utilization: 0.1
      distributed_executor_backend: "mp"
      max_num_batched_tokens: 512
      max_model_len: 4096
      custom_process_next_stage_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_tts.talker2code2wav_async_chunk
    # Use named connector to apply runtime.connectors.extra.
    output_connectors:
      to_stage_1: connector_of_shared_memory
    default_sampling_params:
      temperature: 0.9
      top_k: 50
      max_tokens: 4096
      seed: 42
      detokenize: false
      repetition_penalty: 1.05
      stop_token_ids: [2150]
  - stage_id: 1
    stage_type: llm
    runtime:
      devices: "0"
    engine_args:
      model_stage: code2wav
      max_num_seqs: 1
      model_arch: Qwen3TTSCode2Wav
      worker_type: generation
      scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
      enforce_eager: true
      trust_remote_code: true
      async_scheduling: true
      enable_prefix_caching: false
      engine_output_type: audio
      gpu_memory_utilization: 0.3
      distributed_executor_backend: "mp"
      # Must be divisible by num_code_groups and cover (left_context + chunk).
      max_num_batched_tokens: 8192
      # async_chunk appends windows per step; max_model_len must cover accumulated stream.
      max_model_len: 32768
    engine_input_source: [0]
    final_output: true
    final_output_type: audio
    # Distributed connector configuration
    input_connectors:
      from_stage_0: connector_of_shared_memory
    tts_args:
      max_instructions_length: 500
    default_sampling_params:
      temperature: 0.0
      top_p: 1.0
      top_k: -1
      max_tokens: 65536
      seed: 42
      detokenize: true
      repetition_penalty: 1.0
runtime:
  enabled: true
  defaults:
    window_size: -1
    max_inflight: 1
  connectors:
    connector_of_shared_memory:
      name: SharedMemoryConnector
      extra:
        shm_threshold_bytes: 65536
        # Frame-aligned codec streaming transport.
        codec_streaming: true
        # Connector polling / timeout (unit: loop count, sleep interval in seconds).
        connector_get_sleep_s: 0.01
        connector_get_max_wait_first_chunk: 3000
        connector_get_max_wait: 300
        # Align with Omni: small chunks with sufficient context overlap.
        codec_chunk_frames: 25
        codec_left_context_frames: 25
  edges:
    - from: 0
      to: 1
      window_size: -1

qwen3_tts.yaml参数一定要注意设置:engine_args:gpu_memory_utilization: 0.1

四、运行测试

1、启动Qwen3-ASR-1.7B

docker exec -it qwen3-asr bash
cd /home/models/
nohup ./Qwen3-ASR-1.7B.sh &

2、启动Qwen3.5-9B

docker exec -it vllm0.21-new bash
cd /home/models/
nohup ./Qwen3.5-9B.sh &

3、启动Qwen3-TTS-12Hz-1.7B-VoiceDesign

docker exec -it qwen3-tts bash
cd /home/models/
nohup ./Qwen3-TTS-12Hz-1.7B-VoiceDesign.sh &

4、启动同声传译自编程序

root@:/opt# source audio-MCP/bin/activate
(audio-MCP) root@:/opt# gunicorn -k eventlet -w 1 同声传译-TTS流式切换-手机版:app --bind 0.0.0.0:5001
/opt/audio-MCP/lib/python3.13/site-packages/gunicorn/workers/geventlet.py:10: EventletDeprecationWarning: 
Eventlet is deprecated. It is currently being maintained in bugfix mode, and
we strongly recommend against using it for new projects.
If you are already using Eventlet, we recommend migrating to a different
framework.  For more detail see
https://eventlet.readthedocs.io/en/latest/asyncio/migration.html
  import eventlet
[2026-07-05 13:57:01 +0800] [3858228] [INFO] Starting gunicorn 22.0.0
[2026-07-05 13:57:01 +0800] [3858228] [INFO] Listening at: http://0.0.0.0:5001 (3858228)
[2026-07-05 13:57:01 +0800] [3858228] [INFO] Using worker: eventlet
[2026-07-05 13:57:01 +0800] [3858229] [INFO] Booting worker with pid: 3858229
1 RLock(s) were not greened, to fix this error make sure you run eventlet.monkey_patch() before importing any other modules.
==================================================
🌐 以太天枢 · 同声传译 服务启动中...
   访问地址: http://localhost:5001   gunicorn -k eventlet -w 1 同声传译:app --bind 0.0.0.0:5001
   ASR 模型: Qwen3-ASR-1.7B
   翻译模型: Qwen3.5-9B
   TTS 模型: Qwen3-TTS-12Hz-1.7B-VoiceDesign (默认流式关闭,播放完毕自动恢复麦克风)
   新增: VAD 静音持续时间可调 (0.2~2.5秒)
   新增: TTS 播放时麦克风静音选项
==================================================

5、访问同声传译自编程序

用谷歌浏览器访问,访问之前需要做安全授权设置:

地址栏执行:chrome://flags/

搜索:Insecure origins treated as secure

填入:http://192.168.222.65:5001  #根据实际的URL填写

K100_AI单卡全离线部署同声传译系统

然后访问:http://192.168.222.65:5001

K100_AI单卡全离线部署同声传译系统

先执行噪音校准,获取背景噪声。根据说话人的语速调整VAD静音持续时间,语速越快,该时间就越小。

© 版权声明

相关文章