Qwen3-ForcedAligner Web应用:Node.js全栈开发实战
Qwen3-ForcedAligner Web应用:Node.js全栈开发实战
想象一下,你手头有一堆音频文件,可能是会议录音、访谈内容,或者播客节目。你想把这些音频转成文字,这本身已经够麻烦了,但更头疼的是,你还需要知道每个字、每句话在音频里出现的确切时间点。比如,你想给视频加字幕,或者做语音分析,没有精确的时间戳,后续工作简直寸步难行。
这就是语音强制对齐要解决的问题。传统的做法要么精度不够,要么操作复杂,对开发者很不友好。好在,现在有了Qwen3-ForcedAligner这个强大的开源模型,它能以非自回归的方式,高效、精准地预测文本在音频中的时间戳。
不过,模型本身只是一个“引擎”。要让普通用户,甚至是你自己团队里的非技术人员能用起来,我们还需要一个友好、易用的“驾驶舱”。这就是我们今天要做的:用Node.js搭建一个完整的Web应用,把Qwen3-ForcedAligner的能力包装起来,让用户通过浏览器就能轻松上传音频、获取带时间戳的文本,并且能实时看到处理进度。
这个实战项目会覆盖从前端上传界面、后端大文件处理,到WebSocket实时通信的全链路开发。你会发现,用Node.js构建这样的AI应用,既高效又有趣。
1. 项目目标与技术栈
我们的目标是构建一个功能完整的Web应用,核心是调用Qwen3-ForcedAligner模型进行语音对齐。用户通过网页上传音频文件(比如WAV、MP3)和对应的文本,后端处理后,返回一个包含每个字或词精确起止时间戳的JSON结果。
考虑到音频文件可能比较大,以及模型推理需要一定时间,我们需要处理好大文件上传和长时间任务的状态通知。整个技术栈选择如下:
- 后端运行时:Node.js。它的事件驱动、非阻塞I/O模型非常适合处理高并发的I/O密集型任务,比如文件上传和WebSocket通信。
- 后端框架:Express.js。轻量、灵活,生态丰富,能快速搭建RESTful API。
- 文件上传:使用
multer中间件处理multipart/form-data格式的上传。 - 大文件与进度:前端采用分块上传,后端提供进度查询接口,结合WebSocket实现实时进度推送。
- 模型调用:通过Python子进程或HTTP API(如果模型已部署为独立服务)来调用Qwen3-ForcedAligner。这里为了简化,我们先采用子进程方式。
- 前端:简单的HTML、CSS和JavaScript。使用Fetch API进行文件分块上传,使用WebSocket接收处理进度。
- 实时通信:Socket.IO。它封装了WebSocket,提供了更简单的API和自动重连等高级功能。
整个应用的架构很简单:用户在前端上传文件,后端接收后,启动一个Python脚本调用模型,处理过程中通过WebSocket向前端发送进度,处理完成后返回结果。
2. 后端核心:Express.js服务搭建
我们先从后端开始,创建一个基本的Express服务器,并配置好文件上传和静态文件服务。
首先,初始化项目并安装依赖:
mkdir qwen-aligner-webapp && cd qwen-aligner-webapp
npm init -y
npm install express multer socket.io cors
npm install --save-dev nodemon
然后,创建 server.js 作为我们的主入口文件:
// server.js
const express = require('express');
const cors = require('cors');
const path = require('path');
const { createServer } = require('http');
const { Server } = require('socket.io');
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: {
origin: "http://localhost:3000", // 前端开发服务器地址
methods: ["GET", "POST"]
}
});
// 中间件
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, 'public'))); // 静态文件目录
// 引入路由
const uploadRoutes = require('./routes/upload');
const taskRoutes = require('./routes/task');
app.use('/api/upload', uploadRoutes);
app.use('/api/task', taskRoutes);
// Socket.IO 连接处理
io.on('connection', (socket) => {
console.log('客户端已连接:', socket.id);
socket.on('join-task-room', (taskId) => {
socket.join(taskId);
console.log(`客户端 ${socket.id} 加入任务房间: ${taskId}`);
});
socket.on('disconnect', () => {
console.log('客户端断开连接:', socket.id);
});
});
// 将 io 实例挂载到 app 上,方便在路由中访问
app.set('io', io);
const PORT = process.env.PORT || 3001;
httpServer.listen(PORT, () => {
console.log(`服务器运行在 http://localhost:${PORT}`);
});
这段代码搭建了一个基础的HTTP服务器,集成了Socket.IO,并预留了上传和任务管理的API路由。静态文件服务指向 public 目录,我们稍后会在这里放前端页面。
3. 处理大文件上传:分块与进度
直接上传大文件到Node.js服务可能会遇到内存和超时问题。更好的做法是前端将文件切成小块,一块一块地上传,后端接收后拼接起来。同时,我们需要告诉前端上传的进度。
我们先创建处理上传的路由文件 routes/upload.js:
// routes/upload.js
const express = require('express');
const multer = require('multer');
const fs = require('fs');
const path = require('path');
const router = express.Router();
// 确保上传目录存在
const uploadDir = path.join(__dirname, '../uploads');
const chunksDir = path.join(uploadDir, 'chunks');
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
if (!fs.existsSync(chunksDir)) fs.mkdirSync(chunksDir, { recursive: true });
// 配置 multer 用于接收文件块(内存存储,因为块较小)
const chunkUpload = multer({ storage: multer.memoryStorage() });
// 上传文件块
router.post('/chunk', chunkUpload.single('chunk'), async (req, res) => {
try {
const { fileHash, chunkIndex, totalChunks, fileName } = req.body;
const chunk = req.file;
if (!chunk) {
return res.status(400).json({ error: '未接收到文件块' });
}
// 为每个文件创建一个临时目录存放其所有块
const chunkDir = path.join(chunksDir, fileHash);
if (!fs.existsSync(chunkDir)) {
fs.mkdirSync(chunkDir, { recursive: true });
}
// 保存块文件
const chunkPath = path.join(chunkDir, `${chunkIndex}`);
fs.writeFileSync(chunkPath, chunk.buffer);
// 计算并返回进度
const uploadedChunks = fs.readdirSync(chunkDir).length;
const progress = Math.round((uploadedChunks / totalChunks) * 100);
res.json({
success: true,
chunkIndex: parseInt(chunkIndex),
progress,
message: `块 ${chunkIndex} 上传成功`
});
} catch (error) {
console.error('上传块失败:', error);
res.status(500).json({ error: '上传块失败', details: error.message });
}
});
// 合并所有块为完整文件
router.post('/merge', express.json(), async (req, res) => {
try {
const { fileHash, fileName, totalChunks } = req.body;
const chunkDir = path.join(chunksDir, fileHash);
const finalFilePath = path.join(uploadDir, fileName);
// 检查是否所有块都已上传
const uploadedChunks = fs.readdirSync(chunkDir).length;
if (uploadedChunks !== parseInt(totalChunks)) {
return res.status(400).json({
error: '块不完整',
uploaded: uploadedChunks,
total: totalChunks
});
}
// 按索引顺序读取并合并块
const writeStream = fs.createWriteStream(finalFilePath);
for (let i = 0; i < totalChunks; i++) {
const chunkPath = path.join(chunkDir, `${i}`);
const chunkBuffer = fs.readFileSync(chunkPath);
writeStream.write(chunkBuffer);
}
writeStream.end();
writeStream.on('finish', () => {
// 合并完成后,清理块临时目录
fs.rmSync(chunkDir, { recursive: true, force: true });
res.json({
success: true,
filePath: finalFilePath,
fileName
});
});
writeStream.on('error', (err) => {
console.error('合并文件失败:', err);
res.status(500).json({ error: '合并文件失败', details: err.message });
});
} catch (error) {
console.error('合并请求失败:', error);
res.status(500).json({ error: '合并请求失败', details: error.message });
}
});
module.exports = router;
这个路由提供了两个关键端点:/chunk 用于上传单个文件块,/merge 用于在所有块上传完成后将它们合并成一个完整的文件。我们还通过文件哈希来管理属于同一个文件的所有块。
4. 任务管理与模型调用
文件上传完成后,我们需要创建一个任务来处理这个文件。这个任务会调用Python脚本(或HTTP服务)来运行Qwen3-ForcedAligner模型。由于模型推理可能需要几秒到几十秒,我们需要异步处理,并通过WebSocket向客户端推送进度。
首先,创建一个简单的任务管理器 services/taskManager.js:
// services/taskManager.js
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
class TaskManager {
constructor(io) {
this.io = io;
this.tasks = new Map(); // taskId -> taskInfo
}
// 创建新的对齐任务
async createAlignTask(audioPath, text, language = 'Chinese', taskId) {
const taskInfo = {
id: taskId,
audioPath,
text,
language,
status: 'pending', // pending, processing, completed, failed
progress: 0,
result: null,
error: null
};
this.tasks.set(taskId, taskInfo);
// 通过 WebSocket 通知任务已创建
this.io.to(taskId).emit('task-update', {
taskId,
status: 'pending',
progress: 0,
message: '任务已创建,等待处理'
});
// 异步启动处理
setTimeout(() => this.processTask(taskId), 100);
return taskInfo;
}
// 处理任务(调用Python脚本)
async processTask(taskId) {
const task = this.tasks.get(taskId);
if (!task) return;
task.status = 'processing';
task.progress = 10;
this.io.to(taskId).emit('task-update', {
taskId,
status: 'processing',
progress: 10,
message: '开始处理音频文件'
});
try {
// 假设我们有一个Python脚本 align.py 来调用模型
// 这个脚本需要接收音频路径、文本和语言参数
const pythonScript = path.join(__dirname, '../scripts/align.py');
// 准备参数
const args = [
pythonScript,
'--audio', task.audioPath,
'--text', task.text,
'--language', task.language,
'--output', path.join(__dirname, `../results/${taskId}.json`)
];
// 创建结果目录
const resultsDir = path.join(__dirname, '../results');
if (!fs.existsSync(resultsDir)) {
fs.mkdirSync(resultsDir, { recursive: true });
}
// 启动Python子进程
const pythonProcess = spawn('python3', args);
let stdoutData = '';
let stderrData = '';
pythonProcess.stdout.on('data', (data) => {
stdoutData += data.toString();
// 可以解析Python脚本输出的进度信息
// 这里简单模拟进度更新
if (task.progress < 80) {
task.progress += 10;
this.io.to(taskId).emit('task-update', {
taskId,
status: 'processing',
progress: task.progress,
message: `处理中... ${task.progress}%`
});
}
});
pythonProcess.stderr.on('data', (data) => {
stderrData += data.toString();
console.error(`Python脚本错误 (${taskId}):`, data.toString());
});
// 等待进程结束
await new Promise((resolve, reject) => {
pythonProcess.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Python脚本退出码: ${code}\n${stderrData}`));
}
});
pythonProcess.on('error', (err) => {
reject(err);
});
});
// 读取结果文件
const resultPath = path.join(__dirname, `../results/${taskId}.json`);
if (fs.existsSync(resultPath)) {
const resultData = fs.readFileSync(resultPath, 'utf8');
task.result = JSON.parse(resultData);
task.status = 'completed';
task.progress = 100;
this.io.to(taskId).emit('task-update', {
taskId,
status: 'completed',
progress: 100,
message: '处理完成',
result: task.result
});
// 清理结果文件(可选)
// fs.unlinkSync(resultPath);
} else {
throw new Error('结果文件未生成');
}
} catch (error) {
console.error(`处理任务失败 (${taskId}):`, error);
task.status = 'failed';
task.error = error.message;
this.io.to(taskId).emit('task-update', {
taskId,
status: 'failed',
progress: task.progress,
message: `处理失败: ${error.message}`
});
}
}
// 获取任务状态
getTask(taskId) {
return this.tasks.get(taskId);
}
// 获取所有任务
getAllTasks() {
return Array.from(this.tasks.values());
}
}
module.exports = TaskManager;
接下来,创建对应的路由 routes/task.js:
// routes/task.js
const express = require('express');
const router = express.Router();
const TaskManager = require('../services/taskManager');
// 注意:TaskManager 需要在 server.js 中初始化并传入 io 实例
// 这里我们假设通过中间件将 taskManager 挂载到 req.app
router.use((req, res, next) => {
if (!req.app.get('taskManager')) {
// 初始化 TaskManager
const io = req.app.get('io');
req.app.set('taskManager', new TaskManager(io));
}
next();
});
// 创建新的对齐任务
router.post('/align', express.json(), async (req, res) => {
try {
const { audioPath, text, language } = req.body;
const taskManager = req.app.get('taskManager');
if (!audioPath || !text) {
return res.status(400).json({ error: '缺少 audioPath 或 text 参数' });
}
// 生成唯一任务ID
const taskId = `task_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// 创建任务
const task = await taskManager.createAlignTask(audioPath, text, language, taskId);
res.json({
success: true,
taskId: task.id,
status: task.status,
message: '对齐任务已创建'
});
} catch (error) {
console.error('创建任务失败:', error);
res.status(500).json({ error: '创建任务失败', details: error.message });
}
});
// 查询任务状态
router.get('/:taskId', (req, res) => {
try {
const { taskId } = req.params;
const taskManager = req.app.get('taskManager');
const task = taskManager.getTask(taskId);
if (!task) {
return res.status(404).json({ error: '任务不存在' });
}
res.json({
taskId: task.id,
status: task.status,
progress: task.progress,
result: task.result,
error: task.error
});
} catch (error) {
console.error('查询任务失败:', error);
res.status(500).json({ error: '查询任务失败', details: error.message });
}
});
module.exports = router;
最后,我们需要一个Python脚本 scripts/align.py 来实际调用Qwen3-ForcedAligner模型。这是一个简化示例:
#!/usr/bin/env python3
# scripts/align.py
import argparse
import json
import torch
from qwen_asr import Qwen3ForcedAligner
import sys
def main():
parser = argparse.ArgumentParser(description='使用 Qwen3-ForcedAligner 进行语音对齐')
parser.add_argument('--audio', required=True, help='音频文件路径')
parser.add_argument('--text', required=True, help='要对齐的文本')
parser.add_argument('--language', default='Chinese', help='语言代码')
parser.add_argument('--output', required=True, help='输出JSON文件路径')
args = parser.parse_args()
try:
# 加载模型(在实际应用中,可以考虑模型预加载)
print(f"正在加载模型...", file=sys.stderr)
model = Qwen3ForcedAligner.from_pretrained(
"Qwen/Qwen3-ForcedAligner-0.6B",
dtype=torch.bfloat16,
device_map="cuda:0", # 根据实际情况调整
)
print(f"开始处理音频: {args.audio}", file=sys.stderr)
# 执行对齐
results = model.align(
audio=args.audio,
text=args.text,
language=args.language,
)
# 格式化结果
aligned_segments = []
for segment in results[0]:
aligned_segments.append({
'text': segment.text,
'start_time': segment.start_time,
'end_time': segment.end_time
})
output_data = {
'audio_file': args.audio,
'text': args.text,
'language': args.language,
'aligned_segments': aligned_segments
}
# 保存结果
with open(args.output, 'w', encoding='utf-8') as f:
json.dump(output_data, f, ensure_ascii=False, indent=2)
print(f"处理完成,结果已保存到: {args.output}", file=sys.stderr)
except Exception as e:
print(f"处理失败: {str(e)}", file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()
注意:这个Python脚本需要安装 qwen-asr 包,并且要有合适的GPU环境。在实际部署时,你可能需要将模型服务独立部署,然后通过HTTP API调用,而不是每次都在子进程中加载模型。
5. 前端实现:上传界面与实时进度
后端准备好了,现在我们来创建一个简单的前端页面。在项目根目录创建 public 文件夹,然后在里面创建 index.html:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Qwen3-ForcedAligner 语音对齐工具</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
line-height: 1.6;
color: #333;
max-width: 1000px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background: white;
border-radius: 10px;
padding: 30px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1 {
color: #2c3e50;
margin-bottom: 10px;
border-bottom: 2px solid #3498db;
padding-bottom: 10px;
}
.description {
color: #666;
margin-bottom: 30px;
font-size: 16px;
}
.upload-section, .text-section, .results-section {
margin-bottom: 30px;
padding: 20px;
border: 1px solid #e0e0e0;
border-radius: 8px;
background-color: #fafafa;
}
h2 {
color: #3498db;
margin-bottom: 15px;
font-size: 18px;
}
.file-input {
margin-bottom: 15px;
}
input[type="file"], textarea, select {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
margin-top: 5px;
}
textarea {
min-height: 100px;
resize: vertical;
font-family: monospace;
}
.progress-container {
margin-top: 15px;
}
.progress-bar {
width: 100%;
height: 20px;
background-color: #ecf0f1;
border-radius: 10px;
overflow: hidden;
margin-bottom: 5px;
}
.progress-fill {
height: 100%;
background-color: #3498db;
width: 0%;
transition: width 0.3s ease;
}
.progress-text {
font-size: 14px;
color: #666;
}
.button {
background-color: #3498db;
color: white;
border: none;
padding: 12px 24px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
transition: background-color 0.2s;
}
.button:hover {
background-color: #2980b9;
}
.button:disabled {
background-color: #bdc3c7;
cursor: not-allowed;
}
.status-message {
padding: 10px;
border-radius: 4px;
margin-top: 15px;
display: none;
}
.status-success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.status-error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.status-info {
background-color: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
.result-item {
padding: 10px;
border-bottom: 1px solid #eee;
font-family: monospace;
font-size: 14px;
}
.result-item:last-child {
border-bottom: none;
}
.timestamp {
color: #e74c3c;
font-weight: bold;
}
.text {
color: #2c3e50;
}
.task-id {
font-family: monospace;
background-color: #f8f9fa;
padding: 2px 6px;
border-radius: 3px;
font-size: 12px;
color: #6c757d;
}
</style>
</head>
<body>
<div class="container">
<h1>Qwen3-ForcedAligner 语音对齐工具</h1>
<p class="description">
上传音频文件和对应文本,获取精确到字词级别的时间戳对齐结果。支持多种语言和方言。
</p>
<div class="upload-section">
<h2>1. 上传音频文件</h2>
<div class="file-input">
<input type="file" id="audioFile" accept=".wav,.mp3,.m4a,.flac">
<div class="progress-container" id="uploadProgressContainer" style="display: none;">
<div class="progress-bar">
<div class="progress-fill" id="uploadProgressFill"></div>
</div>
<div class="progress-text" id="uploadProgressText">0%</div>
</div>
</div>
<div id="uploadStatus" class="status-message"></div>
</div>
<div class="text-section">
<h2>2. 输入要对齐的文本</h2>
<textarea id="inputText" placeholder="请输入或粘贴要对齐的文本..."></textarea>
<div style="margin-top: 15px;">
<label for="languageSelect">选择语言:</label>
<select id="languageSelect">
<option value="Chinese">中文</option>
<option value="English">英文</option>
<option value="Japanese">日文</option>
<option value="Korean">韩文</option>
<option value="French">法文</option>
<option value="German">德文</option>
<option value="Spanish">西班牙文</option>
</select>
</div>
</div>
<div style="text-align: center; margin: 30px 0;">
<button id="startAlignBtn" class="button" disabled>开始对齐处理</button>
</div>
<div class="results-section">
<h2>3. 处理进度与结果</h2>
<div id="taskStatus" class="status-message status-info"></div>
<div class="progress-container" id="taskProgressContainer" style="display: none;">
<div class="progress-bar">
<div class="progress-fill" id="taskProgressFill"></div>
</div>
<div class="progress-text" id="taskProgressText">0%</div>
</div>
<div id="resultsContainer" style="display: none;">
<h3>对齐结果:</h3>
<div id="resultsList"></div>
<div style="margin-top: 15px;">
<button id="downloadBtn" class="button">下载JSON结果</button>
</div>
</div>
</div>
</div>
<script src="/socket.io/socket.io.js"></script>
<script>
// 全局变量
let currentTaskId = null;
let uploadedFilePath = null;
let socket = null;
// DOM 元素
const audioFileInput = document.getElementById('audioFile');
const inputText = document.getElementById('inputText');
const languageSelect = document.getElementById('languageSelect');
const startAlignBtn = document.getElementById('startAlignBtn');
const uploadProgressContainer = document.getElementById('uploadProgressContainer');
const uploadProgressFill = document.getElementById('uploadProgressFill');
const uploadProgressText = document.getElementById('uploadProgressText');
const uploadStatus = document.getElementById('uploadStatus');
const taskStatus = document.getElementById('taskStatus');
const taskProgressContainer = document.getElementById('taskProgressContainer');
const taskProgressFill = document.getElementById('taskProgressFill');
const taskProgressText = document.getElementById('taskProgressText');
const resultsContainer = document.getElementById('resultsContainer');
const resultsList = document.getElementById('resultsList');
const downloadBtn = document.getElementById('downloadBtn');
// 初始化 Socket.IO 连接
function initSocket() {
socket = io('http://localhost:3001');
socket.on('connect', () => {
console.log('已连接到服务器');
});
socket.on('task-update', (data) => {
updateTaskProgress(data);
});
socket.on('disconnect', () => {
console.log('与服务器断开连接');
});
}
// 上传音频文件(分块上传)
async function uploadAudioFile(file) {
const CHUNK_SIZE = 1024 * 1024; // 1MB 每块
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
const fileHash = await calculateFileHash(file);
uploadProgressContainer.style.display = 'block';
showStatus(uploadStatus, '开始上传文件...', 'info');
// 上传每个块
for (let i = 0; i < totalChunks; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('fileHash', fileHash);
formData.append('chunkIndex', i);
formData.append('totalChunks', totalChunks);
formData.append('fileName', file.name);
try {
const response = await fetch('/api/upload/chunk', {
method: 'POST',
body: formData
});
const result = await response.json();
if (!result.success) {
throw new Error(result.error || '上传块失败');
}
// 更新进度条
const progress = result.progress;
uploadProgressFill.style.width = `${progress}%`;
uploadProgressText.textContent = `${progress}%`;
if (progress === 100) {
showStatus(uploadStatus, '所有块上传完成,正在合并文件...', 'info');
}
} catch (error) {
showStatus(uploadStatus, `上传失败: ${error.message}`, 'error');
return null;
}
}
// 所有块上传完成,合并文件
try {
const mergeResponse = await fetch('/api/upload/merge', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
fileHash: fileHash,
fileName: file.name,
totalChunks: totalChunks
})
});
const mergeResult = await mergeResponse.json();
if (!mergeResult.success) {
throw new Error(mergeResult.error || '合并文件失败');
}
showStatus(uploadStatus, `文件上传成功: ${file.name}`, 'success');
return mergeResult.filePath;
} catch (error) {
showStatus(uploadStatus, `合并文件失败: ${error.message}`, 'error');
return null;
}
}
// 计算文件哈希(简化版)
async function calculateFileHash(file) {
// 在实际应用中,应该使用更可靠的哈希算法
// 这里使用文件名+大小+最后修改时间作为简单哈希
return `${file.name}_${file.size}_${file.lastModified}`.replace(/[^a-zA-Z0-9]/g, '_');
}
// 开始对齐任务
async function startAlignmentTask(audioPath, text, language) {
try {
const response = await fetch('/api/task/align', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
audioPath: audioPath,
text: text,
language: language
})
});
const result = await response.json();
if (!result.success) {
throw new Error(result.error || '创建任务失败');
}
currentTaskId = result.taskId;
showStatus(taskStatus, `任务已创建,ID: <span class="task-id">${currentTaskId}</span>`, 'info');
taskProgressContainer.style.display = 'block';
// 加入任务房间以接收进度更新
socket.emit('join-task-room', currentTaskId);
return result.taskId;
} catch (error) {
showStatus(taskStatus, `创建任务失败: ${error.message}`, 'error');
return null;
}
}
// 更新任务进度
function updateTaskProgress(data) {
if (data.taskId !== currentTaskId) return;
taskProgressFill.style.width = `${data.progress}%`;
taskProgressText.textContent = `${data.progress}%`;
let statusClass = 'info';
let statusText = data.message;
if (data.status === 'processing') {
statusText = `处理中... ${data.message}`;
} else if (data.status === 'completed') {
statusClass = 'success';
statusText = '处理完成!';
showResults(data.result);
} else if (data.status === 'failed') {
statusClass = 'error';
statusText = `处理失败: ${data.message}`;
}
showStatus(taskStatus, statusText, statusClass);
}
// 显示对齐结果
function showResults(resultData) {
resultsContainer.style.display = 'block';
resultsList.innerHTML = '';
if (!resultData || !resultData.aligned_segments) {
resultsList.innerHTML = '<div class="result-item">无结果数据</div>';
return;
}
resultData.aligned_segments.forEach(segment => {
const item = document.createElement('div');
item.className = 'result-item';
const startTime = formatTime(segment.start_time);
const endTime = formatTime(segment.end_time);
item.innerHTML = `
<span class="timestamp">[${startTime} - ${endTime}]</span>
<span class="text"> ${segment.text}</span>
`;
resultsList.appendChild(item);
});
// 保存结果数据供下载
window.alignmentResult = resultData;
}
// 格式化时间(秒转为 00:00.000 格式)
function formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = (seconds % 60).toFixed(3);
return `${mins.toString().padStart(2, '0')}:${secs.padStart(6, '0')}`;
}
// 显示状态消息
function showStatus(element, message, type) {
element.innerHTML = message;
element.className = 'status-message';
switch (type) {
case 'success':
element.classList.add('status-success');
break;
case 'error':
element.classList.add('status-error');
break;
case 'info':
element.classList.add('status-info');
break;
}
element.style.display = 'block';
}
// 下载结果
function downloadResults() {
if (!window.alignmentResult) {
alert('没有可下载的结果');
return;
}
const dataStr = JSON.stringify(window.alignmentResult, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `alignment_result_${currentTaskId || Date.now()}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
// 事件监听
audioFileInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
// 重置状态
uploadedFilePath = null;
startAlignBtn.disabled = true;
uploadProgressFill.style.width = '0%';
uploadProgressText.textContent = '0%';
// 上传文件
const filePath = await uploadAudioFile(file);
if (filePath) {
uploadedFilePath = filePath;
// 检查是否已输入文本,启用开始按钮
if (inputText.value.trim()) {
startAlignBtn.disabled = false;
}
}
});
inputText.addEventListener('input', () => {
// 只有同时有上传的文件和输入的文本时才启用开始按钮
startAlignBtn.disabled = !(uploadedFilePath && inputText.value.trim());
});
startAlignBtn.addEventListener('click', async () => {
if (!uploadedFilePath || !inputText.value.trim()) {
showStatus(taskStatus, '请先上传音频文件并输入文本', 'error');
return;
}
// 重置任务状态
currentTaskId = null;
resultsContainer.style.display = 'none';
taskProgressFill.style.width = '0%';
taskProgressText.textContent = '0%';
taskProgressContainer.style.display = 'block';
// 禁用按钮防止重复提交
startAlignBtn.disabled = true;
showStatus(taskStatus, '正在创建对齐任务...', 'info');
// 开始任务
const language = languageSelect.value;
await startAlignmentTask(uploadedFilePath, inputText.value.trim(), language);
// 重新启用按钮
startAlignBtn.disabled = false;
});
downloadBtn.addEventListener('click', downloadResults);
// 初始化
document.addEventListener('DOMContentLoaded', () => {
initSocket();
showStatus(taskStatus, '准备就绪,请上传音频文件和输入文本', 'info');
});
</script>
</body>
</html>
这个前端页面提供了完整的用户界面:文件上传区域、文本输入框、语言选择、进度显示和结果展示。它使用分块上传处理大文件,通过WebSocket实时接收处理进度,并在完成后以清晰的形式展示时间戳对齐结果。
6. 运行与测试
现在,让我们把整个应用跑起来。首先,确保你的项目结构大致如下:
qwen-aligner-webapp/
├── node_modules/
├── public/
│ └── index.html
├── scripts/
│ └── align.py
├── routes/
│ ├── upload.js
│ └── task.js
├── services/
│ └── taskManager.js
├── uploads/ (自动创建)
├── results/ (自动创建)
├── package.json
├── server.js
└── .gitignore
然后,在 package.json 中添加启动脚本:
{
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
}
}
现在,启动开发服务器:
npm run dev
打开浏览器,访问 http://localhost:3001,你应该能看到上传界面。要测试完整流程,你需要:
- 准备一个音频文件(如WAV格式)和对应的文本。
- 在页面上传音频文件。
- 在文本框中输入或粘贴文本。
- 选择语言(默认为中文)。
- 点击"开始对齐处理"按钮。
后端会创建一个任务,调用Python脚本处理。处理过程中,你会看到进度条更新和状态消息。处理完成后,对齐结果会显示在页面上,你可以看到每个文本片段对应的时间戳。
7. 部署优化与扩展建议
我们完成了一个可工作的原型,但在生产环境中,还需要考虑更多因素:
1. 模型服务化 目前我们在每次请求时都通过Python子进程加载模型,这非常低效。更好的做法是将Qwen3-ForcedAligner部署为独立的HTTP服务(比如使用FastAPI),然后Node.js后端通过API调用它。这样模型只需加载一次,可以处理多个并发请求。
2. 任务队列 对于高并发场景,应该使用任务队列(如Bull、RabbitMQ)来管理对齐任务,避免Node.js事件循环被长时间运行的同步任务阻塞。
3. 文件存储 上传的文件目前保存在服务器本地磁盘。在生产环境中,应该使用对象存储服务(如AWS S3、阿里云OSS),并考虑文件清理策略。
4. 错误处理与重试 增加更完善的错误处理、任务重试机制,以及用户友好的错误提示。
5. 身份验证与授权 为API添加身份验证(如JWT),防止未授权访问。
6. 前端优化 可以添加拖拽上传、批量处理、历史记录查看等功能,提升用户体验。
7. 容器化部署 使用Docker容器化应用,便于部署和扩展。
这个项目展示了如何用Node.js全栈技术将先进的AI模型能力转化为实用的Web应用。虽然Qwen3-ForcedAligner本身很强大,但只有通过这样易用的界面,它的价值才能真正发挥出来。你可以基于这个原型,根据实际需求进行扩展和优化,构建出更加强大、稳定的语音对齐服务平台。
获取更多AI镜像
想探索更多AI镜像和应用场景?访问 CSDN星图镜像广场,提供丰富的预置镜像,覆盖大模型推理、图像生成、视频生成、模型微调等多个领域,支持一键部署。
更多推荐

所有评论(0)