Skip to content

Commit 3612809

Browse files
committed
feat(audio): 给音频存储配置开一条落盘通道
1 parent af47f2e commit 3612809

3 files changed

Lines changed: 203 additions & 18 deletions

File tree

Lines changed: 183 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,35 @@
11
//! 音频落盘策略配置(REQ-068 / v0.6.0 M4 的**配置面**;批 8 T27 从 `audio_store.rs` 抽出)。
22
//!
3-
//! @ai-context: 本件只承载**类型与默认值**(结构体 + `Default` + 两个 `DEFAULT_*`)
4-
//! —— 与 WAV 写侧(`audio_store.rs` 的 `SessionAudioWriter` / `cleanup`)
5-
//! 物理分离:配策略与写字节是两件事,混在一处会让"改默认值"与"改写盘逻辑"
6-
//! 在同一文件里互相牵动(AGENTS.md §3「纯逻辑与副作用物理分离」)。
7-
//! @ai-context: 由 `audio_store.rs` 以 `#[path]` 声明(同 `audio_align` 先例)⇒ `lib.rs`
3+
//! @ai-context: 产品问题 #1(用户裁决 U1-a #1):面板显示的「落盘启用状态」用户改不了 ——
4+
//! `AudioStoreConfig` 此前**只有 `Default`**(无 JSON / env 通道)⇒ `status.enabled`
5+
//! 恒 `true`。本件补齐持久化通道,把开关做成真的。
6+
//! @ai-context: 形态**逐条对齐同域先例** `audio_preproc_config.rs`:`load`(缺失 / 损坏 ⇒
7+
//! 默认 + 可观测日志,不阻断会话)/ `save`(原子写 = `.tmp` → rename)/
8+
//! `effective`(配置文件 > env > 默认)。**唯一形态差异**:本配置有 3 个字段而
9+
//! env 只管总开关 ⇒ `effective()` 返回生效后的 `Self`(先例是单字段配置、返回
10+
//! `bool`;若此处也返回 `bool`,调用点得手工拼结构体才能交给
11+
//! `SessionAudioWriter::create`)。
12+
//! @ai-context: 读取优先级:`{data_dir}/audio-store.json` > env `ENTROPY_AUDIO_STORE`
13+
//! (开发期快速实测)> 默认(开 / 保留 30 天 / 预算 4GB)。
14+
//! @ai-context: 副作用 = 读写**应用数据目录**内的单个 JSON(AGENTS.md §4:文件系统访问限定
15+
//! 应用数据目录);`save` 先写 `.tmp` 再 rename(防写一半损坏配置)。
16+
//! @ai-context: 边界 = ① 配置**不进 SQLite** ⇒ 零迁移(`db_migrations.rs` 零改动);② 开关只在
17+
//! **下次实时会话**生效(不热切换进行中会话 —— 音频链路抖动风险,同
18+
//! audio-preproc.json 先例);③ 本件**不改**音频存储格式(WAV 仍 16kHz 单声道
19+
//! PCM16),只改「是否存储」;④ 任何失败都不阻断会话主链路(音频是增强数据源)。
20+
//! @ai-context: 本件由 `audio_store.rs` 以 `#[path]` 声明(同 `audio_align` 先例)⇒ `lib.rs`
821
//! 一行未动;`AudioStoreConfig` 与两个 `DEFAULT_*` 仍从 `audio_store` 原路径
922
//! 再导出 ⇒ 既有调用点零改动。
10-
//! @ai-context: 本步是**行为等价的纯搬家**:一个调用点也不改(配置通道是下一步)。
11-
//! @ai-context: 边界 = 本件不改音频存储格式(WAV 仍 16kHz 单声道 PCM16),也没有任何 IO。
23+
24+
use serde::{Deserialize, Serialize};
1225

1326
/// 默认保留期(天)。
1427
pub const DEFAULT_RETENTION_DAYS: u64 = 30;
1528
/// 默认磁盘预算(字节;1 小时 ≈ 115MB,预算 4GB ≈ 35 小时会话)。
1629
pub const DEFAULT_DISK_BUDGET_BYTES: u64 = 4 * 1024 * 1024 * 1024;
1730

