Files
memind/src/voice/micCapture.ts
T
John 2e14873f2d Initial commit: Memind H5 portal with MindSpace, Plaza, and agent jobs.
Track application source and tests; exclude local env, user workspaces, and runtime data via .gitignore.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 15:04:43 -07:00

148 lines
4.1 KiB
TypeScript

import { extendMicRelease, waitForMicRelease } from './micSession';
const PREFERRED_MIME_TYPES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/mp4',
'audio/ogg;codecs=opus',
'audio/ogg',
];
export class MicCapture {
private stream: MediaStream | null = null;
private recorder: MediaRecorder | null = null;
private chunks: Blob[] = [];
private context: AudioContext | null = null;
private analyser: AnalyserNode | null = null;
private source: MediaStreamAudioSourceNode | null = null;
static isSupported() {
return (
typeof navigator !== 'undefined' &&
!!navigator.mediaDevices?.getUserMedia &&
typeof MediaRecorder !== 'undefined'
);
}
static pickMimeType() {
for (const type of PREFERRED_MIME_TYPES) {
if (MediaRecorder.isTypeSupported(type)) return type;
}
return undefined;
}
get recording() {
return this.recorder?.state === 'recording';
}
get levelAnalyser() {
return this.analyser;
}
/** 开始采集;后续 call 模式可改为 onChunk 流式回调 */
async start(): Promise<void> {
if (this.recording) return;
await waitForMicRelease();
this.chunks = [];
this.stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
});
const mimeType = MicCapture.pickMimeType();
this.recorder = mimeType
? new MediaRecorder(this.stream, { mimeType })
: new MediaRecorder(this.stream);
this.recorder.ondataavailable = (event) => {
if (event.data.size > 0) this.chunks.push(event.data);
};
this.context = new AudioContext();
this.analyser = this.context.createAnalyser();
this.analyser.fftSize = 256;
this.analyser.smoothingTimeConstant = 0.75;
this.source = this.context.createMediaStreamSource(this.stream);
this.source.connect(this.analyser);
if (this.context.state === 'suspended') {
await this.context.resume();
}
this.recorder.start(250);
}
async stop(): Promise<Blob> {
const recorder = this.recorder;
if (!recorder || recorder.state === 'inactive') {
await this.dispose();
return new Blob();
}
const disposePromise = new Promise<Blob>((resolve) => {
recorder.onstop = () => {
const blob = new Blob(this.chunks, { type: recorder.mimeType || 'audio/webm' });
void this.dispose().then(() => resolve(blob));
};
try {
if (recorder.state === 'recording') {
recorder.requestData();
}
} catch {
// Some WebViews omit requestData(); stop() still flushes on stop.
}
recorder.stop();
});
extendMicRelease(disposePromise);
return disposePromise;
}
async cancel(): Promise<void> {
const recorder = this.recorder;
if (recorder && recorder.state !== 'inactive') {
recorder.onstop = null;
try {
if (recorder.state === 'recording') {
recorder.requestData();
}
} catch {
// ignore
}
recorder.stop();
}
const disposePromise = this.dispose();
extendMicRelease(disposePromise);
await disposePromise;
}
private async dispose() {
this.source?.disconnect();
this.source = null;
this.analyser = null;
const context = this.context;
this.context = null;
if (context) {
try {
await context.close();
} catch {
// ignore close races
}
}
this.stream?.getTracks().forEach((track) => track.stop());
this.stream = null;
this.recorder = null;
this.chunks = [];
}
}
export function mapMicError(err: unknown) {
const name = err instanceof DOMException ? err.name : '';
if (name === 'NotAllowedError' || name === 'PermissionDeniedError') {
return '请在浏览器设置中允许麦克风';
}
if (name === 'NotFoundError' || name === 'DevicesNotFoundError') {
return '未检测到麦克风设备';
}
if (name === 'NotReadableError') {
return '麦克风被其他应用占用';
}
return err instanceof Error ? err.message : '无法访问麦克风';
}