# course-recorder 冻结契约(v1) 本文件是宿主半边(`src/host/*`)与客户端半边(`src/client/*`)之间**唯一的接口契约**。 两侧按本文件独立实现,任何改动都必须先改本文件并同步另一侧。 ## 0. 事实前提(已实机验证,不得推翻) 1. `ctx.speechToText` 只支持**完整录音**转写,无流式接口(`dsh-experimental-speech-to-text/README.md:69`)。 单次上限 = `speechController` 默认 `maxAudioBytes = 4 MiB`、`maxDurationSeconds = 120`。 ⇒ 实时转写 = 客户端按时长切段 → 宿主逐段调用转写 → 增量入库。 2. `validateWave` 只接受 canonical WAV:16 kHz / 单声道 / PCM16,44 字节头。 ⇒ 客户端必须自己重采样并编码,不能依赖 MediaRecorder 的 webm/opus 输出。 3. `ctx.webServer.register({kind,path,handler})` 注册的自定义路由**不经过** `/api` 认证通道 (`dsh-host-webserver/lib/index.js:229-245`)。⇒ 客户端同源 `fetch()` 直连即可,无需 token。 4. 桌面版 Electron 主进程对**应用窗口**的 media 权限:`details.mediaTypes === ['audio']` 时**允许** (`app.asar!/lib/main.js:6274-6296`,`installMicrophonePermissions`)⇒ `getUserMedia({audio:true})` 可用。 5. 桌面版对**内嵌 BrowserView** 无条件 `setDisplayMediaRequestHandler((_r, cb) => cb({}))` (`app.asar!/lib/main.js:9762-9770`)⇒ `getDisplayMedia()` 很可能拿不到任何轨道。 ⇒ 系统音频走"可选策略 + 如实上报",绝不假装成功。 6. 本机**没有 ffmpeg**,且重采样不依赖 ffmpeg:客户端用 Web Audio 完成。 ## 1. 传输 - 全部走**同源 HTTP**(默认 `location.origin`),路径前缀 `/course-recorder`。 - 请求/响应体一律 UTF-8 JSON(`Content-Type: application/json`),除 `GET` 无体外。 - 统一返回信封,**成功与失败都返回 200 + JSON**(只有路由不存在才是 404): ```json { "ok": true, "action": "class-status", "data": { }, "error": null } { "ok": false, "action": "class-status", "data": null, "error": { "code": "no-active-session", "message": "当前没有进行中的录音" } } ``` - `error.code` 取值:`bad-request`、`no-active-session`、`not-recording`、`transcribe-failed`、 `write-failed`、`storage-unavailable`、`unsupported`、`shutdown`、`internal`、`forbidden`。 - `forbidden` 专门表示围栏拒绝(非 loopback 来源,或 POST 缺少/错配 `token`);响应体仍是 `{ok:false,...}` 信封,但 HTTP 状态码是 403,客户端只做原样透传、不重试。 ### 路由表(宿主实现,客户端调用) | 方法 | 路径 | 请求体 | `data` | |---|---|---|---| | GET | `/course-recorder/status` | — | `Status`(见 1.1) | | POST | `/course-recorder/start` | `{ course?, title?, audioSource?, consent: true, segmentSeconds? }` | `{ sessionId, course, title, startedAt, consentAt }` | | POST | `/course-recorder/attach` | `{}` | `{ sessionId, status, lastSeq, elapsedMs, segmentCount, autoStopAt }`(接续宿主仍在进行的那节录音;没有活会话时 `no-active-session`) | | POST | `/course-recorder/stream` | `{ sessionId, seq, capturedAt, level, audio: {format:'pcm16', sampleRate:16000, channels:1, base64} }` | `{ seq, accepted, bufferedMs, pending }` | | POST | `/course-recorder/pause` | `{ sessionId? }` | `{ pausedAt, bufferedMs }` | | POST | `/course-recorder/resume` | `{ sessionId? }` | `{ resumedAt }` | | POST | `/course-recorder/stop` | `{ sessionId?, force?, reason? }` | `{ sessionId, stoppedAt, endedAt, durationMs, segmentCount, noteDraftReady }` | | GET | `/course-recorder/devices` | — | `{ reportedAt, devices:[...], note }`(宿主不能枚举音频设备,返回客户端上次上报结果或空) | | POST | `/course-recorder/devices` | `{ devices: [{deviceId,label,kind,isLoopbackHint}], active: [deviceId] }` | `{ stored: true }` | | POST | `/course-recorder/level` | `{ sessionId, level, source }` | `{ ok: true }`(低频心跳,可省略) | | GET | `/course-recorder/sessions` | — | `{ sessions: SessionCatalogEntry[], courseChoices: string[], defaultCourse: string }`(只读,不带 token;`SessionCatalogEntry = { sessionId, course, courseId, subPath, title, startedAt, startedAtLocal, endedAt, endedAtLocal, durationMs, status, segmentCount, transcribed, sessionDir }`) | | POST | `/course-recorder/transcribe` | `{ token, sessionId?, course?, style?, reason?, user_authorized }` | `{ sessionId, noteId, jobId, state, authorized, authorizationOutcome, course, report, basis, evidence, classification, reclassified, markdownPath, jsonPath, summary, topics, quotes }`(要 token;`user_authorized !== true` 时只整理、不归类,`state='draft'`) | | POST | `/course-recorder/reclassify` | `{ token, sessionId, course, reason?, user_authorized }` | `{ sessionId, changed, alreadyInCourse, moved, suffix, from, to, updated, refsUnchanged }`(要 token,且必须 `user_authorized === true`,否则 `missing-user-authorization`) | `seq` 必须从 1 起严格递增,宿主按 `seq` 丢弃重复包。 客户端被 dsh 卸载(切换会话、进入插件页、刷新页面)后重建时,不要直接 `start`:先用 `/attach` 接上宿主手上那节还没结束的录音,并把 `seq` 从返回的 `lastSeq` 继续往上数—— 从 1 重新计数的话,整节课的块都会被当成重复静默丢弃。 ### 1.1 `Status`(客户端 2 秒轮询一次) ```ts type Status = { serverTime: number; // epoch ms recording: null | { sessionId: string; course: string; title: string; status: 'recording' | 'paused'; startedAt: number; pausedAt: number | null; pausedTotalMs: number; elapsedMs: number; // 不含暂停时间 segmentSeconds: number; bufferedMs: number; // 尚未成段的音频 level: number; // 0..1,最近一次上报的 RMS sources: Array<{ name: string; level: number; lastChunkAt: number }>; chunksAccepted: number; chunksRejected: number; pendingTranscriptions: number; // 转写队列长度 autoStopAt: number | null; // 保险到期时刻(epoch ms),由 maxSessionMs 算出 autoStopped: boolean; // 这节是否被保险自动收尾 lastError: string | null; }; segments: Array<{ // 最近 N 段,新的在前 seq: number; index: number; startMs: number; endMs: number; status: 'pending' | 'done' | 'failed' | 'empty'; text: string; inferenceMs: number; error: string | null; }>; output: { root: string; sessionDir: string | null; writable: boolean; lastError: string | null }; asr: { provider: string; // 本插件将要请求的 provider(= config.providerId) providerAvailable: boolean; // 该 provider 是否在 roster 里 language: string; // 本插件将要请求的语言(= config.language) available: boolean; // speechController 可调用 **且** provider 在 roster 里 controller: boolean; // ctx.speechController.transcribe 是否可调用 registry: boolean; // ctx.speechToText.snapshot 是否可调用 serviceSelection: { providerId: string | null; language: string | null } | null; }; }; ``` ### 1.2 音频块格式 - `audio.base64`:**原始小端 PCM16 交错样本**(无 WAV 头),16 kHz、单声道。客户端已把多路源混音完成。 - 块长:客户端按 **2000 ms**(32000 采样 ≈ 42667 字符 base64 ≈ 43 KB)一块上传; 该长度由 `src/client/ui.js` 的 `TRANSPORT_CHUNK_SAMPLES` 决定。 - **单块上限 1 MiB base64 字符 = 393216 采样 = 24.576 秒**(16 kHz 单声道 PCM16)。 两个数值同源:`MAX_CHUNK_BASE64_CHARS` 是唯一定义,采样上限由 `4 * ceil(bytes / 3)` 推出(`src/client/core.js`)。超限宿主回 `bad-request`,客户端 `transport.sendChunk` 也会在发送前拒绝。 - **发送前必须过 `splitForTransport(samples, maxSamples)`**:它把超长段切成多块, 并把每块钳到硬上限,所以调用方传错参数也不会超限。 (事故记录:客户端曾按 45 s 分段直接发送,连续讲话超过 24.576 s 时整段被拒、原话丢失。) - 客户端分段(2–8 s)只是**上传批大小**,不是转写边界;真正的切段在宿主 `src/host/segmenter.js`(`segmentMs` / `maxSegmentMs` / 静音切段)。 - `level`:0..1 的 RMS,用于实时电平表;不参与转写。 ## 2. 语音源策略(客户端实现,宿主只存结果) 客户端按顺序尝试,并把 **每一步的真实结果** 写进 `class_status` 能读到的位置(通过 `POST /devices` + UI 提示): 1. `mic`(默认,必成功):`getUserMedia({audio:{echoCancellation:true,noiseSuppression:true}})`。 2. `display`(可选,试探):`getDisplayMedia({video:true,audio:true})`,若返回流中存在 `audio` 轨道则 保留视频轨但**不消费**(或立即 `track.stop()` 视频轨),仅取音频轨;失败则记录 `unavailable`。 3. `loopback-input`(可选,用户把系统输出设备改为"立体声混音/Stereo Mix"或虚拟声卡后, 该设备会出现在麦克风枚举里):与 `mic` 同路径,只是 `deviceId` 不同。 多源混音在客户端完成:`AudioContext` → 各 `MediaStreamAudioSourceNode` → `GainNode`(每源增益) → `ScriptProcessorNode` 或 `AudioWorklet` → Float32 累加 → 线性重采样到 16 kHz → PCM16。 每源单独计算 RMS 上报,供 UI 显示"哪一路在响"。 **重复采集警告**:麦克风会拾取扬声器外放,若同时录系统音频,同一句话会被转写两次。 UI 必须在同时启用两路时显示警告,并提供"麦克风增益 / 系统音频增益"两个滑杆。 ## 3. 客户端模块契约 客户端产物是**未压缩的纯浏览器 JS**(`lib/client.js`),必须通过 **`window` 上**的模块 facade 挂载: ```js (function () { const loader = typeof window === 'object' && window !== null ? window.__ModuleLoader__ : undefined; if (loader === undefined || typeof loader.load !== 'function') { console.warn('course-recorder: window.__ModuleLoader__ is unavailable ...'); return; // 绝不抛错:见下面的"致命失败"说明 } loader.load({ id: 'dsh-course-recorder', factory(require) { const React = require('react'); const h = React.createElement; /* ... */ return { inject: ['slots'], apply(ctx) { /* ctx.slots.inject(...) */ } }; }, }); })(); ``` **这里的两个细节都是 load-bearing,改动前先读完:** 1. **facade 只挂在 `window` 上,没有裸全局。** dsh 的 web shell 往 `` 注入的是 `window.__ModuleLoader__`(app.asar 内 74 个内置 client 产物全部写作 `window.__ModuleLoader__.load({...})`;shell 侧还会 `throw new Error("web boot: window.__ModuleLoader__ bootstrap facade is missing")`)。 写成裸 `_ModuleLoader__` 会在渲染进程求值模块体时抛 `ReferenceError: _ModuleLoader__ is not defined`。 2. **client 模块 import 失败是致命的,不是"少个面板"。** 渲染进程启动时收集 client 模块的失败条目并 `throw new Error("web boot: N entries did not activate")`, 界面弹「应用无法启动或已意外停止」,用户唯一的出路是点 「禁用第三方插件、备份 profile patch 并重启」——即**整个第三方插件生态被关掉**。 所以包装层必须带守卫:facade 缺失时告警并 `return`,宁可面板不出现,也不能让应用起不来。 - 只能 `require('react')`(其他 Harness Client 包**禁止** import)。 - 未编译产物**不能用 JSX**,用 `React.createElement` / `h`。 - 所有资源在 `apply` 内用 `ctx.effect`/`ctx.on` 注册并返回清理函数。 - 样式只用 `--dsw-alias-*` 主题变量;文案走 `ctx.get('locale')`(缺失时用中文默认)。 - 挂载点:`conversation.composer.dock`(`{name,id,order}` + 组件),组件内用 `fetch` 轮询。 - 回归测试见 `test/client.test.mjs`:它用**只提供 `window.__ModuleLoader__`** 的环境求值产物, 并断言"facade 缺失时不抛错"。不要改回把裸名字当形参传进去的写法——那正是当初漏掉这个 bug 的原因。 ## 4. 宿主持久化产物(`D:\课程`) ``` D:\课程\ index.json # 课程索引镜像(归类后按 storage 现况重建;此前不存在则新建) 未命名课程\ # 课堂中不猜课程:新录音的默认落点 \ # 只允许 专业课 / 文化课 / 马课 / 其他课程(可带一级子目录) \ session.json # 录始时间/录束时间/时长/状态 transcript.jsonl # 每行一个 TranscriptSegment segments\seg-0001.wav # 保留的分段音频(可在 Config 关闭) notes\ draft-.md # 授权整理后生成的草稿(含录音结束时间) note-.md # notes_update 定稿 ``` `session.json` 必含 `endedAt`(每次录音结束时间,用户明确要求)与 `endedAtLocal`(本地可读时间)。 **课程归类的硬约束(`src/host/courses.js` + `reclassifySession`)** - 顶层课程只认四个字面量:`专业课` / `文化课` / `马课` / `其他课程`,最多带一级子目录(`文化课/数学`); 反斜杠按分隔符处理(Windows 手写习惯),三段以上、`..`、路径字符一律 `bad-request`。 - 只在**用户授意转译**的那一刻归类:用户明确给了科目就听用户的,没说才按**这节课自己的转录**判断。 判据命中不足两个词时归 `其他课程`,并在笔记开头标「〔分类存疑:疑似 X〕」,绝不硬塞。 - 归类只动路径与索引:移动会话目录 → 改写 `session.json` 的 course/courseId/outputRoot/sessionDir → `sessions` 换 key → `segments` / `questions` / `materials` 的 `courseId` → `postings` 重建键 → 按 storage 现况重建根目录的 `index.json`(不存在就新建;已存在但不是本格式则原样不动)。 - **音频、转录内容、`session_id`、片段号、时间戳一个字节都不改**(`refsUnchanged: true`), 所以已生成的笔记引用继续有效。 - 目标已有同名目录时追加 `-2` / `-3`,绝不覆盖;中途任何一步失败都按相反顺序回滚(含把 `session.json` 写回原记录),失败抛 `归类失败,已回滚:…`。 **按课程请教(`course_ask` / `course_search` 的 `course` 参数)** - 传了 `course` 时,`buildContext`(`src/host/context.js`)把范围从「一节」放大到「该课程**全部节次**」: 笔记按 `sessionIds` 跨节聚合、实时转录取最近 3 节拼接、检索按课程顶层 `courseId` 跨节; `context.courseScope` 会带回 `{ course, subPath, label, courseId, sessionCount, sessionIds }`, `renderContextBlock` 里写明「范围:按课程「X」请教,跨该课程 N 节课检索」。 - 课程名统一走 `resolveCourse`(内部 `normalizeCourse`:四个顶层 + 最多一级子目录)。**非法名直接 `bad-request`,不会悄悄退回「当前会话的课程」**——静默回退会让用户以为这门课已经检索过。 - 该课程下一条录音都没有:`courseScope.sessionCount === 0`,工具返回 `noCourseRecordHint`, 回答必须直说「这门课还没有课堂记录」,不得编造。 - 该课程有录音但没有任何已整理笔记:`groundedIn: 'live'`,回答标注「基于实时转录,未整理」。 ## 5. 工具注册(模型面) `ctx.tools.register` **不做任何 schema 编译**:definition 原样进注册表, 组装请求时 `schemaOf` 把 `definition.parameters` **逐字**交给 provider 当作 `function.parameters`。所以 `parameters` 必须是完整的 raw JSON Schema: ```js { type: 'object', properties: { session_id: { type: 'string', description: '…' } }, required: ['session_id'], // 没有必填项就整个键省略 } ``` `defineTool`(`@deepseek-ai/dsh-tools`)内部会做这一步编译,但该包只在 Harness bundle 内可解析,所以 `src/host/tools.js` 的 `tool()` 包装自己调 `parametersToJsonSchema()` 完成同样的事:把「属性名 → 单属性 schema」包装成 object 根,并把属性级 `required: true` 提升到根 `required` 数组。 **2026-10-09 实机事故(勿重犯)**:`tool()` 曾直接把裸属性表当 `parameters` 注册,根缺 `type`,provider 在模型运行前就拒绝整个请求—— ``` Invalid schema for function 'class_pause': schema must be a JSON Schema of 'type: "object"', got 'type: null'. ``` 表现为「对话框能开,一发消息就运行失败」,会话里 `llmMs=0`、`response` 为空 (`session-05d12727` 实证)。回归测试见 `test/tools-schema.test.mjs`:它挂载真实 host 半边,逐个断言 21 个工具的根 `type` 为 `object`、属性级不得出现 `required`。 改动 `tool()` 或新增工具后必须能过。