Skip to content

Commit 5b881de

Browse files
committed
feat(audio): 新增会话音频路径只读命令
1 parent 17a8b8c commit 5b881de

5 files changed

Lines changed: 314 additions & 1 deletion

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,7 @@ pub fn handle() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send + Sync
334334
// 会话音频落盘(REQ-068,v0.6.0 M4:状态/清理——M6 清理 UI 消费)
335335
crate::commands_audio::session_audio_status,
336336
crate::commands_audio::session_audio_cleanup,
337+
crate::commands_audio::session_audio_path,
337338
// 音频预处理链(REQ-101,v0.7.0 M1:CER 微基准定默认后的用户开关)
338339
crate::commands_audio::audio_preproc_status,
339340
crate::commands_audio::audio_preproc_set,

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

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
//! (bin/cer_bench.rs)定默认值后的用户开关通道;配置 JSON 持久化
1010
//! 应用数据目录(AudioPreprocConfig,原子写),下次实时会话生效。
1111
12+
//! @ai-context: 批 6 T22(R5.5/PB1):新增只读命令 session_audio_path。它是本模块里
13+
//! **唯一**直接读文件的入口(WAV 头 44 字节 + T23 的对齐 sidecar)——
14+
//! 读侧留在本文件是为了不碰 audio_store.rs(T23 的单写者面)。
15+
1216
use tauri::State;
1317

