Skip to content

Commit 911b619

Browse files
committed
fix(audio): 写块按时间戳补静音对齐
1 parent 375e505 commit 911b619

5 files changed

Lines changed: 245 additions & 19 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ fn book_stays_unusable_after_a_single_deviation() {
101101
// Assert:失效后基准不再推进(纯追加不改变已写样本的会话轴末端)
102102
assert_eq!(book.first_ts_ms, Some(T0));
103103
assert_eq!(book.last_end_ms, Some(T0 + 200));
104-
assert_eq!(book.aligned, false);
104+
assert!(!book.aligned);
105105
}
106106

107107
// ⑩ 批量 ≡ 增量(对拍):两条路径在 ≥7 条夹具上逐元素相等

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

Lines changed: 76 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
//! 实时链路音频落盘(REQ-068 / v0.6.0 M4,S4 早期排)。
22
//!
3-
//! @ai-context: 实时链路当前不落盘(已核查)——本模块按会话落 WAV
4-
//! (16kHz 单声道 PCM16,~115MB/小时),为 AL3 漂移实测
5-
//! (REQ-063 真机校准数据源)、V4 两遍解码(v0.7.0+)、
6-
//! X1 回听(待议)铺路。
3+
//! @ai-context: 实时链路**已落盘**(2026-09-13 就地更正,批 6 T23):接线点 =
4+
//! `live_session.rs:258-262` 创建 · `live_session_loop.rs:162-164` 每 200ms
5+
//! 写块 · `:391-397` finalize。**旧注逐字「实时链路当前不落盘(已核查)」
6+
//! 与代码自相矛盾** ⇒ 保留一行作历史注记(标签不许说谎,批 3 A5/R-3 先例)。
7+
//! @ai-context: 本模块按会话落 WAV(16kHz 单声道 PCM16,~115MB/小时),为 AL3 漂移实测
8+
//! (REQ-063 真机校准数据源)、V4 两遍解码(v0.7.0+)、X1 回听(待议)铺路。
9+
//! @ai-context: T23/R5.5-b:**WAV 轴 ≡ 会话轴** —— 写块按 `chunk.timestamp_ms` 的空档
10+
//! 等长补静音(纯函数 `audio_align`;基准轴 = 会话轴,暂停两侧抵消);
11+
//! 失效安全(时间戳缺失/回退/超大空档 ⇒ 纯追加 + `aligned = false`,样本
12+
//! 一个不丢);`finalize` 把自证量写进 `{id}.wav.meta.json`(读侧
13+
//! `commands_audio::read_aligned`;**历史录音无 sidecar ⇒ false**)。
714
//! @ai-context: 策略可配:默认开 / 保留期 30 天 / 磁盘预算上限(超限删最旧,
815
//! 在会话结束时触发);清理 UI 由命令层暴露(M6 前端消费)。
916
//! @ai-context: 落盘失败不阻断会话主链路(本地优先铁律的降级方向:音频是
@@ -19,7 +26,6 @@ use std::path::{Path, PathBuf};
1926
/// @ai-context: 由父模块自己声明(DISPATCH-TEMPLATE §二);`pub(crate)` 是跨模块引用的
2027
/// 必要条件(`live_session*` 与本模块的测试都要用)。
2128
#[path = "audio_align.rs"]
22-
#[allow(dead_code)] // 本提交只落纯函数 + 单测(尚未接线);接线提交落地后即删
2329
pub(crate) mod audio_align;
2430

2531
/// 默认保留期(天)。
@@ -63,12 +69,36 @@ pub struct CleanupSummary {
6369
pub freed_bytes: u64,
6470
}
6571

66-
/// 会话音频写入器(有状态:目标文件 + 已写样本数)。
72+
/// 会话音频写入器(有状态:目标文件 + 已写样本数 + 对齐簿记 + sidecar 路径)。
6773
pub struct SessionAudioWriter {
6874
file: Option<std::fs::File>,
6975
samples_written: u64,
76+
/// 对齐簿记(T23/R5.5-b:`aligned` 是自证量,禁止恒 true)
77+
book: audio_align::AlignBook,
78+
/// 对齐 sidecar 路径(`{id}.wav.meta.json`;`create` 时定下 ⇒ 不必再传 session_id)
79+
meta_path: PathBuf,
7080
}
7181