18-
/// 落盘策略配置。
19-
#[derive(Debug, Clone, Copy, PartialEq)]
31+
/// 落盘策略配置(JSON;应用数据目录 `audio-store.json`)。
32+
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
2033
pub struct AudioStoreConfig {
2134
/// 总开关(默认开;关闭=现状行为零开销)
2235
pub enabled: bool,
@@ -26,6 +39,49 @@ pub struct AudioStoreConfig {
2639
pub disk_budget_bytes: u64,
2740
}
2841

42+
impl AudioStoreConfig {
43+
/// 从 JSON 文件加载(缺失 / 损坏 → 默认值 + 可观测日志,不阻断启动)。
44+
///
45+
/// @ai-context: 「损坏」= serde 解析失败(截断 / 手改错)。缺失与损坏**同档**回退默认 ——
46+
/// 配置读不出来不该让会话起不来(同 audio-preproc.json 先例)。
47+
pub fn load(path: &std::path::Path) -> Self {
48+
match std::fs::read_to_string(path) {
49+
Ok(json) => serde_json::from_str(&json).unwrap_or_else(|e| {
50+
eprintln!("[AudioStore] 配置解析失败(回退默认值): {}", e);
51+
Self::default()
52+
}),
53+
Err(_) => Self::default(),
54+
}
55+
}
56+
57+
/// 原子写配置(先写临时文件再 rename——防写一半损坏配置)。
58+
///
59+
/// @ai-context: `parent` 不存在则创建(首启时该级目录可能还没有);临时名走
60+
/// `with_extension` ⇒ `audio-store.tmp`(与先例同形)。
61+
pub fn save(&self, path: &std::path::Path) -> std::io::Result<()> {
62+
let json = serde_json::to_string_pretty(self)
63+
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
64+
if let Some(parent) = path.parent() {
65+
std::fs::create_dir_all(parent)?;
66+
}
67+
let tmp = path.with_extension("tmp");
68+
std::fs::write(&tmp, json)?;
69+
std::fs::rename(&tmp, path)?;
70+
Ok(())
71+
}
72+
73+
/// 生效配置(配置文件 > env `ENTROPY_AUDIO_STORE` > 默认)。
74+
///
75+
/// @ai-context: env 只覆盖**总开关**(`=1` 开、其余关 —— 与先例 `ENTROPY_AUDIO_PREPROC`
76+
/// 逐字同形);保留期 / 预算无 env 通道(它们不在产品问题 #1 的面内)。
77+
pub fn effective(&self) -> Self {
78+
match std::env::var("ENTROPY_AUDIO_STORE") {
79+
Ok(v) => Self { enabled: v == "1", ..*self },
80+
Err(_) => *self,
81+
}
82+
}
83+
}
84+
2985
/// 默认配置:开启(保留 30 天 / 预算 4GB;关闭 = 现状行为零开销)。
3086
impl Default for AudioStoreConfig {
3187
fn default() -> Self {
@@ -36,3 +92,121 @@ impl Default for AudioStoreConfig {
3692
}
3793
}
3894
}
95+
96+
#[cfg(test)]
97+
mod tests {
98+
use super::*;
99+
100+
/// 独立临时目录夹具(pid + tag ⇒ 并发用例互不踩;Drop 时整体删除)。
101+
struct TempDir {
102+
root: std::path::PathBuf,
103+
}
104+
105+
impl TempDir {
106+
fn new(tag: &str) -> Self {
107+
let root = std::env::temp_dir()
108+
.join(format!("entropy-audio-store-{}-{}", std::process::id(), tag));
109+
std::fs::create_dir_all(&root).expect("建临时目录");
110+
Self { root }
111+
}
112+
113+
fn cfg(&self) -> std::path::PathBuf {
114+
self.root.join("audio-store.json")
115+
}
116+
}
117+
118+
impl Drop for TempDir {
119+
fn drop(&mut self) {
120+
let _ = std::fs::remove_dir_all(&self.root);
121+
}
122+
}
123+
124+
#[test]
125+
fn default_is_enabled_with_frozen_policy() {
126+
// 默认:开 + 保留 30 天 + 预算 4GB(策略默认值与原 audio_store 常量同源)
127+
let cfg = AudioStoreConfig::default();
128+
assert!(cfg.enabled);
129+
assert_eq!(cfg.retention_days, DEFAULT_RETENTION_DAYS);
130+
assert_eq!(cfg.disk_budget_bytes, DEFAULT_DISK_BUDGET_BYTES);
131+
}
132+
133+
#[test]
134+
fn load_missing_file_defaults() {
135+
// 缺失配置 → 默认开(不阻断启动)
136+
let cfg = AudioStoreConfig::load(std::path::Path::new("C:/nonexistent/audio-store.json"));
137+
assert_eq!(cfg, AudioStoreConfig::default());
138+
}
139+
140+
#[test]
141+
fn save_then_load_roundtrip() {
142+
// Arrange:非默认的策略值(证明三个字段都真的落盘,而非只落了开关)
143+
let d = TempDir::new("roundtrip");
144+
let cfg = AudioStoreConfig { enabled: false, retention_days: 7, disk_budget_bytes: 1_024 };
145+
// Act
146+
cfg.save(&d.cfg()).expect("保存成功");
147+
let loaded = AudioStoreConfig::load(&d.cfg());
148+
// Assert:读回逐字段相等(roundtrip)
149+
assert_eq!(loaded, cfg);
150+
}
151+
152+
#[test]
153+
fn corrupted_json_falls_back_without_panic() {
154+
// 损坏 JSON → 默认值(防御:配置错误不劣化音频链路;**不 panic**)
155+
let d = TempDir::new("corrupt");
156+
std::fs::write(d.cfg(), b"{ not json").unwrap();
157+
assert_eq!(AudioStoreConfig::load(&d.cfg()), AudioStoreConfig::default());
158+
}
159+
160+
#[test]
161+
fn user_toggle_persists_disabled() {
162+
// 🔴 V1 核心:用户关闭 ⇒ 落盘 ⇒ **重新 load 同一文件**仍为 false(默认开不覆盖用户选择)
163+
let d = TempDir::new("off");
164+
let saved = AudioStoreConfig { enabled: false, ..AudioStoreConfig::default() };
165+
saved.save(&d.cfg()).expect("保存成功");
166+
// 重新载入(模拟下一次会话/命令调用;**不是**重启真机)
167+
let reloaded = AudioStoreConfig::load(&d.cfg());
168+
assert!(!reloaded.enabled, "配置文件显式 false 应保持关闭");
169+
assert_eq!(reloaded, saved, "保存后读回 == 保存值");
170+
}
171+
172+
#[test]
173+
fn save_is_idempotent() {
174+
// 幂等:连存两次 == 存一次(原子写的字节确定)
175+
let d = TempDir::new("idem");
176+
let cfg = AudioStoreConfig { enabled: false, retention_days: 7, disk_budget_bytes: 1_024 };
177+
cfg.save(&d.cfg()).expect("第一次保存");
178+
let once = std::fs::read(d.cfg()).unwrap();
179+
cfg.save(&d.cfg()).expect("第二次保存");
180+
let twice = std::fs::read(d.cfg()).unwrap();
181+
assert_eq!(twice, once, "连存两次应与存一次逐字节相同");
182+
// 且临时文件不残留(rename 而非 copy)
183+
assert!(!d.root.join("audio-store.tmp").exists());
184+
}
185+
186+
#[test]
187+
fn env_overrides_config_switch() {
188+
// env 开发期通道优先于配置文件(快速实测不落盘)
189+
unsafe {
190+
std::env::set_var("ENTROPY_AUDIO_STORE", "1");
191+
}
192+
let cfg = AudioStoreConfig { enabled: false, ..AudioStoreConfig::default() };
193+
assert!(cfg.effective().enabled, "env=1 应覆盖配置文件关闭态");
194+
unsafe {
195+
std::env::set_var("ENTROPY_AUDIO_STORE", "0");
196+
}
197+
assert!(!cfg.effective().enabled, "env=0 应保持关闭");
198+
unsafe {
199+
std::env::remove_var("ENTROPY_AUDIO_STORE");
200+
}
201+
assert!(!cfg.effective().enabled, "无 env 时回退配置文件");
202+
}
203+
204+
#[test]
205+
fn effective_without_env_keeps_policy_fields() {
206+
// env 只管总开关:保留期 / 预算在任何 env 下都原样透传(无 env 通道)
207+
let cfg = AudioStoreConfig { enabled: true, retention_days: 3, disk_budget_bytes: 512 };
208+
let eff = cfg.effective();
209+
assert_eq!(eff.retention_days, 3);
210+
assert_eq!(eff.disk_budget_bytes, 512);
211+
}
212+
}

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

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,28 +61,39 @@ pub struct SessionAudioStatus {
6161
pub disk_budget_bytes: u64,
6262
/// 落盘策略开关(false=未启用,前端提示)
6363
pub enabled: bool,
64+
/// 生效开关(env ENTROPY_AUDIO_STORE 覆盖配置文件时与 `enabled` 不同)
65+
pub effective: bool,
6466
}
6567

66-
/// 查询会话音频落盘状态(数量/占用/策略)。
67-
#[tauri::command]
68-
pub fn session_audio_status(state: State<'_, AppState>) -> SessionAudioStatus {
69-
let dir = state.data_dir.join("session-audio");
70-
let (file_count, total_bytes) = audio_dir_stats(&dir);
71-
let config = AudioStoreConfig::default();
68+
/// 组装状态载荷(状态查询与 setter 回读**共用** ⇒ 两者走同一条取数路径,不会各读各的)。
69+
///
70+
/// @ai-context: 配置**每次调用都从磁盘读**(不缓存):命令层无状态,而配置文件还能被 env
71+
/// 之外的路径(用户手工编辑 `{data_dir}/audio-store.json`)改动 —— 缓存会让
72+
/// 面板显示陈旧值。文件名与先例 `audio-preproc.json` 同形。
73+
fn status_of(data_dir: &std::path::Path) -> SessionAudioStatus {
74+
let (file_count, total_bytes) = audio_dir_stats(&data_dir.join("session-audio"));
75+
let config = AudioStoreConfig::load(&data_dir.join("audio-store.json"));
7276
SessionAudioStatus {
7377
file_count,
7478
total_bytes,
7579
retention_days: config.retention_days,
7680
disk_budget_bytes: config.disk_budget_bytes,
7781
enabled: config.enabled,
82+
effective: config.effective().enabled,
7883
}
7984
}
8085

86+
/// 查询会话音频落盘状态(数量/占用/策略/开关)。
87+
#[tauri::command]
88+
pub fn session_audio_status(state: State<'_, AppState>) -> SessionAudioStatus {
89+
status_of(&state.data_dir)
90+
}
91+
8192
/// 手动触发音频清理(超保留期删除 + 超预算删最旧)。
8293
#[tauri::command]
8394
pub fn session_audio_cleanup(state: State<'_, AppState>) -> Result<crate::audio_store::CleanupSummary, String> {
8495
let dir = state.data_dir.join("session-audio");
85-
let config = AudioStoreConfig::default();
96+
let config = AudioStoreConfig::load(&state.data_dir.join("audio-store.json"));
8697
Ok(cleanup(&dir, config.retention_days, config.disk_budget_bytes))
8798
}
8899

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -254,11 +254,11 @@ pub(crate) fn run_session_after_engine(
254254
let speech_active = Arc::new(AtomicBool::new(false));
255255
// REQ-291(v0.19.7):媒体级"最后有声时刻"共享戳(音频线程写/屏幕线程读)
256256
let media_sound: Arc<Mutex<Option<std::time::Instant>>> = Arc::new(Mutex::new(None));
257-
// M4/REQ-068(S4):实时链路音频落盘(WAV PCM16;创建失败降级不阻断)
257+
// M4/REQ-068(S4):实时链路音频落盘(WAV PCM16;创建失败降级不阻断);T27 起生效配置 = audio-store.json > env ENTROPY_AUDIO_STORE > 默认开
258258
let mut audio_writer = crate::audio_store::SessionAudioWriter::create(
259259
&params.data_dir.join("session-audio"),
260260
session_id,
261-
&crate::audio_store::AudioStoreConfig::default(),
261+
&crate::audio_store::AudioStoreConfig::load(&params.data_dir.join("audio-store.json")).effective(),
262262
);
263263
let screen_worker: Option<JoinHandle<()>> = if ocr_enabled {
264264
// worker 需独立持有 Db/AppHandle(主循环仍要使用,先 clone 再 move 进闭包)

0 commit comments

Comments
 (0)