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))
})