Skip to content

Commit c0aeeb1

Browse files
committed
feat(pwa): M3 Task9 - WebCaptureAdapter 麦克风应急通道 + 会话启停 PWA 分支
1 parent f80e944 commit c0aeeb1

9 files changed

Lines changed: 173 additions & 4 deletions
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
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+
}

‎client/src/features/classroom/hooks/useClassroomCapture.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,8 @@ export function useClassroomCapture() {
237237

238238
/** P0-4 软阻断启动入口:checking 禁用;down 时确认后 localOnly 启动 */
239239
const requestStart = useCallback(async () => {
240-
if (!selectedWindow || gatewayHealth.status === 'checking') return;
240+
// PWA 下无窗口选择,仅检查网关健康;Electron 下需先选窗口
241+
if ((window.electronAPI && !selectedWindow) || gatewayHealth.status === 'checking') return;
241242
if (gatewayHealth.status === 'down' && !isLocalAsrReady()) {
242243
const ok = await askConfirm({
243244
title: 'AI 网关不可用',
@@ -385,6 +386,6 @@ export function useClassroomCapture() {
385386
gatewayStatus: gatewayHealth.status,
386387
requestStart,
387388
// 派生
388-
canStart: !!selectedWindow && gatewayHealth.status !== 'checking',
389+
canStart: (!window.electronAPI || !!selectedWindow) && gatewayHealth.status !== 'checking',
389390
};
390391
}

‎client/src/features/classroom/hooks/useSessionControl.ts‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { analyzePartial } from '@/lib/ai/sessionAnalyzer';
1414
import { refreshLocalAsrStatus } from '../utils/asrTranscriber';
1515
import { loadSessionHotwords, clearSessionHotwords, getSessionHotwordsString } from '../utils/hotwordRuntime';
1616
import { getAudioSourcePreference } from '@/lib/capture/audioSourcePreference';
17+
import { webCaptureAdapter } from '../capture/WebCaptureAdapter';
1718
import type { AudioSourceKind } from '@/lib/capture/audioSourceStrategy';
1819
import type {
1920
CaptureManager,
@@ -89,7 +90,22 @@ export function useSessionControl({
8990
// localOnly(P0-4 软阻断确认后传入):网关探针已前移删除,此参数仅承载
9091
// "用户已知网关不可用、仅本地采集"语义,启动时序不受影响
9192
void opts;
92-
if (!selectedWindow || !window.electronAPI) return;
93+
// PWA/浏览器:麦克风应急通道(无窗口、无 Electron 采集能力)
94+
if (!window.electronAPI) {
95+
try {
96+
setStatus('capturing');
97+
session.resetForStart();
98+
refreshLocalAsrStatus().catch(() => { /* PWA 下返回 false,自动走云端 */ });
99+
void loadSessionHotwords(courseMeta.courseName);
100+
await webCaptureAdapter.start(crypto.randomUUID());
101+
soundPlayer.play('capture_start');
102+
} catch (err) {
103+
setStatus('error');
104+
console.error('[useClassroomCapture] Web start failed:', err);
105+
}
106+
return;
107+
}
108+
if (!selectedWindow) return;
93109
try {
94110
setStatus('capturing');
95111
session.resetForStart();
@@ -276,7 +292,20 @@ export function useSessionControl({
276292
}, [session, askConfirm, onMergePartials]);
277293

278294
const handleStop = useCallback(async () => {
279-
if (!window.electronAPI) return;
295+
// PWA/浏览器:停止麦克风采集 + 收尾(audio 模式以转写文本为笔记素材)
296+
if (!window.electronAPI) {
297+
try {
298+
clearSessionHotwords();
299+
webCaptureAdapter.stop();
300+
soundPlayer.play('capture_stop');
301+
setStatus('idle');
302+
await finalizeSmartSession();
303+
} catch (err) {
304+
setStatus('error');
305+
console.error('[useClassroomCapture] Web stop failed:', err);
306+
}
307+
return;
308+
}
280309
try {
281310
// 最先清除重启回调,防止 watchdog 在停止过程中触发
282311
frameRestartRef.current = null;

docs/superpowers/specs/2026-08-16-classroom-recognition-upgrade-design.md renamed to docs/archive/2026-08-16/2026-08-16-classroom-recognition-upgrade-design.md

File renamed without changes.

docs/superpowers/plans/2026-08-16-classroom-recognition-upgrade.md renamed to docs/archive/2026-08-16/2026-08-16-classroom-recognition-upgrade.md

File renamed without changes.

docs/knowledge/solutions/2026-08-classroom-recognition-review.md renamed to docs/archive/2026-08-16/2026-08-classroom-recognition-review.md

File renamed without changes.

docs/knowledge/solutions/2026-08-local-ocr-integration.md renamed to docs/archive/2026-08-16/2026-08-local-ocr-integration.md

File renamed without changes.

docs/knowledge/solutions/2026-08-mixed-language-spike.md renamed to docs/archive/2026-08-16/2026-08-mixed-language-spike.md

File renamed without changes.
File renamed without changes.

0 commit comments

Comments
 (0)