录音切片上传

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>录音上传系统</title>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }

    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      max-width: 900px;
      margin: 0 auto;
      padding: 40px 20px;
      background: #f5f5f5;
    }

    .container {
      background: white;
      border-radius: 12px;
      padding: 30px;
      box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
    }

    h1 {
      font-size: 28px;
      margin-bottom: 30px;
      color: #333;
    }

    .controls {
      display: flex;
      gap: 10px;
      margin-bottom: 30px;
    }

    button {
      padding: 12px 24px;
      font-size: 16px;
      border: none;
      border-radius: 6px;
      cursor: pointer;
      transition: all 0.3s;
      font-weight: 500;
    }

    button:disabled {
      opacity: 0.5;
      cursor: not-allowed;
    }

    #startBtn {
      background: #28a745;
      color: white;
    }

    #startBtn:hover:not(:disabled) {
      background: #218838;
    }

    #stopBtn {
      background: #dc3545;
      color: white;
    }

    #stopBtn:hover:not(:disabled) {
      background: #c82333;
    }

    #abortBtn {
      background: #6c757d;
      color: white;
    }

    #abortBtn:hover:not(:disabled) {
      background: #5a6268;
    }

    .status-panel {
      background: #f8f9fa;
      border-radius: 8px;
      padding: 20px;
      margin-bottom: 20px;
    }

    .status-row {
      display: flex;
      justify-content: space-between;
      padding: 8px 0;
      border-bottom: 1px solid #e9ecef;
    }

    .status-row:last-child {
      border-bottom: none;
    }

    .status-label {
      font-weight: 600;
      color: #495057;
    }

    .status-value {
      color: #212529;
      font-family: 'Courier New', monospace;
    }

    .log-panel {
      background: #1e1e1e;
      border-radius: 8px;
      padding: 15px;
      max-height: 400px;
      overflow-y: auto;
    }

    .log-panel h3 {
      color: #fff;
      margin-bottom: 15px;
      font-size: 16px;
    }

    .log-item {
      padding: 6px 0;
      font-family: 'Courier New', monospace;
      font-size: 13px;
      line-height: 1.5;
    }

    .log-success {
      color: #4caf50;
    }

    .log-error {
      color: #f44336;
    }

    .log-info {
      color: #2196f3;
    }

    .log-panel::-webkit-scrollbar {
      width: 8px;
    }

    .log-panel::-webkit-scrollbar-track {
      background: #2d2d2d;
      border-radius: 4px;
    }

    .log-panel::-webkit-scrollbar-thumb {
      background: #555;
      border-radius: 4px;
    }

    .log-panel::-webkit-scrollbar-thumb:hover {
      background: #666;
    }
  </style>
</head>

