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,你应该能看到上传界面。要测试完整流程,你需要:

  1. 准备一个音频文件(如WAV格式)和对应的文本。
  2. 在页面上传音频文件。
  3. 在文本框中输入或粘贴文本。
  4. 选择语言(默认为中文)。
  5. 点击"开始对齐处理"按钮。

后端会创建一个任务,调用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星图镜像广场,提供丰富的预置镜像,覆盖大模型推理、图像生成、视频生成、模型微调等多个领域,支持一键部署。

更多推荐