1use std::f32::consts::PI;
2use std::io::BufWriter;
3use std::path::Path;
4
5use anyhow::{ensure, Result};
6use hound::{SampleFormat, WavSpec, WavWriter};
7use serde::Serialize;
8
9use crate::postprocessing::BeatResult;
10
11const CLICK_SAMPLE_RATE: u32 = 44100;
13const CLICK_DURATION: f32 = 0.1; const CLICK_ATTACK: f32 = 0.01; const CLICK_DECAY: f32 = 0.05; const DOWNBEAT_FREQ: f32 = 880.0; const BEAT_FREQ: f32 = 440.0; const ORIGINAL_GAIN: f32 = 0.7;
21const CLICK_GAIN: f32 = 0.3;
22
23pub fn beat_counts(result: &BeatResult) -> Vec<i32> {
29 let mut counts = Vec::with_capacity(result.beats.len());
30 let mut counter = 0i32;
31
32 for &beat_time in &result.beats {
33 if is_downbeat(beat_time, &result.downbeats) {
34 counter = 1;
35 } else {
36 counter += 1;
37 }
38 counts.push(counter);
39 }
40
41 counts
42}
43
44fn is_downbeat(beat_time: f32, downbeats: &[f32]) -> bool {
46 downbeats.iter().any(|&db| (beat_time - db).abs() < 0.001)
47}
48
49pub fn write_beats_file(path: &Path, result: &BeatResult) -> Result<()> {
54 use std::io::Write;
55
56 let counts = beat_counts(result);
57 let file = std::fs::File::create(path)?;
58 let mut writer = BufWriter::new(file);
59
60 for (&time, &count) in result.beats.iter().zip(counts.iter()) {
61 writeln!(writer, "{:.3}\t{}", time, count)?;
62 }
63
64 Ok(())
65}
66
67pub fn write_click_track(path: &Path, result: &BeatResult) -> Result<()> {
73 ensure!(!result.beats.is_empty(), "No beats to generate click track");
74
75 let counts = beat_counts(result);
76 let total_duration = result.beats.last().unwrap() + CLICK_DURATION + CLICK_DECAY;
77 let total_samples = (total_duration * CLICK_SAMPLE_RATE as f32) as usize;
78 let mut buffer = vec![0.0f32; total_samples];
79
80 for (&beat_time, &count) in result.beats.iter().zip(counts.iter()) {
81 let freq = if count == 1 { DOWNBEAT_FREQ } else { BEAT_FREQ };
82 let click = generate_sine_click(freq, CLICK_SAMPLE_RATE);
83 let start = (beat_time * CLICK_SAMPLE_RATE as f32) as usize;
84 mix_into(&mut buffer, &click, start);
85 }
86
87 normalize(&mut buffer);
88 write_wav(path, &buffer, CLICK_SAMPLE_RATE)
89}
90
91pub fn write_mixed_audio(
96 path: &Path,
97 result: &BeatResult,
98 original_samples: &[f32],
99 sample_rate: u32,
100) -> Result<()> {
101 ensure!(!result.beats.is_empty(), "No beats to generate mixed audio");
102
103 let counts = beat_counts(result);
104 let original_duration = original_samples.len() as f32 / sample_rate as f32;
105 let last_beat_end = result.beats.last().unwrap() + CLICK_DURATION + CLICK_DECAY;
106 let total_duration = original_duration.max(last_beat_end);
107 let total_samples = (total_duration * sample_rate as f32) as usize;
108
109 let mut buffer = vec![0.0f32; total_samples];
110
111 for (i, &sample) in original_samples.iter().enumerate() {
113 if i < buffer.len() {
114 buffer[i] = sample * ORIGINAL_GAIN;
115 }
116 }
117
118 for (&beat_time, &count) in result.beats.iter().zip(counts.iter()) {
120 let freq = if count == 1 { DOWNBEAT_FREQ } else { BEAT_FREQ };
121 let click = generate_sine_click(freq, sample_rate);
122 let start = (beat_time * sample_rate as f32) as usize;
123 mix_into_scaled(&mut buffer, &click, start, CLICK_GAIN);
124 }
125
126 normalize(&mut buffer);
127 write_wav(path, &buffer, sample_rate)
128}
129
130pub fn calculate_bpm(result: &BeatResult) -> Option<f32> {
135 if result.beats.len() < 2 {
136 return None;
137 }
138
139 let mut intervals: Vec<f32> = result
140 .beats
141 .windows(2)
142 .map(|w| w[1] - w[0])
143 .filter(|&iv| iv > 0.1 && iv < 3.0)
144 .collect();
145
146 if intervals.is_empty() {
147 return None;
148 }
149
150 intervals.sort_by(|a, b| a.total_cmp(b));
151 let n = intervals.len();
152 let median = if n.is_multiple_of(2) {
153 (intervals[n / 2 - 1] + intervals[n / 2]) / 2.0
154 } else {
155 intervals[n / 2]
156 };
157
158 Some(60.0 / median)
159}
160
161#[derive(Serialize)]
163pub struct BeatEntry {
164 pub time: f32,
166 pub beat: i32,
168 pub downbeat: bool,
170}
171
172#[derive(Serialize)]
174pub struct JsonOutput {
175 pub beats: Vec<BeatEntry>,
176 pub downbeats: Vec<f32>,
177 pub bpm: Option<f32>,
178}
179
180pub fn build_json_output(result: &BeatResult) -> JsonOutput {
182 let counts = beat_counts(result);
183 let beats = result
184 .beats
185 .iter()
186 .zip(counts.iter())
187 .map(|(&time, &beat)| BeatEntry {
188 time,
189 beat,
190 downbeat: beat == 1,
191 })
192 .collect();
193
194 JsonOutput {
195 beats,
196 downbeats: result.downbeats.clone(),
197 bpm: calculate_bpm(result),
198 }
199}
200
201pub fn print_json_stdout(result: &BeatResult) -> Result<()> {
203 let output = build_json_output(result);
204 let json = serde_json::to_string_pretty(&output)?;
205 println!("{}", json);
206 Ok(())
207}
208
209pub fn write_json_file(path: &Path, result: &BeatResult) -> Result<()> {
211 let output = build_json_output(result);
212 let file = std::fs::File::create(path)?;
213 let writer = BufWriter::new(file);
214 serde_json::to_writer_pretty(writer, &output)?;
215 Ok(())
216}
217
218#[derive(Serialize)]
220pub struct BatchFileEntry {
221 pub input: String,
222 pub duration_secs: f32,
223 pub processing_time_secs: f32,
224 pub outputs: Vec<String>,
225}
226
227#[derive(Serialize)]
229pub struct BatchSummary {
230 pub total_files: usize,
231 pub failed_files: usize,
232 pub total_duration_secs: f32,
233 pub total_processing_time_secs: f32,
234 pub model_loading_time_secs: f32,
235 pub realtime_factor: f32,
236}
237
238#[derive(Serialize)]
240pub struct BatchSummaryOutput {
241 pub files: Vec<BatchFileEntry>,
242 pub summary: BatchSummary,
243}
244
245pub fn write_batch_json(path: &Path, output: &BatchSummaryOutput) -> Result<()> {
247 let file = std::fs::File::create(path)?;
248 let writer = BufWriter::new(file);
249 serde_json::to_writer_pretty(writer, output)?;
250 Ok(())
251}
252
253fn generate_sine_click(frequency: f32, sample_rate: u32) -> Vec<f32> {
255 let num_samples = (CLICK_DURATION * sample_rate as f32) as usize;
256 let attack_samples = (CLICK_ATTACK * sample_rate as f32) as usize;
257 let decay_samples = (CLICK_DECAY * sample_rate as f32) as usize;
258
259 let mut waveform = Vec::with_capacity(num_samples);
260
261 for i in 0..num_samples {
262 let t = i as f32 / sample_rate as f32;
263 let amplitude = if i < attack_samples {
264 i as f32 / attack_samples as f32
266 } else if i > num_samples - decay_samples {
267 (num_samples - i) as f32 / decay_samples as f32
269 } else {
270 1.0
271 };
272 waveform.push(amplitude * (2.0 * PI * frequency * t).sin());
273 }
274
275 waveform
276}
277
278fn mix_into(dst: &mut [f32], src: &[f32], offset: usize) {
280 for (i, &sample) in src.iter().enumerate() {
281 let pos = offset + i;
282 if pos < dst.len() {
283 dst[pos] += sample;
284 }
285 }
286}
287
288fn mix_into_scaled(dst: &mut [f32], src: &[f32], offset: usize, gain: f32) {
290 for (i, &sample) in src.iter().enumerate() {
291 let pos = offset + i;
292 if pos < dst.len() {
293 dst[pos] += sample * gain;
294 }
295 }
296}
297
298fn normalize(buffer: &mut [f32]) {
300 let max_val = buffer.iter().fold(0.0f32, |m, &s| m.max(s.abs()));
301 if max_val > 1.0 {
302 let scale = 1.0 / max_val;
303 for sample in buffer.iter_mut() {
304 *sample *= scale;
305 }
306 }
307}
308
309fn write_wav(path: &Path, samples: &[f32], sample_rate: u32) -> Result<()> {
311 let spec = WavSpec {
312 channels: 1,
313 sample_rate,
314 bits_per_sample: 32,
315 sample_format: SampleFormat::Float,
316 };
317
318 let file = std::fs::File::create(path)?;
319 let buf = BufWriter::new(file);
320 let mut writer = WavWriter::new(buf, spec)?;
321
322 for &sample in samples {
323 writer.write_sample(sample)?;
324 }
325
326 writer.finalize()?;
327 Ok(())
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333 use std::io::Read;
334
335 fn make_result(beats: Vec<f32>, downbeats: Vec<f32>) -> BeatResult {
336 BeatResult { beats, downbeats }
337 }
338
339 #[test]
340 fn test_beat_counts_basic() {
341 let result = make_result(vec![0.5, 1.0, 1.5, 2.0], vec![0.5]);
342 let counts = beat_counts(&result);
343 assert_eq!(counts, vec![1, 2, 3, 4]);
344 }
345
346 #[test]
347 fn test_beat_counts_multiple_downbeats() {
348 let result = make_result(vec![0.5, 1.0, 1.5, 2.0, 2.5, 3.0], vec![0.5, 2.0]);
349 let counts = beat_counts(&result);
350 assert_eq!(counts, vec![1, 2, 3, 1, 2, 3]);
351 }
352
353 #[test]
354 fn test_beat_counts_no_downbeats() {
355 let result = make_result(vec![0.5, 1.0, 1.5], vec![]);
356 let counts = beat_counts(&result);
357 assert_eq!(counts, vec![1, 2, 3]);
358 }
359
360 #[test]
361 fn test_beat_counts_beats_before_first_downbeat() {
362 let result = make_result(vec![0.5, 1.0, 1.5, 2.0], vec![1.5]);
363 let counts = beat_counts(&result);
364 assert_eq!(counts, vec![1, 2, 1, 2]);
365 }
366
367 #[test]
368 fn test_write_beats_file() {
369 let dir = tempfile::tempdir().unwrap();
370 let path = dir.path().join("test.beats");
371 let result = make_result(vec![0.1, 0.6, 1.1], vec![0.1]);
372
373 write_beats_file(&path, &result).unwrap();
374
375 let mut content = String::new();
376 std::fs::File::open(&path)
377 .unwrap()
378 .read_to_string(&mut content)
379 .unwrap();
380
381 let lines: Vec<&str> = content.lines().collect();
382 assert_eq!(lines.len(), 3);
383 assert_eq!(lines[0], "0.100\t1");
384 assert_eq!(lines[1], "0.600\t2");
385 assert_eq!(lines[2], "1.100\t3");
386 }
387
388 #[test]
389 fn test_write_click_track() {
390 let dir = tempfile::tempdir().unwrap();
391 let path = dir.path().join("clicks.wav");
392 let result = make_result(vec![0.1, 0.6, 1.1], vec![0.1]);
393
394 write_click_track(&path, &result).unwrap();
395
396 let reader = hound::WavReader::open(&path).unwrap();
398 let spec = reader.spec();
399 assert_eq!(spec.channels, 1);
400 assert_eq!(spec.sample_rate, CLICK_SAMPLE_RATE);
401 assert_eq!(spec.sample_format, SampleFormat::Float);
402 assert_eq!(spec.bits_per_sample, 32);
403
404 let samples: Vec<f32> = reader.into_samples::<f32>().map(|s| s.unwrap()).collect();
406 assert!(samples.iter().any(|&s| s.abs() > 0.01));
407 }
408
409 #[test]
410 fn test_write_mixed_audio() {
411 let dir = tempfile::tempdir().unwrap();
412 let path = dir.path().join("mixed.wav");
413 let result = make_result(vec![0.1, 0.6], vec![0.1]);
414
415 let original = vec![0.0f32; 44100 * 2];
417 write_mixed_audio(&path, &result, &original, 44100).unwrap();
418
419 let reader = hound::WavReader::open(&path).unwrap();
420 let spec = reader.spec();
421 assert_eq!(spec.channels, 1);
422 assert_eq!(spec.sample_rate, 44100);
423
424 let samples: Vec<f32> = reader.into_samples::<f32>().map(|s| s.unwrap()).collect();
425 assert!(samples.len() >= 44100 * 2);
427 assert!(samples.iter().any(|&s| s.abs() > 0.01));
429 }
430
431 #[test]
432 fn test_calculate_bpm_120() {
433 let result = make_result(vec![0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0], vec![0.0]);
435 let bpm = calculate_bpm(&result).unwrap();
436 assert!((bpm - 120.0).abs() < 0.1, "Expected ~120 BPM, got {}", bpm);
437 }
438
439 #[test]
440 fn test_calculate_bpm_too_few_beats() {
441 let result = make_result(vec![0.5], vec![]);
442 assert!(calculate_bpm(&result).is_none());
443 }
444
445 #[test]
446 fn test_calculate_bpm_empty() {
447 let result = make_result(vec![], vec![]);
448 assert!(calculate_bpm(&result).is_none());
449 }
450
451 #[test]
452 fn test_build_json_output() {
453 let result = make_result(vec![0.5, 1.0, 1.5, 2.0, 2.5, 3.0], vec![0.5, 2.0]);
454 let json_out = build_json_output(&result);
455
456 assert_eq!(json_out.beats.len(), 6);
457 assert_eq!(json_out.downbeats, vec![0.5, 2.0]);
458 assert!(json_out.bpm.is_some());
459
460 assert_eq!(json_out.beats[0].beat, 1);
462 assert!(json_out.beats[0].downbeat);
463
464 assert_eq!(json_out.beats[1].beat, 2);
466 assert!(!json_out.beats[1].downbeat);
467
468 assert_eq!(json_out.beats[3].beat, 1);
470 assert!(json_out.beats[3].downbeat);
471
472 let json_str = serde_json::to_string(&json_out).unwrap();
474 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
475 assert!(parsed["beats"].is_array());
476 assert!(parsed["downbeats"].is_array());
477 assert!(parsed["bpm"].is_number());
478 }
479
480 #[test]
481 fn test_write_batch_json() {
482 let dir = tempfile::tempdir().unwrap();
483 let path = dir.path().join("beat_this.json");
484
485 let batch = BatchSummaryOutput {
486 files: vec![
487 BatchFileEntry {
488 input: "song1.mp3".to_string(),
489 duration_secs: 120.0,
490 processing_time_secs: 1.5,
491 outputs: vec!["song1.json".to_string(), "song1.beats".to_string()],
492 },
493 BatchFileEntry {
494 input: "song2.wav".to_string(),
495 duration_secs: 60.0,
496 processing_time_secs: 0.8,
497 outputs: vec!["song2.json".to_string()],
498 },
499 ],
500 summary: BatchSummary {
501 total_files: 2,
502 failed_files: 0,
503 total_duration_secs: 180.0,
504 total_processing_time_secs: 2.3,
505 model_loading_time_secs: 0.04,
506 realtime_factor: 78.3,
507 },
508 };
509
510 write_batch_json(&path, &batch).unwrap();
511
512 let content = std::fs::read_to_string(&path).unwrap();
513 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
514
515 assert_eq!(parsed["files"].as_array().unwrap().len(), 2);
517 assert_eq!(parsed["files"][0]["input"], "song1.mp3");
518 assert_eq!(parsed["files"][0]["duration_secs"], 120.0);
519 assert_eq!(parsed["files"][0]["processing_time_secs"], 1.5);
520 assert_eq!(
521 parsed["files"][0]["outputs"],
522 serde_json::json!(["song1.json", "song1.beats"])
523 );
524 assert!(parsed["files"][0]["beats"].is_null());
526 assert!(parsed["files"][0]["bpm"].is_null());
527
528 assert_eq!(parsed["summary"]["total_files"], 2);
530 assert_eq!(parsed["summary"]["failed_files"], 0);
531 assert_eq!(parsed["summary"]["total_duration_secs"], 180.0);
532 assert_eq!(parsed["summary"]["realtime_factor"], 78.3);
533 }
534
535 #[test]
536 fn test_generate_sine_click() {
537 let click = generate_sine_click(440.0, 44100);
538 let expected_len = (CLICK_DURATION * 44100.0) as usize;
539 assert_eq!(click.len(), expected_len);
540
541 assert!(click[0].abs() < 0.01);
543 assert!(click.last().unwrap().abs() < 0.05);
545 let peak = click.iter().fold(0.0f32, |m, &s| m.max(s.abs()));
547 assert!(
548 peak > 0.9,
549 "Peak amplitude should be near 1.0, got {}",
550 peak
551 );
552 }
553}