<body>
  <div class="container">
    <h1>🎙️ 录音上传系统</h1>

    <div class="controls">
      <!-- 关键: type="button" 防止表单提交 -->
      <button id="startBtn" type="button">开始录音</button>
      <button id="stopBtn" type="button" disabled>停止录音</button>
      <button id="abortBtn" type="button" disabled>中止上传</button>
    </div>

    <div class="status-panel">
      <div class="status-row">
        <span class="status-label">录音状态:</span>
        <span class="status-value" id="recordStatus">未开始</span>
      </div>
      <div class="status-row">
        <span class="status-label">上传状态:</span>
        <span class="status-value" id="uploadStatus">idle</span>
      </div>
      <div class="status-row">
        <span class="status-label">录音时长:</span>
        <span class="status-value"><span id="duration">0</span>s</span>
      </div>
      <div class="status-row">
        <span class="status-label">会话 ID:</span>
        <span class="status-value" id="sessionIdDisplay">-</span>
      </div>
    </div>

    <div class="log-panel">
      <h3>📋 操作日志</h3>
      <div id="logContainer"></div>
    </div>
  </div>

  <script type="module">
    import { createRecorderUploader } from './createRecorderUploader.js'

    const startBtn = document.getElementById('startBtn')
    const stopBtn = document.getElementById('stopBtn')
    const abortBtn = document.getElementById('abortBtn')
    const recordStatus = document.getElementById('recordStatus')
    const uploadStatus = document.getElementById('uploadStatus')
    const durationEl = document.getElementById('duration')
    const sessionIdDisplay = document.getElementById('sessionIdDisplay')
    const logContainer = document.getElementById('logContainer')

    let mediaRecorder = null
    let uploader = null
    let startTime = 0
    let durationTimer = null

    let sessionId = null
    let chunkIndex = 0

    function log(message, type = 'info') {
      const item = document.createElement('div')
      item.className = `log-item log-${type}`
      item.textContent = `[${new Date().toLocaleTimeString()}] ${message}`
      logContainer.appendChild(item)
      logContainer.scrollTop = logContainer.scrollHeight
    }

    async function uploadChunk(blob) {
      const formData = new FormData()
      formData.append('sessionId', sessionId)
      formData.append('chunkIndex', chunkIndex++)
      formData.append('chunk', blob)

      const response = await fetch('http://localhost:3000/upload/chunk', {
        method: 'POST',
        body: formData,
      })

      if (!response.ok) {
        throw new Error(`chunk 上传失败: ${response.status}`)
      }

      return response.json()
    }

    function createUploader() {
      return createRecorderUploader({
        // chunkSize: 2 * 1024 * 1024,
        chunkSize: 100,
        maxRetry: 3,
        maxQueueSize: 10,
        upload: uploadChunk,
        hooks: {
          onChunk: (blob) => {
            log(`切片生成: ${(blob.size / 1024).toFixed(2)} KB`)
          },
          onUploadSuccess: (blob) => {
            log(`上传成功: ${(blob.size / 1024).toFixed(2)} KB`, 'success')
          },
          onUploadRetry: ({ error, retry }) => {
            log(`上传重试(${retry}): ${error.message}`, 'error')
          },
          onError: (error) => {
            log(`致命错误: ${error.message}`, 'error')
          },
          onAbort: (reason) => {
            log(`上传中止: ${reason}`, 'error')
          },
        },
      })
    }

    // 开始录音 - 使用箭头函数防止 this 绑定问题
    startBtn.onclick = async function (event) {
      // 阻止任何默认行为
      if (event) {
        event.preventDefault()
        event.stopPropagation()
      }

      try {
        sessionId = `session-${crypto.randomUUID()}`
        chunkIndex = 0
        uploader = createUploader()

        sessionIdDisplay.textContent = sessionId

        const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
        mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' })

        mediaRecorder.ondataavailable = (e) => {
          if (e.data && e.data.size > 0) {
            log(`MediaRecorder data: ${(e.data.size / 1024).toFixed(2)} KB`)
            uploader.push(e.data)
          }
        }

        mediaRecorder.start(1000)

        startTime = Date.now()
        durationTimer = setInterval(() => {
          durationEl.textContent = Math.floor((Date.now() - startTime) / 1000)
        }, 1000)

        recordStatus.textContent = '录音中'
        uploadStatus.textContent = 'idle'

        startBtn.disabled = true
        stopBtn.disabled = false
        abortBtn.disabled = false

        log(`开始录音,sessionId=${sessionId}`, 'success')
      } catch (err) {
        log(`录音失败: ${err.message}`, 'error')
      }

      // 明确返回 false 防止任何表单提交
      return false
    }

    // 停止录音
    stopBtn.onclick = async function (event) {
      if (event) {
        event.preventDefault()
        event.stopPropagation()
      }

      if (!mediaRecorder) return false

      try {
        // 步骤1: 停止录音并等待最后的数据
        log('正在停止录音...', 'info')

        await new Promise((resolve) => {
          const handleData = (e) => {
            if (e.data && e.data.size > 0) {
              log(`接收最后的数据: ${(e.data.size / 1024).toFixed(2)} KB`, 'info')
              uploader.push(e.data)
            }
          }

          const handleStop = () => {
            log('MediaRecorder 已停止', 'info')
            mediaRecorder.removeEventListener('dataavailable', handleData)
            mediaRecorder.removeEventListener('stop', handleStop)
            setTimeout(resolve, 100)
          }

          mediaRecorder.addEventListener('dataavailable', handleData)
          mediaRecorder.addEventListener('stop', handleStop)
          mediaRecorder.stop()
        })

        // 步骤2: 停止音频轨道
        mediaRecorder.stream.getTracks().forEach(t => t.stop())
        clearInterval(durationTimer)

        recordStatus.textContent = '已停止'

        // 步骤3: 等待所有数据上传完成
        log('正在等待所有数据上传完成...', 'info')
        await uploader.flush()

        // 步骤4: 检查上传器状态
        const stateInfo = uploader.getStateInfo()
        log(`上传器状态: ${JSON.stringify(stateInfo)}`, 'info')
        // 步骤4.5: 额外等待确保服务器端完全处理完毕
        log('等待服务器处理...', 'info')
        await new Promise(resolve => setTimeout(resolve, 500))

        // 步骤5: 通知服务器合并文件
        log('正在请求服务器合并文件...', 'info')
        const res = await fetch('http://localhost:3000/upload/complete', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ sessionId }),
        })

        if (!res.ok) {
          const errorText = await res.text()
          throw new Error(`Complete request failed with status ${res.status}: ${errorText}`)
        }

        const result = await res.json()

        log(
          `✅ 合并完成: ${result.file}, 切片数=${result.chunkCount}, 大小=${(result.size / 1024 / 1024).toFixed(2)} MB`,
          'success'
        )
        log(`下载地址: http://localhost:3000/download/${sessionId}`, 'success')

      } catch (err) {
        log(`❌ 上传失败: ${err.message}`, 'error')
        console.error('Upload error details:', err)
      } finally {
        startBtn.disabled = false
        stopBtn.disabled = true
        abortBtn.disabled = true
      }

      return false
    }

    // 中止
    abortBtn.onclick = async function (event) {
      if (event) {
        event.preventDefault()
        event.stopPropagation()
      }

      try {
        if (mediaRecorder && mediaRecorder.state !== 'inactive') {
          await new Promise((resolve) => {
            mediaRecorder.onstop = resolve
            mediaRecorder.stop()
          })
          mediaRecorder.stream.getTracks().forEach(t => t.stop())
        }

        clearInterval(durationTimer)

        if (uploader) {
          uploader.abort('用户中止')
        }

        recordStatus.textContent = '已中止'
        log('录音和上传已中止', 'info')

      } catch (err) {
        log(`中止时出错: ${err.message}`, 'error')
      } finally {
        startBtn.disabled = false
        stopBtn.disabled = true
        abortBtn.disabled = true
      }

      return false
    }

    // 定时更新状态
    setInterval(() => {
      if (uploader) {
        const state = uploader.getState()
        const stateInfo = uploader.getStateInfo()
        uploadStatus.textContent = `${state} (队列:${stateInfo.queueLength}, 缓冲:${(stateInfo.bufferSize / 1024).toFixed(0)}KB)`
      }
    }, 500)

    // 阻止整个页面的表单提交
    document.addEventListener('submit', (e) => {
      e.preventDefault()
      return false
    })

    // 阻止键盘 Enter 触发
    document.addEventListener('keydown', (e) => {
      if (e.key === 'Enter' && e.target.tagName !== 'TEXTAREA') {
        e.preventDefault()
        return false
      }
    })
  </script>
