2e14873f2d
Track application source and tests; exclude local env, user workspaces, and runtime data via .gitignore. Co-authored-by: Cursor <cursoragent@cursor.com>
148 lines
4.1 KiB
TypeScript
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 : '无法访问麦克风';
|
|
}
|