class VoiceRecorder {
/**
* @param {Object} options
* @param {HTMLElement} options.el 录音按钮DOM
* @param {number} options.minDuration 最小录音时长,单位ms,默认500
* @param {number} options.maxDuration 最大录音时长,单位ms,默认60000
* @param {Function} options.onStart 开始录音
* @param {Function} options.onStop 停止录音
* @param {Function} options.onComplete 录音完成,返回Blob
* @param {Function} options.onError 错误
* @param {Function} options.onDuration 录音时长变化
*/
constructor(options = {}) {
this.el = options.el;
if (!(this.el instanceof HTMLElement)) {
throw new Error("VoiceRecorder el必须是HTMLElement");
}
this.minDuration = options.minDuration ?? 500;
this.maxDuration = options.maxDuration ?? 60000;
this.onStart = options.onStart || (() => {});
this.onStop = options.onStop || (() => {});
this.onComplete = options.onComplete || (() => {});
this.onError = options.onError || ((error) => {
console.error("VoiceRecorder", error);
});
this.onDuration = options.onDuration || (() => {});
// MediaStream
this.stream = null;
// MediaRecorder
this.mediaRecorder = null;
// 音频数据
this.chunks = \[\];
// 状态
this.isRecording = false;
// 当前录音开始时间
this.startTime = 0;
// 最大录音时长定时器
this.maxDurationTimer = null;
// 是否正在销毁
this.destroyed = false;
// 防止重复处理
this.isStopping = false;
// 当前录音格式
this.mimeType = "";
// 事件绑定
this.bindEvents();
}
/**
* ==============================
* 静态能力检测
* ==============================
*/
static checkSupport() {
const result = {
supported: true,
secureContext: window.isSecureContext,
mediaDevices: !!navigator.mediaDevices,
getUserMedia: !!(
navigator.mediaDevices &&
navigator.mediaDevices.getUserMedia
),
mediaRecorder: typeof MediaRecorder !== "undefined",
mimeType: null,
reason: ""
};
// HTTPS / localhost
if (!result.secureContext) {
result.supported = false;
result.reason = "当前页面不是安全上下文,请使用HTTPS访问";
return result;
}
// MediaDevices
if (!result.mediaDevices) {
result.supported = false;
result.reason = "当前浏览器不支持navigator.mediaDevices";
return result;
}
// getUserMedia
if (!result.getUserMedia) {
result.supported = false;
result.reason = "当前浏览器不支持getUserMedia";
return result;
}
// MediaRecorder
if (!result.mediaRecorder) {
result.supported = false;
result.reason = "当前浏览器不支持MediaRecorder";
return result;
}
// 音频格式
result.mimeType = VoiceRecorder.getSupportedMimeType();
if (!result.mimeType) {
result.supported = false;
result.reason = "当前浏览器没有找到支持的音频录制格式";
return result;
}
return result;
}
/**
* 获取浏览器支持的音频录制格式
*
* 优先级:
* MP4 > WebM > OGG
*/
static getSupportedMimeType() {
if (typeof MediaRecorder === "undefined") {
return "";
}
const types = [
"audio/mp4",
"audio/webm;codecs=opus",
"audio/webm",
"audio/ogg;codecs=opus"
];
for (const type of types) {
try {
if (MediaRecorder.isTypeSupported(type)) {
return type;
}
} catch (error) {
console.warn(
"VoiceRecorder MIME检测失败:",
type,
error
);
}
}
return "";
}
/**
* ==============================
* 初始化
* ==============================
*/
bindEvents() {
/**
* 使用Pointer Events统一处理:
*
* PC mouse
* 手机 touch
* stylus
*
* 避免同时维护mousedown/touchstart。
*/
this.el.addEventListener(
"pointerdown",
this.handlePointerDown
);
this.el.addEventListener(
"pointerup",
this.handlePointerUp
);
this.el.addEventListener(
"pointercancel",
this.handlePointerCancel
);
this.el.addEventListener(
"pointerleave",
this.handlePointerLeave
);
// 防止移动端长按弹出菜单
this.el.style.touchAction = "none";
this.el.style.userSelect = "none";
}
/**
* ==============================
* 开始录音
* ==============================
*/
handlePointerDown = async (event) => {
event.preventDefault();
if (this.destroyed) {
return;
}
if (this.isRecording || this.isStopping) {
return;
}
// 确保pointer事件仍然绑定在当前元素
try {
this.el.setPointerCapture(event.pointerId);
} catch (error) {
// 部分环境可能不支持,忽略
}
await this.start();
};
/**
* ==============================
* 停止录音
* ==============================
*/
handlePointerUp = (event) => {
event.preventDefault();
this.stop();
};
handlePointerCancel = () => {
this.stop();
};
handlePointerLeave = () => {
/**
* 这里不直接停止。
*
* 因为移动端/PC拖动手指或鼠标离开元素时,
* 用户可能仍然希望继续录音。
*
* 真正结束以pointerup/pointercancel为准。
*/
};
/**
* ==============================
* 开始录音
* ==============================
*/
async start() {
if (this.destroyed) {
throw new Error("VoiceRecorder 实例已经销毁");
}
if (this.isRecording) {
return;
}
// 前置能力检测
const support = VoiceRecorder.checkSupport();
if (!support.supported) {
const error = new Error(support.reason);
this.onError(error);
return;
}
try {
// 获取麦克风
if (!this.stream) {
this.stream =
await navigator.mediaDevices.getUserMedia({
audio: true,
video: false
});
// 监听麦克风Track结束
this.bindTrackEvents();
}
// 如果流已经失效,重新获取
if (!this.isStreamActive()) {
this.releaseStream();
this.stream =
await navigator.mediaDevices.getUserMedia({
audio: true,
video: false
});
this.bindTrackEvents();
}
this.chunks = \[\];
this.isStopping = false;
this.mimeType =
VoiceRecorder.getSupportedMimeType();
const recorderOptions = {};
if (this.mimeType) {
recorderOptions.mimeType = this.mimeType;
}
this.mediaRecorder =
new MediaRecorder(
this.stream,
recorderOptions
);
// 音频数据
this.mediaRecorder.ondataavailable =
(event) => {
if (
event.data &&
event.data.size > 0
) {
this.chunks.push(event.data);
}
};
// 录音停止
this.mediaRecorder.onstop =
() => {
this.handleRecorderStop();
};
// 录音异常
this.mediaRecorder.onerror =
(event) => {
this.isRecording = false;
this.isStopping = false;
this.clearMaxDurationTimer();
this.onError(
event.error ||
new Error("录音发生异常")
);
};
// 开始录音
this.mediaRecorder.start();
this.isRecording = true;
this.startTime = Date.now();
// 最大录音时间
this.maxDurationTimer =
setTimeout(() => {
if (this.isRecording) {
this.stop();
}
}, this.maxDuration);
this.onStart();
} catch (error) {
this.isRecording = false;
this.isStopping = false;
this.clearMaxDurationTimer();
this.onError(
this.normalizeError(error)
);
}
}
/**
* ==============================
* 停止录音
* ==============================
*/
stop() {
if (!this.isRecording) {
return;
}
if (!this.mediaRecorder) {
return;
}
if (
this.mediaRecorder.state === "inactive"
) {
return;
}
this.isStopping = true;
this.clearMaxDurationTimer();
try {
this.mediaRecorder.stop();
this.isRecording = false;
this.onStop();
} catch (error) {
this.isRecording = false;
this.isStopping = false;
this.onError(
this.normalizeError(error)
);
}
}
/**
* ==============================
* MediaRecorder停止后的处理
* ==============================
*/
handleRecorderStop() {
const duration =
Date.now() - this.startTime;
this.isRecording = false;
this.isStopping = false;
this.clearMaxDurationTimer();
// 没有录到数据
if (!this.chunks.length) {
this.onError(
new Error("未获取到录音数据")
);
return;
}
// 录音时间太短
if (duration < this.minDuration) {
this.chunks = \[\];
this.onError(
new Error(
`录音时间过短,至少需要${this.minDuration}ms`
)
);
return;
}
const mimeType =
this.mediaRecorder?.mimeType ||
this.chunks0?.type ||
this.mimeType ||
"audio/webm";
const blob = new Blob(
this.chunks,
{
type: mimeType
}
);
const result = {
blob,
type: blob.type,
size: blob.size,
duration
};
// 清空chunks
this.chunks = \[\];
// 最终结果
this.onComplete(result);
}
/**
* ==============================
* 检查Stream是否有效
* ==============================
*/
isStreamActive() {
if (!this.stream) {
return false;
}
const tracks =
this.stream.getAudioTracks();
return (
tracks.length > 0 &&
tracks.some(track => track.readyState === "live")
);
}
/**
* ==============================
* 监听麦克风Track
* ==============================
*/
bindTrackEvents() {
if (!this.stream) {
return;
}
this.stream
.getAudioTracks()
.forEach(track => {
track.onended =
this.handleTrackEnded;
});
}
handleTrackEnded = () => {
console.warn(
"VoiceRecorder 麦克风Track已经结束"
);
if (this.isRecording) {
this.stop();
}
};
/**
* ==============================
* 释放Stream
* ==============================
*/
releaseStream() {
if (!this.stream) {
return;
}
this.stream
.getTracks()
.forEach(track => {
track.onended = null;
track.stop();
});
this.stream = null;
}
/**
* ==============================
* 清除最大录音定时器
* ==============================
*/
clearMaxDurationTimer() {
if (this.maxDurationTimer) {
clearTimeout(
this.maxDurationTimer
);
this.maxDurationTimer = null;
}
}
/**
* ==============================
* 错误统一处理
* ==============================
*/
normalizeError(error) {
if (error instanceof Error) {
return error;
}
const message =
error?.message ||
"未知录音错误";
const normalized =
new Error(message);
if (error?.name) {
}
return normalized;
}
/**
* ==============================
* 获取状态
* ==============================
*/
getState() {
if (this.destroyed) {
return "destroyed";
}
if (this.isRecording) {
return "recording";
}
if (this.isStopping) {
return "stopping";
}
return "idle";
}
/**
* ==============================
* 获取当前Stream
* ==============================
*/
getStream() {
return this.stream;
}
/**
* ==============================
* 销毁
* ==============================
*/
destroy() {
if (this.destroyed) {
return;
}
this.destroyed = true;
this.clearMaxDurationTimer();
// 停止Recorder
if (
this.mediaRecorder &&
this.mediaRecorder.state !== "inactive"
) {
try {
this.mediaRecorder.stop();
} catch (error) {
console.warn(
"VoiceRecorder stop失败:",
error
);
}
}
// 释放麦克风
this.releaseStream();
// 移除事件
this.el.removeEventListener(
"pointerdown",
this.handlePointerDown
);
this.el.removeEventListener(
"pointerup",
this.handlePointerUp
);
this.el.removeEventListener(
"pointercancel",
this.handlePointerCancel
);
this.el.removeEventListener(
"pointerleave",
this.handlePointerLeave
);
this.mediaRecorder = null;
this.chunks = \[\];
this.isRecording = false;
this.isStopping = false;
}
}