</body>

</html>
javascript 复制代码
export function createRecorderUploader(options) {
  const {
    chunkSize = 2 * 1024 * 1024,
    maxRetry = 3,
    maxQueueSize = 10,
    upload,
    backoff = defaultBackoff,
    hooks = {},
  } = options

  if (typeof upload !== 'function') {
    throw new Error('upload(blob) is required')
  }

  /** ---------- 内部状态 ---------- */
  let bufferBlobs = []
  let bufferSize = 0
  let uploadQueue = []
  let uploading = false

  let state = 'idle' // idle | uploading | flushing | stopped | error | aborted
  let fatalError = null

  const callHook = (name, payload) => {
    try {
      hooks[name]?.(payload)
    } catch (_) { }
  }

  const ensureUsable = () => {
    if (state === 'error') throw fatalError
    if (state === 'aborted') throw new Error('recorder aborted')
    if (state === 'stopped') throw new Error('recorder already stopped')
  }

  /** ---------- 数据入口 ---------- */
  function push(blob) {
    // 允许在 flushing 状态下继续接收数据
    if (state === 'error') throw fatalError
    if (state === 'aborted') throw new Error('recorder aborted')
    if (state === 'stopped') throw new Error('recorder already stopped')

    if (!blob || blob.size === 0) return

    bufferBlobs.push(blob)
    bufferSize += blob.size

    if (bufferSize >= chunkSize) {
      flushBuffer()
    }
  }

  /** ---------- buffer → queue ---------- */
  function flushBuffer(force = false) {
    if (bufferSize === 0 && !force) return
    if (uploadQueue.length >= maxQueueSize) {
      fail(new Error('upload queue overflow'))
      return
    }

    const merged =
      bufferSize > 0
        ? new Blob(bufferBlobs, { type: bufferBlobs[0]?.type })
        : null

    bufferBlobs = []
    bufferSize = 0

    if (merged) {
      uploadQueue.push({
        blob: merged,
        retry: 0,
      })
      callHook('onChunk', merged)
    }

    processQueue()
  }

  /** ---------- 核心上传循环 ---------- */
  async function processQueue() {
    if (uploading || fatalError) return
    uploading = true

    // 只有在非 flushing 状态时才更新为 uploading
    if (state !== 'flushing') {
      state = 'uploading'
    }

    try {
      while (uploadQueue.length > 0) {
        const task = uploadQueue[0]
        try {
          await upload(task.blob)
          uploadQueue.shift()
          callHook('onUploadSuccess', task.blob)
        } catch (err) {
          task.retry++
          callHook('onUploadRetry', {
            error: err,
            retry: task.retry,
          })

          if (task.retry > maxRetry) {
            throw err
          }
          await backoff(task.retry)
        }
      }

      // flush 完成后设置为 stopped
      if (state === 'flushing') {
        state = 'stopped'
      } else if (state === 'uploading') {
        state = 'idle'
      }
    } catch (err) {
      fail(err)
      throw err
    } finally {
      uploading = false
    }
  }

  /** ---------- flush / abort ---------- */
  async function flush() {
    // 检查当前状态
    if (state === 'error') throw fatalError
    if (state === 'aborted') throw new Error('recorder aborted')
    if (state === 'stopped') throw new Error('recorder already stopped')

    // 标记为 flushing 状态,但仍允许接收数据
    state = 'flushing'

    // 刷新缓冲区
    flushBuffer(true)

    // 等待所有上传完成
    await processQueue()

    // 确保状态已更新为 stopped
    if (state === 'flushing') {
      state = 'stopped'
    }
  }

  function abort(reason = 'aborted by user') {
    if (state === 'aborted') return
    state = 'aborted'
    bufferBlobs = []
    bufferSize = 0
    uploadQueue = []
    fatalError = new Error(reason)
    callHook('onAbort', reason)
  }

  function fail(err) {
    if (fatalError) return
    fatalError = err
    state = 'error'
    callHook('onError', err)
  }

  /** ---------- 状态读取 ---------- */
  function getState() {
    return state
  }

  function getStateInfo() {
    return {
      state,
      queueLength: uploadQueue.length,
      bufferSize,
      bufferBlobsCount: bufferBlobs.length,
      hasFatalError: !!fatalError,
      uploading
    }
  }

  return {
    push,
    flush,
    abort,
    getState,
    getStateInfo,
  }
}

