海光DCU环境下的轻量级语音克隆系统设计与实现
一、引言近年来随着生成式人工智能的爆发语音克隆技术逐渐从实验室走向实际应用场景如个性化语音助手、有声内容创作、虚拟数字人等。传统的语音克隆方案往往依赖大规模GPU集群和高昂的云端API费用对中小企业或个人开发者并不友好。与此同时国产算力平台正在快速崛起以海光DCU深度计算单元为代表的异构加速卡凭借其兼容CUDA生态和较高的性价比为AI应用的本地化部署提供了新的可能。本文基于海光DCU K00_AI单卡环境设计并实现了一套完整的语音克隆Web系统。该系统采用B/S架构前端提供音频上传、录音、参考文本修正和目标文本合成等交互功能后端集成自动语音识别ASR和语音合成TTS服务其中TTS选用Qwen3-TTS-12Hz-1.7B-Base模型支持基于参考音频的零样本语音克隆。系统还包含音频格式转换ffmpeg、临时文件自动清理、Base64音频编码传输等工程化细节确保在单卡资源受限的条件下仍能稳定处理并发请求。整个项目从实际部署角度出发解决了三个核心问题一是如何将用户提供的任意音频含WebM录音转换为标准WAV格式以适配ASR接口二是如何利用ASR结果辅助参考文本的准确提取提升克隆质量三是如何通过RESTful API将TTS生成的音频流直接返回前端播放与下载。通过该系统的开源实践我们希望为国产AI硬件生态贡献一份可复用的参考案例同时也为语音技术爱好者提供一个快速上手的脚手架。在后续章节中我们将详细阐述系统架构设计、模块划分、关键代码逻辑以及运行效果并分享在海光DCU上部署时的性能调优经验。二、方案设计1、总体架构系统采用前后端分离的Flask Web应用整体分为三层用户交互层、业务逻辑层和模型服务层。用户交互层提供HTML页面支持音频文件上传、浏览器录音MediaRecorder、参考文本编辑和合成文本输入。业务逻辑层由Flask路由构成负责音频格式转换、ASR请求封装、TTS请求构造以及临时文件生命周期管理。模型服务层则独立部署ASR服务端口8084和TTS服务端口8088两者均基于海光DCU加速推理。2、核心模块音频预处理模块利用ffmpeg将上传的非WAV格式如WebM转为16kHz、16bit PCM的WAV文件确保ASR接口的兼容性。ASR接入模块读取本地WAV文件以multipart/form-data形式调用ASR接口并从返回文本中通过正则提取asr_text标签内的内容作为参考文本。语音克隆模块将参考WAV编码为Base64 DataURI与参考文本、目标文本一同构造成JSON请求调用TTS服务的/v1/audio/speech端点获得合成音频二进制流并保存为WAV文件返回可访问的URL。文件管理模块设置临时文件夹temp_audio和temp_synth并启动后台守护线程每10分钟清理一次超过30分钟的文件防止磁盘占满。前端交互模块利用HTML5原生API实现录音、文件选择和音频播放通过Fetch异步请求与后端通信提升用户体验。3、技术选型与部署Web框架Flask轻量适合快速原型。ASR/TTS服务均采用HTTP接口便于解耦可独立扩展。硬件平台海光DCU K00_AI单卡显存32GB通过torch和transformers加载Qwen3-TTS模型实测单次克隆请求响应时间在3~5秒内含推理。并发策略单卡下使用同步处理配合NginxuWSGI可提升并发能力但本方案侧重单用户场景。程序工作流程图三、实施方法及代码3.1硬件环境本方案的硬件平台为一台H3C服务器配置如下组件规格CPU2×海光74902.7GHz64C内存16×32GDDR5GPU8×海光DCU64GBK100_AI需占用一张K100_AI显卡3.2软件栈本方案的软件栈基于Docker容器化技术构建使用经过海光DCU适配的vLLM推理镜像镜像一Qwen3-ASR-1.7Bharbor.sourcefind.cn:5443/dcu/admin/base/custom:vllm0.15.1-ubuntu22.04-dtk26.04-0130-py3.10-20260220该镜像基于vLLM 0.15.1推理框架、DTK 26.04Python 3.10环境单卡部署Qwen3-ASR-1.7B200ms即可完成语音识别。镜像二Qwen3-TTS-12Hz-1.7B-Baseharbor.sourcefind.cn:5443/dcu/admin/base/custom:vllm018-ubuntu22.04-dtk26.04-nemotron-20260422该镜像基于vLLM0.18.1和vLLM-omni 0.18.0推理框架、DTK 26.04Python 3.10环境单卡部署Qwen3-TTS-12Hz-1.7B-Base平均语音合成延迟低于97ms。软件项目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-Base环境部署参照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、语音克隆系统自编程序代码如下import os import re import uuid import time import base64 import threading from flask import Flask, request, jsonify, render_template_string, send_from_directory import requests app Flask(__name__) # ---------- 配置 ---------- ASR_URL http://192.168.222.65:8084/v1/audio/transcriptions TTS_URL http://192.168.222.65:8088/v1/audio/speech AUTH_HEADER {Authorization: Bearer PassWord123456} UPLOAD_FOLDER os.path.join(os.path.dirname(os.path.abspath(__file__)), temp_audio) SYNTH_FOLDER os.path.join(os.path.dirname(os.path.abspath(__file__)), temp_synth) os.makedirs(UPLOAD_FOLDER, exist_okTrue) os.makedirs(SYNTH_FOLDER, exist_okTrue) # ---------- 临时文件定期清理 ---------- def cleanup_old_files(): 删除超过30分钟的临时文件 now time.time() for folder in [UPLOAD_FOLDER, SYNTH_FOLDER]: for fname in os.listdir(folder): fpath os.path.join(folder, fname) if os.path.isfile(fpath) and now - os.path.getmtime(fpath) 1800: try: os.remove(fpath) except Exception: pass def periodic_cleanup(): while True: cleanup_old_files() time.sleep(600) # 每10分钟清理一次 # 启动清理线程 threading.Thread(targetperiodic_cleanup, daemonTrue).start() # ---------- 前端页面 ---------- HTML_TEMPLATE !DOCTYPE html html langzh head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title语音克隆系统/title style body { font-family: Arial, sans-serif; max-width: 800px; margin: 40px auto; padding: 0 20px; } .section { margin-bottom: 30px; } label { font-weight: bold; display: block; margin-top: 10px; } textarea, input[typetext] { width: 100%; padding: 8px; margin-top: 5px; box-sizing: border-box; } button { padding: 10px 20px; margin-right: 10px; cursor: pointer; } audio { width: 100%; margin-top: 10px; } .hidden { display: none; } .error { color: red; } .info { color: #555; font-size: 0.9em; } /style /head body h1 语音克隆系统/h1 !-- 步骤1上传/录音 -- div classsection h3第一步上传或录制参考音频/h3 input typefile idaudioFile acceptaudio/* button iduploadBtn上传并识别/button span classinfo或/span button idstartRecord开始录音/button button idstopRecord disabled停止录音/button div idrecordingStatus classinfo/div /div !-- 步骤2参考文本ASR识别结果可修改 -- div classsection h3第二步参考文本音频内容可修改/h3 textarea idrefText rows3 placeholderASR 识别结果会显示在这里.../textarea input typehidden idfileId /div !-- 步骤3目标合成文本 -- div classsection h3第三步要合成的文本/h3 textarea idsynthText rows4 placeholder请输入您想要合成的文本内容...滚滚长江东逝水浪花淘尽英雄。是非成败转头空。青山依旧在几度夕阳红。白发渔樵江渚上惯看秋月春风。一壶浊酒喜相逢。古今多少事都付笑谈中。/textarea /div !-- 步骤4克隆并播放 -- div classsection button idcloneBtn开始克隆/button span idcloneStatus classinfo/span div idaudioPlayer classhidden h3合成结果/h3 audio controls idaudioEl/audio br a iddownloadLink href# downloadcloned_audio.wav下载音频/a /div /div div iderrorMsg classerror/div script let mediaRecorder; let audioChunks []; let recordedBlob null; // 录音功能 document.getElementById(startRecord).onclick async () { audioChunks []; recordedBlob null; try { const stream await navigator.mediaDevices.getUserMedia({ audio: true }); mediaRecorder new MediaRecorder(stream); mediaRecorder.ondataavailable e audioChunks.push(e.data); mediaRecorder.onstop () { recordedBlob new Blob(audioChunks, { type: audio/webm }); document.getElementById(recordingStatus).textContent 录音完成准备上传; }; mediaRecorder.start(); document.getElementById(startRecord).disabled true; document.getElementById(stopRecord).disabled false; document.getElementById(recordingStatus).textContent 正在录音...; } catch(err) { showError(无法访问麦克风: err.message); } }; document.getElementById(stopRecord).onclick () { if (mediaRecorder mediaRecorder.state ! inactive) { mediaRecorder.stop(); mediaRecorder.stream.getTracks().forEach(t t.stop()); document.getElementById(startRecord).disabled false; document.getElementById(stopRecord).disabled true; } }; // 上传音频并识别 function uploadAudio(blob) { const formData new FormData(); const file document.getElementById(audioFile).files[0]; if (file) { formData.append(file, file); } else if (blob) { // 浏览器录音默认是 webm服务端需要转成 wav简单处理后端调用 ffmpeg这里使用前端编码 wav // 为了简化将录音 blob 直接发送后端用 ffmpeg 转码需安装 ffmpeg formData.append(file, blob, recording.webm); } else { showError(请先选择音频文件或完成录音); return; } document.getElementById(uploadBtn).disabled true; fetch(/upload, { method: POST, body: formData }) .then(r r.json().then(data ({ status: r.status, body: data }))) .then(({ status, body }) { if (status 200) { document.getElementById(refText).value body.text || ; document.getElementById(fileId).value body.file_id || ; document.getElementById(errorMsg).textContent ; } else { showError(body.error || 识别失败); } }) .catch(err showError(请求出错: err.message)) .finally(() { document.getElementById(uploadBtn).disabled false; }); } // 上传按钮文件选择 document.getElementById(uploadBtn).onclick () { uploadAudio(null); }; // 录音停止后自动上传可选这里提供一个自动上传机制 document.getElementById(stopRecord).addEventListener(click, function() { // 延迟一下等待 blob 准备就绪 setTimeout(() { if (recordedBlob) { uploadAudio(recordedBlob); } }, 200); }); // 克隆按钮 document.getElementById(cloneBtn).onclick () { const fileId document.getElementById(fileId).value; const refText document.getElementById(refText).value.trim(); const synthText document.getElementById(synthText).value.trim(); if (!fileId) { showError(请先上传并识别参考音频); return; } if (!refText) { showError(参考文本不能为空); return; } if (!synthText) { showError(合成文本不能为空); return; } document.getElementById(cloneBtn).disabled true; document.getElementById(cloneStatus).textContent 正在合成...; fetch(/clone, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ file_id: fileId, ref_text: refText, input_text: synthText }) }) .then(r r.json().then(data ({ status: r.status, body: data }))) .then(({ status, body }) { if (status 200 body.url) { const audioEl document.getElementById(audioEl); audioEl.src body.url; document.getElementById(audioPlayer).classList.remove(hidden); document.getElementById(downloadLink).href body.url; document.getElementById(cloneStatus).textContent 合成完成; document.getElementById(errorMsg).textContent ; } else { showError(body.error || 克隆失败); } }) .catch(err showError(请求出错: err.message)) .finally(() { document.getElementById(cloneBtn).disabled false; document.getElementById(cloneStatus).textContent ; }); }; function showError(msg) { document.getElementById(errorMsg).textContent msg; } /script /body /html app.route(/) def index(): return render_template_string(HTML_TEMPLATE) # ---------- ASR 识别接口 ---------- app.route(/upload, methods[POST]) def upload_and_recognize(): if file not in request.files: return jsonify({error: 没有上传文件}), 400 file request.files[file] if file.filename : return jsonify({error: 文件名为空}), 400 # 保存原始上传文件可能为 webm需要转成 wav 再发给 ASR original_name file.filename ext os.path.splitext(original_name)[1].lower() file_id str(uuid.uuid4()) temp_original_path os.path.join(UPLOAD_FOLDER, f{file_id}_original{ext}) file.save(temp_original_path) # 如果不是 wav尝试用 ffmpeg 转换为 wav需安装 ffmpeg wav_path os.path.join(UPLOAD_FOLDER, f{file_id}.wav) if ext ! .wav: import subprocess try: subprocess.run( [ffmpeg, -y, -i, temp_original_path, -acodec, pcm_s16le, -ar, 16000, wav_path], checkTrue, capture_outputTrue, timeout30 ) os.remove(temp_original_path) # 删除原始非 wav 文件 except Exception as e: return jsonify({error: f音频转换失败需要 ffmpeg 支持: {str(e)}}), 500 else: # 已经是 wav直接重命名 os.rename(temp_original_path, wav_path) # 调用 ASR API try: with open(wav_path, rb) as f: files {file: (os.path.basename(wav_path), f, audio/wav)} resp requests.post(ASR_URL, headersAUTH_HEADER, filesfiles, timeout60) if resp.status_code ! 200: return jsonify({error: fASR 服务返回错误: {resp.text}}), 502 result resp.json() raw_text result.get(text, ) # 解析 asr_text... 格式 match re.search(rasr_text(.*), raw_text, re.DOTALL) if match: recognized_text match.group(1).strip() else: recognized_text raw_text.strip() # 保留 wav 文件用于后续克隆 # 清理旧文件已在后台线程处理 return jsonify({text: recognized_text, file_id: file_id}) except Exception as e: return jsonify({error: fASR 请求异常: {str(e)}}), 500 # ---------- 语音克隆接口 ---------- app.route(/clone, methods[POST]) def clone_voice(): data request.get_json() if not data: return jsonify({error: 请求数据为空}), 400 file_id data.get(file_id) ref_text data.get(ref_text, ).strip() input_text data.get(input_text, ).strip() if not file_id or not ref_text or not input_text: return jsonify({error: 缺少必要参数 file_id, ref_text, input_text}), 400 # 查找原始 wav 文件 wav_path os.path.join(UPLOAD_FOLDER, f{file_id}.wav) if not os.path.exists(wav_path): return jsonify({error: 参考音频文件不存在或已过期请重新上传}), 404 # 读取音频并编码为 base64 try: with open(wav_path, rb) as f: audio_b64 base64.b64encode(f.read()).decode(utf-8) except Exception as e: return jsonify({error: f读取音频文件失败: {str(e)}}), 500 ref_audio_data fdata:audio/wav;base64,{audio_b64} # 调用 TTS 克隆 API tts_payload { model: Qwen3-TTS-12Hz-1.7B-Base, input: input_text, language: Chinese, task_type: Base, x_vector_only_mode: False, ref_text: ref_text, ref_audio: ref_audio_data, max_new_tokens: 256 } try: resp requests.post(TTS_URL, jsontts_payload, headersAUTH_HEADER, timeout120) if resp.status_code ! 200: return jsonify({error: fTTS 服务返回错误: {resp.text}}), 502 # 保存合成音频 synth_id str(uuid.uuid4()) synth_path os.path.join(SYNTH_FOLDER, f{synth_id}.wav) with open(synth_path, wb) as f: f.write(resp.content) return jsonify({url: f/serve_audio/{synth_id}.wav}) except Exception as e: return jsonify({error: fTTS 请求异常: {str(e)}}), 500 # ---------- 提供音频文件下载/播放 ---------- app.route(/serve_audio/filename) def serve_audio(filename): return send_from_directory(SYNTH_FOLDER, filename, as_attachmentFalse) if __name__ __main__: app.run(host0.0.0.0, port5001, debugTrue)3.3模型下载与准备1、Qwen3-ASR-1.7B语音识别模型下载链接https://modelscope.cn/models/Qwen/Qwen3-ASR-1.7B/files2、Qwen3-TTS-12Hz-1.7B-Base语音克隆模型下载链接https://modelscope.cn/models/Qwen/Qwen3-TTS-12Hz-1.7B-Base/files3.4模型启动参数1、Qwen3-ASR-1.7B启动参数cat Qwen3-ASR-1.7B.sh HIP_VISIBLE_DEVICES6 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 PassWord1234562、Qwen3-TTS-12Hz-1.7B-Base启动参数cat Qwen3-TTS-12Hz-1.7B-Base.sh HIP_VISIBLE_DEVICES6 vllm-omni serve /home/models/Qwen3-TTS-12Hz-1.7B-Base \ --stage-configs-path qwen3-tts_pytorch-master/qwen3_tts.yaml \ --gpu-memory-utilization 0.1 \ --omni \ --port 8088 \ --trust-remote-code \ --enforce-eager \ --served-model-name Qwen3-TTS-12Hz-1.7B-Base 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: -1qwen3_tts.yaml参数一定要注意设置engine_argsgpu_memory_utilization: 0.1四、运行测试1、启动Qwen3-ASR-1.7Bdocker exec -it qwen3-asr bash cd /home/models/ nohup ./Qwen3-ASR-1.7B.sh 2、启动Qwen3-TTS-12Hz-1.7B-Basedocker exec -it qwen3-tts bash cd /home/models/ nohup ./Qwen3-TTS-12Hz-1.7B-Base.sh 3、启动语音克隆自编程序python 语音克隆.py * Serving Flask app 语音克隆 * Debug mode: on WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead. * Running on all addresses (0.0.0.0) * Running on http://127.0.0.1:5001 * Running on http://192.168.222.65:5001 Press CTRLC to quit * Restarting with stat * Debugger is active! * Debugger PIN: 105-818-0564、访问语音克隆自编程序用谷歌浏览器访问访问之前需要做安全授权设置地址栏执行chrome://flags/搜索Insecure origins treated as secure填入http://192.168.222.65:5001然后访问http://192.168.222.65:5001先上传自己的语音样本文件或者通过实时录音录制语音文件通过ASR解析语音样本内容程序后台将语音文件和解析的语音内容发送给TTS完成语音克隆。五、测评效果如果你追求全球化、多语言音频克隆推荐选择Qwen3-TTS。它拥有10种语言支持和97ms的极低延迟框架也更通用灵活。其音色克隆质量足够优秀但在极致相似度上可能略逊于IndexTTS-2。如果你后续有私有化部署、国产硬件适配、低成本 AI 落地的需求请务必【关注我】。