qualia_core_db/hypermedia/processors/
audio.rs1use std::collections::HashMap;
16
17use super::super::{fnv60, AssetRef, AssetRole, Descriptors, Processor, ProcessorOutput};
18
19#[derive(Debug, Clone, Default, PartialEq)]
21pub struct AudioSpectralSummary {
22 pub sample_rate: u32,
23 pub channels: u16,
24 pub duration_secs: f32,
25 pub dominant_hz: Option<f32>,
27}
28
29#[derive(Debug, Clone, Default)]
32pub struct WavProcessor;
33
34const FRAME_SIZE: usize = 256;
35const HOP: usize = 128;
36
37impl WavProcessor {
38 pub fn analyse(bytes: &[u8]) -> Option<AudioSpectralSummary> {
40 let wav = parse_wav(bytes)?;
41 let duration_secs = if wav.sample_rate > 0 {
42 wav.samples.len() as f32 / wav.sample_rate as f32
43 } else {
44 0.0
45 };
46 let dominant_hz = dominant_frequency(&wav.samples, wav.sample_rate);
47 Some(AudioSpectralSummary {
48 sample_rate: wav.sample_rate,
49 channels: wav.channels,
50 duration_secs,
51 dominant_hz,
52 })
53 }
54}
55
56impl Processor for WavProcessor {
57 fn handles(&self, media_type: &str) -> bool {
58 matches!(media_type, "audio/wav" | "audio/x-wav" | "audio/wave")
59 }
60
61 fn process(&self, asset_uri: &str, bytes: &[u8], _media_type: &str) -> ProcessorOutput {
62 let summary = WavProcessor::analyse(bytes).unwrap_or_default();
63
64 let mut topics = vec!["audio".to_string()];
65 if let Some(hz) = summary.dominant_hz {
66 topics.push(pitch_band(hz).to_string());
68 }
69
70 let mut text = format!(
71 "audio recording; {:.2}s; {} Hz; {} channel(s)",
72 summary.duration_secs, summary.sample_rate, summary.channels
73 );
74 if let Some(hz) = summary.dominant_hz {
75 text.push_str(&format!("; dominant {hz:.0} Hz"));
76 }
77
78 let descriptors = Descriptors {
79 topics,
80 document_type: Some("audio".to_string()),
81 ..Default::default()
82 };
83
84 let meta_uri = format!("{asset_uri}#acoustic");
85 let derived = vec![AssetRef::new(
86 &meta_uri,
87 fnv60(text.as_bytes()),
88 "text/plain",
89 AssetRole::Analysis,
90 )
91 .derived_from(asset_uri)];
92 let mut derived_bytes = HashMap::new();
93 derived_bytes.insert(meta_uri, text.into_bytes());
94
95 ProcessorOutput {
96 derived,
97 derived_bytes,
98 descriptors,
99 flags: Vec::new(),
100 }
101 }
102}
103
104fn pitch_band(hz: f32) -> &'static str {
105 match hz {
106 h if h < 250.0 => "low-frequency",
107 h if h < 2000.0 => "mid-frequency",
108 _ => "high-frequency",
109 }
110}
111
112struct WavData {
113 sample_rate: u32,
114 channels: u16,
115 samples: Vec<f32>,
117}
118
119fn parse_wav(b: &[u8]) -> Option<WavData> {
122 if b.len() < 12 || &b[0..4] != b"RIFF" || &b[8..12] != b"WAVE" {
123 return None;
124 }
125 let mut fmt: Option<(u16, u16, u32, u16)> = None; let mut data: Option<&[u8]> = None;
127 let mut i = 12;
128 while i + 8 <= b.len() {
129 let id = &b[i..i + 4];
130 let size = u32::from_le_bytes([b[i + 4], b[i + 5], b[i + 6], b[i + 7]]) as usize;
131 let start = i + 8;
132 let end = start.checked_add(size)?.min(b.len());
133 match id {
134 b"fmt " if end - start >= 16 => {
135 let c = &b[start..end];
136 let audio_format = u16::from_le_bytes([c[0], c[1]]);
137 let channels = u16::from_le_bytes([c[2], c[3]]);
138 let sample_rate = u32::from_le_bytes([c[4], c[5], c[6], c[7]]);
139 let bits = u16::from_le_bytes([c[14], c[15]]);
140 fmt = Some((audio_format, channels, sample_rate, bits));
141 }
142 b"data" => data = Some(&b[start..end]),
143 _ => {}
144 }
145 i = start + size + (size & 1);
147 }
148
149 let (audio_format, channels, sample_rate, bits) = fmt?;
150 let data = data?;
151 let channels = channels.max(1);
152 let ch = channels as usize;
153
154 let mut samples = Vec::new();
155 match (audio_format, bits) {
156 (1, 16) => {
157 let frame = 2 * ch;
159 let mut o = 0;
160 while o + 2 <= data.len() {
161 let v = i16::from_le_bytes([data[o], data[o + 1]]) as f32 / 32768.0;
162 samples.push(v);
163 o += frame; }
165 }
166 (1, 8) => {
167 let frame = ch;
168 let mut o = 0;
169 while o < data.len() {
170 let v = (data[o] as f32 - 128.0) / 128.0;
171 samples.push(v);
172 o += frame;
173 }
174 }
175 (1, 32) => {
176 let frame = 4 * ch;
177 let mut o = 0;
178 while o + 4 <= data.len() {
179 let raw = i32::from_le_bytes([data[o], data[o + 1], data[o + 2], data[o + 3]]);
180 samples.push(raw as f32 / 2_147_483_648.0);
181 o += frame;
182 }
183 }
184 (3, 32) => {
185 let frame = 4 * ch;
186 let mut o = 0;
187 while o + 4 <= data.len() {
188 let v = f32::from_le_bytes([data[o], data[o + 1], data[o + 2], data[o + 3]]);
189 samples.push(v);
190 o += frame;
191 }
192 }
193 _ => return None, }
195
196 Some(WavData {
197 sample_rate,
198 channels,
199 samples,
200 })
201}
202
203fn dominant_frequency(samples: &[f32], sample_rate: u32) -> Option<f32> {
207 if samples.len() < FRAME_SIZE || sample_rate == 0 {
208 return None;
209 }
210 let spec = crate::audio::forward_stft(samples, FRAME_SIZE, HOP).ok()?;
211 if spec.is_empty() {
212 return None;
213 }
214 let mags = crate::audio::stft_magnitudes(&spec);
215 let half = FRAME_SIZE / 2;
218 let mut acc = vec![0.0f32; half];
219 for frame in &mags {
220 for (k, a) in acc.iter_mut().enumerate() {
221 if let Some(m) = frame.get(k) {
222 *a += *m;
223 }
224 }
225 }
226 let (mut best_k, mut best_v) = (0usize, -1.0f32);
228 for (k, &v) in acc.iter().enumerate().skip(1) {
229 if v > best_v {
230 best_v = v;
231 best_k = k;
232 }
233 }
234 if best_v <= 0.0 {
235 return None;
236 }
237 Some(best_k as f32 * sample_rate as f32 / FRAME_SIZE as f32)
238}
239
240#[cfg(test)]
241mod tests {
242 use super::super::super::{by_topic, ingest_with};
243 use super::*;
244 use std::f32::consts::TAU;
245
246 fn sine_wav(freq: f32, sample_rate: u32, secs: f32) -> Vec<u8> {
248 let n = (sample_rate as f32 * secs) as usize;
249 let mut data = Vec::with_capacity(n * 2);
250 for i in 0..n {
251 let t = i as f32 / sample_rate as f32;
252 let s = (TAU * freq * t).sin();
253 let q = (s * 32767.0) as i16;
254 data.extend_from_slice(&q.to_le_bytes());
255 }
256 let byte_rate = sample_rate * 2;
257 let mut b = Vec::new();
258 b.extend_from_slice(b"RIFF");
259 b.extend_from_slice(&(36 + data.len() as u32).to_le_bytes());
260 b.extend_from_slice(b"WAVE");
261 b.extend_from_slice(b"fmt ");
263 b.extend_from_slice(&16u32.to_le_bytes());
264 b.extend_from_slice(&1u16.to_le_bytes()); b.extend_from_slice(&1u16.to_le_bytes()); b.extend_from_slice(&sample_rate.to_le_bytes());
267 b.extend_from_slice(&byte_rate.to_le_bytes());
268 b.extend_from_slice(&2u16.to_le_bytes()); b.extend_from_slice(&16u16.to_le_bytes()); b.extend_from_slice(b"data");
272 b.extend_from_slice(&(data.len() as u32).to_le_bytes());
273 b.extend_from_slice(&data);
274 b
275 }
276
277 #[test]
278 fn wav_header_decodes_duration_and_channels() {
279 let wav = sine_wav(440.0, 8000, 0.5);
280 let s = WavProcessor::analyse(&wav).expect("wav parsed");
281 assert_eq!(s.sample_rate, 8000);
282 assert_eq!(s.channels, 1);
283 assert!(
284 (s.duration_secs - 0.5).abs() < 0.02,
285 "≈0.5s, got {}",
286 s.duration_secs
287 );
288 }
289
290 #[test]
291 fn stft_finds_the_dominant_tone() {
292 let wav = sine_wav(1000.0, 8000, 0.5);
294 let s = WavProcessor::analyse(&wav).expect("wav parsed");
295 let hz = s.dominant_hz.expect("dominant frequency");
296 assert!((hz - 1000.0).abs() < 80.0, "dominant ≈1000 Hz, got {hz}");
298 }
299
300 #[test]
301 fn audio_ingest_is_findable_by_topic() {
302 let wav = sine_wav(440.0, 8000, 0.3);
303 let proc = WavProcessor;
304 assert!(proc.handles("audio/wav"));
305 let r = ingest_with(&proc, "urn:audio:clip", "audio/wav", 0xA0D10, &wav);
306 let subj = r.container.primary.subject();
307 assert!(
308 by_topic(&r.quins, "audio").contains(&subj),
309 "findable as audio"
310 );
311 }
312}