/** ---------- 默认退避 ---------- */
function defaultBackoff(retry) {
  const delay = Math.min(2 ** retry * 1000, 30000)
  return new Promise((r) => setTimeout(r, delay))
}
javascript 复制代码
const http = require('http')
const fs = require('fs')
const path = require('path')
const { IncomingForm } = require('formidable')
const { pipeline } = require('stream/promises')

const PORT = 3000
const UPLOAD_ROOT = path.join(__dirname, 'uploads')

fs.mkdirSync(UPLOAD_ROOT, { recursive: true })

const mergingSessions = new Set()

// 全局上传锁 - 简单粗暴但有效
let uploadLock = Promise.resolve()

function send(res, code, data) {
  res.writeHead(code, { 'Content-Type': 'application/json' })
  res.end(JSON.stringify(data))
}

function cors(res) {
  res.setHeader('Access-Control-Allow-Origin', '*')
  res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS')
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
}

const server = http.createServer(async (req, res) => {
  cors(res)

  console.log(`\n${req.method} ${req.url}`)

  if (req.method === 'OPTIONS') {
    res.writeHead(200)
    return res.end()
  }

  const url = req.url || ''

  /* ================= 上传 chunk ================= */
  if (url === '/upload/chunk' && req.method === 'POST') {
    // ⭐ 关键: 立即获取锁,在解析前就阻塞
    const currentLock = uploadLock
    let releaseLock
    uploadLock = new Promise(resolve => {
      releaseLock = resolve
    })

    try {
      // 等待前一个上传完成
      await currentLock

      // 现在开始解析
      const form = new IncomingForm({
        multiples: false,
        maxFileSize: 20 * 1024 * 1024,
      })

      const result = await new Promise((resolve, reject) => {
        form.parse(req, (err, fields, files) => {
          if (err) {
            console.error('❌ Parse error:', err)
            reject(err)
            return
          }

          const sessionId = fields.sessionId?.[0]
          const chunkIndex = fields.chunkIndex?.[0]
          const file = files.chunk?.[0]

          if (!sessionId || chunkIndex === undefined || !file) {
            reject(new Error('Missing fields'))
            return
          }

          const sessionDir = path.join(UPLOAD_ROOT, sessionId)
          fs.mkdirSync(sessionDir, { recursive: true })

          const filename = `chunk-${String(chunkIndex).padStart(6, '0')}`
          const targetPath = path.join(sessionDir, filename)

          if (fs.existsSync(targetPath)) {
            fs.unlinkSync(file.filepath)
            console.log(`✓ Chunk ${chunkIndex} (dup)`)
            resolve({ success: true, duplicated: true })
            return
          }

          fs.renameSync(file.filepath, targetPath)
          console.log(`✓ Chunk ${chunkIndex}: ${(file.size / 1024).toFixed(2)} KB`)

          resolve({
            success: true,
            sessionId,
            chunkIndex,
            size: file.size,
          })
        })
      })

      send(res, 200, result)
    } catch (err) {
      send(res, 500, { error: err.message })
    } finally {
      // 释放锁
      releaseLock()
    }

    return
  }

  /* ================= 完成并合并 ================= */
  if (url === '/upload/complete' && req.method === 'POST') {
    let body = ''

    req.on('data', chunk => {
      body += chunk.toString()
    })

    req.on('end', async () => {
      try {
        const { sessionId } = JSON.parse(body)
        console.log(`\n📦 Complete: ${sessionId}`)

        if (!sessionId) {
          return send(res, 400, { error: 'sessionId required' })
        }

        // ⭐ 等待所有 chunk 上传完成
        console.log(`⏳ 等待上传队列...`)
        await uploadLock
        console.log(`✓ 上传队列已清空`)

        if (mergingSessions.has(sessionId)) {
          return send(res, 409, { error: 'merge in progress' })
        }

        const sessionDir = path.join(UPLOAD_ROOT, sessionId)

        if (!fs.existsSync(sessionDir)) {
          console.log(`❌ 目录不存在`)
          return send(res, 404, { error: 'session not found' })
        }

        const outputFile = path.join(sessionDir, 'merged.webm')

        if (fs.existsSync(outputFile)) {
          const stats = fs.statSync(outputFile)
          return send(res, 200, {
            success: true,
            alreadyMerged: true,
            file: 'merged.webm',
            size: stats.size,
          })
        }

        mergingSessions.add(sessionId)

        try {
          const files = fs.readdirSync(sessionDir)
            .filter(f => f.startsWith('chunk-'))
            .sort()

          console.log(`🔄 合并 ${files.length} 个切片`)

          if (files.length === 0) {
            throw new Error('No chunks')
          }

          const writeStream = fs.createWriteStream(outputFile)

          for (const file of files) {
            const chunkPath = path.join(sessionDir, file)
            const readStream = fs.createReadStream(chunkPath)
            await pipeline(readStream, writeStream, { end: false })
          }

          writeStream.end()

          await new Promise((resolve, reject) => {
            writeStream.on('finish', resolve)
            writeStream.on('error', reject)
          })

          const stats = fs.statSync(outputFile)
          console.log(`✅ ${(stats.size / 1024 / 1024).toFixed(2)} MB\n`)

          send(res, 200, {
            success: true,
            file: 'merged.webm',
            chunkCount: files.length,
            size: stats.size,
          })
        } finally {
          mergingSessions.delete(sessionId)
        }
      } catch (err) {
        console.error('❌', err.message)
        send(res, 500, { error: err.message })
      }
    })
    return
  }

  /* ================= 下载 ================= */
  if (url.startsWith('/download/') && req.method === 'GET') {
    const sessionId = url.split('/').pop()
    const filePath = path.join(UPLOAD_ROOT, sessionId, 'merged.webm')

    if (!fs.existsSync(filePath)) {
      res.writeHead(404)
      return res.end('File not found')
    }

    const stats = fs.statSync(filePath)
    res.writeHead(200, {
      'Content-Type': 'audio/webm',
      'Content-Length': stats.size,
      'Content-Disposition': `attachment; filename="recording-${sessionId}.webm"`,
    })

    const readStream = fs.createReadStream(filePath)
    readStream.pipe(res)
    return
  }

  res.writeHead(404)
  res.end('Not Found')
})

