初始化
const recordInfo = await loadRecordInfo()
const uploader = createRecorderUploader({
chunkSize: recordInfo.chunkSize,
upload: async (blob) => {
// 假设 blob 对应 uploadedBytes 之后的数据
await uploadToServer(blob)
// 上传成功后推进 uploadedBytes
recordInfo.uploadedBytes += blob.size
recordInfo.state = 'UPLOADING'
await saveRecordInfo(recordInfo)
},
hooks: {
onError(err) {
recordInfo.state = 'ERROR'
saveRecordInfo(recordInfo)
},
onAbort() {
recordInfo.state = 'ABORTED'
saveRecordInfo(recordInfo)
}
}
})
恢复
async function resumeUploadIfNeeded() {
if (recordInfo.uploadedBytes >= recordInfo.fileSize) return
const fd = await fs.open(recordInfo.pcmPath, fs.OpenMode.READ_ONLY)
let offset = recordInfo.uploadedBytes
const buf = new ArrayBuffer(recordInfo.chunkSize)
while (offset < recordInfo.fileSize) {
const readLen = Math.min(
recordInfo.chunkSize,
recordInfo.fileSize - offset
)
const { bytesRead } = await fs.read(fd.fd, buf, {
offset: 0,
length: readLen,
position: offset
})
if (bytesRead <= 0) break
uploader.push(new Blob([buf.slice(0, bytesRead)]))
offset += bytesRead
}
await fs.close(fd)
}
开始新的
audioRecorder.on('audioData', async (pcmBuffer) => {
// 写文件
await fs.appendFile(recordInfo.pcmPath, pcmBuffer)
// 更新 fileSize
recordInfo.fileSize += pcmBuffer.byteLength
recordInfo.state = 'RECORDING'
await saveRecordInfo(recordInfo)
// 推给 uploader
uploader.push(new Blob([pcmBuffer]))
})
停止
async function stopRecord() {
recordInfo.state = 'PENDING_UPLOAD'
await saveRecordInfo(recordInfo)
await uploader.flush()
recordInfo.state = 'COMPLETED'
await saveRecordInfo(recordInfo)
}
recordInfoStore.ts
// recordInfoStore.ts
import fs from '@ohos.file.fs'
export interface RecordInfo {
sessionId: string
pcmPath: string
uploadedBytes: number
fileSize: number
sampleRate: number
channels: number
chunkSize: number
state: string
lastUpdate: number
}
const RECORD_INFO_PATH = '/data/storage/recordInfo.txt'
export async function loadRecordInfo(): Promise<RecordInfo | null> {
try {
const data = await fs.readText(RECORD_INFO_PATH)
return JSON.parse(data)
} catch {
return null
}
}
export async function saveRecordInfo(info: RecordInfo) {
info.lastUpdate = Date.now()
const tmp = RECORD_INFO_PATH + '.tmp'
await fs.writeText(tmp, JSON.stringify(info))
await fs.rename(tmp, RECORD_INFO_PATH)
}
type UploadTask = {
data: Uint8Array
retry: number
}
type RecorderUploaderOptions = {
chunkSize: number
maxRetry?: number
maxQueueSize?: number
// 核心:上传一个 chunk(offset 用于服务端校验)
upload: (data: Uint8Array, offset: number) => Promise<void>
// 断点续传持久化
persistUploaded: (uploadedBytes: number) => Promise<void>
restoreUploaded: () => Promise<number>
backoff?: (retry: number) => Promise<void>
hooks?: {
onChunk?: (chunk: Uint8Array) => void
onUploadSuccess?: (chunk: Uint8Array, offset: number) => void
onUploadRetry?: (info: { retry: number; error: any }) => void
onError?: (err: any) => void
}
}
export function createRecorderUploader(options: RecorderUploaderOptions) {
const {
chunkSize,
maxRetry = 3,
maxQueueSize = 10,
upload,
persistUploaded,
restoreUploaded,
backoff = defaultBackoff,
hooks = {},
} = options
/** ---------- 核心游标 ---------- */
let buffer = new Uint8Array(0)
let uploadedBytes = 0 // 已成功上传的字节数
let uploadQueue: UploadTask[] = []
/** ---------- 状态 ---------- */
let uploading = false
let fatalError: any = null
let state:
| 'idle'
| 'recording'
| 'resuming'
| 'uploading'
| 'flushing'
| 'stopped'
| 'error' = 'idle'
/** ---------- 初始化(恢复 uploadedBytes) ---------- */
async function init() {
uploadedBytes = await restoreUploaded()
}
/** ---------- 数据入口(录音时调用) ---------- */
function push(data: Uint8Array) {
ensureUsable()
state = 'recording'
// 合并 buffer
const merged = new Uint8Array(buffer.length + data.length)
merged.set(buffer)
merged.set(data, buffer.length)
buffer = merged
if (buffer.length >= chunkSize) {
flushBuffer()
}
}
/** ---------- buffer → queue ---------- */
function flushBuffer(force = false) {
if (buffer.length === 0 && !force) return
if (uploadQueue.length >= maxQueueSize) {
fail(new Error('upload queue overflow'))
return
}
if (buffer.length > 0) {
const chunk = buffer.slice(0, chunkSize)
buffer = buffer.slice(chunkSize)
uploadQueue.push({
data: chunk,
retry: 0,
})
hooks.onChunk?.(chunk)
}
processQueue()
}
/** ---------- 核心上传循环 ---------- */
async function processQueue() {
if (uploading || fatalError) return
uploading = true
state = state === 'flushing' ? 'flushing' : 'uploading'
try {
while (uploadQueue.length > 0) {
const task = uploadQueue[0]
try {
await upload(task.data, uploadedBytes)
uploadedBytes += task.data.length
await persistUploaded(uploadedBytes)
uploadQueue.shift()
hooks.onUploadSuccess?.(task.data, uploadedBytes)
} catch (err) {
task.retry++
hooks.onUploadRetry?.({ retry: task.retry, error: err })
if (task.retry > maxRetry) {
throw err
}
await backoff(task.retry)
}
}
if (state === 'flushing') {
state = 'stopped'
} else {
state = 'idle'
}
} catch (err) {
fail(err)
throw err
} finally {
uploading = false
}
}
/** ---------- 恢复 session(App 重启后) ---------- */
async function resumeFromLocal(
reader: {
size: () => number
read: (offset: number, length: number) => Uint8Array
}
) {
ensureUsable()
state = 'resuming'
const fileSize = reader.size()
if (uploadedBytes >= fileSize) {
return
}
let offset = uploadedBytes
while (offset < fileSize) {
const len = Math.min(chunkSize, fileSize - offset)
const chunk = reader.read(offset, len)
await upload(chunk, offset)
offset += chunk.length
uploadedBytes = offset
await persistUploaded(uploadedBytes)
}
}
/** ---------- flush(录音结束) ---------- */
async function flush() {
ensureUsable()
state = 'flushing'
flushBuffer(true)
await processQueue()
}
/** ---------- 状态 & 错误 ---------- */
function ensureUsable() {
if (state === 'error') throw fatalError
}
function fail(err: any) {
if (fatalError) return
fatalError = err
state = 'error'
hooks.onError?.(err)
}
function getStateInfo() {
return {
state,
uploadedBytes,
bufferSize: buffer.length,
queueLength: uploadQueue.length,
uploading,
hasFatalError: !!fatalError,
}
}
return {
init,
push,
flush,
resumeFromLocal,
getStateInfo,
}
}
/** ---------- 默认退避 ---------- */
function defaultBackoff(retry: number) {
const delay = Math.min(2 ** retry * 1000, 30000)
return new Promise<void>(resolve => setTimeout(resolve, delay))
}
<!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>
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))
}
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))
})
逻辑重构(页面刷新后逻辑)
前端
<!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'
/*************************************************
* 1. 常量 & DOM
*************************************************/
const API_BASE = 'http://localhost:3000'
const dom = {
startBtn: document.getElementById('startBtn'),
stopBtn: document.getElementById('stopBtn'),
abortBtn: document.getElementById('abortBtn'),
recordStatus: document.getElementById('recordStatus'),
uploadStatus: document.getElementById('uploadStatus'),
duration: document.getElementById('duration'),
sessionId: document.getElementById('sessionIdDisplay'),
log: document.getElementById('logContainer'),
}
/*************************************************
* 2. 全局状态
*************************************************/
const state = {
mediaRecorder: null,
uploader: null,
sessionId: null,
chunkIndex: 0,
startTime: 0,
timer: null,
}
/*************************************************
* 3. 工具函数
*************************************************/
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
function log(message, type = 'info') {
const el = document.createElement('div')
el.className = `log-item log-${type}`
el.textContent = `[${new Date().toLocaleTimeString()}] ${message}`
dom.log.appendChild(el)
dom.log.scrollTop = dom.log.scrollHeight
}
function updateButtons({ start, stop, abort }) {
dom.startBtn.disabled = !start
dom.stopBtn.disabled = !stop
dom.abortBtn.disabled = !abort
}
/*************************************************
* 4. 上传相关
*************************************************/
async function uploadChunk(blob) {
const fd = new FormData()
fd.append('sessionId', state.sessionId)
fd.append('chunkIndex', state.chunkIndex++)
fd.append('chunk', blob)
const res = await fetch(`${API_BASE}/upload/chunk`, {
method: 'POST',
body: fd,
})
if (!res.ok) throw new Error(`chunk 上传失败: ${res.status}`)
return res.json()
}
function createUploader() {
return createRecorderUploader({
chunkSize: 0.2 * 1024 * 1024,
maxRetry: 3,
maxQueueSize: 10,
upload: uploadChunk,
hooks: {
onChunk: b => log(`切片生成 ${(b.size / 1024).toFixed(2)} KB`),
onUploadSuccess: b => log(`上传成功 ${(b.size / 1024).toFixed(2)} KB`, 'success'),
onUploadRetry: e => log(`重试 ${e.retry}: ${e.error.message}`, 'error'),
onAbort: r => log(`上传中止: ${r}`, 'error'),
onError: e => log(`致命错误: ${e.message}`, 'error'),
},
})
}
/*************************************************
* 5. 录音流程
*************************************************/
async function startRecording() {
state.sessionId = `session-${crypto.randomUUID()}`
state.chunkIndex = 0
state.uploader = createUploader()
dom.sessionId.textContent = state.sessionId
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
state.mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' })
state.mediaRecorder.ondataavailable = e => {
if (e.data?.size) {
log(`录音数据 ${(e.data.size / 1024).toFixed(2)} KB`)
state.uploader.push(e.data)
}
}
state.mediaRecorder.start(1000)
state.startTime = Date.now()
state.timer = setInterval(() => {
dom.duration.textContent = Math.floor((Date.now() - state.startTime) / 1000)
}, 1000)
dom.recordStatus.textContent = '录音中'
updateButtons({ start: false, stop: true, abort: true })
log(`开始录音 ${state.sessionId}`, 'success')
}
async function stopRecording() {
log('正在停止录音...', 'info')
await new Promise(resolve => {
state.mediaRecorder.onstop = resolve
state.mediaRecorder.stop()
})
state.mediaRecorder.stream.getTracks().forEach(t => t.stop())
clearInterval(state.timer)
dom.recordStatus.textContent = '已停止'
log('等待所有切片上传完成...', 'info')
await state.uploader.flush()
log('请求服务器合并文件...', 'info')
const res = await fetch(`${API_BASE}/upload/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: state.sessionId }),
})
if (!res.ok) throw new Error(await res.text())
const result = await res.json()
log(`合并完成: ${result.file}`, 'success')
log(`下载地址: ${API_BASE}/download/${state.sessionId}`, 'success')
}
async function abortAll() {
if (state.mediaRecorder?.state !== 'inactive') {
state.mediaRecorder.stop()
state.mediaRecorder.stream.getTracks().forEach(t => t.stop())
}
state.uploader?.abort('用户中止')
clearInterval(state.timer)
dom.recordStatus.textContent = '已中止'
log('录音与上传已中止', 'info')
}
/*************************************************
* 6. active 恢复检测(你指出遗漏的核心)
*************************************************/
async function pollUntilMerged(sessionId) {
log('检测到未完成录音,轮询服务器中...', 'info')
while (true) {
await sleep(2000)
const res = await fetch(`${API_BASE}/recording/active`)
if (!res.ok) return
const sessions = await res.json()
const current = sessions.find(s => s.sessionId === sessionId)
if (!current) {
log('session 已完成合并', 'success')
log(`下载地址: ${API_BASE}/download/${sessionId}`, 'success')
break
}
}
}
async function checkExistingRecording() {
try {
const res = await fetch(`${API_BASE}/recording/active`)
if (!res.ok) return
const sessions = await res.json()
if (!sessions.length) return
const latest = sessions.length > 1 ? sessions.sort((a, b) => b.lastActive - a.lastActive)[0] : sessions[0];
dom.sessionId.textContent = latest.sessionId
if (latest.status === 'recording') {
dom.recordStatus.textContent = '恢复中'
pollUntilMerged(latest.sessionId)
} else if (latest.status === 'merged') {
log(`发现已完成录音 ${latest.sessionId}`, 'success')
log(`下载地址: ${API_BASE}/download/${latest.sessionId}`, 'success')
}
} catch (err) {
log(`恢复检测失败: ${err.message}`, 'error')
}
}
/*************************************************
* 7. 按钮事件
*************************************************/
dom.startBtn.onclick = async e => {
e.preventDefault()
try { await startRecording() }
catch (err) { log(err.message, 'error') }
}
dom.stopBtn.onclick = async e => {
e.preventDefault()
try { await stopRecording() }
catch (err) { log(err.message, 'error') }
finally { updateButtons({ start: true, stop: false, abort: false }) }
}
dom.abortBtn.onclick = async e => {
e.preventDefault()
await abortAll()
updateButtons({ start: true, stop: false, abort: false })
}
/*************************************************
* 8. 上传状态轮询
*************************************************/
setInterval(() => {
if (!state.uploader) return
const info = state.uploader.getStateInfo()
dom.uploadStatus.textContent =
`${state.uploader.getState()} 队列:${info.queueLength} 缓冲:${(info.bufferSize / 1024).toFixed(0)}KB`
}, 500)
/*************************************************
* 9. 页面加载恢复检测
*************************************************/
window.addEventListener('load', checkExistingRecording)
</script>
</body>
</html>
服务端
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()
const sessions = new Map()
// 全局上传锁 - 简单粗暴但有效
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`)
sessions.set(sessionId, {
lastChunkAt: Date.now(),
status: 'recording',
})
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 {
// mergeSession(JSON.parse(body).sessionId)
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
}
/* ================= 活动的录制会话列表 ================= */
if (url === '/recording/active' && req.method === 'GET') {
const result = []
for (const [sessionId, meta] of sessions.entries()) {
result.push({
sessionId,
status: meta.status,
lastActive: meta.lastChunkAt,
})
}
return send(res, 200, result)
}
res.writeHead(404)
res.end('Not Found')
})
async function mergeSession(sessionId) {
if (mergingSessions.has(sessionId)) return
mergingSessions.add(sessionId)
try {
const sessionDir = path.join(UPLOAD_ROOT, sessionId)
const outputFile = path.join(sessionDir, 'merged.webm')
if (!fs.existsSync(sessionDir)) return
if (fs.existsSync(outputFile)) return
const files = fs.readdirSync(sessionDir)
.filter(f => f.startsWith('chunk-'))
.sort()
if (files.length === 0) return
const writeStream = fs.createWriteStream(outputFile)
for (const file of files) {
const readStream = fs.createReadStream(path.join(sessionDir, file))
await pipeline(readStream, writeStream, { end: false })
}
writeStream.end()
await new Promise(r => writeStream.on('finish', r))
const meta = sessions.get(sessionId)
if (meta) meta.status = 'merged'
console.log(`🧩 Auto merged: ${sessionId}`)
} finally {
mergingSessions.delete(sessionId)
}
}
const INACTIVE_TIMEOUT = 30 * 1000 // 30 秒
const SCAN_INTERVAL = 5 * 1000
setInterval(async () => {
const now = Date.now()
for (const [sessionId, meta] of sessions.entries()) {
if (
meta.status === 'recording' &&
now - meta.lastChunkAt > INACTIVE_TIMEOUT
) {
console.log(`⏱ Session inactive, auto merging: ${sessionId}`)
await uploadLock // 等待所有上传完成
await mergeSession(sessionId)
}
}
}, SCAN_INTERVAL)
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))
})

340

被折叠的 条评论
为什么被折叠?



