class VoiceRecorder {
constructor(options = {}) {
this.targetElement = options.targetElement;
this.maxDuration = options.maxDuration ?? 60000;
this.cancelDistance = options.cancelDistance ?? 80;
this.onStart = options.onStart;
this.onStop = options.onStop;
this.onCancel = options.onCancel;
this.onComplete = options.onComplete;
this.onError = options.onError;
this.stream = null;
this.recorder = null;
this.chunks = \[\];
this.isRecording = false;
this.isCancel = false;
this.startTime = 0;
this.timer = null;
this.startY = 0;
const check = this.check();
if (!check.respstate) {
throw new Error(check.result.message);
}
this.bind();
}
/**
* 参数及浏览器能力检查
*
* @returns {{
* respstate: boolean,
* result: Object
* }}
*/
check() {
if (!(this.targetElement instanceof HTMLElement)) {
return {
respstate: false,
result: {
code: "INVALID_PARAM",
message: "targetElement必须是HTMLElement"
}
};
}
if (!window.isSecureContext) {
return {
respstate: false,
result: {
code: "UNSAFE_CONTEXT",
message: "当前环境不支持麦克风,请使用HTTPS访问"
}
};
}
if (
!navigator.mediaDevices ||
!navigator.mediaDevices.getUserMedia
) {
return {
respstate: false,
result: {
code: "NOT_SUPPORTED",
message: "当前浏览器不支持getUserMedia"
}
};
}
if (typeof MediaRecorder === "undefined") {
return {
respstate: false,
result: {
code: "NOT_SUPPORTED",
message: "当前浏览器不支持MediaRecorder"
}
};
}
const mimeType = this.getMimeType();
if (!mimeType) {
return {
respstate: false,
result: {
code: "MIME_NOT_SUPPORTED",
message: "当前浏览器没有支持的音频录制格式"
}
};
}
return {
respstate: true,
result: {
mimeType
}
};
}
/**
* 获取浏览器支持的录音格式
*/
getMimeType() {
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 (e) {
// 某些浏览器检测MIME时可能直接抛异常
}
}
return "";
}
/**
* 绑定事件
*/
bind() {
this.targetElement.addEventListener(
"pointerdown",
this.start
);
this.targetElement.addEventListener(
"pointermove",
this.move
);
this.targetElement.addEventListener(
"pointerup",
this.stop
);
this.targetElement.addEventListener(
"pointercancel",
this.cancel
);
this.targetElement.style.touchAction = "none";
this.targetElement.style.userSelect = "none";
}
/**
* 开始录音
*/
start = async (event) => {
event.preventDefault();
if (this.isRecording) {
return;
}
this.startY = event.clientY;
this.isCancel = false;
try {
if (!this.stream) {
this.stream =
await navigator.mediaDevices.getUserMedia({
audio: true,
video: false
});
}
const mimeType = this.getMimeType();
this.chunks = \[\];
this.recorder = new MediaRecorder(
this.stream,
mimeType
? { mimeType }
: undefined
);
this.recorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) {
this.chunks.push(event.data);
}
};
this.recorder.onstop = () => {
this.complete();
};
this.recorder.onerror = (event) => {
this.isRecording = false;
this.clearTimer();
this.onError?.(
event.error ||
new Error("录音发生异常")
);
};
this.recorder.start();
this.isRecording = true;
this.startTime = Date.now();
// 最大录音时间
this.timer = setTimeout(() => {
if (this.isRecording) {
// 超时属于正常结束
this.stop();
}
}, this.maxDuration);
this.onStart?.();
} catch (error) {
this.isRecording = false;
this.clearTimer();
this.onError?.(error);
}
};
/**
* 手指/鼠标移动
*
* 向上滑超过cancelDistance,取消录音
*/
move = (event) => {
if (!this.isRecording) {
return;
}
const distance = this.startY - event.clientY;
if (distance >= this.cancelDistance) {
this.cancel();
}
};
/**
* 正常停止录音
*/
stop = (event) => {
event?.preventDefault();
if (!this.isRecording || !this.recorder) {
return;
}
if (this.isCancel) {
return;
}
this.isRecording = false;
this.clearTimer();
if (this.recorder.state !== "inactive") {
this.recorder.stop();
}
this.onStop?.();
};
/**
* 取消录音
*/
cancel = () => {
if (!this.isRecording || !this.recorder) {
return;
}
this.isCancel = true;
this.isRecording = false;
this.clearTimer();
if (this.recorder.state !== "inactive") {
this.recorder.stop();
}
// 告诉业务:用户取消了录音
this.onCancel?.();
};
/**
* 录音完成
*/
complete() {
if (this.isCancel) {
this.chunks = \[\];
this.isCancel = false;
return;
}
if (!this.chunks.length) {
this.onError?.(
new Error("未获取到录音数据")
);
return;
}
const duration = Date.now() - this.startTime;
const type =
this.recorder?.mimeType ||
this.chunks0?.type ||
"audio/webm";
const blob = new Blob(this.chunks, {
type
});
this.chunks = \[\];
this.onComplete?.({
blob,
type: blob.type,
size: blob.size,
duration
});
}
/**
* 清除超时定时器
*/
clearTimer() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
/**
* 销毁
*/
destroy() {
this.clearTimer();
if (
this.recorder &&
this.recorder.state !== "inactive"
) {
try {
this.recorder.stop();
} catch (e) {
// ignore
}
}
if (this.stream) {
this.stream.getTracks().forEach(track => {
track.stop();
});
}
this.targetElement.removeEventListener(
"pointerdown",
this.start
);
this.targetElement.removeEventListener(
"pointermove",
this.move
);
this.targetElement.removeEventListener(
"pointerup",
this.stop
);
this.targetElement.removeEventListener(
"pointercancel",
this.cancel
);
this.stream = null;
this.recorder = null;
this.chunks = \[\];
this.isRecording = false;
this.isCancel = false;
}
}