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>
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
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 : '无法访问麦克风';
|
||||
}
|
||||
Reference in New Issue
Block a user