//! Forge runners: decode a source sample, run a forge operation, and write the //! result back into the content-addressed store + VFS. //! //! These tie the pure DSP ([`super::chop`], [`super::conform`]) to the store/VFS //! so the browser backend can offer one-call "chop into slices" / "conform for //! device" actions. Originals are never touched — every result is a new sample. use std::path::{Path, PathBuf}; use crate::db::Database; use crate::error::{io_err, CoreError}; use crate::export::convert::ConvertedAudio; use crate::export::decode::decode_multichannel; use crate::export::encode::encode_wav; use crate::id_types::{NodeId, VfsId}; use crate::store::SampleStore; use crate::vfs; use super::chop::{compute_slices, render_slice, ChopMethod}; use super::conform::{conform, resolve_overshoot, ConformTarget, OvershootReport}; /// Result of a chop-to-VFS run. #[derive(Debug)] pub struct ChopResult { /// The new directory node holding the slices. pub dir_node: NodeId, /// Number of slices created. pub slice_count: usize, } /// Result of a conform-to-VFS run. #[derive(Debug)] pub struct ConformResult { /// The new sample's content hash. pub hash: String, /// Present when the conformed buffer overshot full scale at an integer /// target — either flagged (left for the encoder to clamp) or trimmed, /// depending on the caller's `auto_trim` choice. pub overshoot: Option, } /// Decode `source_path`, chop it with `method`, and write each slice as a new /// sample into a new `"{base_name}_slices"` directory under `parent_id`. /// /// Slices are encoded as 24-bit WAV (lossless headroom for further forging). /// Returns the directory node and slice count. pub fn chop_to_vfs( store: &SampleStore, db: &Database, vfs_id: VfsId, source_path: &Path, base_name: &str, parent_id: Option, method: &ChopMethod, ) -> Result { let decoded = decode_multichannel(source_path)?; let slices = compute_slices(&decoded.samples, decoded.channels, decoded.sample_rate, method)?; if slices.is_empty() { return Err(CoreError::Internal("chop produced no slices".to_string())); } let stem = sanitize_stem(base_name); let dir_name = format!("{stem}_slices"); let dir_node = vfs::create_directory(db, vfs_id, parent_id, &dir_name)?; let temp_dir = forge_temp_dir()?; let width = slice_index_width(slices.len()); // Count only slices actually written, and number them sequentially, so the // reported count never overstates what landed in the VFS and the file names // carry no gaps even if a slice renders empty. let mut written = 0usize; for slice in slices.iter() { let buf = render_slice(&decoded.samples, decoded.channels, slice); if buf.is_empty() { continue; } let converted = ConvertedAudio { samples: buf, sample_rate: decoded.sample_rate, channels: decoded.channels, }; let slice_name = format!("{stem}_{:0width$}.wav", written + 1, width = width); let temp_path = temp_dir.join(&slice_name); encode_wav(&converted, 24, &temp_path)?; let import = store.import(&temp_path, db); let _ = std::fs::remove_file(&temp_path); let hash = import?; vfs::create_sample_link(db, vfs_id, Some(dir_node), &slice_name, &hash)?; written += 1; } Ok(ChopResult { dir_node, slice_count: written }) } /// Decode `source_path`, conform it to `target`, and write the result as a new /// sibling sample under `parent_id`. /// /// The conformed f32 signal is carried pristine to the encode boundary. Resampling /// can push inter-sample (true) peaks past `±1.0`; at an integer target the /// encoder must then clamp (a destructive, irreversible edit). `auto_trim` /// chooses how to resolve that one forced case: /// - `false` (default): leave the signal untouched and report the overshoot, so /// the forge makes no implicit edit; the encoder clamp is the disclosed /// last-resort exception. /// - `true`: apply the gentlest reversible fix (a single linear gain to full /// scale) instead of clamping, reported in the result. /// /// 32-bit float targets store the f32 verbatim and never clip, so the check is /// skipped for them. #[allow(clippy::too_many_arguments)] // store/db/vfs context + target + the overshoot policy flag pub fn conform_to_vfs( store: &SampleStore, db: &Database, vfs_id: VfsId, source_path: &Path, base_name: &str, parent_id: Option, target: &ConformTarget, auto_trim: bool, ) -> Result { let decoded = decode_multichannel(source_path)?; let mut conformed = conform(&decoded.samples, decoded.channels, decoded.sample_rate, target)?; // Integer targets clamp at the encoder; 32-bit float is a lossless f32 // passthrough with no clamp, so only the integer path can overshoot. let overshoot = if conformed.bit_depth == 32 { None } else { resolve_overshoot(&mut conformed.audio.samples, auto_trim) }; let stem = sanitize_stem(base_name); let rate_tag = format_rate_tag(target.sample_rate); let out_name = format!("{stem}_{rate_tag}_{}bit.wav", target.bit_depth); let temp_dir = forge_temp_dir()?; let temp_path = temp_dir.join(&out_name); encode_wav(&conformed.audio, conformed.bit_depth, &temp_path)?; let import = store.import(&temp_path, db); let _ = std::fs::remove_file(&temp_path); let hash = import?; vfs::create_sample_link(db, vfs_id, parent_id, &out_name, &hash)?; Ok(ConformResult { hash, overshoot }) } /// Drop the extension and strip path-unfriendly characters from a base name. fn sanitize_stem(name: &str) -> String { let (stem, _ext) = crate::util::split_name_ext(name); let cleaned: String = stem .chars() .map(|c| if c == '/' || c == '\\' || c.is_control() { '_' } else { c }) .collect(); let trimmed = cleaned.trim(); if trimmed.is_empty() { "sample".to_string() } else { trimmed.to_string() } } /// Zero-pad width for slice indices so they sort lexically. Minimum 2 digits /// (so 4 slices name 01..04); widens for 100+ slices (3 digits) and beyond. fn slice_index_width(count: usize) -> usize { count.to_string().len().max(2) } /// Format a sample rate as a compact tag: 44100 -> "44k", 22050 -> "22k". fn format_rate_tag(rate: u32) -> String { if rate >= 1000 { format!("{}k", rate / 1000) } else { rate.to_string() } } /// Per-process temp dir for forge intermediates; created on demand. fn forge_temp_dir() -> Result { let dir = std::env::temp_dir().join("audiofiles_forge"); std::fs::create_dir_all(&dir).map_err(|e| io_err(&dir, e))?; Ok(dir) } #[cfg(test)] mod tests { use super::*; use crate::export::ExportChannels; use std::io::Write; fn write_test_wav(path: &Path, channels: u16, sample_rate: u32, samples: &[f32]) { let bytes_per_sample = 4u16; let block_align = channels * bytes_per_sample; let data_size = (samples.len() as u32) * 4; let file_size = 36 + data_size; let mut buf = Vec::with_capacity(44 + data_size as usize); buf.extend_from_slice(b"RIFF"); buf.extend_from_slice(&file_size.to_le_bytes()); buf.extend_from_slice(b"WAVE"); buf.extend_from_slice(b"fmt "); buf.extend_from_slice(&16u32.to_le_bytes()); buf.extend_from_slice(&3u16.to_le_bytes()); // IEEE float buf.extend_from_slice(&channels.to_le_bytes()); buf.extend_from_slice(&sample_rate.to_le_bytes()); buf.extend_from_slice(&(sample_rate * block_align as u32).to_le_bytes()); buf.extend_from_slice(&block_align.to_le_bytes()); buf.extend_from_slice(&(bytes_per_sample * 8).to_le_bytes()); buf.extend_from_slice(b"data"); buf.extend_from_slice(&data_size.to_le_bytes()); for &s in samples { buf.extend_from_slice(&s.to_le_bytes()); } std::fs::File::create(path).unwrap().write_all(&buf).unwrap(); } #[test] fn chop_creates_slice_dir_and_samples() { let dir = tempfile::tempdir().unwrap(); let db = Database::open_in_memory().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); let vfs_id = vfs::create_vfs(&db, "T").unwrap(); // 1000 mono frames of nonzero content. let samples: Vec = (0..1000).map(|i| ((i % 50) as f32 / 50.0) - 0.5).collect(); let src = dir.path().join("loop.wav"); write_test_wav(&src, 1, 44100, &samples); let result = chop_to_vfs( &store, &db, vfs_id, &src, "loop.wav", None, &ChopMethod::EqualDivisions(4), ) .unwrap(); assert_eq!(result.slice_count, 4); // The directory exists with 4 sample children. let children = vfs::list_children(&db, vfs_id, Some(result.dir_node)).unwrap(); assert_eq!(children.len(), 4); assert!(children.iter().all(|c| c.sample_hash.is_some())); assert_eq!(children[0].name, "loop_01.wav"); } #[test] fn conform_creates_sibling_sample() { let dir = tempfile::tempdir().unwrap(); let db = Database::open_in_memory().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); let vfs_id = vfs::create_vfs(&db, "T").unwrap(); // Stereo 44.1k content. let samples: Vec = (0..4096 * 2).map(|i| ((i % 100) as f32 / 100.0) - 0.5).collect(); let src = dir.path().join("pad.wav"); write_test_wav(&src, 2, 44100, &samples); let target = ConformTarget { sample_rate: 22050, bit_depth: 16, channels: ExportChannels::Mono, }; let result = conform_to_vfs(&store, &db, vfs_id, &src, "pad.wav", None, &target, false).unwrap(); // Benign in-range content must not be flagged as overshooting. assert!(result.overshoot.is_none()); let hash = result.hash; assert!(!hash.is_empty()); let children = vfs::list_children(&db, vfs_id, None).unwrap(); assert_eq!(children.len(), 1); assert_eq!(children[0].name, "pad_22k_16bit.wav"); // The stored file decodes back at the conformed spec. let ext = crate::store::sample_extension(&db, &hash).unwrap(); let path = store.sample_path(&hash, &ext).unwrap(); let decoded = decode_multichannel(&path).unwrap(); assert_eq!(decoded.sample_rate, 22050); assert_eq!(decoded.channels, 1); } #[test] fn sanitize_stem_drops_ext_and_slashes() { assert_eq!(sanitize_stem("kick.wav"), "kick"); assert_eq!(sanitize_stem("a/b.wav"), "a_b"); assert_eq!(sanitize_stem(" "), "sample"); } #[test] fn rate_tag_formats() { assert_eq!(format_rate_tag(44100), "44k"); assert_eq!(format_rate_tag(48000), "48k"); assert_eq!(format_rate_tag(800), "800"); } }