父母的声音,在他们不在场的时候,继续给孩子讲故事。
家长发一条 10 秒语音,孩子听到 15 分钟父母声音讲的故事。
爸爸:"今天讲三只小猪吧,小猪就叫小豆子"
↓ 10 秒,通勤路上随手一条
系统:这条语音同时是 ① 指令 ② 当日新鲜声纹样本
↓
孩子:20:30,15 分钟爸爸声音的、主角叫小豆子的故事
那条语音既是指令也是样本,所以家长不需要"完成录音任务"——采样是使用的副产品。 同类产品的首要死因是冷启动,这个循环是为了绕开它。
立项阶段的骨架。 核心算法完整且有测试覆盖,周边是可工作的最小实现。
真正该做的下一步不是继续写代码,而是手工版:给 5 个家庭手动生成音频、发过去、观察两周。
成本接近零,但能证伪最大的风险——孩子买不买账。qc 就是为这一步准备的。
| 模块 | 状态 |
|---|---|
core/audioqc |
✅ 完整,17 项测试(2 项在 core 自己的套件里)。纯 Kotlin,服务端与 App 共用同一份 |
server/schedule |
✅ 完整,20 项测试。6 时区整年 DST 遍历 |
server/pipeline |
✅ 完整,12 项测试 |
server/tts |
✅ 接口 + CosyVoice 2 客户端 + mock |
core/json |
✅ 内置最小实现,6 项测试 |
server/httpapi |
|
server/store |
|
web/ |
✅ Safari/Chrome 可装的 Web App。录音→门禁→判决,离线可用 |
android/ |
PcmRecorder 已对桩编译验证,其余靠 CI |
worker/ |
|
| iOS | ❌ 未开始,见 docs/RELEASING.md |
Kotlin / JDK 17+。服务端运行时零依赖——java.time、java.net.http、
com.sun.net.httpserver 全在 JDK 里,WAV 解码和 JSON 都是仓库内的实现,
不是要跟版本的依赖。安卓端只加 Compose。
core 不碰任何 JVM 桌面 API,所以服务端和手机跑的是同一份门禁代码。
这不是洁癖:家长被 App 判「重录」、同一条录音却被服务端接受,就是我们自相矛盾被用户抓到。
一份实现让这件事不可能发生,而不只是不太可能。
克隆质量由参考音频质量主导,而烂参考不会大声失败——它产出一个"几乎像"父母的声音。 孩子对语调的敏感度远高于成人,会比任何成年人更早察觉不对。所以:样本要么过这道门,要么永不进入合成。
不信任容器声明的采样率。安卓 HAL 层把 16kHz 上采样成 48kHz 是常态,唯一的识别方法是看频谱在哪里停止:
| 信号 | 实测 cutoff | 判定 |
|---|---|---|
| 真 48kHz 麦克风 | 19969 Hz | PASS |
| 16k 上采样伪装 48k | 8016 Hz | WARN |
| 通话 / VoIP 链路 | 3492 Hz | FAIL |
这三个数字被钉进了断言(cutoff discriminates the three classes at the measured values),
防止后续改动悄悄把三类糊到一起。另测 SNR、削波(只算连续段,孤立峰值不算)、有效语音时长、DC 偏置。
$ ./gradlew :server:qc -Pargs="--profile enrolment dad.wav"
dad.wav FAIL
48000 Hz / 16-bit / 1 ch, 30.0s (23.3s speech, 78%)
SNR 65.2 dB cutoff 3492 Hz clipping 0.00%
✗ band_limited: audio is band-limited; it has been through a call or
voice-chat pipeline and is not usable as a reference送达锚定孩子本地时区,DST 策略显式定义:
- 本地时刻不存在(春季跳变)→ 时钟跃过该时刻的瞬间
- 本地时刻出现两次(秋季回拨)→ 较早的一次
ZoneRules.getValidOffsets() 返回 0/1/2 个 offset,正好就是这三种情况,所以策略是一个
when(size) 而不是需要推导的东西。把 WallKind 和时刻一起记下来,是为了让 DST 那两天
能在测试里断言,而不是从工单里发现。
降级阶梯保证孩子的 20:30 永不空白,且家长忘了录音时,惩罚不落在孩子身上:
| 当天状态 | 孩子听到 | Tier |
|---|---|---|
| 有当日语音 | 开场白 + 当日点的故事 | fresh |
| 无当日语音,有库存 | 库存开场白 + 故事 | inventory |
| 只有声纹 | 无开场白,预设故事 | voice_only |
| 合成失败 | 重播已缓存 | cached |
voice_only 及以下不提示"爸爸没来"。激励靠开场白的稀缺性,不靠断供。
Plan.startGenerationAt 是 GPU 队列的全部调度策略:按它排序即可。
12 语言 × 全球时区意味着睡前高峰在 UTC 上滚动一整圈,按 deadline 排序会自动填谷。
web/ 是一个可以「添加到主屏幕」的 Web App,iPhone 和安卓都能用,不需要 Mac、
不需要开发者账号、不需要装任何东西。推一次代码,GitHub Pages 自动部署
(仓库里开一次:Settings → Pages → Source → GitHub Actions)。
用的是同一份 audioqc.js,跟 Kotlin 服务端逐项对过数(19969 / 8016 / 3492 Hz)。
它能做的:按住录音 → 拿到未压缩 PCM(AudioWorklet,不是 MediaRecorder,理由见
D-014)→ 当场出判决和频谱图 → 逐条下载 WAV 存档。离线可用,不联网。
它做不到的:iOS 不让网页选「未处理」采集源,系统降噪绕不掉。界面上那行 「浏览器实际给的」就是告诉你这次到底拿到了什么——原生 App 能绕,网页不能。
推一次代码,CI 自动出包,在 Actions → Artifacts 下载安装。用 debug keystore
自动签名,不需要配任何密钥。细节和正式版签名见 docs/RELEASING.md。
IPA 需要 macOS + Xcode + 你自己的 Apple 开发者证书,而且还需要一个 iOS 客户端——
同样见 docs/RELEASING.md,里面写了两条路和推荐哪条。
$ ./gradlew :core:suite :server:suite # 测试套件
$ ./gradlew :server:qc -Pargs="--json recordings/*.wav" # 质量门禁
$ ./gradlew :server:run # 服务
$ ./gradlew :android:assembleDebug # APK(需 Android SDK)两个套件是分开的:每个模块测自己的 internal,因为 Kotlin 只给同模块的测试源集
friend 权限。:testkit 是共用的测试 runner,只以 testImplementation 被依赖,
不进服务端产物也不进 APK。理由见 D-016。
没有 Android SDK 的机器上 :android 会被自动跳过,服务端照常构建。
构建主机连不上 Maven 时,用 kotlinc 直接编译(项目零依赖、测试用仓库内 runner,所以这条路是通的):
$ KOTLINC=/path/to/kotlinc/bin/kotlinc ./scripts/build.sh这个脚本分模块编译,friend path 只指向模块自己,所以它和 Gradle 对模块边界的判断一致。
早先的版本把 core 和 server 编进同一个目录,internal 在那条路上等于不存在——全绿,
推上 CI 立刻红。D-016 记的就是这件事。
TTS worker 需要 GPU 和 CosyVoice 2,装法见 worker/requirements.txt 顶部。
这几条写进了架构而不是隐私政策,拆掉它们需要先读 docs/DECISIONS.md 里的理由:
- 绝不生成父母没说过的话(D-002)。
StorySource是只含已批准值的 enum,非法来源构造不出来; 唯一的字符串入口StorySource.fromWire直接拒绝而不是给默认值。合成链路上没有重复检查—— 死代码正是红线悄悄失效的方式:后人读到不可达分支,认为冗余,连真正的守卫一起删掉。 - 永不采集儿童语音(D-003)。孩子端不申请麦克风权限。
Child里没有任何字段能存音频。 这同时是绕开 COPPA/GDPR-K 和留在 App Store Kids Category 之外的凭据。 - 声纹注册需活体挑战(D-005)。API 里没有"上传音频来克隆"的路径——那是诈骗工具的形状。
- 对端音频永不落盘(D-004)。德国刑法 §201 把未经同意录制他人言语定为刑事犯罪。
- 声纹存哪。服务端集中存储是 GPU 调度的前提,但等于持有一个泄露后无法重置的高价值资产。 方向是每家独立密钥 + 原始录音短期销毁 + 长期只留 embedding。在验证孩子买不买账之前属于过早优化。
- 首发打哪个市场。引擎(CosyVoice 2)支持中日英韩粤,泰越葡西暂时做不了—— 移工输出国那条线要等换引擎或解决许可,见 D-015。
- 已故父母声音的伦理立场。产品一定会被这样使用,主动做还是明确禁止,出事之前定。
详见 docs/DECISIONS.md。