海光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秒内(含推理)。
并发策略:单卡下使用同步处理,配合Nginx+uWSGI可提升并发能力,但本方案侧重单用户场景。
程序工作流程图

三、实施方法及代码
3.1硬件环境
本方案的硬件平台为一台H3C服务器,配置如下:
|
组件 |
规格 |
|
CPU |
2×海光74902.7GHz64C |
|
内存 |
16×32GDDR5 |
|
GPU |
8×海光DCU64GBK100_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-TTS-12Hz-1.7B-Base):
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-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 PassWord@123456"}
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_ok=True)
os.makedirs(SYNTH_FOLDER, exist_ok=True)
# ---------- 临时文件定期清理 ----------
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(target=periodic_cleanup, daemon=True).start()
# ---------- 前端页面 ----------
HTML_TEMPLATE = """
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.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[type="text"] { 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 class="section">
<h3>第一步:上传或录制参考音频</h3>
<input type="file" id="audioFile" accept="audio/*">
<button id="uploadBtn">上传并识别</button>
<span class="info">或</span>
<button id="startRecord">开始录音</button>
<button id="stopRecord" disabled>停止录音</button>
<div id="recordingStatus" class="info"></div>
</div>
<!-- 步骤2:参考文本(ASR识别结果,可修改) -->
<div class="section">
<h3>第二步:参考文本(音频内容,可修改)</h3>
<textarea id="refText" rows="3" placeholder="ASR 识别结果会显示在这里..."></textarea>
<input type="hidden" id="fileId">
</div>
<!-- 步骤3:目标合成文本 -->
<div class="section">
<h3>第三步:要合成的文本</h3>
<textarea id="synthText" rows="4" placeholder="请输入您想要合成的文本内容...">滚滚长江东逝水,浪花淘尽英雄。是非成败转头空。青山依旧在,几度夕阳红。白发渔樵江渚上,惯看秋月春风。一壶浊酒喜相逢。古今多少事,都付笑谈中。</textarea>
</div>
<!-- 步骤4:克隆并播放 -->
<div class="section">
<button id="cloneBtn">开始克隆</button>
<span id="cloneStatus" class="info"></span>
<div id="audioPlayer" class="hidden">
<h3>合成结果</h3>
<audio controls id="audioEl"></audio>
<br>
<a id="downloadLink" href="#" download="cloned_audio.wav">下载音频</a>
</div>
</div>
<div id="errorMsg" class="error"></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],
check=True, capture_output=True, timeout=30
)
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, headers=AUTH_HEADER, files=files, timeout=60)
if resp.status_code != 200:
return jsonify({'error': f'ASR 服务返回错误: {resp.text}'}), 502
result = resp.json()
raw_text = result.get('text', '')
# 解析 "<asr_text>..." 格式
match = re.search(r'<asr_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': f'ASR 请求异常: {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 = f"data: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, json=tts_payload, headers=AUTH_HEADER, timeout=120)
if resp.status_code != 200:
return jsonify({'error': f'TTS 服务返回错误: {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': f'TTS 请求异常: {str(e)}'}), 500
# ---------- 提供音频文件下载/播放 ----------
@app.route('/serve_audio/<filename>')
def serve_audio(filename):
return send_from_directory(SYNTH_FOLDER, filename, as_attachment=False)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5001, debug=True)
3.3模型下载与准备
1、Qwen3-ASR-1.7B(语音识别模型):
下载链接:https://modelscope.cn/models/Qwen/Qwen3-ASR-1.7B/files
2、Qwen3-TTS-12Hz-1.7B-Base(语音克隆模型)
下载链接:
https://modelscope.cn/models/Qwen/Qwen3-TTS-12Hz-1.7B-Base/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-TTS-12Hz-1.7B-Base启动参数:
cat Qwen3-TTS-12Hz-1.7B-Base.sh
HIP_VISIBLE_DEVICES=6 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: -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-TTS-12Hz-1.7B-Base
docker 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 CTRL+C to quit
* Restarting with stat
* Debugger is active!
* Debugger PIN: 105-818-056
4、访问语音克隆自编程序
用谷歌浏览器访问,访问之前需要做安全授权设置:
地址栏执行: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 落地的需求,请务必【关注我】。
更多推荐



所有评论(0)