server.listen(PORT, () => {
  console.log(`\n${'='.repeat(50)}`)
  console.log(`✅ Server: http://localhost:${PORT}`)
  console.log(`📁 Uploads: ${UPLOAD_ROOT}`)
  console.log(`${'='.repeat(50)}\n`)
})

process.on('SIGINT', () => {
  console.log('\n👋 Bye')
  server.close(() => process.exit(0))
})
相关推荐
LYFlied2 小时前
【算法解题模板】-【回溯】----“试错式”问题解决利器
前端·数据结构·算法·leetcode·面试·职场和发展
程序员小寒2 小时前
前端高频面试题:深拷贝和浅拷贝的区别?
前端·javascript·面试
狮子座的男孩2 小时前
html+css基础:07、css2的复合选择器_伪类选择器(概念、动态伪类、结构伪类(核心)、否定伪类、UI伪类、目标伪类、语言伪类)及伪元素选择器
前端·css·经验分享·html·伪类选择器·伪元素选择器·结构伪类
zhougl9962 小时前
Vue 中的 `render` 函数
前端·javascript·vue.js
听风吟丶2 小时前
Spring Boot 自动配置深度解析:原理、实战与源码追踪
前端·bootstrap·html
跟着珅聪学java2 小时前
HTML中设置<select>下拉框默认值的详细教程
开发语言·前端·javascript
IT_陈寒2 小时前
JavaScript 性能优化:5个被低估的V8引擎技巧让你的代码提速50%
前端·人工智能·后端
想睡好2 小时前
setup
前端·javascript·html
桜吹雪2 小时前
DeepSeekV3.2模型内置Agent体验
javascript·人工智能