|
| 1 | +/** |
| 2 | + * WebCaptureAdapter — PWA/浏览器麦克风采集适配器(课堂助手应急通道) |
| 3 | + * |
| 4 | + * @ai-context: 移动端 PWA 的课堂助手音频源(Spec §3.2 音频源决策 v4 的应急 |
| 5 | + * 通道,主力为系统录屏导入)。链路:getUserMedia 麦克风 → MediaRecorder 分段 |
| 6 | + * (5s)→ decodeAudioData 解码 → 重采样 16kHz 单声道 → encodeWavBase64 转 |
| 7 | + * WAV base64 → 构造 AudioSegment → captureEventBus 广播 |
| 8 | + * `smart:audio_segment_ready`,复用 useClassroomEvents 既有云端转写链路 |
| 9 | + * (transcribeWithRetry → liveTranscripts)。桌面端(Electron)不走本适配器 |
| 10 | + *(走主进程 endpoint loopback)。 |
| 11 | + * @ai-context EN: mobile PWA classroom audio source (emergency channel per |
| 12 | + * Spec §3.2 v4; the primary channel is system screen-record import). Pipeline: |
| 13 | + * getUserMedia mic → MediaRecorder 5s slices → decodeAudioData → resample to |
| 14 | + * 16kHz mono → encodeWavBase64 → emit `smart:audio_segment_ready` to reuse the |
| 15 | + * existing cloud-transcription chain. Electron desktop does not use this |
| 16 | + * adapter (it uses the main-process endpoint loopback). |
| 17 | + */ |
| 18 | +import { captureEventBus } from '@/lib/capture'; |
| 19 | +import { encodeWavBase64 } from '@/lib/capture/wavEncoder'; |
| 20 | +import type { AudioSegment } from '@/lib/capture'; |
| 21 | + |
| 22 | +/** 分段时长(毫秒)——与 Electron 按段转写的 chunkDurationMs 口径一致 */ |
| 23 | +const SEGMENT_MS = 5000; |
| 24 | +/** 目标采样率(与网关 ASR 端点约定的 sample_rate 一致) */ |
| 25 | +const TARGET_SAMPLE_RATE = 16000; |
| 26 | + |
| 27 | +export class WebCaptureAdapter { |
| 28 | + private stream: MediaStream | null = null; |
| 29 | + private recorder: MediaRecorder | null = null; |
| 30 | + private audioCtx: AudioContext | null = null; |
| 31 | + private sessionId: string | null = null; |
| 32 | + private segmentStartAt = 0; |
| 33 | + |
| 34 | + /** 是否正在录制 */ |
| 35 | + get active(): boolean { |
| 36 | + return this.recorder?.state === 'recording'; |
| 37 | + } |
| 38 | + |
| 39 | + /** |
| 40 | + * 启动麦克风采集(须在用户手势调用栈中调用,否则 iOS 拒绝 getUserMedia) |
| 41 | + * @param sessionId - 会话 ID(emit 音频段时透传,供下游校验会话一致性) |
| 42 | + */ |
| 43 | + async start(sessionId: string): Promise<void> { |
| 44 | + this.sessionId = sessionId; |
| 45 | + this.stream = await navigator.mediaDevices.getUserMedia({ |
| 46 | + audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true }, |
| 47 | + }); |
| 48 | + this.audioCtx = new AudioContext(); |
| 49 | + await this.audioCtx.resume().catch(() => { /* 非手势调用栈下 resume 被拒,静默 */ }); |
| 50 | + const mimeType = pickSupportedMimeType(); |
| 51 | + this.recorder = new MediaRecorder(this.stream, mimeType ? { mimeType } : undefined); |
| 52 | + this.segmentStartAt = Date.now(); |
| 53 | + this.recorder.ondataavailable = (ev) => { |
| 54 | + if (ev.data.size > 0) void this.handleChunk(ev.data); |
| 55 | + }; |
| 56 | + this.recorder.start(SEGMENT_MS); |
| 57 | + } |
| 58 | + |
| 59 | + /** 停止采集并释放资源(幂等) */ |
| 60 | + stop(): void { |
| 61 | + try { this.recorder?.stop(); } catch { /* 已停止 */ } |
| 62 | + this.stream?.getTracks().forEach((t) => t.stop()); |
| 63 | + void this.audioCtx?.close().catch(() => { /* 静默 */ }); |
| 64 | + this.recorder = null; |
| 65 | + this.stream = null; |
| 66 | + this.audioCtx = null; |
| 67 | + this.sessionId = null; |
| 68 | + } |
| 69 | + |
| 70 | + /** 解码单个分段 → 重采样 → 编码 → 广播音频段(失败静默,不影响采集) */ |
| 71 | + private async handleChunk(blob: Blob): Promise<void> { |
| 72 | + const sid = this.sessionId; |
| 73 | + if (!sid || !this.audioCtx) return; |
| 74 | + try { |
| 75 | + const arrayBuffer = await blob.arrayBuffer(); |
| 76 | + const audioBuffer = await this.audioCtx.decodeAudioData(arrayBuffer); |
| 77 | + const pcm = resampleToMono(audioBuffer, TARGET_SAMPLE_RATE); |
| 78 | + if (pcm.length === 0) return; |
| 79 | + const audioBase64 = encodeWavBase64(pcm, TARGET_SAMPLE_RATE, 1); |
| 80 | + const segment: AudioSegment = { |
| 81 | + id: crypto.randomUUID(), |
| 82 | + timestampStart: this.segmentStartAt, |
| 83 | + timestampEnd: Date.now(), |
| 84 | + audioBase64, |
| 85 | + energy: computeRms(pcm), |
| 86 | + }; |
| 87 | + this.segmentStartAt = Date.now(); |
| 88 | + captureEventBus.emit('smart:audio_segment_ready', { sessionId: sid, segment }); |
| 89 | + } catch (err) { |
| 90 | + console.warn('[WebCaptureAdapter] 分段解码失败(已跳过该段):', err); |
| 91 | + } |
| 92 | + } |
| 93 | +} |
| 94 | + |
| 95 | +/** 全局单例(与 captureEventBus 同级的采集通道,供 useSessionControl 直接引用) */ |
| 96 | +export const webCaptureAdapter = new WebCaptureAdapter(); |
| 97 | + |
| 98 | +/** 选择当前浏览器支持的录音容器(iOS 偏好 mp4/aac,Android 偏好 webm/opus) */ |
| 99 | +function pickSupportedMimeType(): string { |
| 100 | + for (const type of ['audio/webm', 'audio/mp4', 'audio/ogg']) { |
| 101 | + if (MediaRecorder.isTypeSupported(type)) return type; |
| 102 | + } |
| 103 | + return ''; |
| 104 | +} |
| 105 | + |
| 106 | +/** |
| 107 | + * 重采样到目标采样率 + 混为单声道(线性插值,多声道取平均) |
| 108 | + * @ai-context: decodeAudioData 输出采样率/声道数随浏览器而异(常见 44.1k/48k、 |
| 109 | + * 1-2 声道),而网关 ASR 约定 16kHz 单声道,故统一重采样混单。 |
| 110 | + */ |
| 111 | +export function resampleToMono(buffer: AudioBuffer, targetRate: number): Float32Array { |
| 112 | + const srcRate = buffer.sampleRate; |
| 113 | + const channels = Math.min(buffer.numberOfChannels, 2); |
| 114 | + const srcLen = buffer.length; |
| 115 | + if (srcLen === 0) return new Float32Array(0); |
| 116 | + const ratio = srcRate / targetRate; |
| 117 | + const outLen = Math.max(0, Math.floor(srcLen / ratio)); |
| 118 | + const out = new Float32Array(outLen); |
| 119 | + for (let i = 0; i < outLen; i++) { |
| 120 | + const srcIndex = i * ratio; |
| 121 | + const i0 = Math.floor(srcIndex); |
| 122 | + const i1 = Math.min(i0 + 1, srcLen - 1); |
| 123 | + const frac = srcIndex - i0; |
| 124 | + let sample = 0; |
| 125 | + for (let c = 0; c < channels; c++) { |
| 126 | + const ch = buffer.getChannelData(c); |
| 127 | + sample += ch[i0] * (1 - frac) + ch[i1] * frac; |
| 128 | + } |
| 129 | + out[i] = sample / channels; |
| 130 | + } |
| 131 | + return out; |
| 132 | +} |
| 133 | + |
| 134 | +/** 计算 PCM 均方根能量(0-1 量级,供 AudioSegment.energy 字段) */ |
| 135 | +export function computeRms(pcm: Float32Array): number { |
| 136 | + let sum = 0; |
| 137 | + for (let i = 0; i < pcm.length; i++) sum += pcm[i] * pcm[i]; |
| 138 | + return pcm.length > 0 ? Math.sqrt(sum / pcm.length) : 0; |
| 139 | +} |
0 commit comments