1418
use crate::audio_preproc_config::AudioPreprocConfig;
@@ -81,3 +85,148 @@ pub fn session_audio_cleanup(state: State<'_, AppState>) -> Result<crate::audio_
8185
let config = AudioStoreConfig::default();
8286
Ok(cleanup(&dir, config.retention_days, config.disk_budget_bytes))
8387
}
88+
89+
/// 会话音频目录名(与写入侧 `audio_store` 的 `{data_dir}/session-audio/{id}.wav` 一致)。
90+
const SESSION_AUDIO_DIR: &str = "session-audio";
91+
/// WAV 头长度(RIFF 12 + fmt 24 + data 8;与 `audio_store::WAV_HEADER_LEN` 同值)。
92+
const WAV_HEADER_LEN: usize = 44;
93+
/// `data` 块长度字段在头内的偏移(`audio_store::write_header` 的写入位置)。
94+
const WAV_DATA_LEN_OFFSET: usize = 40;
95+
/// PCM16 单声道 16 kHz ⇒ 32000 B/s ⇒ 1 ms = 32 字节(`audio_store` 的 SAMPLE_RATE 与
96+
/// CHANNELS 是私有常量 ⇒ 此处按同一契约复述,不跨模块引用、不改 T23 的单写者面)。
97+
const BYTES_PER_MS: u64 = 32;
98+
99+
/// 会话音频引用(只读;前端 `convertFileSrc(path)` 得可播放 URL)。
100+
///
101+
/// @ai-context: R5.5-b 第 3 条的「对齐自证量」通道。`aligned` **禁止恒 true**:它读 T23 的
102+
/// 对齐簿记 sidecar(`{id}.wav.meta.json`),**缺失/损坏 ⇒ false**(历史录音
103+
/// 不得被当作已对齐)⇒ T23 落地前 sidecar 无人写,**今天的值恒 false**(如实
104+
/// 反映「无时间基准」)。语义是「**不能保证**对齐」而非「一定没对齐」⇒ UI 文案
105+
/// 不得说「这条录音没有对齐」(ADR-035 后果③)。
106+
/// @ai-context: `durationMs` 是「能不能播」的**唯一**判据(R5.5-b 约束 3:只有已 finalize 的
107+
/// WAV 可播,创建时头部 data 长度为 0)——`None` ⇒ 前端必须禁用播放并如实提示,
108+
/// **不得**把它读作「0 秒」。
109+
#[derive(Debug, Clone, serde::Serialize, PartialEq)]
110+
#[serde(rename_all = "camelCase")]
111+
pub struct SessionAudioRef {
112+
/// 应用数据目录内的**绝对路径**(`{data_dir}/session-audio/{id}.wav`)
113+
pub path: String,
114+
/// 该录音的 WAV 轴是否与会话轴对齐(T23 的簿记;无 sidecar ⇒ false)
115+
pub aligned: bool,
116+
/// 音频时长(毫秒;由 WAV 头 data 长度换算,`None` = 未 finalize/未知)
117+
pub duration_ms: Option<u64>,
118+
}
119+
120+
/// T23 的对齐 sidecar 的读取面(只取 `aligned`;其余键本任务不消费)。
121+
///
122+
/// @ai-context: 键名照计划 Task 23 Step 3 的冻结快照(`{ "version": 1, "aligned": bool,
123+
/// "firstTsMs", "samplesWritten" })。**未知键不报错、缺 `aligned` 回落 false**
124+
/// —— 保守:宁可说「无基准」,也不假称已对齐(R5.5-b「禁止恒 true」)。
125+
#[derive(serde::Deserialize)]
126+
struct AudioAlignSidecar {
127+
#[serde(default)]
128+
aligned: bool,
129+
}
130+
131+
/// 读对齐自证量:sidecar 缺失/损坏/T23 未落地 ⇒ **false**。
132+
///
133+
/// @ai-context: 副作用 = 一次只读文件读取;边界 = 任何失败都不报错、一律取保守值 false
134+
/// (「历史录音」与「读不出来」在语义上是同一档:都不能保证对齐)。
135+
fn read_aligned(base: &std::path::Path, session_id: i64) -> bool {
136+
let meta = base.join(format!("{}.wav.meta.json", session_id));
137+
let Ok(text) = std::fs::read_to_string(&meta) else { return false };
138+
serde_json::from_str::<AudioAlignSidecar>(&text).map(|s| s.aligned).unwrap_or(false)
139+
}
140+
141+
/// 从 WAV 头读时长(毫秒;`None` = 未 finalize / 非本模块写入形态 / 读失败)。
142+
///
143+
/// @ai-context: 只读头 44 字节(**不**整读文件 —— 会话 WAV 约 115 MB/小时)。
144+
/// `data` 长度字段在 `audio_store` 创建时写 0、`finalize()` 是唯一回填点
145+
/// ⇒ 0 表示「未 finalize」(或零样本,今天两者不可区分)⇒ 返 `None`,
146+
/// **不发明时长**。
147+
fn wav_duration_ms(path: &std::path::Path) -> Option<u64> {
148+
use std::io::Read;
149+
let mut file = std::fs::File::open(path).ok()?;
150+
let mut hdr = [0u8; WAV_HEADER_LEN];
151+
file.read_exact(&mut hdr).ok()?;
152+
// 只认写入侧产出的形态(RIFF/WAVE + 偏移 36 的 data 块)——异类文件不当作音频
153+
if &hdr[0..4] != b"RIFF" || &hdr[8..12] != b"WAVE" || &hdr[36..40] != b"data" {
154+
return None;
155+
}
156+
let raw: [u8; 4] = hdr[WAV_DATA_LEN_OFFSET..WAV_HEADER_LEN].try_into().ok()?;
157+
let data_len = u32::from_le_bytes(raw);
158+
if data_len == 0 {
159+
return None;
160+
}
161+
Some(u64::from(data_len) / BYTES_PER_MS)
162+
}
163+
164+
/// 解析会话音频文件并做**目录边界校验**(AGENTS.md §4 安全红线②)。
165+
///
166+
/// @ai-context: 独立成 `file_name` 入参是为了让**越界样本可测**(`..` / 符号链接伪装成
167+
/// `{id}.wav`);调用侧由 `session_id: i64` 生成 ⇒ 类型上不含路径分隔符。
168+
/// 返回**构造路径**而非 `canonicalize` 的 `\\?\` 形态:asset protocol 的 scope 是
169+
/// `$APPDATA/**` glob,先例 `commands_images.rs:55` 也返回构造路径 ⇒ canonicalize
170+
/// 只用于边界判定(避免把扩展长度前缀送进 scope 匹配)。
171+
/// 边界:不存在/不是普通文件 ⇒ `Ok(None)`(无音频**不是**错误);越界 ⇒ `Err`。
172+
fn confined_audio_file(
173+
base: &std::path::Path,
174+
file_name: &str,
175+
) -> Result<Option<std::path::PathBuf>, String> {
176+
let path = base.join(file_name);
177+
if !path.is_file() {
178+
return Ok(None); // 无音频(含导入会话:其音轨在 %TEMP% 且导入结束即删)
179+
}
180+
// 双保险:canonicalize 后必须仍在会话音频目录内(防符号链接/嵌套穿越)
181+
let canonical_base = std::fs::canonicalize(base).map_err(|e| format!("路径越界拒绝: {}", e))?;
182+
let canonical_path = std::fs::canonicalize(&path).map_err(|e| format!("路径越界拒绝: {}", e))?;
183+
if !canonical_path.starts_with(&canonical_base) {
184+
return Err("路径越界拒绝".to_string());
185+
}
186+
Ok(Some(path))
187+
}
188+
189+
/// `session_audio_path` 的命令体(只依赖 `data_dir` ⇒ 可单测;`#[tauri::command]` 是薄壳)。
190+
///
191+
/// @ai-context: 顺序 = ① 校验入参(`<= 0` ⇒ Err)② 后端构造路径 + 目录边界双保险
192+
/// ③ 无文件 ⇒ `Ok(None)` ④ 读 sidecar(`aligned`)+ WAV 头(`durationMs`)。
193+
fn session_audio_ref_in(
194+
data_dir: &std::path::Path,
195+
session_id: i64,
196+
) -> Result<Option<SessionAudioRef>, String> {
197+
if session_id <= 0 {
198+
return Err("无效的会话 id".to_string());
199+
}
200+
let base = data_dir.join(SESSION_AUDIO_DIR);
201+
let Some(path) = confined_audio_file(&base, &format!("{}.wav", session_id))? else {
202+
return Ok(None);
203+
};
204+
Ok(Some(SessionAudioRef {
205+
path: path.to_string_lossy().into_owned(),
206+
aligned: read_aligned(&base, session_id),
207+
duration_ms: wav_duration_ms(&path),
208+
}))
209+
}
210+
211+
/// 查询会话音频文件路径(前端 `convertFileSrc(path)` 得可播放 URL;只读)。
212+
///
213+
/// @ai-context: R5.5/PB1 的**唯一**新增 IPC(R12.2 追认出参形状)。路径由**后端**从
214+
/// `state.data_dir` 构造——前端**不传路径**(AGENTS.md §4「文件系统访问限定
215+
/// 应用数据目录」)。本命令**不构造 asset URL**:URL 形态
216+
/// `http://asset.localhost/<encodeURIComponent(绝对路径)>` 由前端
217+
/// `convertFileSrc` 生成,与既有 5 处图片调用点同形(`commands_images.rs:55`)。
218+
/// @ai-context: 副作用 = 只读文件系统(`is_file` + 读 WAV 头 44 字节 + 读 sidecar);
219+
/// 不写盘、不连库、不发事件。边界:非法 id ⇒ `Err`;无音频 ⇒ `Ok(None)`;
220+
/// 越界 ⇒ `Err`。**不声称「播放已可用」**(真机不可验证,R5.5-b 诚实边界)。
221+
#[tauri::command]
222+
pub fn session_audio_path(
223+
state: State<'_, AppState>,
224+
session_id: i64,
225+
) -> Result<Option<SessionAudioRef>, String> {
226+
session_audio_ref_in(&state.data_dir, session_id)
227+
}
228+
229+
/// 单测独立文件(保持本文件 ≤300 行,AGENTS.md §3)。
230+
#[cfg(test)]
231+
#[path = "commands_audio_tests.rs"]
232+
mod tests;
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
//! `commands_audio` 的单测(批 6 T22:`session_audio_path`)。
2+
//!
3+
//! @ai-context: 判据面 = R12.2 要求的三条**行为级**判据(无音频 ⇒ `None`;非法 id ⇒ `Err`;
4+
//! 路径越界 ⇒ `Err`)+ R5.5-b 的「`aligned` 不得恒 true」+ `durationMs`
5+
//! 作为「能不能播」的唯一判据(未 finalize ⇒ `None`)。
6+
//! @ai-context: 副作用 = 只在 `%TEMP%/entropy-t22-*` 建/删夹具;**不**连库、**不**读真实
7+
//! `%APPDATA%`、**不**写仓库内任何路径(用完即删)。
8+
//! @ai-context: 被测对象 = 命令体 `session_audio_ref_in`(`#[tauri::command]` 是薄壳:
9+
//! `State<'_, AppState>` 需要运行中的 `AppHandle`,单测里无法构造,
10+
//! 且本任务不改 `lib.rs`/`AppState`)+ 边界核心 `confined_audio_file`。
11+
12+
use super::*;
13+
14+
/// 夹具:独立临时 `data_dir`(内含 `session-audio/`),Drop 时整体删除。
15+
struct TempDataDir {
16+
/// 充当 `AppState::data_dir` 的根(`session-audio/` 的父目录)
17+
root: std::path::PathBuf,
18+
/// 会话音频目录(`{root}/session-audio`)
19+
base: std::path::PathBuf,
20+
}
21+
22+
impl TempDataDir {
23+
/// 建夹具(目录名带 pid + 原子序号 ⇒ 并发用例互不踩)。
24+
fn new(tag: &str) -> Self {
25+
static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
26+
let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
27+
let root =
28+
std::env::temp_dir().join(format!("entropy-t22-{}-{}-{}", std::process::id(), tag, n));
29+
let base = root.join(SESSION_AUDIO_DIR);
30+
std::fs::create_dir_all(&base).expect("建临时会话音频目录");
31+
Self { root, base }
32+
}
33+
34+
/// 写一个 fmt 合法的 WAV:`data_len` = **头部声明的** data 长度,`payload` = 实际追加字节数
35+
/// (两者不等即模拟 `finalize` 未回填的崩溃残留)。
36+
fn write_wav(&self, name: &str, data_len: u32, payload: usize) {
37+
let mut buf = Vec::with_capacity(WAV_HEADER_LEN + payload);
38+
buf.extend_from_slice(b"RIFF");
39+
buf.extend_from_slice(&(36 + data_len).to_le_bytes());
40+
buf.extend_from_slice(b"WAVE");
41+
buf.extend_from_slice(b"fmt ");
42+
buf.extend_from_slice(&16u32.to_le_bytes());
43+
buf.extend_from_slice(&1u16.to_le_bytes()); // PCM
44+
buf.extend_from_slice(&1u16.to_le_bytes()); // 单声道
45+
buf.extend_from_slice(&16_000u32.to_le_bytes());
46+
buf.extend_from_slice(&32_000u32.to_le_bytes());
47+
buf.extend_from_slice(&2u16.to_le_bytes());
48+
buf.extend_from_slice(&16u16.to_le_bytes());
49+
buf.extend_from_slice(b"data");
50+
buf.extend_from_slice(&data_len.to_le_bytes());
51+
buf.resize(WAV_HEADER_LEN + payload, 0);
52+
std::fs::write(self.base.join(name), &buf).expect("写夹具 WAV");
53+
}
54+
55+
/// 写 T23 的对齐 sidecar(键名照计划 Task 23 Step 3 的冻结快照)。
56+
fn write_sidecar(&self, name: &str, aligned: bool) {
57+
let json =
58+
format!(r#"{{"version":1,"aligned":{},"firstTsMs":0,"samplesWritten":16000}}"#, aligned);
59+
std::fs::write(self.base.join(name), json).expect("写夹具 sidecar");
60+
}
61+
}
62+
63+
impl Drop for TempDataDir {
64+
fn drop(&mut self) {
65+
let _ = std::fs::remove_dir_all(&self.root);
66+
}
67+
}
68+
69+
#[test]
70+
fn non_positive_session_id_is_err() {
71+
let d = TempDataDir::new("badid");
72+
// Act & Assert:入参校验(安全红线①)——在任何文件系统访问之前
73+
assert_eq!(session_audio_ref_in(&d.root, 0).unwrap_err(), "无效的会话 id");
74+
assert_eq!(session_audio_ref_in(&d.root, -1).unwrap_err(), "无效的会话 id");
75+
}
76+
77+
#[test]
78+
fn missing_audio_is_none_not_err() {
79+
let d = TempDataDir::new("none");
80+
// Act:目录里没有 `{id}.wav`(导入会话即此形态——其音轨在 %TEMP% 且导入结束即删)
81+
let got = session_audio_ref_in(&d.root, 7).expect("无音频不是错误");
82+
// Assert:`None`(不是空串、不是死路径、不是 Err)
83+
assert_eq!(got, None);
84+
}
85+
86+
#[test]
87+
fn finalized_wav_yields_ref_confined_to_session_audio_dir() {
88+
let d = TempDataDir::new("ok");
89+
d.write_wav("7.wav", 32_000, 32_000); // 头部声明 1 s,实际也写满 1 s
90+
// Act
91+
let first = session_audio_ref_in(&d.root, 7).expect("有音频").expect("有引用");
92+
let second = session_audio_ref_in(&d.root, 7).expect("幂等").expect("有引用");
93+
// Assert ① 返回的是**构造路径**(非 canonicalize 的 `\\?\` 形态:asset scope 是 glob)
94+
assert_eq!(first.path, d.base.join("7.wav").to_string_lossy().into_owned());
95+
// Assert ② 返回路径的 canonicalize 仍在 session-audio 目录内(判据 V2 逐字口径)
96+
let canon = std::fs::canonicalize(&first.path).expect("返回路径存在");
97+
assert!(canon.starts_with(std::fs::canonicalize(&d.base).expect("基目录存在")));
98+
// Assert ③ 无 sidecar ⇒ `aligned` 不得为 true(历史录音不保证对齐)
99+
assert!(!first.aligned, "无 sidecar 的录音不得被当作已对齐");
100+
// Assert ④ durationMs = 头部 data 长度 ÷ 32 B/ms(32000 ÷ 32 = 1000)
101+
assert_eq!(first.duration_ms, Some(1000));
102+
// Assert ⑤ 同一文件两次调用载荷相等(确定性)
103+
assert_eq!(first, second);
104+
}
105+
106+
#[test]
107+
fn unfinalized_wav_reports_no_duration() {
108+
let d = TempDataDir::new("unfin");
109+
// 崩溃残留形态:头里 data 长度仍是创建时的 0,但样本已写了 >44 字节
110+
d.write_wav("9.wav", 0, 6_400);
111+
// Act
112+
let r = session_audio_ref_in(&d.root, 9).expect("有音频").expect("有引用");
113+
// Assert:未 finalize ⇒ `None`(**不**当作 0 时长、**不**发明数字)
114+
assert_eq!(r.duration_ms, None);
115+
assert!(!r.aligned);
116+
}
117+
118+
#[test]
119+
fn sidecar_drives_aligned_flag() {
120+
let d = TempDataDir::new("sidecar");
121+
d.write_wav("3.wav", 32_000, 32_000);
122+
d.write_sidecar("3.wav.meta.json", true);
123+
// Act & Assert:sidecar 报 true ⇒ 如实透传(不得恒 false 掩盖真实簿记)
124+
assert!(session_audio_ref_in(&d.root, 3).unwrap().unwrap().aligned);
125+
// 损坏的 sidecar ⇒ 保守 false(不 panic、不假称已对齐)
126+
std::fs::write(d.base.join("3.wav.meta.json"), b"{ not json").unwrap();
127+
assert!(!session_audio_ref_in(&d.root, 3).unwrap().unwrap().aligned);
128+
}
129+
130+
#[test]
131+
fn outside_path_is_rejected_by_boundary_check() {
132+
let d = TempDataDir::new("escape");
133+
d.write_wav("7.wav", 32_000, 32_000);
134+
std::fs::write(d.root.join("outside.wav"), b"x").expect("越界样本");
135+
// 正对照:同一份样本在目录内可被接受(证明下面的拒绝来自边界判定,而非文件缺失)
136+
assert!(confined_audio_file(&d.base, "7.wav").expect("目录内应接受").is_some());
137+
// Act & Assert:`..` 伪装成 `{id}.wav` ⇒ 越界拒绝(安全红线②)
138+
assert_eq!(confined_audio_file(&d.base, "../outside.wav").unwrap_err(), "路径越界拒绝");
139+
}
140+
141+
#[test]
142+
fn foreign_bytes_and_directories_are_not_audio() {
143+
let d = TempDataDir::new("foreign");
144+
// ① 非 RIFF/WAVE 的异类文件 ⇒ 不声称时长(但路径仍可定位)
145+
std::fs::write(d.base.join("1.wav"), vec![0u8; 128]).unwrap();
146+
assert_eq!(wav_duration_ms(&d.base.join("1.wav")), None);
147+
assert_eq!(session_audio_ref_in(&d.root, 1).unwrap().unwrap().duration_ms, None);
148+
// ② 目录占位(不是普通文件)⇒ 与「无音频」同档:`Ok(None)`
149+
std::fs::create_dir_all(d.base.join("2.wav")).unwrap();
150+
assert_eq!(session_audio_ref_in(&d.root, 2).unwrap(), None);
151+
// ③ 短于 44 字节头 ⇒ 读不满 ⇒ `None`
152+
std::fs::write(d.base.join("4.wav"), b"RIFF").unwrap();
153+
assert_eq!(session_audio_ref_in(&d.root, 4).unwrap().unwrap().duration_ms, None);
154+
}
155+
156+
#[test]
157+
fn wire_shape_is_camel_case_with_null_duration() {
158+
// 未 finalize 的线格式:`durationMs` 是 `null`(前端据此禁用播放)——与
159+
// `types_contract_tests` 的 assert_wire!(`Some` 形态)互补。
160+
let v = SessionAudioRef { path: "p".into(), aligned: false, duration_ms: None };
161+
assert_eq!(serde_json::to_string(&v).unwrap(), r#"{"path":"p","aligned":false,"durationMs":null}"#);
162+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ fn contract_keys_and_order() {
6868
assert_wire!("CanvasNodePosition", CanvasNodePosition { node_id: 1, x: 0.5, y: 0.5 }, r#"{"nodeId":1,"x":0.5,"y":0.5}"#);
6969
assert_wire!("CanvasViewport", CanvasViewport { viewport_x: 0.5, viewport_y: 0.5, zoom: 0.5 }, r#"{"viewportX":0.5,"viewportY":0.5,"zoom":0.5}"#);
7070
assert_wire!("CanvasPrefs", CanvasPrefs { edge_style: "smoothstep".into(), edge_arrows: true, layout_algorithm: "radial".into() }, r#"{"edgeStyle":"smoothstep","edgeArrows":true,"layoutAlgorithm":"radial"}"#);
71+
assert_wire!("SessionAudioRef", crate::commands_audio::SessionAudioRef { path: "p".into(), aligned: false, duration_ms: Some(1000) }, r#"{"path":"p","aligned":false,"durationMs":1000}"#);
7172
}
7273

7374
#[test]

0 commit comments

Comments
 (0)