Soundiary · 工程技术文档 v1.0
本文是《Soundiary 技术方案 v2.0》的工程实现篇,面向开发、测试与上架执行。三条不可动摇的工程底线:① 音频不进任何长期存储,转写完成即删除(等待传输的临时文件也不例外);② 全部地区共享的唯一二进制中不存在任何第三方联网代码;③ 端侧模型随包分发,运行时零下载。所有设计决策如与这三条冲突,以本页为准。
0 · 文档信息与约定
0.1 三条工程底线
| # | 底线 | 工程强制手段 | 违反后果 |
|---|---|---|---|
| ① | 原始音频永不写入任何持久化介质 | 禁用 AVAudioRecorder / AVAssetWriter / FileManager 写音频;CI 静态扫描 + 运行时断言 assertNoAudioFileAPIsLinked() | PIPL 敏感个人信息违规;「不可回放」卖点失效 |
| ② | 全 App 零第三方联网(全球统一,无例外) | 云端 Provider 代码物理删除(不用 #if 包裹)+ URLSession 符号扫描 + NSAppTransportSecurity 全禁 + 无宿主白名单 | 备案豁免 100% 驳回,虚假申报可致封号 |
| ③ | 模型随包分发,运行时不下载 | 模型内置于 IPA(不使用 On-Demand Resources / Background Assets),全球同一份 | 触发网络请求,同 ② |
✅ ②的当前状态:云端 Provider 与「用户自填 API Key」相关代码已按此条物理删除(不是用 #if 包裹)—— 设置页的密钥输入分组、洞察页的云端分派、顶栏承诺条的分流文案、以及 LLMConfig/LLMClient 等实现一并移除,归档在 Docs/archive/。核验方式(4 项全过才算数):nm -a 无 URLSession 符号、strings 无 chat/completions、otool -L 未链接 CFNetwork、打包后的 Info.plist 里不存在 NSAppTransportSecurity 键(连空字典都不留)。
早期方案设想过「CN Target(无联网)+ Global Target(含 Token),中国区只下发前者」。 这在技术上不成立:App Store 同一 bundle ID 的同一版本只有一个二进制,不支持按国家/商店分发不同构建 (Apple 官方:"You can only have one version of your app in the store at one time, regardless of region.")。
因此 v2 定为单一 iOS Target、单一 bundle ID(com.soundiary.app)、单一 ASC 记录:
全球用户拿到完全相同的二进制,放弃「用户自配云端 Token」功能,换取备案豁免最稳、长期维护最轻。
若日后确需 Token 功能,唯一路径是另发一个独立 App,而不是给现有包加开关。
0.2 术语
| 术语 | 含义 |
|---|---|
| 快照(Snapshot) | 一次采样窗(默认 8 秒,含人声时最长 30 秒)加工后的产物:文本 + 声纹向量 + 环境标签 + 响度包络。不含音频 |
| 采集即弃 | PCM 只进内存缓冲区,转写完成即刻 memset 清零。不进任何长期存储——仅当需要在本人的手表与手机之间传输时,短暂生成一个受系统保护的临时文件,投递有结果即删除 |
| 抽样感知 | Watch 每 3–5 分钟唤醒采集一小段,而非连续录音。占空比 3–8% |
| L1 / L2 / L3 | 摘要引擎的三级降级:L1 = Foundation Models(iOS 26+,需 A17 Pro+ 且非国行设备/大陆账号);L2 = 自带 Core ML 量化模型(全球兜底);L3 = 规则模板 |
1 · 工程结构与 Target
1.1 Xcode 工程拓扑
把 SamplingPolicy、AudioFrame、SoundSnapshot、BudgetEstimator 全部放进 SoundiaryKit,Watch 与 iPhone 双端复用,避免两侧逻辑漂移导致时间轴对不齐。
1.2 能力矩阵(全球统一,无地区差异)
| 能力 | 状态 | 说明 |
|---|---|---|
| 声音抽样采集 | ✅ | Watch 抽样 → iPhone 本地加工 |
| 端侧 ASR | ✅ | 系统 Speech 优先,自带 Whisper 兜底 |
| 端侧 LLM(三级降级) | ✅ | L1 Foundation Models(需 A17 Pro+ 且非国行设备/大陆账号)→ L2 自带 Core ML(全球兜底)→ L3 规则模板 |
| 云端 Provider 配置 | 不存在 | 已放弃 Token 功能,见 §0.1 |
| API Key 输入界面 | 不存在 | 包内不得出现任何密钥输入 UI |
| StoreKit / 内购 | 已移除 | 改为付费下载后,App 运行时完全不联网 |
| Safari 外链(政策/支持页) | ✅ | 系统浏览器打开,非 App 内获取内容 |
| 崩溃/分析 SDK | 0 个 | 任何新增都需先在隐私标签声明并重估豁免风险 |
2 · 构建配置与离线审计
2.1 xcconfig(全局唯一配置)
// Config/Soundiary.xcconfig —— 唯一一份,没有 CN / Global 之分
PRODUCT_BUNDLE_IDENTIFIER = com.soundiary.app
ENABLE_ON_DEMAND_RESOURCES = NO
// 不设 INFOPLIST_KEY_NSAppTransportSecurity:没有任何网络请求,ATS 无用武之地
// ⚠️ 不要定义 SOUNDIARY_OFFLINE_ONLY 之类的「全球版」开关:
// 单一 App 没有第二个构建目标,宏无从区分,反而会让人误以为存在全球分支。
2.2 后端选择:运行时判断,不是编译期
public enum ModelHub {
public static var availableBackends: [LLMBackend] {
// L1:Foundation Models。必须用 isAvailable,不能按地区字符串分流——
// 官方口径是「国行设备 或 大陆 Apple Account」二选一即不可用;
// 非大陆设备 + 非大陆账号,人在中国大陆也能用。别误伤常驻海外的用户。
if FoundationModels.isAvailable { return [.foundationModels, .coreMLBundled, .templateFallback] }
// L2:自带模型是老设备与大陆用户的唯一路径;L3 兜底。
return [.coreMLBundled, .templateFallback]
}
}
审核员与备案审核可能反编译或抓包。要求:__TEXT 段中不出现 URLSession / NSURLSession / nw_connection / 第三方域名字符串。既然只有一个 Target,就把云端 Provider 相关文件从工程里彻底删除,而不是用 #if SOUNDIARY_OFFLINE_ONLY 包裹——Swift 的条件编译在 Release 下虽会剔除未使用分支,但符号表与字符串常量可能残留。
2.3 CI 卡口脚本(提交前必跑)
#!/bin/bash
# Scripts/audit_offline.sh —— 全 App 零联网审计
set -e
APP="$BUILD_DIR/Soundiary.app"
BIN="$APP/Soundiary"
echo "== 1) 网络符号扫描 =="
if nm -u "$BIN" | grep -E "URLSession|NSURLConnection|nw_connection_create|CFNetwork"; then
echo "❌ 发现网络符号(需人工确认白名单)"; exit 1
fi
echo "== 2) 第三方域名字符串扫描 =="
if strings "$BIN" | grep -Ei "(\.com|\.cn|\.net|\.io)/|https?://" | grep -v "apple.com" | grep -v "soundiary"; then
echo "❌ 发现疑似第三方域名"; exit 1
fi
echo "== 3) 可回放录音 API 扫描 =="
if nm -u "$BIN" | grep -E "AVAudioRecorder|AVAssetWriter|AVAudioFile"; then
echo "❌ 出现可产出可回放录音的 API,违反底线①"; exit 1
fi
echo "== 4) 动态库清单 =="
otool -L "$BIN" | grep -v "/usr/lib|/System|@rpath.*Soundiary" || true
echo "✅ 全部通过"
把此脚本加入 Build Phases → Run Script(唯一 Target 即可),失败即构建失败,避免侥幸。
3 · Watch 声音抽样引擎
主录音设备 = Apple Watch。核心结论:不做连续录音,做声音抽样。
3.1 为什么只能是抽样
| 约束 | 实测/官方结论 | 抽样方案如何规避 |
|---|---|---|
| watchOS 第三方 App 后台麦克风存活 | 离开前台后麦克风被挂起,社区实测平均约 17 秒 | 单次采样 6–8 秒 < 阈值,天然在存活窗口内完成 |
| 后台模式白名单 | 无 audio 后台模式;HKWorkoutSession 保活会被其他运动 App 顶掉且属滥用 | 不申请任何特殊后台模式,用 Background Refresh + 本地通知唤醒 |
| 电量 | 连续录音约 3–4 小时耗尽 | 占空比 3–8%,续航影响 < 8%/天 |
| 用户认知 | 「全天录音」引起强烈隐私焦虑 | UI 明确写「全天抽样感知」,配透明度面板 |
3.2 采样策略参数
public struct SamplingPolicy {
public var idleInterval: TimeInterval = 180 // 静默期基础间隔 3 分钟
public var baseDuration: TimeInterval = 8 // 单次采样 8 秒(< 17s)
public var voiceExtendedDuration: TimeInterval = 30 // 连续人声时延长上限
public var voiceExtendThreshold: TimeInterval = 1.2 // 持续人声 1.2s 触发延长
public var sampleRate: Double = 16_000 // 16 kHz,转写与声纹共用
public var continuous: Bool = false // true = 连续采样档(需 HKWorkoutSession 保活)
public var continuousWindowSeconds: TimeInterval = 30 // 连续档窗长
public var bodySampleInterval: TimeInterval = 0 // 体征采样间隔(0 = 随音频窗采)
public var bodyOnlyPushInterval: TimeInterval = 0 // 纯体征窗推送间隔(0 = 不推)
public static let standard = SamplingPolicy()
public static let lowPower = SamplingPolicy(idleInterval: 600, baseDuration: 6,
voiceExtendedDuration: 20, voiceExtendThreshold: 1.5,
bodySampleInterval: 60, bodyOnlyPushInterval: 300)
public static let dense = SamplingPolicy(continuous: true, continuousWindowSeconds: 30)
public var dutyCycle: Double { baseDuration / idleInterval } // ≈ 4.4%
}
// ⚠️ 档位由用户在设置里选(标准 / 省电 / 连续),首版没有「夜间自动降频」——
// 入睡检测已于 2026-10-15 正式取消(见 §3.6),首版只按时间表采样,不判断用户是否入睡。
3.3 调度与唤醒时序
3.4 后台调度的可行手段(按推荐度排序)
| 手段 | 可靠性 | 是否需特殊权限 | 说明 |
|---|---|---|---|
BGAppRefreshTask(watchOS 支持有限) | 中 | 否 | 系统按使用习惯调度,间隔不可控;作为软触发而非唯一依赖 |
本地通知(UNNotification)静默唤醒 | 中高 | 通知权限 | 用 UNCalendarNotificationTrigger 排队多个触发点,点击前 App 已在前台短暂存活 |
| 用户主动开始感知(大按钮 + 档位选择) | 高 | 麦克风 | 主路径:点「开始感知」→ 申请麦克风权限 → 按所选档位(标准 / 省电 / 连续)采样;开始前的用法说明与 watchOS 时长限制在 UI 里明示 |
HKWorkoutSession 保活 | 高 | HealthKit | 连续档已采纳:watchOS 上长时间后台使用麦克风的唯一合法通道,结束即 discardWorkout()、不在「健身记录」留训练。⚠️ 能力滥用有 4.2 / 2.4 拒审风险 —— 本 App 真实读取心率 / HRV 做时间轴对齐,属健康向用途;且连续档由用户主动选择,不是默认行为 |
WKExtendedRuntimeSession | — | — | 不可用:仅限正念、理疗、智能闹钟、物理治疗四类场景 |
Watch 上提供一个大按钮「开始感知一天」。点击后 App 保持前台运行、屏幕自动调暗(非锁屏),此时录音完全合法且稳定。用户抬腕看表即自动暂停并显示「感知暂停中」。这比追求隐形后台更诚实,也天然满足「用户知情同意」——审核员最在意的就是这一点。
3.5 电量与可感知时长估算
public enum BudgetEstimator {
/// 返回:预计可感知小时数 + 档位文案 key
public static func estimate(policy: SamplingPolicy = .standard,
battery: Double) -> (hours: Double, levelKey: String) {
let health = HealthCalibration.shared.degradationFactor // 电池健康度 → 0.6~1.0
let drainPerHour = policy.dutyCycle * 7.5 // 经验系数:满占空比 7.5%/h
+ 1.2 // 蓝牙传输基线
let usable = max(0, battery - 0.15) * health // 保留 15% 安全电量
let hours = usable / drainPerHour
return (hours, hours > 10 ? "budget.long" : hours > 4 ? "budget.mid" : "budget.short")
}
}
| 电池健康度 | 当前电量 | 标准档预计可感知 | UI 提示文案 key |
|---|---|---|---|
| 100% | 100% | ≈ 43 小时 | budget.long ·「电量充足,可覆盖整天」 |
| 85% | 60% | ≈ 20 小时 | budget.long |
| 80% | 30% | ≈ 6.5 小时 | budget.mid ·「可感知至傍晚,建议睡前充电」 |
| 75% | 20% | ≈ 2.6 小时 | budget.short ·「电量偏低,已自动切换省电抽样」 |
⚠️ 经验系数 7.5%/h 需用真机实测校准(建议 Series 7/9/Ultra 各测 3 轮),并在设置页以「估算值,非精确值」声明。
3.6 入睡检测(三重判据) 已取消 · 任何版本都不实现
⚠️ 本功能已于 2026-10-15 正式取消,任何版本都不会实现,本节设计仅作历史留存。代码中不存在任何入睡判断逻辑(sleep.* 界面文案零引用);HealthKit 只读心率 / HRV / 静息心率,不读睡眠数据。采样完全由用户选定的档位掌控——想停就停,不需要"检测入睡"。
| 判据 | 数据源 | 阈值 | 权重 |
|---|---|---|---|
| 声学静默 | 本引擎响度包络 | 连续 3 个采样窗 dBFS < 阈值且无人声 | 0.4 |
| 心率下探 | HealthKit heartRate | 较个人静息基线下降 ≥ 8% | 0.3 |
| 手腕静止 | CMMotionManager / 腕部姿态 | 连续 10 分钟无显著位移 | 0.3 |
(以下为历史设计,已取消)综合得分 ≥ 0.7 判定入睡 → 自动切换 nightInterval(10 分钟)并继续低频环境监测;连续 30 分钟无人声则完全停止采样,晨起(首次显著运动 + 光照/时间窗)自动恢复。
4 · 采集即弃流水线(iPhone 侧)
4.1 数据流
4.2 关键实现要点
| 环节 | 实现 | 注意事项 |
|---|---|---|
| 接收 | WCSession 文件通道(transferFile)逐窗到达;Inbox 文件在回调内同步读完再删 | 系统负责排队与自动重试;单窗元数据随文件原子到达,不存在「帧到了但结束标记丢了」的半截窗 |
| 缓冲 | 整窗缓冲,容量 = 30 秒 × 16kHz × 2B ≈ 960 KB(解码后) | 窗内 PCM 只在内存;转写完成即释放并清零,不借助任何缓存写回磁盘 |
| VAD | 能量 + 频谱平坦度双判据,1.2s 持续人声触发延长 | 阈值对三语无差异;低信噪比场景降级为「有人声/无人声」二分类 |
| ASR | Whisper-tiny Core ML(int8),流式 chunk 2s 滑窗 | 中文需确保模型含 zh token;language 由用户主语言设定 |
| 声纹 | 每 3 秒取一次 192d embedding,窗内聚类 | 只存 向量,不存音频;向量为派生特征,不可还原语音 |
| 落库 | SoundSnapshot 写入 SwiftData | 字段见 §7 |
| 清零 | 成功后立即清零并 discardBytes += n | 计数器供透明度面板展示「今日已丢弃 4.2GB」 |
用户手机里没有任何一段可播放的录音,意味着:手机丢了、被取证、被云端同步,都拿不到你的声音。同时存储成本从「每天几百 MB」降到「每天几十 KB」。上架文案直接写:「你的声音,从未被保存。」
5 · 端侧模型与 Core ML 集成(全球统一)
5.1 三级降级
| 级别 | 后端 | 适用设备 | 可用性 | 质量 |
|---|---|---|---|---|
| L1 | Foundation Models(iOS 26+,端侧 ~3B) | 支持 en / es / zh-Hans | 需 A17 Pro+ 且非国行设备、非大陆 Apple Account | ★★★★★ |
| L2 | 自带 Core ML 模型 | 全部设备——老设备与大陆用户的唯一路径 | ✅ 随包分发,全球同一份 | ★★★☆☆ |
| L3 | 规则模板兜底 | 低端设备 / 内存不足 / 模型加载失败 | ✅ | ★★☆☆☆ |
5.2 模型清单与实际体积
| 用途 | 推荐模型 | 精度 | 体积 | 输入/输出 |
|---|---|---|---|---|
| 语音转写 | Whisper-tiny(多语版,含 zh/es/en,39M 参数) | int8 | ≈ 40 MB | 16kHz mel → 文本 |
| 日记/总结生成 | Qwen2.5-0.5B-Instruct(494M 参数) | int4(palettized)+ fp16 embedding | ≈ 300 MB | ≤1024 token → ≤512 token |
| 声纹嵌入 | ECAPA-TDNN(small,512d→192d PCA) | fp16 | ≈ 8 MB | 3s 音频 → 192d 向量 |
| 环境声分类 | YAMNet-small / AudioSet 子集微调 | int8 | ≈ 12 MB | 0.96s → 10 类标签 |
| 模型合计 | ≈ 360 MB | IPA 总量 ≈ 420 MB | ||
Qwen2.5-0.5B-Instruct 共 494M 参数,其中 embedding 就占 136M。可核对的公开实测值:
- GGUF Q4_K_M ≈ 379–398 MB、Q3_K_M ≈ 355 MB、Q8_0 ≈ 531 MB、fp16 ≈ 994 MB;
- 转 Core ML 并做 4-bit palettization 后约 250–320 MB(embedding 常保留较高精度)。
本方案取 300 MB。Whisper-tiny 为 39M 参数,int8 约 40 MB。 早期文档写的「130 MB / 75 MB」是把参数总量误当作可全量 int4 的结果,实际偏低约 2.5 倍——若按那个数字做包体规划,上线后一定会超。
iOS 对超过 200 MB 的 App 在蜂窝网络下会提示用户确认。这个限制从 iOS 13 起可由用户手动绕过 (设置 → App Store → App 下载 → 「始终允许」),不是硬阻断,不必当成不可逾越的红线。
应对:① App Store 描述首屏写明「首次安装建议连接 Wi-Fi(约 420 MB,此后完全离线运行)」; ② 首次启动检测到蜂窝网络时给一次友好提示;③ 绝不采用按需下载——那会直接破坏备案豁免。
5.3 模型转换与打包
# 1) Whisper → Core ML(使用 whisper.cpp 的 models/convert-whisper-to-coreml.py
# 或 HuggingFace 上已转换好的 aniemore/whisper-tiny-coreml)
python convert-whisper-to-coreml.py --model tiny --encoder-only false --output Models/
xcrun coremlcompiler compile WhisperTiny_int8.mlpackage Models/
# 2) Qwen2.5-0.5B → Core ML int4(coremltools + palettization)
python - <<'PY'
import coremltools as ct, torch, numpy as np
from transformers import AutoModelForCausalLM, AutoTokenizer
name = "Qwen/Qwen2.5-0.5B-Instruct"
m = AutoModelForCausalLM.from_pretrained(name, torch_dtype=torch.float16).eval()
tok = AutoTokenizer.from_pretrained(name)
ids = torch.tensor([tok("今天我").input_ids]) # 追踪用样例(长度决定 KV cache)
traced = torch.jit.trace(m, (ids,), strict=False)
mlmodel = ct.convert(traced, inputs=[ct.TensorType("input_ids", shape=(1, ct.RangeDim(1,1024)))],
compute_units=ct.ComputeUnit.CPU_AND_NE,
minimum_deployment_target=ct.target.iOS18)
op = ct.optimize.coreml.OpPalettizerConfig(mode="kmeans", nbits=4, weight_threshold=512)
mlmodel = ct.compression_utils.palettize_weights(mlmodel, config=op)
mlmodel.save("Qwen25_05B_int4.mlpackage")
PY
xcrun coremlcompiler compile Qwen25_05B_int4.mlpackage Models/
# 3) 声纹 / 环境分类同理(ECAPA 用 torchaudio 导出 ONNX 再转 Core ML)
# 4) 加入 Xcode:拖入 SoundiaryKit/Resources/Models/
# Target Membership → 勾选 Soundiary(唯一 Target,全球同一份)
5.4 运行时加载与内存
public actor CoreMLBackend: LLMBackend {
private var model: MLModel?
public func load() async throws {
// 首次推理前才加载;加载后常驻,直到收到内存警告
let cfg = MLModelConfiguration()
cfg.computeUnits = .cpuAndNeuralEngine // NE 加速,CPU 兜底
cfg.allowLowPrecisionAccumulationOnGPU = true
guard let url = Bundle.main.url(forResource: "Qwen25_05B_int4", withExtension: "mlmodelc")
else { throw ModelError.bundledModelMissing }
model = try MLModel(contentsOf: url, configuration: cfg)
}
public func unloadOnMemoryPressure() { model = nil } // 收到 UIApplicationDidReceiveMemoryWarning
}
推理时机:只在充电 + 连接 Wi-Fi(仅指局域网存在与否,不产生请求)或用户主动打开「今日故事」时批量执行;不在采样瞬间跑大模型,避免发热。
5.5 三语提示词
// PromptCatalog.swift —— 提示词必须随界面语言走,否则中文用户拿到英文腔日记
public enum PromptCatalog {
public static func dailyStory(locale: Locale, snapshots: [String]) -> String {
switch locale.language.languageCode {
case .chinese:
return """
以下是用户一天的语音转写片段(按时间排序,已去除音频):
\(snapshots.joined(separator: "\n"))
请用简体中文写一段 300 字以内的第三人称日记,语气克制温柔,不要臆测未提及的事实。
"""
case .spanish:
return """
Fragmentos transcritos del día (sin audio): ...
Escribe un diario en tercera persona de menos de 300 palabras, en español, tono sobrio.
"""
default: // .english
return """
Transcribed fragments of the day (audio discarded): ...
Write a third-person diary under 300 words, in English, restrained tone.
"""
}
}
}
6 · 多源融合时间轴
6.1 数据源与对齐
| 来源 | API | 取数方式 | 粒度 | 是否离开设备 |
|---|---|---|---|---|
| 声音快照 | Watch 端本引擎 | 实时 | 8–30s 窗 | 否 |
| 心率 / HRV / 静息心率 | HealthKit(手表端读取) | 节流查询:心率 20s / HRV 60s / 静息心率 30min | 单点 | 否 |
| 气压 | Watch CMAltimeter | 本地气压计实时读数(NSMotionUsageDescription) | 连续 | 否 |
| 备忘录 / 提醒 / 日历 v2 规划 | 无直接 API → 用户手动导出或快捷指令 | 见 6.3 | 条目 | 否 |
⚠️ 首版只读上表前三行。血压 / 睡眠 / 步数 / 运动均不读(早期文档写过,已按实现删除);位置与照片见文首范围声明,属完整版规划。
6.2 时间轴对齐算法
public struct TimelineBus {
/// 统一时间基准:所有事件转为 UTC 后按本地时区分桶(桶宽 5 分钟)
public func fuse(snapshots: [SoundSnapshot],
health: [HealthSample]) -> [TimelineBucket] {
let bucketSize: TimeInterval = 300
// 1) 各自按 startDate 落入桶
// 2) 桶内做「跨模态共现」打分:声音×心率
// 3) 生成自然语言线索,如「19:40 心率 92 + 地铁环境声 → 通勤途中」
}
}
钟差校正:Watch 与 iPhone 的时间戳分别记录,用 WCSession 激活时的往返做一次偏移估计(通常 < 50ms),记录在 AudioFrame.capturedAt 校正值中。
6.3 备忘录的可行接入方式
- App Intents + 快捷指令(推荐):提供一个
ImportNotesIntent,用户自己在「快捷指令」App 里配置「每天 22:00 取今日备忘录 → 传给 Soundiary」,全程本地; - 文件导入:用户在备忘录里「存储到文件」→ 用
.fileImporter导入.txt,App 解析时间线; - 粘贴板/分享面板:App 注册
NSExtension,从备忘录分享到 Soundiary。
不要尝试私有 API 或读取 ~/Library/Group Containers 的备忘录数据库——必被拒。
7 · 数据模型与保留策略
7.1 SwiftData Schema
@Model final class Day {
var date: Date // 本地日历日(零点由用户时区决定)
var story: String? // LLM 生成的当日故事
var emotionScore: Double? // -1.0 ~ 1.0
var todos: [TodoItem]
@Relationship(deleteRule: .cascade) var snapshots: [SoundSnapshotEntity]
var discardedBytes: Int64 // 透明度面板用
var sensedSeconds: Int
}
@Model final class SoundSnapshotEntity {
var id: UUID
var startAt: Date
var endAt: Date
var transcript: String // 可能为空(纯环境声)
var envLabel: String // quiet / speech / crowd / traffic / music ...
var loudness: [Float] // 每 5s 一个 dBFS,≈ 6 个值
var voiceprint: Data? // 192 × Float32 = 768B,加密存储
var clusterID: Int?
var isPartial: Bool // 传输中断标记
// ❗ 没有任何 audioURL / audioData 字段 —— 这是设计约束,不是遗漏
}
@Model final class Speaker {
var clusterID: Int
var nickname: String? // 用户确认后填写
var centroid: Data // 声纹中心向量(加密)
var confirmed: Bool = false // 用户是否已确认身份
var lastSeen: Date
}
@Model final class Insight {
var title: String
var body: String
var kind: String // pattern / knowledge / habit / reminder
var evidenceDayIDs: [UUID]
var createdAt: Date
var isRead: Bool
}
7.2 保留策略(默认,用户可改)
| 数据 | 默认保留 | 用户可选项 | 存储位置 |
|---|---|---|---|
| 转写文本 | 90 天 | 30 / 90 / 365 天 / 永久 | App Group 容器,加密 |
| 声纹向量 | 随说话人档案;删除说话人即清除 | 可随时单删 | 同上 |
| 响度/环境标签 | 30 天 | — | 同上 |
| HealthKit 缓存 | 不落库,仅内存聚合后写入统计值 | — | — |
| 音频 | 0 秒(从不写入) | 不可开启 | — |
| 洞察 / 故事 | 永久(用户手动删除) | — | 同上 |
7.3 导出与删除
- 导出:
UIActivityViewController导出 JSON / Markdown(明文,用户自选保存位置)。导出内容不含任何音频,导出动作本身不联网。 - 删除:设置 → 数据管理,提供「删除今天 / 删除 30 天前 / 删除全部」。删除时对加密 SQLite 执行
VACUUM并覆写空闲页,避免残留。 - 卸载即清空:所有数据存于 App Group 容器(非 iCloud、非 CloudKit),卸载 App 后系统自动清除。需在隐私政策中明确告知。
8 · 存储加密与密钥
| 层次 | 方案 | 说明 |
|---|---|---|
| 文件级 | NSFileProtectionComplete(Data Protection Class A) | 设备未解锁时文件不可读;用户若未设锁屏密码则降级为 B,需在首次启动时检测并提示 |
| 数据库级 | SQLCipher 或 SwiftData + 自定义 ValueTransformer 加密敏感字段 | 不引入第三方库 → 用 CryptoKit 自实现 AES-GCM 字段加密 |
| 密钥 | Secure Enclave 生成 P-256 密钥对;对称密钥用 kSecAccessControlBiometryCurrentSet 封装 | 私钥永不出 Enclave;Face ID 变更即失效 |
| 密钥存储 | Keychain kSecClassGenericPassword + WhenUnlockedThisDeviceOnly | ThisDeviceOnly 是关键:iCloud 钥匙串同步会引入 Apple 服务器同步,虽属 Apple 但为审计清晰起见禁用 |
| 内存 | 音频缓冲释放前 memset;声纹向量用后即弃 | Swift String 不可变,敏感文本用 [UInt8] 手动管理 |
// SecureKeyStore 关键片段
let access = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
[.privateKeyUsage, .biometryCurrentSet], // 生物识别变更后密钥自动失效
nil)!
let attrs: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits as String: 256,
kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave, // 硬件背书
kSecPrivateKeyAttrs as String: [kSecAttrIsPermanent: true, kSecAttrAccessControl: access]
]
本项目放弃了云端 Provider 与用户自填 Token功能(换来备案豁免最稳 + 维护最轻,见 §0.1),因此不需要存储任何第三方 API Key——密钥体系反而更简单:Secure Enclave 只保护本地数据加密密钥,不存在远程凭据泄露面。
9 · 零联网保障工程化
9.1 允许的网络行为白名单(全 App)
| 行为 | 目标 | 是否允许 | 依据 |
|---|---|---|---|
| 系统 Safari 打开政策/支持页 | 用户自有站点 | ✅ | 由系统浏览器发起,App 内不获取内容 |
| 任何 App 内 URLSession 请求 | — | ❌ | 底线② |
| 崩溃上报 / 分析 / 归因 SDK | — | ❌ | 包括 Firebase、Sentry、AppsFlyer、友盟等 |
| On-Demand Resources / Background Assets 下载模型 | Apple CDN | ❌ | 虽连 Apple,但会被认定为「运行时下载内容」,风险高 |
| iCloud / CloudKit 同步 | Apple | 谨慎 | 建议关闭;若开启需在隐私标签声明,且可能被认定提供网络服务 |
| App Store 更新检查 / 评分弹窗 | Apple | ✅(系统行为) | 使用 SKStoreReviewController,不自建 |
9.2 抓包自检流程(提交前必做)
今日已感知 8 小时 12 分
已丢弃原始音频 4.2 GB
本地占用 48 KB
第三方网络请求 0 次
最后联网行为 无(App 运行时零联网)
10 · 三语本地化工程
10.1 语言与区域
| 语言 | 代码 | 基准 | App Store 本地化 | 说明 |
|---|---|---|---|---|
| 简体中文 | zh-Hans | ✅ 基准语言 | 中国大陆 | 基础语言,必须提供 |
| 英语 | en | — | 美国 / 英国 / 澳大利亚 / 加拿大 | — |
| 西班牙语 | es | — | 西班牙 / 墨西哥 / 拉美 | 注意 es-ES 与 es-419 用词差异,先做一套中性拉美西语,后续拆分 |
10.2 目录与命名
10.3 易错点清单
| 问题 | 说明 | 处理 |
|---|---|---|
| 提示词未本地化 | 界面是中文,模型却用英文提示词 → 日记带英文腔 | Prompts.strings 三语 + 单元测断言「输出语言 == 界面语言」 |
| 西语文案溢出 | 西语平均比英语长 20–25% | SwiftUI 用 .minimumScaleFactor(0.8) + fixedSize 禁用;截图逐语言目视检查 |
| 日期/数字格式 | zh 用「10月11日」,es 用「11 oct」,en 用「Oct 11」 | 一律用 Formatter / .formatted(),禁止手写拼接 |
| 复数 | es/en 有复数规则,zh 无 | 使用 .stringsdict |
| ASR 语言设定 | Whisper 需指定语言 | 跟随系统首选语言顺序;不支持的语言回退英文 |
| 权限文案 | InfoPlist.strings 需按语言本地化,否则审核员看到英文描述中文 App | 三语齐全 |
| App Store 元数据 | 标题/副标题/关键词/描述/宣传文本需各语言单独填 | 中文关键词注意逗号分隔无空格 |
11 · Info.plist 与权限清单
11.1 iOS(iPhone Target)
| Key | 值(zh-Hans / 建议) | 用途 | 必需 |
|---|---|---|---|
NSMicrophoneUsageDescription | Soundiary 通过 Apple Watch 感知环境声音,用于生成当日故事。声音仅在设备内存中实时处理,不会保存为录音文件。 | 麦克风。实际录音发生在 Watch 端(iPhone target 零 AVAudioEngine 引用,本机不会弹框);键保留是因为麦克风是产品核心能力,删了将来任何 iPhone 侧录音都会直接崩 | ✅ |
NSHealthShareUsageDescription / NSHealthUpdateUsageDescription | 不声明 | iPhone target 零 HK* 引用 —— 健康数据全部由手表端读取、手表自己弹框;iPhone 只经 WatchConnectivity 收体征值。若将来看见这两条,见 SoundiaryApp/Info.plist 注释里的恢复说明 | — |
NSSpeechRecognitionUsageDescription | 在设备上把声音转成文字,用于生成日记。全部在您的设备内完成。 | Speech 框架(iPhone 侧转写与自检都要) | 必填 |
NSAppTransportSecurity | 不声明(App 内不存在任何网络请求,连本地网络例外也不设) | 网络 | — |
UIBackgroundModes | 不声明(iOS 端无后台音频需求;watchOS 侧见 §11.2) | 后台 | — |
⚠️ 本地通知(UNUserNotificationCenter)不需要用途描述键 —— 早期文档列的 NSUserNotificationsUsageDescription 不存在也不需要。
11.2 watchOS(Watch App Target)
| Key | 值 | 注意 |
|---|---|---|
NSMicrophoneUsageDescription | Soundiary 每隔几分钟短暂采样一段声音,并立即转成文字。原始音频默认不会被保存(主动录音保存的文件仅存本机)。 | Watch 与 iOS 各自声明 |
NSHealthShareUsageDescription | 读取心率与心率变异性,为时间轴补充身体信号并估算压力。数据只留在您的设备上,绝不上传。 | 只读心率 / HRV / 静息心率,不读睡眠 |
NSHealthUpdateUsageDescription | 开启连续采样期间启动一次体能训练会话,让 watchOS 在后台保留麦克风。停止后会丢弃该会话,不会在「健身记录」里留下训练。 | 不能少:授权里声明了「写入体能训练」,缺这条 startActivity 直接抛错 |
NSMotionUsageDescription | 读取手表的气压计,为时间轴补充气压读数与天气趋势。不记录任何运动行为,也不上传任何数据。 | 气压计属「运动与健身」,缺了会崩 |
WKBackgroundModes | 不声明 audio(该模式在 watchOS 上不向第三方开放) | 声明会直接导致审核被拒 |
11.3 HealthKit 授权请求代码要点
// 手表端实际请求的范围(WatchSensingEngine.BodySignals + WorkoutKeepAlive)
let toRead: Set<HKObjectType> = [
HKObjectType.quantityType(forIdentifier: .heartRate)!,
HKObjectType.quantityType(forIdentifier: .heartRateVariabilitySDNN)!,
HKObjectType.quantityType(forIdentifier: .restingHeartRate)!
]
let toShare: Set<HKSampleType> = [HKObjectType.workoutType()] // 仅为后台执行权,结束即 discardWorkout()
try await healthStore.requestAuthorization(toShare: toShare, read: toRead)
// ⚠️ 审核红线:HealthKit 数据不得用于广告、数据经纪、不得写入 iCloud、不得披露给第三方
// ⚠️ 血压 / 睡眠 / 步数 / 运动一律不读 —— 权限范围与 §6.1 数据来源表严格一致
12 · 知情同意流程
12.1 四步引导(首次启动)
⚠️ 状态说明(2026-10-15 核对):这四步目前是设计稿,代码中尚未实现——iPhone 端搜不到任何首次启动引导页(无 onboard / firstLaunch / walkthrough 相关实现)。因此 不要把「四步引导」「逐项可关的同意流程」写进 App Store 描述,否则属虚假宣称。当前真实的授权流程是:Watch 端采样时由系统弹窗逐项询问麦克风与 HealthKit,用户可逐项拒绝;设置页常驻感知状态开关与「今日透明度」面板。
| # | 页面 | 核心文案(zh-Hans) | 强制动作 |
|---|---|---|---|
| 1 | 这是什么 | Soundiary 用 Apple Watch「听」你的一天,再把它写成故事。它不录音,你的声音不会被保存。 | 继续 |
| 2 | 声音怎么被处理 | 手表每 3–5 分钟采集 6–8 秒声音 → 立刻转成文字 → 声音立即丢弃。全程在你自己的设备上完成,没有任何服务器参与。 | 勾选「我已理解」 |
| 3 | 权限 | 麦克风 / 健康。每一项都独立可关。 | 逐项授权 |
| 4 | 开始 | 你可以随时在设置里停止感知、导出或删除全部数据。 | 启动 |
12.2 持续可见的同意状态(PIPL / GDPR 要求「可撤回」)
- 设置页顶部常驻「感知状态」开关,一键停止且立即清零缓冲;
- 每次采样窗开始时,Watch 端可显示极轻量的状态点(可选,用户可关);
- 通知栏常驻(可选):「Soundiary 正在感知 · 点击停止」——iOS 26 支持持续通知;
- 任何时刻进入 App 都能看到今天的透明度面板(§9)。
录音(即使是加工成文本)会采集到第三方(家人、同事、路人)的语音。PIPL 第十三条要求处理个人信息需取得同意,而用户对他人声音无法取得同意。工程上的缓解措施:
- 默认在「单人环境」标签下才生成含说话内容的文本;检测到多人会话时,只保留「多人交谈 · N 人」的抽象标签,不转写内容;
- 提供「隐私模式」:在任何非白名单地点(如办公场所、他人住所)自动降为环境标签模式;
- 隐私政策明确告知用户:你有责任在开启感知前告知同处一室的人,App 提供一键生成的告知卡片。
13 · 付费下载配置(无内购)
本 App 采用付费下载(Paid App)模型:用户在 App Store 付费后下载,下载即完整版。因此不存在任何内购项目,代码里也没有 import StoreKit、没有 IAPManager、没有解锁状态、没有权益页、没有「恢复购买」按钮。
13.1 定价配置(在 App Store Connect 中设置,不在代码里)
| 字段 | 值 |
|---|---|
| App 定价方式 | 付费(Paid),不是免费 |
| 中国区价格 | ¥1(最低档) |
| 美国区价格 | $0.99(见方案文档 §13.1) |
| 其他区 | 各区最低档 |
| 家庭共享 | 建议开启(提升转化) |
| 内购项目 | 无。整个「App 内购买」菜单跳过 |
| 订阅 | 无。不创建任何订阅组、不设置自动续费 |
App 一旦设为免费,无法再改回付费。提交前务必确认定价档位。
13.2 换设备后如何「恢复」
付费 App 不需要 App 内的「恢复购买」按钮,也因此不触发 Guideline 3.1.1(该条款只针对 App 内购买)。换设备后:
- 用原 Apple ID 打开 App Store;
- 进入「已购项目 / Purchases」;
- 找到 Soundiary,点云下载图标重新下载——无需重复付费。
恢复流程完全由 Apple 侧处理,App 不参与、不存储任何支付凭据。
14 · 备案豁免提交包
14.1 提交路径
- 登录 App Store Connect → 页面底部 「联系我们 / Contact Us」;
- 依次选择:App Store 分发 → 其他疑问 / Additional Questions;
- 在附件中上传《不适用备案说明函》(PDF,含 Team ID 与手写签名);
- 正文写明:App 名称、Apple ID、Bundle ID、Team ID、中国区上架诉求、以及「本 App 不提供互联网信息服务」的事实依据。
14.2 说明函模板(可直接复制打印)
致:苹果公司(Apple Inc.)App Store 审核团队
开发者:[开发者主体全称] Apple Developer Team ID:[XXXXXXXXXX]
应用名称:Soundiary Bundle ID:com.soundiary.app
本函就上述应用在中国大陆地区分发所涉《移动互联网应用程序信息服务管理规
定》项下的备案要求,作如下事实说明与承诺:
一、无互联网信息服务提供行为。本应用为纯本地工具类应用。全部数据采集、
加工、存储均在用户设备本地完成。开发者不运营、不托管、不部署任何用于
接收、存储、处理或分发用户数据的后端服务器。
二、无用户内容与社区功能。本应用不提供用户注册、登录、账号体系、用户
社区、内容发布、评论、转发、信息流、即时通讯、直播、搜索等任何互联网信
息服务功能。应用内不存在任何可由用户上传并由开发者分发的内容。
三、网络行为。本应用为付费下载,付款在下载阶段由 App Store 在应用之外完成,应用运行时不发起任何网络连接。唯一的联网行为是(1)通过系统 Safari 浏览器打开本应用隐私政策与技术支持网页,该行为由系统浏览器发起,应用内不获取、不渲染任何网络内容。除上述外,本应用不存在任何网络连接行为。
四、无第三方 SDK。本应用未集成任何第三方软件开发工具包,包括但不限于
统计分析、崩溃上报、广告归因、推送、登录分享类 SDK。
五、数据处理承诺。本应用采集的声音信息仅用于设备内实时转写,原始音频
在设备本地处理完毕后立即删除,不进任何长期存储、不生成任何可回放的录音
文件;仅当需要在本人的 Apple Watch 与 iPhone 之间传输时,短暂生成一个
受系统保护的临时文件,投递有结果即删除,用户亦无法回放;心率、HRV 与静息
心率等健康数据仅用于时间轴对齐,全部留存于本机。本应用不收集、不上传、
不共享、不出售任何个人信息。
综上,本应用不属于《移动互联网应用程序信息服务管理规定》所规制的「互联
网信息服务提供者」,恳请贵司依据相关规定与既有实践,豁免本应用在中国大
陆地区的备案号填报要求。
本函所述内容真实、准确、完整。如与事实不符,开发者愿承担由此产生的全部
法律责任,并接受贵司依据开发者计划许可协议采取的处理措施。
开发者(签名):____________________ 日期:______年____月____日
[加盖开发者主体公章]
14.3 提交前自检(全部 ✅ 才可提交)
- 抓包报告显示 0 次第三方请求(含冷启动、后台、IAP 全流程)
- 二进制符号扫描通过(§2.3 脚本 4 项全绿)
- 无第三方 SDK(检查
Pods/、SPM 依赖、Frameworks/为空) - 隐私政策已上线且 URL 可访问(HTTPS)
- App 隐私标签填写为「未收集数据」或仅 Apple 支付相关(见 §15)
- App 内无「登录 / 注册 / 账号」字样
- App 内无用户生成内容入口(无评论、无分享社区、无反馈上传附件)
- 技术支持 URL 与隐私政策 URL 均已填写且有效
① 包名/主体不一致:说明函中的 Bundle ID、Team ID 必须与 ASC 后台完全一致。
② 隐私政策里有「我们可能收集……」这类兜底措辞:审核员会直接判定存在收集行为。政策必须绝对化表述("不收集" 而非 "不主动收集")。
15 · App Store 隐私标签填写对照
ASC → App 信息 → App 隐私 → 按下列填写。
| 数据类型 | 本应用是否收集 | 用途 | 是否关联身份 | 是否用于追踪 |
|---|---|---|---|---|
| 联系人 | 否 | — | — | — |
| 健康与健身 | 否(仅本地读取并立即聚合) | — | — | — |
| 财务信息 | 否(支付由 Apple 处理) | — | — | — |
| 位置 | 否(App 不使用定位) | — | — | — |
| 敏感信息 | 否 | — | — | — |
| 联系方式 | 否 | — | — | — |
| 用户内容 | 否(不离开设备、不上传) | — | — | — |
| 浏览历史 | 否 | — | — | — |
| 搜索历史 | 否 | — | — | — |
| 标识符 | 否 | — | — | — |
| 购买记录 | 由 Apple 处理 | App 功能(解锁) | 否(开发者不可见) | 否 |
| 使用情况数据 | 否 | — | — | — |
| 诊断 | 否(无崩溃 SDK) | — | — | — |
✅ 目标状态:「此 App 未收集数据」(Data Not Collected)。这是最有说服力的隐私标签,也是豁免申请的有力佐证。
勾选 「购买记录 → App 功能(下载)→ 不关联身份 → 不用于追踪」,并在备注中说明「付费下载的购买由 Apple 在 App Store 侧处理,开发者不获取」。切勿为了「看起来合规」而勾选健康/音频数据——那会被认定为存在收集行为,直接导致豁免失败。
16 · 测试与验收
16.1 测试矩阵
| 类别 | 用例 | 验收标准 |
|---|---|---|
| 功能 | 采样窗触发、人声延长、体征节拍 | 窗节奏符合档位设定(入睡检测已于 2026-10-15 取消,不纳入测试) |
| 中断 | 手表后台被挂起、iPhone 远离、蓝牙断开、来电打断 | 快照标记 partial,不崩溃、不丢当天叙事 |
| 电量 | 连续 8 小时标准档 | Watch 耗电增量 ≤ 20%(Series 9 实测) |
| 转写 | 中/英/西三语各 200 条样本 | CER / WER ≤ 20%(Whisper-tiny 合理区间) |
| 声纹 | 5 人 × 各 20 段 | 聚类纯度 ≥ 0.8,误合并率 < 10% |
| 隐私 | 抓包、符号扫描、文件系统快照 | 0 第三方请求;Documents 目录无音频文件 |
| 本地化 | 三语 × 全部界面 + 截图 | 无截断、无硬编码英文、日期格式正确 |
| 降级 | 低端设备(iPhone 11 / Watch SE)内存压力 | 自动卸载模型 → 模板兜底,不 OOM |
| 定价 | 付费下载、换机重新下载 | App Store「已购项目」可重新下载,无需重复付费 |
16.2 隐私回归测试(每次发版必跑)
func testNoAudioArtifactsOnDisk() throws {
let urls = try FileManager.default.contentsOfDirectory(
at: FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0],
includingPropertiesForKeys: nil)
let audio = urls.filter { ["m4a","caf","wav","mp3","opus"].contains($0.pathExtension) }
XCTAssertEqual(audio.count, 0, "磁盘上出现音频文件,违反底线①")
}
func testNoNetworkBackendExists() throws {
// 单一 App:不存在任何云端后端,连枚举 case 都不该有。
// 由 CI 脚本 audit_offline.sh 在构建期扫描 __TEXT 符号表保证。
XCTAssertEqual(ModelHub.availableBackends.count, 3,
"只允许 foundationModels / coreMLBundled / templateFallback 三个后端")
}
17 · 发布清单
17.1 上架前(T-14 天)
- 隐私政策三语上线(HTTPS,需含「生效日期」「联系邮箱」)
- 技术支持页三语上线
- 《不适用备案说明函》PDF 完成并签名盖章
- 抓包报告 + 符号扫描报告归档
- App 图标 1024×1024(无 alpha、无圆角)
- 三语宣传图各 6 屏 iPhone(1290×2796)+ 2 屏 Watch(410×502)
- 三语元数据:标题(≤30)、副标题(≤30)、关键词(≤100)、描述、宣传文本
- 年龄分级问卷填写(注意:涉及健康数据,如实填写)
- 出口合规(加密导出):使用免申报例外(5.D.992.c)——仅使用标准加密且不提供网络安全功能
- IAP 商品审核通过
17.2 提审后
- 若收到「需备案号」驳回 → 回复工单并附说明函 PDF(同一工单内追加,不要新建)
- 若收到 5.1.1(数据收集最小化)→ 附上隐私政策链接与透明度面板截图
- 若收到 4.2(最低限度功能)→ 演示完整日流程录屏(感知→故事→洞察)
- 记录每次驳回模板与应对话术,形成团队知识库
17.3 版本记录
| 版本 | 日期 | 主要变更 |
|---|---|---|
| Eng Spec 1.0 | 2026-10-11 | 首版。确立采集即弃架构、单一 App 零联网、端侧模型随包分发、三语工程与备案豁免提交包 |