82+
/// 对齐自证量 sidecar 的写入面(键名 = 批 6 冻结快照;读侧 `commands_audio.rs:125-139`)。
83+
///
84+
/// @ai-context: 键名**不得**单方面改名 —— T22 的读侧按这份快照实现,改名会让它**静默
85+
/// 回落 `false`**(R43④ 裁决:改名必须同提交改读侧)。
86+
#[derive(serde::Serialize)]
87+
#[serde(rename_all = "camelCase")]
88+
struct AlignSidecar {
89+
/// 契约版本(本批 = 1)
90+
version: u32,
91+
/// 自证量(**禁止恒 true**;见 `audio_align::AlignBook`)
92+
aligned: bool,
93+
/// 首个带时间戳的块的 `timestamp_ms`(`null` = 无基准)
94+
first_ts_ms: Option<i64>,
95+
/// 已写样本总数(含补的静音)
96+
samples_written: u64,
97+
}
98+
99+
/// 对齐 sidecar 契约版本。
100+
const ALIGN_SIDECAR_VERSION: u32 = 1;
101+
72102
impl SessionAudioWriter {
73103
/// 创建会话音频文件(含 WAV 头;失败 → None——落盘降级不阻断主链路)。
74104
///
@@ -84,27 +114,47 @@ impl SessionAudioWriter {
84114
if write_header(&mut file, 0).is_err() {
85115
return None;
86116
}
87-
Some(Self { file: Some(file), samples_written: 0 })
117+
Some(Self {
118+
file: Some(file),
119+
samples_written: 0,
120+
book: audio_align::AlignBook::new(),
121+
meta_path: session_audio_dir.join(format!("{}.wav.meta.json", session_id)),
122+
})
88123
}
89124

90125
/// 追加一块样本(f32 → PCM16;失败静默降级——落盘不阻断主链路)。
91-
pub fn write_chunk(&mut self, samples: &[f32]) {
92-
let Some(file) = self.file.as_mut() else { return };
93-
let mut buf = Vec::with_capacity(samples.len() * 2);
126+
///
127+
/// @ai-context: T23/R5.5-b:写前按 `timestamp_ms` 与会话轴的**空档等长补静音**
128+
/// (`audio_align::silence_gap_samples`)⇒ WAV 轴 ≡ 会话轴;时间戳
129+
/// 缺失/回退/超大空档 ⇒ **失效安全**(不补、`aligned = false`、退回
130+
/// 纯追加),**样本一个不丢**。补零与样本**一次 `write_all`**(写失败
131+
/// 仍是「释放句柄、后续不再尝试」的既有降级)。
132+
pub fn write_chunk(&mut self, samples: &[f32], timestamp_ms: Option<i64>) {
133+
if self.file.is_none() {
134+
return;
135+
}
136+
let pad = self.book.step(timestamp_ms, samples.len()).unwrap_or(0);
137+
let mut buf = Vec::with_capacity((pad + samples.len()) * 2);
138+
buf.resize(pad * 2, 0); // PCM16 静音 = 全 0 字节(小端 i16 的 0 即两字节 0)
94139
for s in samples {
95140
// f32 → i16 量化(round 半远离零——截断会引入 -0.5 偏置)
96141
let v = (s.clamp(-1.0, 1.0) * 32767.0).round() as i16;
97142
buf.extend_from_slice(&v.to_le_bytes());
98143
}
144+
let Some(file) = self.file.as_mut() else { return };
99145
if file.write_all(&buf).is_err() {
100146
// 写盘失败:释放句柄(后续块不再尝试——降级为不落盘)
101147
self.file = None;
102148
} else {
103-
self.samples_written += samples.len() as u64;
149+
self.samples_written += (pad + samples.len()) as u64;
104150
}
105151
}
106152

107-
/// 结束会话:回填 WAV 长度字段(RIFF/data)并关闭文件。
153+
/// 结束会话:回填 WAV 长度字段(RIFF/data)并关闭文件,随后写对齐 sidecar。
154+
///
155+
/// @ai-context: T23:sidecar **最后写** ⇒ 它存在即说明 WAV 已 finalize(读侧
156+
/// `durationMs` 才有值);句柄已释放(写盘失败)时连 WAV 长度都没回填
157+
/// ⇒ 不写 sidecar(读侧 `durationMs = None`,本就不该播)。
108158
pub fn finalize(&mut self) {
109159
let Some(mut file) = self.file.take() else { return };
110160
// data 长度 = 样本数 × 2 字节;RIFF 长度 = 36 + data 长度
@@ -115,6 +165,20 @@ impl SessionAudioWriter {
115165
let _ = file.seek(std::io::SeekFrom::Start(40));
116166
let _ = file.write_all(&(data_len as u32).to_le_bytes());
117167
let _ = file.sync_all();
168+
self.write_align_sidecar();
169+
}
170+
171+
/// 写对齐 sidecar(失败静默——与 WAV 写盘同向:落盘降级不阻断主链路)。
172+
fn write_align_sidecar(&self) {
173+
let sidecar = AlignSidecar {
174+
version: ALIGN_SIDECAR_VERSION,
175+
aligned: self.book.is_aligned(),
176+
first_ts_ms: self.book.first_ts_ms,
177+
samples_written: self.samples_written,
178+
};
179+
if let Ok(text) = serde_json::to_string(&sidecar) {
180+
let _ = std::fs::write(&self.meta_path, text);
181+
}
118182
}
119183
}
120184

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

Lines changed: 146 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
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
69
use 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() {
128131
fn 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+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ pub(crate) fn run_audio_loop(
160160
// M4/REQ-068(S4):原始样本落盘(预处理前——V4 两遍解码/
161161
// AL3 漂移实测需原始音频;写盘失败内部降级不阻断)
162162
if let Some(w) = ctx.audio_writer.as_mut() {
163-
w.write_chunk(&chunk.samples);
163+
w.write_chunk(&chunk.samples, i64::try_from(chunk.timestamp_ms).ok());
164164
}
165165
// M6/REQ-041 A1:预处理(默认直通零开销;开启后 AGC/削波/动态阈值)
166166
let processed = audio_pre.process(&chunk.samples, SILENCE_RMS_THRESHOLD);

0 commit comments

Comments
 (0)