//! Export sample-info helpers: assemble a `RhaiSampleInfo` for device-profile //! conform and probe bit depth from a file header. Split out of direct.rs. #[cfg(feature = "device-profiles")] use audiofiles_core::export::ExportItem; /// Build a RhaiSampleInfo from an ExportItem and database lookups. #[cfg(feature = "device-profiles")] pub(super) fn build_sample_info( db: &audiofiles_core::db::Database, store: &audiofiles_core::store::SampleStore, item: &ExportItem, ) -> audiofiles_rhai::types::RhaiSampleInfo { // Query audio_analysis for sample_rate and channels. Read the integers as // i64 so a valid-but-out-of-range stored value isn't reported as a query // error and lumped in with the legitimate "no analysis row yet" case; range // problems are surfaced explicitly below instead. Only QueryReturnedNoRows // is a silent (0,0) fallback, a genuine read error or a corrupt value // (negative / oversized rate or channels) is logged so a device profile // can't silently drop the sample on mystery zeros. let (sample_rate, channels, duration) = db .conn() .query_row( "SELECT sample_rate, channels, duration FROM audio_analysis WHERE hash = ?1", [&item.hash], |row| { Ok(( row.get::<_, i64>(0)?, row.get::<_, i64>(1)?, row.get::<_, f64>(2)?, )) }, ) .map_or_else( |e| { if !matches!(e, rusqlite::Error::QueryReturnedNoRows) { tracing::warn!( "Failed to query audio_analysis for {}: {e}", &item.hash[..8] ); } (0, 0, item.duration.unwrap_or(0.0)) }, |(sr, ch, dur)| { let sample_rate = u32::try_from(sr).unwrap_or_else(|_| { tracing::warn!("Corrupt sample_rate {sr} for {}", &item.hash[..8]); 0 }); let channels = u16::try_from(ch).unwrap_or_else(|_| { tracing::warn!("Corrupt channels {ch} for {}", &item.hash[..8]); 0 }); (sample_rate, channels, dur) }, ); // Query samples for file_size let file_size = db .conn() .query_row( "SELECT file_size FROM samples WHERE hash = ?1 AND deleted_at IS NULL", [&item.hash], // file_size is non-negative in practice; a corrupt negative surfaces // as an error rather than wrapping silently to a nonsense u64. |row| { let v = row.get::<_, i64>(0)?; u64::try_from(v).map_err(|_| rusqlite::Error::IntegralValueOutOfRange(0, v)) }, ) .unwrap_or_else(|_| { // Fallback: read from store store .sample_path(&item.hash, &item.ext) .ok() .and_then(|p| std::fs::metadata(p).ok()) .map_or(0, |m| m.len()) }); // bit_depth is filled by the caller in a parallel pass off the DB lock, see // resolve_device_profile. Probing it here would open a file per item while // holding the DB lock on the GUI thread. audiofiles_rhai::types::RhaiSampleInfo { hash: item.hash.to_string(), name: item.name.clone(), extension: item.ext.clone(), sample_rate, bit_depth: 0, channels, duration, file_size, } } /// Probe bit depth from a WAV/AIFF file header without full decode. #[cfg(feature = "device-profiles")] pub(super) fn probe_bit_depth(path: &std::path::Path) -> Option { // Try hound first (WAV) if let Ok(reader) = hound::WavReader::open(path) { return Some(reader.spec().bits_per_sample); } // Try symphonia for AIFF/other formats let file = std::fs::File::open(path).ok()?; let mss = symphonia::core::io::MediaSourceStream::new( Box::new(file), symphonia::core::io::MediaSourceStreamOptions::default(), ); let mut hint = symphonia::core::formats::probe::Hint::new(); if let Some(ext) = path.extension().and_then(|e| e.to_str()) { hint.with_extension(ext); } let format = symphonia::default::get_probe() .probe( &hint, mss, symphonia::core::formats::FormatOptions::default(), symphonia::core::meta::MetadataOptions::default(), ) .ok()?; let track = format.default_track(symphonia::core::formats::TrackType::Audio)?; track .codec_params .as_ref()? .audio()? .bits_per_sample .map(|b| b as u16) }