Skip to main content

max / audiofiles

11.0 KB · 298 lines History Blame Raw
1 //! Forge runners: decode a source sample, run a forge operation, and write the
2 //! result back into the content-addressed store + VFS.
3 //!
4 //! These tie the pure DSP ([`super::chop`], [`super::conform`]) to the store/VFS
5 //! so the browser backend can offer one-call "chop into slices" / "conform for
6 //! device" actions. Originals are never touched — every result is a new sample.
7
8 use std::path::{Path, PathBuf};
9
10 use crate::db::Database;
11 use crate::error::{io_err, CoreError};
12 use crate::export::convert::ConvertedAudio;
13 use crate::export::decode::decode_multichannel;
14 use crate::export::encode::encode_wav;
15 use crate::id_types::{NodeId, VfsId};
16 use crate::store::SampleStore;
17 use crate::vfs;
18
19 use super::chop::{compute_slices, render_slice, ChopMethod};
20 use super::conform::{conform, resolve_overshoot, ConformTarget, OvershootReport};
21
22 /// Result of a chop-to-VFS run.
23 #[derive(Debug)]
24 pub struct ChopResult {
25 /// The new directory node holding the slices.
26 pub dir_node: NodeId,
27 /// Number of slices created.
28 pub slice_count: usize,
29 }
30
31 /// Result of a conform-to-VFS run.
32 #[derive(Debug)]
33 pub struct ConformResult {
34 /// The new sample's content hash.
35 pub hash: String,
36 /// Present when the conformed buffer overshot full scale at an integer
37 /// target — either flagged (left for the encoder to clamp) or trimmed,
38 /// depending on the caller's `auto_trim` choice.
39 pub overshoot: Option<OvershootReport>,
40 }
41
42 /// Decode `source_path`, chop it with `method`, and write each slice as a new
43 /// sample into a new `"{base_name}_slices"` directory under `parent_id`.
44 ///
45 /// Slices are encoded as 24-bit WAV (lossless headroom for further forging).
46 /// Returns the directory node and slice count.
47 pub fn chop_to_vfs(
48 store: &SampleStore,
49 db: &Database,
50 vfs_id: VfsId,
51 source_path: &Path,
52 base_name: &str,
53 parent_id: Option<NodeId>,
54 method: &ChopMethod,
55 ) -> Result<ChopResult, CoreError> {
56 let decoded = decode_multichannel(source_path)?;
57 let slices = compute_slices(&decoded.samples, decoded.channels, decoded.sample_rate, method)?;
58 if slices.is_empty() {
59 return Err(CoreError::Internal("chop produced no slices".to_string()));
60 }
61
62 let stem = sanitize_stem(base_name);
63 let dir_name = format!("{stem}_slices");
64 let dir_node = vfs::create_directory(db, vfs_id, parent_id, &dir_name)?;
65
66 let temp_dir = forge_temp_dir()?;
67 let width = slice_index_width(slices.len());
68
69 // Count only slices actually written, and number them sequentially, so the
70 // reported count never overstates what landed in the VFS and the file names
71 // carry no gaps even if a slice renders empty.
72 let mut written = 0usize;
73 for slice in slices.iter() {
74 let buf = render_slice(&decoded.samples, decoded.channels, slice);
75 if buf.is_empty() {
76 continue;
77 }
78 let converted = ConvertedAudio {
79 samples: buf,
80 sample_rate: decoded.sample_rate,
81 channels: decoded.channels,
82 };
83 let slice_name = format!("{stem}_{:0width$}.wav", written + 1, width = width);
84 let temp_path = temp_dir.join(&slice_name);
85 encode_wav(&converted, 24, &temp_path)?;
86
87 let import = store.import(&temp_path, db);
88 let _ = std::fs::remove_file(&temp_path);
89 let hash = import?;
90 vfs::create_sample_link(db, vfs_id, Some(dir_node), &slice_name, &hash)?;
91 written += 1;
92 }
93
94 Ok(ChopResult { dir_node, slice_count: written })
95 }
96
97 /// Decode `source_path`, conform it to `target`, and write the result as a new
98 /// sibling sample under `parent_id`.
99 ///
100 /// The conformed f32 signal is carried pristine to the encode boundary. Resampling
101 /// can push inter-sample (true) peaks past `±1.0`; at an integer target the
102 /// encoder must then clamp (a destructive, irreversible edit). `auto_trim`
103 /// chooses how to resolve that one forced case:
104 /// - `false` (default): leave the signal untouched and report the overshoot, so
105 /// the forge makes no implicit edit; the encoder clamp is the disclosed
106 /// last-resort exception.
107 /// - `true`: apply the gentlest reversible fix (a single linear gain to full
108 /// scale) instead of clamping, reported in the result.
109 ///
110 /// 32-bit float targets store the f32 verbatim and never clip, so the check is
111 /// skipped for them.
112 #[allow(clippy::too_many_arguments)] // store/db/vfs context + target + the overshoot policy flag
113 pub fn conform_to_vfs(
114 store: &SampleStore,
115 db: &Database,
116 vfs_id: VfsId,
117 source_path: &Path,
118 base_name: &str,
119 parent_id: Option<NodeId>,
120 target: &ConformTarget,
121 auto_trim: bool,
122 ) -> Result<ConformResult, CoreError> {
123 let decoded = decode_multichannel(source_path)?;
124 let mut conformed = conform(&decoded.samples, decoded.channels, decoded.sample_rate, target)?;
125
126 // Integer targets clamp at the encoder; 32-bit float is a lossless f32
127 // passthrough with no clamp, so only the integer path can overshoot.
128 let overshoot = if conformed.bit_depth == 32 {
129 None
130 } else {
131 resolve_overshoot(&mut conformed.audio.samples, auto_trim)
132 };
133
134 let stem = sanitize_stem(base_name);
135 let rate_tag = format_rate_tag(target.sample_rate);
136 let out_name = format!("{stem}_{rate_tag}_{}bit.wav", target.bit_depth);
137
138 let temp_dir = forge_temp_dir()?;
139 let temp_path = temp_dir.join(&out_name);
140 encode_wav(&conformed.audio, conformed.bit_depth, &temp_path)?;
141
142 let import = store.import(&temp_path, db);
143 let _ = std::fs::remove_file(&temp_path);
144 let hash = import?;
145 vfs::create_sample_link(db, vfs_id, parent_id, &out_name, &hash)?;
146 Ok(ConformResult { hash, overshoot })
147 }
148
149 /// Drop the extension and strip path-unfriendly characters from a base name.
150 fn sanitize_stem(name: &str) -> String {
151 let (stem, _ext) = crate::util::split_name_ext(name);
152 let cleaned: String = stem
153 .chars()
154 .map(|c| if c == '/' || c == '\\' || c.is_control() { '_' } else { c })
155 .collect();
156 let trimmed = cleaned.trim();
157 if trimmed.is_empty() {
158 "sample".to_string()
159 } else {
160 trimmed.to_string()
161 }
162 }
163
164 /// Zero-pad width for slice indices so they sort lexically. Minimum 2 digits
165 /// (so 4 slices name 01..04); widens for 100+ slices (3 digits) and beyond.
166 fn slice_index_width(count: usize) -> usize {
167 count.to_string().len().max(2)
168 }
169
170 /// Format a sample rate as a compact tag: 44100 -> "44k", 22050 -> "22k".
171 fn format_rate_tag(rate: u32) -> String {
172 if rate >= 1000 {
173 format!("{}k", rate / 1000)
174 } else {
175 rate.to_string()
176 }
177 }
178
179 /// Per-process temp dir for forge intermediates; created on demand.
180 fn forge_temp_dir() -> Result<PathBuf, CoreError> {
181 let dir = std::env::temp_dir().join("audiofiles_forge");
182 std::fs::create_dir_all(&dir).map_err(|e| io_err(&dir, e))?;
183 Ok(dir)
184 }
185
186 #[cfg(test)]
187 mod tests {
188 use super::*;
189 use crate::export::ExportChannels;
190 use std::io::Write;
191
192 fn write_test_wav(path: &Path, channels: u16, sample_rate: u32, samples: &[f32]) {
193 let bytes_per_sample = 4u16;
194 let block_align = channels * bytes_per_sample;
195 let data_size = (samples.len() as u32) * 4;
196 let file_size = 36 + data_size;
197 let mut buf = Vec::with_capacity(44 + data_size as usize);
198 buf.extend_from_slice(b"RIFF");
199 buf.extend_from_slice(&file_size.to_le_bytes());
200 buf.extend_from_slice(b"WAVE");
201 buf.extend_from_slice(b"fmt ");
202 buf.extend_from_slice(&16u32.to_le_bytes());
203 buf.extend_from_slice(&3u16.to_le_bytes()); // IEEE float
204 buf.extend_from_slice(&channels.to_le_bytes());
205 buf.extend_from_slice(&sample_rate.to_le_bytes());
206 buf.extend_from_slice(&(sample_rate * block_align as u32).to_le_bytes());
207 buf.extend_from_slice(&block_align.to_le_bytes());
208 buf.extend_from_slice(&(bytes_per_sample * 8).to_le_bytes());
209 buf.extend_from_slice(b"data");
210 buf.extend_from_slice(&data_size.to_le_bytes());
211 for &s in samples {
212 buf.extend_from_slice(&s.to_le_bytes());
213 }
214 std::fs::File::create(path).unwrap().write_all(&buf).unwrap();
215 }
216
217 #[test]
218 fn chop_creates_slice_dir_and_samples() {
219 let dir = tempfile::tempdir().unwrap();
220 let db = Database::open_in_memory().unwrap();
221 let store = SampleStore::new(dir.path().join("store")).unwrap();
222 let vfs_id = vfs::create_vfs(&db, "T").unwrap();
223
224 // 1000 mono frames of nonzero content.
225 let samples: Vec<f32> = (0..1000).map(|i| ((i % 50) as f32 / 50.0) - 0.5).collect();
226 let src = dir.path().join("loop.wav");
227 write_test_wav(&src, 1, 44100, &samples);
228
229 let result = chop_to_vfs(
230 &store,
231 &db,
232 vfs_id,
233 &src,
234 "loop.wav",
235 None,
236 &ChopMethod::EqualDivisions(4),
237 )
238 .unwrap();
239 assert_eq!(result.slice_count, 4);
240
241 // The directory exists with 4 sample children.
242 let children = vfs::list_children(&db, vfs_id, Some(result.dir_node)).unwrap();
243 assert_eq!(children.len(), 4);
244 assert!(children.iter().all(|c| c.sample_hash.is_some()));
245 assert_eq!(children[0].name, "loop_01.wav");
246 }
247
248 #[test]
249 fn conform_creates_sibling_sample() {
250 let dir = tempfile::tempdir().unwrap();
251 let db = Database::open_in_memory().unwrap();
252 let store = SampleStore::new(dir.path().join("store")).unwrap();
253 let vfs_id = vfs::create_vfs(&db, "T").unwrap();
254
255 // Stereo 44.1k content.
256 let samples: Vec<f32> = (0..4096 * 2).map(|i| ((i % 100) as f32 / 100.0) - 0.5).collect();
257 let src = dir.path().join("pad.wav");
258 write_test_wav(&src, 2, 44100, &samples);
259
260 let target = ConformTarget {
261 sample_rate: 22050,
262 bit_depth: 16,
263 channels: ExportChannels::Mono,
264 };
265 let result =
266 conform_to_vfs(&store, &db, vfs_id, &src, "pad.wav", None, &target, false).unwrap();
267 // Benign in-range content must not be flagged as overshooting.
268 assert!(result.overshoot.is_none());
269 let hash = result.hash;
270 assert!(!hash.is_empty());
271
272 let children = vfs::list_children(&db, vfs_id, None).unwrap();
273 assert_eq!(children.len(), 1);
274 assert_eq!(children[0].name, "pad_22k_16bit.wav");
275
276 // The stored file decodes back at the conformed spec.
277 let ext = crate::store::sample_extension(&db, &hash).unwrap();
278 let path = store.sample_path(&hash, &ext).unwrap();
279 let decoded = decode_multichannel(&path).unwrap();
280 assert_eq!(decoded.sample_rate, 22050);
281 assert_eq!(decoded.channels, 1);
282 }
283
284 #[test]
285 fn sanitize_stem_drops_ext_and_slashes() {
286 assert_eq!(sanitize_stem("kick.wav"), "kick");
287 assert_eq!(sanitize_stem("a/b.wav"), "a_b");
288 assert_eq!(sanitize_stem(" "), "sample");
289 }
290
291 #[test]
292 fn rate_tag_formats() {
293 assert_eq!(format_rate_tag(44100), "44k");
294 assert_eq!(format_rate_tag(48000), "48k");
295 assert_eq!(format_rate_tag(800), "800");
296 }
297 }
298