Skip to main content

beat_this/
output.rs

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
11// Click synthesis constants
12const CLICK_SAMPLE_RATE: u32 = 44100;
13const CLICK_DURATION: f32 = 0.1; // 100ms
14const CLICK_ATTACK: f32 = 0.01; // 10ms
15const CLICK_DECAY: f32 = 0.05; // 50ms
16const DOWNBEAT_FREQ: f32 = 880.0; // A5
17const BEAT_FREQ: f32 = 440.0; // A4
18
19// Mixing gains
20const ORIGINAL_GAIN: f32 = 0.7;
21const CLICK_GAIN: f32 = 0.3;
22
23/// Compute beat counts from beat and downbeat timestamps.
24///
25/// Returns a `Vec<i32>` parallel to `result.beats`:
26/// - 1 for downbeats
27/// - 2, 3, 4, ... for subsequent beats within each measure
28pub 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
44/// Check if a beat time corresponds to a downbeat (within tolerance).
45fn is_downbeat(beat_time: f32, downbeats: &[f32]) -> bool {
46    downbeats.iter().any(|&db| (beat_time - db).abs() < 0.001)
47}
48
49/// Write a `.beats` file: tab-separated `time\tbeat_count` per line.
50///
51/// Beat count is 1 for downbeats, 2..N for subsequent beats in the measure.
52/// Times are formatted with 3 decimal places.
53pub 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
67/// Generate a click-track WAV file.
68///
69/// Downbeats get an 880 Hz click, regular beats get 440 Hz.
70/// Each click is a 100ms sine wave with ADSR envelope.
71/// Output is mono 44100 Hz 32-bit float WAV.
72pub 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
91/// Generate a mixed WAV file: original audio + click track layered on top.
92///
93/// Original audio is scaled to 0.7 gain, clicks are added at 0.3 gain.
94/// Output preserves the original sample rate. Output is mono.
95pub 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    // Copy original audio scaled down
112    for (i, &sample) in original_samples.iter().enumerate() {
113        if i < buffer.len() {
114            buffer[i] = sample * ORIGINAL_GAIN;
115        }
116    }
117
118    // Add clicks
119    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
130/// Calculate BPM from beat timestamps using median inter-beat interval.
131///
132/// Filters out unrealistic intervals (<0.1s or >3.0s, i.e. outside 20–600 BPM).
133/// Returns `None` if fewer than 2 valid intervals exist.
134pub 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/// A single beat entry for JSON output.
162#[derive(Serialize)]
163pub struct BeatEntry {
164    /// Beat timestamp in seconds.
165    pub time: f32,
166    /// Beat count within the measure (1 = downbeat).
167    pub beat: i32,
168    /// Whether this beat is a downbeat.
169    pub downbeat: bool,
170}
171
172/// Top-level JSON output structure.
173#[derive(Serialize)]
174pub struct JsonOutput {
175    pub beats: Vec<BeatEntry>,
176    pub downbeats: Vec<f32>,
177    pub bpm: Option<f32>,
178}
179
180/// Build the JSON output structure from a `BeatResult`.
181pub 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
201/// Write JSON output to stdout.
202pub 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
209/// Write JSON output to a file.
210pub 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/// Per-file entry in batch summary JSON (no beat data, just metadata).
219#[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/// Aggregate metrics for batch processing.
228#[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/// Top-level batch summary JSON (process metadata only).
239#[derive(Serialize)]
240pub struct BatchSummaryOutput {
241    pub files: Vec<BatchFileEntry>,
242    pub summary: BatchSummary,
243}
244
245/// Write batch summary as pretty-printed JSON to a file.
246pub 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
253/// Generate a single ADSR-enveloped sine click.
254fn 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            // Attack: linear ramp up
265            i as f32 / attack_samples as f32
266        } else if i > num_samples - decay_samples {
267            // Decay: linear ramp down
268            (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
278/// Additively mix `src` into `dst` starting at `offset`.
279fn 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
288/// Additively mix `src` into `dst` starting at `offset`, scaled by `gain`.
289fn 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
298/// Normalize buffer in-place if any sample exceeds ±1.0.
299fn 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
309/// Write a mono f32 WAV file.
310fn 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        // Verify WAV file is valid
397        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        // Verify non-zero samples exist
405        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        // 2 seconds of silence at 44100 Hz
416        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        // Should have at least as many samples as original
426        assert!(samples.len() >= 44100 * 2);
427        // Clicks should be audible (non-zero)
428        assert!(samples.iter().any(|&s| s.abs() > 0.01));
429    }
430
431    #[test]
432    fn test_calculate_bpm_120() {
433        // Beats at 0.5s intervals = 120 BPM
434        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        // First beat is downbeat
461        assert_eq!(json_out.beats[0].beat, 1);
462        assert!(json_out.beats[0].downbeat);
463
464        // Second beat is not a downbeat
465        assert_eq!(json_out.beats[1].beat, 2);
466        assert!(!json_out.beats[1].downbeat);
467
468        // Fourth beat is downbeat (2.0)
469        assert_eq!(json_out.beats[3].beat, 1);
470        assert!(json_out.beats[3].downbeat);
471
472        // Serializes to valid JSON
473        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        // Check file entries (summary only, no beat data)
516        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        // No beat data in summary
525        assert!(parsed["files"][0]["beats"].is_null());
526        assert!(parsed["files"][0]["bpm"].is_null());
527
528        // Check summary
529        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        // Starts near zero (attack)
542        assert!(click[0].abs() < 0.01);
543        // Ends near zero (decay)
544        assert!(click.last().unwrap().abs() < 0.05);
545        // Has significant amplitude in the middle
546        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}