22//!
33//! @ai-context: AAA 模式;tempfile 隔离;覆盖 WAV 头/数据 roundtrip、
44//! finalize 回填、保留期/磁盘预算清理、降级路径。
5+ //! @ai-context: T23 的签名连带(R5.5-b):`write_chunk` 加了 `timestamp_ms` 入参 ⇒ 本文件
6+ //! 4 处旧调用点传 `None`(**只改调用形态、断言一字未动**)——`None` 正好
7+ //! 覆盖「无时间基准 ⇒ 纯追加」的降级路径(与 T23 前的行为逐字节相同)。
58
69use super :: * ;
710
@@ -14,10 +17,10 @@ fn wav_header_and_data_roundtrip() {
1417 // Arrange
1518 let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
1619 let mut writer = SessionAudioWriter :: create ( dir. path ( ) , 42 , & config ( ) ) . expect ( "writer" ) ;
17- // Act:写两块样本(每块 100 样本)
20+ // Act:写两块样本(每块 100 样本;无时间戳 ⇒ 纯追加,与 T23 前同形 )
1821 let chunk: Vec < f32 > = ( 0 ..100 ) . map ( |i| ( i as f32 / 100.0 ) - 0.5 ) . collect ( ) ;
19- writer. write_chunk ( & chunk) ;
20- writer. write_chunk ( & chunk) ;
22+ writer. write_chunk ( & chunk, None ) ;
23+ writer. write_chunk ( & chunk, None ) ;
2124 writer. finalize ( ) ;
2225 // Assert:WAV 头(RIFF/WAVE/PCM16/16kHz/单声道/200 样本)
2326 let raw = std:: fs:: read ( dir. path ( ) . join ( "42.wav" ) ) . unwrap ( ) ;
@@ -128,10 +131,148 @@ fn stats_counts_files_and_bytes() {
128131fn write_after_finalize_is_noop ( ) {
129132 let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
130133 let mut writer = SessionAudioWriter :: create ( dir. path ( ) , 7 , & config ( ) ) . unwrap ( ) ;
131- writer. write_chunk ( & [ 0.1 , 0.2 ] ) ;
134+ writer. write_chunk ( & [ 0.1 , 0.2 ] , None ) ;
132135 writer. finalize ( ) ;
133136 // finalize 后写入不再增长(句柄已释放)
134- writer. write_chunk ( & [ 0.3 ] ) ;
137+ writer. write_chunk ( & [ 0.3 ] , None ) ;
135138 let len = std:: fs:: metadata ( dir. path ( ) . join ( "7.wav" ) ) . unwrap ( ) . len ( ) ;
136139 assert_eq ! ( len, ( WAV_HEADER_LEN + 4 ) as u64 ) ;
137140}
141+
142+ // ───────────────────────── T23/R5.5-b:WAV 轴 ≡ 会话轴 ─────────────────────────
143+
144+ /// 一块 200 ms @16 kHz 的样本数(与 `capture/audio_loopback.rs:338` 的块长一致)。
145+ const BLOCK : usize = 3_200 ;
146+
147+ /// 读 sidecar(断言键名与取值;缺文件即红 —— 不做容错)。
148+ fn sidecar ( dir : & std:: path:: Path , id : i64 ) -> serde_json:: Value {
149+ let text = std:: fs:: read_to_string ( dir. join ( format ! ( "{}.wav.meta.json" , id) ) ) . expect ( "sidecar" ) ;
150+ serde_json:: from_str ( & text) . expect ( "sidecar 是 JSON" )
151+ }
152+
153+ /// V1(行为级):空档补静音 —— 文件字节数 == `44 + (3200 + 3200 + 200ms×16 + 3200) × 2`。
154+ #[ test]
155+ fn write_chunk_pads_silence_for_gaps ( ) {
156+ // Arrange:t0 → t0+200ms(连续)→ t0+600ms(空档 200ms)
157+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
158+ let mut writer = SessionAudioWriter :: create ( dir. path ( ) , 23 , & config ( ) ) . expect ( "writer" ) ;
159+ let block = vec ! [ 0.5f32 ; BLOCK ] ;
160+ // Act
161+ writer. write_chunk ( & block, Some ( 1_000 ) ) ;
162+ writer. write_chunk ( & block, Some ( 1_200 ) ) ;
163+ writer. write_chunk ( & block, Some ( 1_600 ) ) ;
164+ writer. finalize ( ) ;
165+ // Assert:44 + 12800×2 字节;data 长度字段同步
166+ let raw = std:: fs:: read ( dir. path ( ) . join ( "23.wav" ) ) . unwrap ( ) ;
167+ assert_eq ! ( raw. len( ) , WAV_HEADER_LEN + 12_800 * 2 , "空档补静音后的文件长度" ) ;
168+ assert_eq ! ( & raw[ 40 ..44 ] , & ( 12_800u32 * 2 ) . to_le_bytes( ) , "finalize 回填 data 长度" ) ;
169+ // 空档区(第 3 块之前)确实是零样本:偏移 = 44 + (3200+3200)×2
170+ let gap = WAV_HEADER_LEN + 6_400 * 2 ;
171+ assert ! ( raw[ gap..gap + BLOCK * 2 ] . iter( ) . all( |b| * b == 0 ) , "空档必须补静音" ) ;
172+ // 反证:样本区不得是零(0.5 ⇒ 16384)—— 防「整段填零」的假绿
173+ assert_eq ! ( i16 :: from_le_bytes( [ raw[ 44 ] , raw[ 45 ] ] ) , 16_384 ) ;
174+ let third = gap + BLOCK * 2 ;
175+ assert_eq ! ( i16 :: from_le_bytes( [ raw[ third] , raw[ third + 1 ] ] ) , 16_384 ) ;
176+ }
177+
178+ /// V2:缺 `timestamp_ms` ⇒ 纯追加 + `aligned = false`,**样本一个不丢**、写盘不中断。
179+ #[ test]
180+ fn missing_timestamp_degrades_to_append_and_marks_unaligned ( ) {
181+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
182+ let mut writer = SessionAudioWriter :: create ( dir. path ( ) , 24 , & config ( ) ) . expect ( "writer" ) ;
183+ let block = vec ! [ 0.25f32 ; BLOCK ] ;
184+ writer. write_chunk ( & block, Some ( 1_000 ) ) ;
185+ writer. write_chunk ( & block, None ) ; // 时间戳缺失 ⇒ 永久失效
186+ writer. write_chunk ( & block, Some ( 9_000 ) ) ; // 后续即便单调也不补(且样本照写)
187+ writer. finalize ( ) ;
188+ let raw = std:: fs:: read ( dir. path ( ) . join ( "24.wav" ) ) . unwrap ( ) ;
189+ assert_eq ! ( raw. len( ) , WAV_HEADER_LEN + 9_600 * 2 , "三块样本一个不少、且没有任何补零" ) ;
190+ assert_eq ! ( sidecar( dir. path( ) , 24 ) [ "aligned" ] , serde_json:: json!( false ) ) ;
191+ assert_eq ! ( sidecar( dir. path( ) , 24 ) [ "samplesWritten" ] , serde_json:: json!( 9_600 ) ) ;
192+ }
193+
194+ /// V3:时间戳非单调 ⇒ 同上,且回退块**照常写入**(不被丢弃、不产生负长度)。
195+ #[ test]
196+ fn non_monotonic_timestamp_degrades_to_append ( ) {
197+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
198+ let mut writer = SessionAudioWriter :: create ( dir. path ( ) , 25 , & config ( ) ) . expect ( "writer" ) ;
199+ let block = vec ! [ 0.25f32 ; BLOCK ] ;
200+ writer. write_chunk ( & block, Some ( 2_000 ) ) ;
201+ writer. write_chunk ( & block, Some ( 1_500 ) ) ; // 回退 500ms
202+ writer. finalize ( ) ;
203+ let raw = std:: fs:: read ( dir. path ( ) . join ( "25.wav" ) ) . unwrap ( ) ;
204+ assert_eq ! ( raw. len( ) , WAV_HEADER_LEN + 6_400 * 2 , "回退块照常写入(纯追加)" ) ;
205+ assert_eq ! ( sidecar( dir. path( ) , 25 ) [ "aligned" ] , serde_json:: json!( false ) ) ;
206+ }
207+
208+ /// V4:`aligned` **不得恒 true** —— 全程单调 ⇒ `true`;且 sidecar 键名 = 冻结快照
209+ /// (T22 读侧按它取 `aligned`;改名会让读侧静默回落 `false`)。
210+ #[ test]
211+ fn sidecar_marks_aligned_session_with_frozen_keys ( ) {
212+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
213+ let mut writer = SessionAudioWriter :: create ( dir. path ( ) , 26 , & config ( ) ) . expect ( "writer" ) ;
214+ let block = vec ! [ 0.1f32 ; BLOCK ] ;
215+ writer. write_chunk ( & block, Some ( 1_000 ) ) ;
216+ writer. write_chunk ( & block, Some ( 1_200 ) ) ;
217+ writer. finalize ( ) ;
218+ let s = sidecar ( dir. path ( ) , 26 ) ;
219+ assert_eq ! ( s[ "version" ] , serde_json:: json!( 1 ) ) ;
220+ assert_eq ! ( s[ "aligned" ] , serde_json:: json!( true ) ) ;
221+ assert_eq ! ( s[ "firstTsMs" ] , serde_json:: json!( 1_000 ) ) ;
222+ assert_eq ! ( s[ "samplesWritten" ] , serde_json:: json!( 6_400 ) ) ;
223+ let mut keys: Vec < String > = s. as_object ( ) . unwrap ( ) . keys ( ) . cloned ( ) . collect ( ) ;
224+ keys. sort ( ) ;
225+ assert_eq ! ( keys, vec![ "aligned" , "firstTsMs" , "samplesWritten" , "version" ] ) ;
226+ }
227+
228+ /// V6:超大空档(> `MAX_GAP_MS`)⇒ 不补(否则会当场写 ≈9.6M 样本静音)+ `aligned = false`。
229+ #[ test]
230+ fn oversized_gap_does_not_write_bulk_silence ( ) {
231+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
232+ let mut writer = SessionAudioWriter :: create ( dir. path ( ) , 27 , & config ( ) ) . expect ( "writer" ) ;
233+ let block = vec ! [ 0.1f32 ; BLOCK ] ;
234+ writer. write_chunk ( & block, Some ( 1_000 ) ) ;
235+ // 空档自**上块末端**(1_200)起算 ⇒ 取 MAX_GAP_MS + 1 才真的超上限
236+ writer. write_chunk ( & block, Some ( 1_200 + audio_align:: MAX_GAP_MS + 1 ) ) ;
237+ writer. finalize ( ) ;
238+ let raw = std:: fs:: read ( dir. path ( ) . join ( "27.wav" ) ) . unwrap ( ) ;
239+ assert_eq ! ( raw. len( ) , WAV_HEADER_LEN + 6_400 * 2 , "超上限不补" ) ;
240+ assert_eq ! ( sidecar( dir. path( ) , 27 ) [ "aligned" ] , serde_json:: json!( false ) ) ;
241+ }
242+
243+ /// 自证不变式(writer 级):`data 长度 ÷ 2 ÷ 16 ==(末块 ts − 首块 ts)+ 块长`,
244+ /// 在**发生过暂停 + 静默窗**的会话形态上同样成立(暂停两侧抵消:端点停采 ↔ 时间戳冻结)。
245+ #[ test]
246+ fn wav_axis_equals_session_axis_after_pause_and_silence_window ( ) {
247+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
248+ let mut writer = SessionAudioWriter :: create ( dir. path ( ) , 28 , & config ( ) ) . expect ( "writer" ) ;
249+ let block = vec ! [ 0.1f32 ; BLOCK ] ;
250+ // 会话形态:连续两块 →(暂停 30s:会话轴上不可见,`timestamp_ms` 已补偿)→ 一块
251+ // → 静默窗 1.8s(无包)→ 收尾两块
252+ for ts in [ 1_000i64 , 1_200 , 1_400 , 3_400 , 3_600 ] {
253+ writer. write_chunk ( & block, Some ( ts) ) ;
254+ }
255+ writer. finalize ( ) ;
256+ let raw = std:: fs:: read ( dir. path ( ) . join ( "28.wav" ) ) . unwrap ( ) ;
257+ let data_len = u64:: from ( u32:: from_le_bytes ( raw[ 40 ..44 ] . try_into ( ) . unwrap ( ) ) ) ;
258+ // 逐字读数:44800 样本 ⇒ 2800 ms == (3600 − 1000) + 200
259+ assert_eq ! ( data_len, 44_800 * 2 ) ;
260+ assert_eq ! ( data_len / 2 / 16 , ( 3_600 - 1_000 ) + 200 ) ;
261+ assert_eq ! ( sidecar( dir. path( ) , 28 ) [ "aligned" ] , serde_json:: json!( true ) ) ;
262+ }
263+
264+ /// R5.5-b 第 3 条:**历史录音必须能区分** —— 本修复之前的 WAV 无 sidecar(读侧 false);
265+ /// 本模块产出的会话**一定**带 sidecar(`true`/`false` 皆可读 ⇒ 与历史录音区分得开)。
266+ #[ test]
267+ fn sidecar_distinguishes_history_from_new_sessions ( ) {
268+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
269+ // 历史录音形态:只有 WAV(T23 之前录的)
270+ std:: fs:: write ( dir. path ( ) . join ( "9.wav" ) , b"RIFF" ) . unwrap ( ) ;
271+ assert ! ( !dir. path( ) . join( "9.wav.meta.json" ) . exists( ) , "历史录音无 sidecar" ) ;
272+ // 新会话:即便无时间基准(`aligned=false`)也**有据可查**
273+ let mut writer = SessionAudioWriter :: create ( dir. path ( ) , 10 , & config ( ) ) . expect ( "writer" ) ;
274+ writer. write_chunk ( & vec ! [ 0.0f32 ; BLOCK ] , None ) ;
275+ writer. finalize ( ) ;
276+ assert ! ( dir. path( ) . join( "10.wav.meta.json" ) . exists( ) , "新会话必写 sidecar" ) ;
277+ assert_eq ! ( sidecar( dir. path( ) , 10 ) [ "aligned" ] , serde_json:: json!( false ) ) ;
278+ }
0 commit comments