Skip to main content

max / audiofiles

20.2 KB · 510 lines History Blame Raw
1 //! End-to-end integration test: import real audio -> analyze -> search -> tag -> export.
2 //!
3 //! Generates a valid 16-bit PCM WAV sine wave programmatically (no external files),
4 //! then exercises the full pipeline: content-addressed import, audio analysis,
5 //! VFS organisation, search by text/properties/tags, and export to filesystem.
6
7 use std::f32::consts::PI;
8 use std::fs;
9 use std::path::{Path, PathBuf};
10
11 use audiofiles_core::analysis::{self, config::AnalysisConfig};
12 use audiofiles_core::db::Database;
13 use audiofiles_core::export::{
14 collect_export_items, enrich_with_tags, run_export, ExportChannels, ExportConfig,
15 ExportFormat,
16 };
17 use audiofiles_core::search::{SearchFilter, SearchScope};
18 use audiofiles_core::store::SampleStore;
19 use audiofiles_core::{search, tags, vfs};
20
21 /// Generate a 16-bit PCM WAV file containing a sine wave.
22 ///
23 /// Parameters:
24 /// - `path`: output file path
25 /// - `sample_rate`: e.g. 44100
26 /// - `frequency`: sine wave frequency in Hz (e.g. 440.0)
27 /// - `duration_secs`: length of the generated audio
28 fn generate_sine_wav(path: &Path, sample_rate: u32, frequency: f32, duration_secs: f32) {
29 let spec = hound::WavSpec {
30 channels: 1,
31 sample_rate,
32 bits_per_sample: 16,
33 sample_format: hound::SampleFormat::Int,
34 };
35 let mut writer = hound::WavWriter::create(path, spec).unwrap();
36 let num_samples = (sample_rate as f32 * duration_secs) as usize;
37 let amplitude = 0.8_f32; // -1.94 dBFS peak
38
39 for i in 0..num_samples {
40 let t = i as f32 / sample_rate as f32;
41 let sample = amplitude * (2.0 * PI * frequency * t).sin();
42 // Scale to 16-bit range
43 let scaled = (sample * i16::MAX as f32) as i16;
44 writer.write_sample(scaled).unwrap();
45 }
46 writer.finalize().unwrap();
47 }
48
49 /// Helper: set up a temp directory, database, sample store, and a generated WAV file.
50 struct TestEnv {
51 dir: tempfile::TempDir,
52 db: Database,
53 store: SampleStore,
54 wav_path: PathBuf,
55 }
56
57 impl TestEnv {
58 fn new() -> Self {
59 let dir = tempfile::tempdir().unwrap();
60 let db = Database::open_in_memory().unwrap();
61 let store_dir = dir.path().join("store");
62 let store = SampleStore::new(&store_dir).unwrap();
63
64 // Generate a ~0.5-second 440 Hz sine wave at 44100 Hz, 16-bit mono
65 let wav_path = dir.path().join("test_sine_440.wav");
66 generate_sine_wav(&wav_path, 44100, 440.0, 0.5);
67
68 Self {
69 dir,
70 db,
71 store,
72 wav_path,
73 }
74 }
75 }
76
77 #[test]
78 fn e2e_import_analyze_search_tag_export() {
79 let env = TestEnv::new();
80
81 // ── Step 1: Import ──────────────────────────────────────────
82 // Import the generated WAV into the content-addressed store.
83 let hash = env.store.import(&env.wav_path, &env.db).unwrap();
84
85 // Verify the hash is a valid 64-char lowercase hex SHA-256.
86 assert_eq!(hash.len(), 64);
87 assert!(hash.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()));
88
89 // Verify the file exists in the store.
90 assert!(env.store.exists(&hash, "wav").unwrap());
91
92 // Verify the sample row exists in the database.
93 let row_count: i64 = env
94 .db
95 .conn()
96 .query_row(
97 "SELECT COUNT(*) FROM samples WHERE hash = ?1",
98 [&hash],
99 |row| row.get(0),
100 )
101 .unwrap();
102 assert_eq!(row_count, 1);
103
104 // Verify deduplication: importing again yields the same hash.
105 let hash2 = env.store.import(&env.wav_path, &env.db).unwrap();
106 assert_eq!(hash, hash2);
107
108 // ── Step 2: Create VFS and link sample ──────────────────────
109 let vfs_id = vfs::create_vfs(&env.db, "TestLibrary").unwrap();
110 let drums_dir = vfs::create_directory(&env.db, vfs_id, None, "Drums").unwrap();
111 let node_id = vfs::create_sample_link(
112 &env.db,
113 vfs_id,
114 Some(drums_dir),
115 "test_sine_440.wav",
116 &hash,
117 )
118 .unwrap();
119
120 // Verify the VFS node was created correctly.
121 let node = vfs::get_node(&env.db, node_id).unwrap();
122 assert_eq!(node.name, "test_sine_440.wav");
123 assert_eq!(node.sample_hash.as_deref(), Some(hash.as_str()));
124
125 // Verify listing children works.
126 let children = vfs::list_children(&env.db, vfs_id, Some(drums_dir)).unwrap();
127 assert_eq!(children.len(), 1);
128 assert_eq!(children[0].name, "test_sine_440.wav");
129
130 // ── Step 3: Run audio analysis ──────────────────────────────
131 let store_path = env.store.sample_path(&hash, "wav").unwrap();
132 let config = AnalysisConfig::default();
133 let result = analysis::analyze_sample(&hash, &store_path, &config).unwrap();
134
135 // Verify basic properties from the generated file.
136 assert_eq!(result.hash, hash);
137 assert_eq!(result.sample_rate, 44100);
138 assert_eq!(result.channels, 1);
139
140 // Duration should be approximately 0.5 seconds.
141 assert!(
142 (result.duration - 0.5).abs() < 0.02,
143 "expected duration ~0.5s, got {}",
144 result.duration
145 );
146
147 // Peak should be close to -1.94 dBFS (0.8 amplitude).
148 assert!(result.peak_db.is_some());
149 let peak = result.peak_db.unwrap();
150 assert!(
151 (peak - (-1.94)).abs() < 0.5,
152 "expected peak ~-1.94 dBFS, got {peak}"
153 );
154
155 // RMS should be about -4.95 dBFS (sine at 0.8 amplitude).
156 assert!(result.rms_db.is_some());
157
158 // BPM may or may not be detected on a short sine wave, but the field should exist.
159 // Musical key should be detected for a pure 440 Hz tone (A).
160 // These are best-effort checks; the exact values depend on the algorithms.
161
162 // Save analysis to DB.
163 analysis::save_analysis(&env.db, &result).unwrap();
164
165 // Verify analysis is retrievable.
166 let loaded = analysis::load_analysis(&env.db, &hash);
167 assert!(loaded.is_some());
168 let loaded = loaded.unwrap();
169 assert_eq!(loaded.hash, hash);
170 assert_eq!(loaded.sample_rate, 44100);
171 assert!(
172 (loaded.duration - result.duration).abs() < 0.001,
173 "loaded duration should match saved"
174 );
175 assert_eq!(loaded.peak_db, result.peak_db);
176
177 // ── Step 4: Search by text (filename) ───────────────────────
178 let filter = SearchFilter {
179 text_query: "sine".to_string(),
180 ..Default::default()
181 };
182 let results = search::search_in_folder(&env.db, &filter, vfs_id, Some(drums_dir)).unwrap();
183 assert_eq!(results.len(), 1);
184 assert_eq!(results[0].node.name, "test_sine_440.wav");
185
186 // Text search that doesn't match should return empty.
187 let filter_miss = SearchFilter {
188 text_query: "nonexistent_sample".to_string(),
189 ..Default::default()
190 };
191 let results_miss =
192 search::search_in_folder(&env.db, &filter_miss, vfs_id, Some(drums_dir)).unwrap();
193 assert!(results_miss.is_empty());
194
195 // ── Step 5: Search by analysis properties ───────────────────
196 // Duration filter: our sample is ~0.5s, so filtering 0.3-0.7 should match.
197 let filter_duration = SearchFilter {
198 duration_min: Some(0.3),
199 duration_max: Some(0.7),
200 ..Default::default()
201 };
202 let results_dur =
203 search::search_in_folder(&env.db, &filter_duration, vfs_id, Some(drums_dir)).unwrap();
204 assert_eq!(results_dur.len(), 1);
205
206 // Duration filter that excludes: minimum 2.0s should find nothing.
207 let filter_long = SearchFilter {
208 duration_min: Some(2.0),
209 ..Default::default()
210 };
211 let results_long =
212 search::search_in_folder(&env.db, &filter_long, vfs_id, Some(drums_dir)).unwrap();
213 assert!(results_long.is_empty());
214
215 // Classification filter: if the analysis classified the sample, search for it.
216 if let Some(ref class) = result.classification {
217 let filter_class = SearchFilter {
218 classifications: vec![class.as_str().to_string()],
219 ..Default::default()
220 };
221 let results_class =
222 search::search_in_folder(&env.db, &filter_class, vfs_id, Some(drums_dir)).unwrap();
223 assert_eq!(results_class.len(), 1);
224 }
225
226 // ── Step 6: Global search ───────────────────────────────────
227 let global_filter = SearchFilter {
228 text_query: "sine".to_string(),
229 scope: SearchScope::Global,
230 ..Default::default()
231 };
232 let global_results = search::search_global(&env.db, &global_filter).unwrap();
233 assert_eq!(global_results.len(), 1);
234 assert_eq!(global_results[0].node.name, "test_sine_440.wav");
235
236 // ── Step 7: Tag the sample and search by tags ───────────────
237 tags::add_tag(&env.db, &hash, "instrument.synth").unwrap();
238 tags::add_tag(&env.db, &hash, "frequency.440hz").unwrap();
239 tags::add_tag(&env.db, &hash, "test").unwrap();
240
241 // Verify tags are stored.
242 let sample_tags = tags::get_sample_tags(&env.db, &hash).unwrap();
243 assert_eq!(sample_tags.len(), 3);
244 assert!(sample_tags.contains(&"instrument.synth".to_string()));
245 assert!(sample_tags.contains(&"frequency.440hz".to_string()));
246 assert!(sample_tags.contains(&"test".to_string()));
247
248 // Search by tag prefix.
249 let filter_tag = SearchFilter {
250 required_tags: vec!["instrument".to_string()],
251 ..Default::default()
252 };
253 let results_tag =
254 search::search_in_folder(&env.db, &filter_tag, vfs_id, Some(drums_dir)).unwrap();
255 assert_eq!(results_tag.len(), 1);
256 assert_eq!(results_tag[0].node.name, "test_sine_440.wav");
257
258 // Search by a tag that doesn't match.
259 let filter_tag_miss = SearchFilter {
260 required_tags: vec!["percussion".to_string()],
261 ..Default::default()
262 };
263 let results_tag_miss =
264 search::search_in_folder(&env.db, &filter_tag_miss, vfs_id, Some(drums_dir)).unwrap();
265 assert!(results_tag_miss.is_empty());
266
267 // Combined: text + tag + duration.
268 let filter_combined = SearchFilter {
269 text_query: "sine".to_string(),
270 required_tags: vec!["test".to_string()],
271 duration_min: Some(0.3),
272 duration_max: Some(0.7),
273 ..Default::default()
274 };
275 let results_combined =
276 search::search_in_folder(&env.db, &filter_combined, vfs_id, Some(drums_dir)).unwrap();
277 assert_eq!(results_combined.len(), 1);
278
279 // ── Step 8: Export ──────────────────────────────────────────
280 let export_dest = env.dir.path().join("export_output");
281
282 // Collect export items from VFS.
283 let mut items = collect_export_items(&env.db, vfs_id, None).unwrap();
284 assert_eq!(items.len(), 1);
285 assert_eq!(items[0].name, "test_sine_440.wav");
286 assert_eq!(items[0].relative_path, PathBuf::from("Drums/test_sine_440.wav"));
287
288 // Enrich with tags.
289 enrich_with_tags(&env.db, &mut items);
290 assert_eq!(items[0].tags.len(), 3);
291
292 // Export as original format, preserving directory structure.
293 let export_config = ExportConfig {
294 format: ExportFormat::Original,
295 sample_rate: None,
296 bit_depth: None,
297 channels: ExportChannels::Original,
298 naming_pattern: None,
299 flatten: false,
300 metadata_sidecar: true,
301 destination: export_dest.clone(),
302 device_profile: None,
303 naming_rules: None,
304 max_file_size_bytes: None,
305 name_overrides: None,
306 };
307
308 let summary = run_export(&items, &export_config, &env.store, |_, _, _| true).unwrap();
309 assert_eq!(summary.total, 1);
310 assert!(summary.errors.is_empty(), "export errors: {:?}", summary.errors);
311
312 // Verify the exported file exists with correct directory structure.
313 let exported_file = export_dest.join("Drums").join("test_sine_440.wav");
314 assert!(exported_file.exists(), "exported file should exist at Drums/test_sine_440.wav");
315
316 // Verify the exported file content matches the store copy.
317 let store_bytes = fs::read(env.store.sample_path(&hash, "wav").unwrap()).unwrap();
318 let export_bytes = fs::read(&exported_file).unwrap();
319 assert_eq!(store_bytes, export_bytes, "exported file should match store content");
320
321 // Verify the metadata sidecar was written.
322 let sidecar_path = export_dest.join("Drums").join("test_sine_440.wav.audiofiles.json");
323 assert!(sidecar_path.exists(), "metadata sidecar should exist");
324
325 let sidecar_content: serde_json::Value =
326 serde_json::from_str(&fs::read_to_string(&sidecar_path).unwrap()).unwrap();
327 assert_eq!(sidecar_content["name"], "test_sine_440.wav");
328 assert!(sidecar_content["hash"].is_string());
329 // Tags should be present in the sidecar.
330 let sidecar_tags = sidecar_content["tags"].as_array().unwrap();
331 assert_eq!(sidecar_tags.len(), 3);
332
333 // ── Step 9: Export with format conversion (WAV 16-bit) ──────
334 let export_dest_wav = env.dir.path().join("export_wav16");
335 let export_config_wav = ExportConfig {
336 format: ExportFormat::Wav,
337 sample_rate: Some(44100),
338 bit_depth: Some(16),
339 channels: ExportChannels::Mono,
340 naming_pattern: None,
341 flatten: true,
342 metadata_sidecar: false,
343 destination: export_dest_wav.clone(),
344 device_profile: None,
345 naming_rules: None,
346 max_file_size_bytes: None,
347 name_overrides: None,
348 };
349
350 let summary_wav =
351 run_export(&items, &export_config_wav, &env.store, |_, _, _| true).unwrap();
352 assert!(summary_wav.errors.is_empty(), "WAV export errors: {:?}", summary_wav.errors);
353
354 let exported_wav = export_dest_wav.join("test_sine_440.wav");
355 assert!(exported_wav.exists(), "re-encoded WAV should exist");
356
357 // Verify it's a valid 16-bit WAV using hound.
358 let reader = hound::WavReader::open(&exported_wav).unwrap();
359 assert_eq!(reader.spec().bits_per_sample, 16);
360 assert_eq!(reader.spec().sample_rate, 44100);
361 assert_eq!(reader.spec().channels, 1);
362 }
363
364 /// Separate test to verify the analysis pipeline produces sensible spectral results
365 /// on a known signal, validating the full decode -> analyze -> persist -> retrieve loop.
366 #[test]
367 fn e2e_analysis_roundtrip_verify_values() {
368 let env = TestEnv::new();
369
370 let hash = env.store.import(&env.wav_path, &env.db).unwrap();
371 let store_path = env.store.sample_path(&hash, "wav").unwrap();
372
373 let config = AnalysisConfig::default();
374 let result = analysis::analyze_sample(&hash, &store_path, &config).unwrap();
375 analysis::save_analysis(&env.db, &result).unwrap();
376
377 // Load back and verify all fields survive the DB roundtrip.
378 let loaded = analysis::load_analysis(&env.db, &hash).unwrap();
379
380 assert_eq!(loaded.sample_rate, result.sample_rate);
381 assert_eq!(loaded.channels, result.channels);
382 assert!((loaded.duration - result.duration).abs() < 0.001);
383 assert_eq!(loaded.peak_db, result.peak_db);
384 assert_eq!(loaded.rms_db, result.rms_db);
385 assert_eq!(loaded.bpm, result.bpm);
386 assert_eq!(loaded.musical_key, result.musical_key);
387 assert_eq!(loaded.is_loop, result.is_loop);
388 assert_eq!(loaded.spectral_centroid, result.spectral_centroid);
389 assert_eq!(loaded.spectral_flatness, result.spectral_flatness);
390 assert_eq!(loaded.spectral_rolloff, result.spectral_rolloff);
391 assert_eq!(loaded.zero_crossing_rate, result.zero_crossing_rate);
392 assert_eq!(loaded.onset_strength, result.onset_strength);
393 assert_eq!(loaded.classification, result.classification);
394
395 // For a pure 440 Hz sine, spectral centroid should be close to 440 Hz.
396 if let Some(centroid) = result.spectral_centroid {
397 assert!(
398 (centroid - 440.0).abs() < 100.0,
399 "spectral centroid of 440Hz sine should be near 440Hz, got {centroid}"
400 );
401 }
402 }
403
404 /// Test that importing multiple different files, tagging them differently,
405 /// and searching with various filter combinations works correctly.
406 #[test]
407 fn e2e_multi_sample_search() {
408 let env = TestEnv::new();
409
410 // Generate a second WAV at a different frequency and longer duration.
411 let wav2_path = env.dir.path().join("bass_note_80hz.wav");
412 generate_sine_wav(&wav2_path, 44100, 80.0, 2.0);
413
414 // Import both.
415 let hash1 = env.store.import(&env.wav_path, &env.db).unwrap();
416 let hash2 = env.store.import(&wav2_path, &env.db).unwrap();
417 assert_ne!(hash1, hash2, "different audio should produce different hashes");
418
419 // Analyze both.
420 let config = AnalysisConfig::default();
421 let store_path1 = env.store.sample_path(&hash1, "wav").unwrap();
422 let result1 = analysis::analyze_sample(&hash1, &store_path1, &config).unwrap();
423 analysis::save_analysis(&env.db, &result1).unwrap();
424
425 let store_path2 = env.store.sample_path(&hash2, "wav").unwrap();
426 let result2 = analysis::analyze_sample(&hash2, &store_path2, &config).unwrap();
427 analysis::save_analysis(&env.db, &result2).unwrap();
428
429 // Create VFS with both samples.
430 let vfs_id = vfs::create_vfs(&env.db, "MultiTest").unwrap();
431 vfs::create_sample_link(&env.db, vfs_id, None, "test_sine_440.wav", &hash1).unwrap();
432 vfs::create_sample_link(&env.db, vfs_id, None, "bass_note_80hz.wav", &hash2).unwrap();
433
434 // Tag differently.
435 tags::add_tag(&env.db, &hash1, "instrument.synth").unwrap();
436 tags::add_tag(&env.db, &hash1, "frequency.high").unwrap();
437 tags::add_tag(&env.db, &hash2, "instrument.bass").unwrap();
438 tags::add_tag(&env.db, &hash2, "frequency.low").unwrap();
439
440 // Search: all samples (no filter).
441 let filter_all = SearchFilter::default();
442 let all = search::search_in_folder(&env.db, &filter_all, vfs_id, None).unwrap();
443 assert_eq!(all.len(), 2);
444
445 // Search by text: "bass" should match only one.
446 let filter_bass = SearchFilter {
447 text_query: "bass".to_string(),
448 ..Default::default()
449 };
450 let bass_results = search::search_in_folder(&env.db, &filter_bass, vfs_id, None).unwrap();
451 assert_eq!(bass_results.len(), 1);
452 assert_eq!(bass_results[0].node.name, "bass_note_80hz.wav");
453
454 // Search by duration: > 1.0s should match only the 2-second sample.
455 let filter_long = SearchFilter {
456 duration_min: Some(1.0),
457 ..Default::default()
458 };
459 let long_results = search::search_in_folder(&env.db, &filter_long, vfs_id, None).unwrap();
460 assert_eq!(long_results.len(), 1);
461 assert_eq!(long_results[0].node.name, "bass_note_80hz.wav");
462
463 // Search by duration: < 1.0s should match only the 0.5-second sample.
464 let filter_short = SearchFilter {
465 duration_max: Some(1.0),
466 ..Default::default()
467 };
468 let short_results = search::search_in_folder(&env.db, &filter_short, vfs_id, None).unwrap();
469 assert_eq!(short_results.len(), 1);
470 assert_eq!(short_results[0].node.name, "test_sine_440.wav");
471
472 // Search by tag: "instrument.synth" should match only the first.
473 let filter_synth = SearchFilter {
474 required_tags: vec!["instrument.synth".to_string()],
475 ..Default::default()
476 };
477 let synth_results = search::search_in_folder(&env.db, &filter_synth, vfs_id, None).unwrap();
478 assert_eq!(synth_results.len(), 1);
479 assert_eq!(synth_results[0].node.name, "test_sine_440.wav");
480
481 // Search by tag prefix "instrument" should match both.
482 let filter_instrument = SearchFilter {
483 required_tags: vec!["instrument".to_string()],
484 ..Default::default()
485 };
486 let instrument_results =
487 search::search_in_folder(&env.db, &filter_instrument, vfs_id, None).unwrap();
488 assert_eq!(instrument_results.len(), 2);
489
490 // Combined: text "bass" + tag "frequency.low" should match one.
491 let filter_combo = SearchFilter {
492 text_query: "bass".to_string(),
493 required_tags: vec!["frequency.low".to_string()],
494 ..Default::default()
495 };
496 let combo_results = search::search_in_folder(&env.db, &filter_combo, vfs_id, None).unwrap();
497 assert_eq!(combo_results.len(), 1);
498 assert_eq!(combo_results[0].node.name, "bass_note_80hz.wav");
499
500 // Combined: text "bass" + tag "frequency.high" should match zero (contradictory).
501 let filter_contradictory = SearchFilter {
502 text_query: "bass".to_string(),
503 required_tags: vec!["frequency.high".to_string()],
504 ..Default::default()
505 };
506 let contradictory_results =
507 search::search_in_folder(&env.db, &filter_contradictory, vfs_id, None).unwrap();
508 assert!(contradictory_results.is_empty());
509 }
510