Skip to content

Commit 375e505

Browse files
committed
feat(audio): 新建 WAV 与会话轴对齐纯函数
1 parent 1a50d2b commit 375e505

3 files changed

Lines changed: 320 additions & 0 deletions

File tree

‎app/src-tauri/src/audio_align.rs‎

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
//! WAV 轴 ≡ 会话轴的对齐纯函数(批 6 T23 / R5.5-b 的根治方案)。
2+
//!
3+
//! @ai-context: Why —— 写块路径今天「纯追加」(WAV 轴 = 已写样本数 ÷ 16000),而会话轴
4+
//! (`segments[].start_ms` = 会话纪元 − 累计暂停)走墙钟;`capture/audio_loopback.rs:432-437`
5+
//! 的**静默窗根本不产包**(`probe-audio-runtime.md` 的 D2)⇒ 两轴必然分叉
6+
//! 且**无上界** ⇒ 播放头会漂到分钟级。本模块判定「这一块之前该补多少静音」。
7+
//! **基准轴 = `AudioChunk.timestamp_ms`**,它**已含暂停补偿**
8+
//! (`audio_loopback.rs:455-466` 逐字 `epoch.elapsed() - paused_ms`;暂停时长在
9+
//! 恢复成功时 `:367-374` 才累加)⇒ 与 `segments[].start_ms` 同轴,无需另立基准。
10+
//! @ai-context: 副作用 = **无**(纯函数、零 IO、零依赖、无全局态);`AlignBook` 是调用方
11+
//! 持有的值类型。消费者 = `audio_store::SessionAudioWriter::write_chunk`
12+
//! 与 `audio_align_tests.rs` / `audio_store_tests.rs`。
13+
//! @ai-context: 边界(**失效安全优先**:宁可退回今天的纯追加,也不写错位置)——`None` 的
14+
//! 四种情形:① 本块时间戳缺失 ② 为负 ③ **回退/乱序**(`next < prev_end`)
15+
//! ④ 空档 > `MAX_GAP_MS`。`None` 的语义统一 =「**本块及后续一律不补**
16+
//! (纯追加)」,由调用方同时置 `aligned = false` ⇒ **样本一个不丢**、
17+
//! **不阻断会话主链路**(`audio_store.rs:9-10` 的降级方向不变)。
18+
//! 首块(`prev_end_ms = None`)⇒ `Some(0)`:T0 由首块锚定。
19+
20+
/// 1 毫秒的样本数(16 kHz ÷ 1000;与 `audio_store::SAMPLE_RATE` 同契约,不重复定义采样率)。
21+
pub const SAMPLES_PER_MS: usize = 16;
22+
23+
/// 单次空档补静音的**上限**(10 分钟)。
24+
///
25+
/// @ai-context: Why 必须有上限:空档由「静默窗无包」造成,**上不封界**(D2)⇒ 无上限会在
26+
/// 一次超长静默里当场分配巨量缓冲(1 小时 = 115 MB)⇒ 超出即 `None`
27+
/// (不补 + `aligned = false`)。10 分钟是批 6 计划定的安全参数(追认项)。
28+
pub const MAX_GAP_MS: i64 = 10 * 60 * 1000;
29+
30+
/// 一个空档应补多少静音样本;`None` = **失效安全**(不补,退回纯追加)。
31+
/// @ai-context: `prev_end_ms` 由调用方按「首个带时间戳的块 + 已写样本数 ÷ 16」推
32+
/// (不额外存时间戳,见 `AlignBook::last_end_ms`);`next_ts_ms` = 本块的
33+
/// `AudioChunk.timestamp_ms`(块**发点** = 块末 ⇒ 首块锚定后,WAV 轴与该块
34+
/// 末样本的会话时刻重合)。
35+
pub fn silence_gap_samples(prev_end_ms: Option<i64>, next_ts_ms: Option<i64>) -> Option<usize> {
36+
let next = next_ts_ms?;
37+
if next < 0 {
38+
return None;
39+
}
40+
let Some(prev_end) = prev_end_ms else {
41+
return Some(0); // 首块锚定 T0:不补
42+
};
43+
if next < prev_end {
44+
return None; // 回退/乱序
45+
}
46+
let gap_ms = next - prev_end;
47+
if gap_ms > MAX_GAP_MS {
48+
return None; // 超大空档:宁可不补,也不写巨量静音
49+
}
50+
Some(gap_ms as usize * SAMPLES_PER_MS)
51+
}
52+
53+
/// 对齐簿记(`SessionAudioWriter` 的字段集合;`aligned` 是**自证量**)。
54+
///
55+
/// @ai-context: `aligned` 初始 `true`(=「尚未发现偏差」,**不**等于「已确认对齐」),
56+
/// 但**只要遇到一次**「缺失/为负/回退/超大空档」就**永久置 `false`**
57+
/// (本次会话内不恢复);`aligned == false` ⇒ 后续块一律纯追加。
58+
/// **禁止**把它写成恒 `true` 的字段(R5.5-b 第 3 条)——读侧
59+
/// `commands_audio::read_aligned` 据此如实区分「有/无时间基准」,
60+
/// 本修复之前录的 WAV 无 sidecar ⇒ 读侧 false(历史录音不假装精确)。
61+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62+
pub struct AlignBook {
63+
/// 自证量(对外值用 `is_aligned()`:空录音不冒充「已对齐」)
64+
pub aligned: bool,
65+
/// 首个带时间戳的块的 `timestamp_ms`(= R5.5-b 的 T0)
66+
pub first_ts_ms: Option<i64>,
67+
/// 已写样本对应的**会话轴末端**(`first_ts_ms + 已写样本数 ÷ 16`,由样本数推)
68+
pub last_end_ms: Option<i64>,
69+
/// 已写样本数(含补的静音;`last_end_ms` 由它推 ⇒ 不额外存「上一块时间戳」)
70+
written: u64,
71+
}
72+
73+
impl AlignBook {
74+
/// 新建(`aligned` 初始 `true` =「尚未发现偏差」)。
75+
pub fn new() -> Self {
76+
Self { aligned: true, first_ts_ms: None, last_end_ms: None, written: 0 }
77+
}
78+
79+
/// 推进一块(**唯一**的状态转移:`write_chunk` 与 `alignment_plan` 共用 ⇒ 不分叉);返回本块之前应补的静音样本数(`None` = 不补)。
80+
pub fn step(&mut self, ts_ms: Option<i64>, samples: usize) -> Option<usize> {
81+
if !self.aligned {
82+
return None; // 已失效:后续纯追加(不补、不丢样本)
83+
}
84+
let Some(ts) = ts_ms else {
85+
self.aligned = false; // ① 缺失时间戳
86+
return None;
87+
};
88+
let pad = match silence_gap_samples(self.last_end_ms, Some(ts)) {
89+
Some(pad) => pad,
90+
None => {
91+
self.aligned = false; // ②/③/④:为负 / 回退 / 超大空档
92+
return None;
93+
}
94+
};
95+
let first = *self.first_ts_ms.get_or_insert(ts); // 首块锚定 T0(此时 pad 必为 0)
96+
self.written += (pad + samples) as u64;
97+
self.last_end_ms = Some(first + (self.written / SAMPLES_PER_MS as u64) as i64);
98+
Some(pad)
99+
}
100+
101+
/// 自证量:`aligned` **且**至少写过一个带时间戳的块(空录音不得自称「已对齐」)。
102+
pub fn is_aligned(&self) -> bool {
103+
self.aligned && self.first_ts_ms.is_some()
104+
}
105+
}
106+
107+
/// 块序列 → 逐块的「写入前应补静音样本数」(纯函数;单测与自证的主入口)。
108+
///
109+
/// @ai-context: 与 `AlignBook::step` 的逐块驱动**逐元素相等**(V5 对拍);生产路径
110+
/// (`write_chunk`)是增量的 ⇒ 本函数无生产调用点(同 `ChunkAccumulator::flush`)。
111+
#[allow(dead_code)]
112+
pub fn alignment_plan(chunks: &[(Option<i64>, usize)]) -> Vec<Option<usize>> {
113+
let mut book = AlignBook::new();
114+
chunks.iter().map(|(ts, n)| book.step(*ts, *n)).collect()
115+
}
116+
117+
/// 单测独立文件(保持本文件 ≤300 行,AGENTS.md §3)。
118+
#[cfg(test)]
119+
#[path = "audio_align_tests.rs"]
120+
mod tests;
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
//! WAV 轴与会话轴对齐的边界单测(批 6 T23 / R5.5-b 第 2 条逐字:≥7 条边界)。
2+
//!
3+
//! @ai-context: AAA 模式;**纯函数、零 IO、无墙钟**(夹具只用相对毫秒)。
4+
//! 覆盖:首块 · 连续块(零空档)· 单空档 · 多空档 · 超大空档 ·
5+
//! 时间戳回退/乱序 · 缺 `timestamp_ms` · 负时间戳 · 批量≡增量对拍 ·
6+
//! **自证不变式**(含「发生过暂停」的会话形态)。
7+
8+
use super::*;
9+
10+
/// 会话纪元(毫秒;夹具只关心相对差)。
11+
const T0: i64 = 1_000;
12+
/// 一块的样本数 = 200 ms @16 kHz(`audio_loopback.rs:338` 的 `block_samples`)。
13+
const BLOCK: usize = 200 * SAMPLES_PER_MS;
14+
/// 一块的毫秒数(自证不变式的常数项:`timestamp_ms` 是块**发点** = 块末)。
15+
const BLOCK_MS: i64 = 200;
16+
17+
// ① 首块:T0 由首块锚定 ⇒ 不补
18+
#[test]
19+
fn first_chunk_anchors_t0_without_padding() {
20+
// Arrange & Act & Assert
21+
assert_eq!(silence_gap_samples(None, Some(T0)), Some(0));
22+
assert_eq!(silence_gap_samples(None, Some(0)), Some(0)); // 会话纪元处起步同样不补
23+
}
24+
25+
// ② 连续块(零空档):上一块末端 == 本块时间戳 ⇒ 不补
26+
#[test]
27+
fn consecutive_blocks_need_zero_padding() {
28+
assert_eq!(silence_gap_samples(Some(T0 + 200), Some(T0 + 200)), Some(0));
29+
}
30+
31+
// ③ 单空档:补**等长**静音(200 ms ⇒ 200 × 16 样本)
32+
#[test]
33+
fn single_gap_pads_equal_length_silence() {
34+
assert_eq!(silence_gap_samples(Some(T0), Some(T0 + 200)), Some(200 * SAMPLES_PER_MS));
35+
assert_eq!(silence_gap_samples(Some(T0), Some(T0 + 1)), Some(SAMPLES_PER_MS));
36+
}
37+
38+
// ④ 多空档:逐块独立、总数守恒(每块各补自己的空档)
39+
#[test]
40+
fn multiple_gaps_are_independent() {
41+
// Arrange:首块 T0 → 空档 300ms → 空档 700ms
42+
let fixture = [
43+
(Some(T0), BLOCK),
44+
(Some(T0 + 200 + 300), BLOCK),
45+
(Some(T0 + 200 + 300 + 200 + 700), BLOCK),
46+
];
47+
// Act
48+
let plan = alignment_plan(&fixture);
49+
// Assert:首块 0;第 2 块补 300ms;第 3 块补 700ms(不叠加)
50+
assert_eq!(plan, vec![Some(0), Some(300 * SAMPLES_PER_MS), Some(700 * SAMPLES_PER_MS)]);
51+
let total: usize = fixture.iter().zip(&plan).map(|((_, n), p)| n + p.unwrap()).sum();
52+
assert_eq!(total, 3 * BLOCK + 1_000 * SAMPLES_PER_MS); // 总数守恒
53+
}
54+
55+
// ⑤ 超大空档:恰好上限仍补,多 1 ms 即失效安全(不补)
56+
#[test]
57+
fn oversized_gap_fails_safe_at_the_limit() {
58+
assert_eq!(
59+
silence_gap_samples(Some(T0), Some(T0 + MAX_GAP_MS)),
60+
Some(MAX_GAP_MS as usize * SAMPLES_PER_MS)
61+
);
62+
assert_eq!(silence_gap_samples(Some(T0), Some(T0 + MAX_GAP_MS + 1)), None);
63+
}
64+
65+
// ⑥ 时间戳回退/乱序:一律不补(`None`),且相等不是回退
66+
#[test]
67+
fn rollback_and_out_of_order_fail_safe() {
68+
assert_eq!(silence_gap_samples(Some(T0 + 200), Some(T0 + 100)), None); // 回退 100ms
69+
assert_eq!(silence_gap_samples(Some(T0 + 200), Some(T0)), None); // 乱序(退回更早)
70+
assert_eq!(silence_gap_samples(Some(T0 + 200), Some(T0 - 1)), None);
71+
}
72+
73+
// ⑦ 缺 `timestamp_ms`:不补;会话级 ⇒ 该块与后续所有块都不补(纯追加)
74+
#[test]
75+
fn missing_timestamp_fails_safe() {
76+
assert_eq!(silence_gap_samples(Some(T0), None), None);
77+
assert_eq!(silence_gap_samples(None, None), None); // 首块就缺时间戳 ⇒ 无基准
78+
let fixture = [(Some(T0), BLOCK), (None, BLOCK), (Some(T0 + 5_000), BLOCK)];
79+
assert_eq!(alignment_plan(&fixture), vec![Some(0), None, None]);
80+
}
81+
82+
// ⑧ 负时间戳:不补(`None`),含首块为负
83+
#[test]
84+
fn negative_timestamp_fails_safe() {
85+
assert_eq!(silence_gap_samples(None, Some(-1)), None);
86+
assert_eq!(silence_gap_samples(Some(T0), Some(-200)), None);
87+
}
88+
89+
// ⑨ 空簿记不冒充「已对齐」;一次偏差即**永久**失效(后续即便单调也不补)
90+
#[test]
91+
fn book_stays_unusable_after_a_single_deviation() {
92+
// Arrange:空簿记(如零样本会话)
93+
let mut book = AlignBook::new();
94+
assert!(!book.is_aligned(), "无任何带时间戳的块 ⇒ 不得自称已对齐");
95+
// Act:首块锚定 → 回退一次 → 恢复单调
96+
assert_eq!(book.step(Some(T0), BLOCK), Some(0));
97+
assert!(book.is_aligned());
98+
assert_eq!(book.step(Some(T0 + 100), BLOCK), None); // 回退 ⇒ 永久失效
99+
assert!(!book.is_aligned());
100+
assert_eq!(book.step(Some(T0 + 100_000), BLOCK), None); // 失效后不再补
101+
// Assert:失效后基准不再推进(纯追加不改变已写样本的会话轴末端)
102+
assert_eq!(book.first_ts_ms, Some(T0));
103+
assert_eq!(book.last_end_ms, Some(T0 + 200));
104+
assert_eq!(book.aligned, false);
105+
}
106+
107+
// ⑩ 批量 ≡ 增量(对拍):两条路径在 ≥7 条夹具上逐元素相等
108+
#[test]
109+
fn batch_plan_equals_incremental_driver_on_all_fixtures() {
110+
let fixtures: Vec<Vec<(Option<i64>, usize)>> = vec![
111+
vec![], // 空序列
112+
vec![(Some(T0), BLOCK)], // 首块
113+
vec![(Some(T0), BLOCK), (Some(T0 + 200), BLOCK)], // 连续
114+
vec![(Some(T0), BLOCK), (Some(T0 + 500), BLOCK)], // 单空档
115+
vec![(Some(T0), BLOCK), (Some(T0 + 500), BLOCK), (Some(T0 + 1_500), BLOCK)], // 多空档
116+
vec![(Some(T0), BLOCK), (Some(T0 + MAX_GAP_MS + 1), BLOCK)], // 超大空档
117+
vec![(Some(T0 + 1_000), BLOCK), (Some(T0 + 500), BLOCK)], // 回退
118+
vec![(Some(T0), BLOCK), (None, BLOCK), (Some(T0 + 9_000), BLOCK)], // 缺时间戳
119+
vec![(Some(T0), BLOCK), (Some(-5), BLOCK)], // 负时间戳
120+
vec![(None, BLOCK), (Some(T0), BLOCK)], // 首块缺时间戳
121+
];
122+
for f in &fixtures {
123+
assert_eq!(alignment_plan(f), incremental_plan(f), "夹具 {:?}", f);
124+
}
125+
}
126+
127+
// ⑪ 自证不变式:补静音后「已写样本数 × 1000 ÷ 16000 ==(末块 ts − 首块 ts)+ 块长」,
128+
// 在「发生过暂停」的会话形态上同样成立(暂停两侧抵消 ⇒ 会话轴上不可见)
129+
#[test]
130+
fn self_proof_invariant_holds_across_pause_and_silence_window() {
131+
// Arrange:连续两块 →(暂停 30s:端点停采 + 时间戳补偿 ⇒ 会话轴冻结点接续)→
132+
// 恢复后一块 → 2s 静默窗(无包)→ 收尾两块
133+
let fixture = vec![
134+
(Some(T0), BLOCK),
135+
(Some(T0 + 200), BLOCK),
136+
(Some(T0 + 400), BLOCK), // 暂停后恢复:会话轴上仍连续
137+
(Some(T0 + 2_400), BLOCK), // 静默窗 1.8s ⇒ 补静音
138+
(Some(T0 + 2_600), BLOCK),
139+
];
140+
// Act
141+
let plan = alignment_plan(&fixture);
142+
let written: u64 = fixture
143+
.iter()
144+
.zip(&plan)
145+
.map(|((_, n), p)| *n as u64 + p.unwrap_or(0) as u64)
146+
.sum();
147+
// Assert:逐字读数 —— 44800 样本;2800 ms == (2600 − 1000) + 200
148+
assert_eq!(plan, vec![Some(0), Some(0), Some(0), Some(1_800 * SAMPLES_PER_MS), Some(0)]);
149+
assert_eq!(written, 44_800);
150+
let first = fixture[0].0.unwrap();
151+
let last = fixture[fixture.len() - 1].0.unwrap();
152+
assert_eq!(written * 1000 / 16_000, (last - first + BLOCK_MS) as u64);
153+
// 等价形态:用「末块会话轴末端」(= `last_end_ms`)替代末块发点,二者差一个块长
154+
let mut book = AlignBook::new();
155+
for (ts, n) in &fixture {
156+
book.step(*ts, *n);
157+
}
158+
assert!(book.is_aligned());
159+
assert_eq!(written * 1000 / 16_000, (book.last_end_ms.unwrap() - first) as u64);
160+
}
161+
162+
/// 逐块驱动(**测试侧独立实现**:只调 `silence_gap_samples`,不复用 `AlignBook`)——
163+
/// V5 对拍的基准:批量与增量两条路径分叉即红。
164+
fn incremental_plan(chunks: &[(Option<i64>, usize)]) -> Vec<Option<usize>> {
165+
let mut out = Vec::new();
166+
let mut aligned = true;
167+
let mut first: Option<i64> = None;
168+
let mut written: u64 = 0;
169+
for (ts, n) in chunks {
170+
if !aligned {
171+
out.push(None);
172+
written += *n as u64;
173+
continue;
174+
}
175+
let prev_end = first.map(|f| f + (written / SAMPLES_PER_MS as u64) as i64);
176+
match silence_gap_samples(prev_end, *ts) {
177+
Some(pad) => {
178+
if first.is_none() {
179+
first = *ts;
180+
}
181+
written += (pad + n) as u64;
182+
out.push(Some(pad));
183+
}
184+
None => {
185+
aligned = false;
186+
written += *n as u64;
187+
out.push(None);
188+
}
189+
}
190+
}
191+
out
192+
}

‎app/src-tauri/src/audio_store.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@
1414
use std::io::{Seek, Write};
1515
use std::path::{Path, PathBuf};
1616

17+
/// WAV 轴 ≡ 会话轴的对齐纯函数(T23/R5.5-b 的根治方案;纯逻辑、零 IO、零依赖)。
18+
///
19+
/// @ai-context: 由父模块自己声明(DISPATCH-TEMPLATE §二);`pub(crate)` 是跨模块引用的
20+
/// 必要条件(`live_session*` 与本模块的测试都要用)。
21+
#[path = "audio_align.rs"]
22+
#[allow(dead_code)] // 本提交只落纯函数 + 单测(尚未接线);接线提交落地后即删
23+
pub(crate) mod audio_align;
24+
1725
/// 默认保留期(天)。
1826
pub const DEFAULT_RETENTION_DAYS: u64 = 30;
1927
/// 默认磁盘预算(字节;1 小时 ≈ 115MB,预算 4GB ≈ 35 小时会话)。

0 commit comments

Comments
 (0)