use std::f64::consts::PI;
use crate::resample::WORKING_SAMPLE_RATE_HZ;
pub(crate) const SYNC_HZ: f64 = 1200.0;
pub(crate) const PORCH_HZ: f64 = 1500.0;
pub(crate) const SEPTR_HZ: f64 = 1500.0;
pub(crate) const BLACK_HZ: f64 = 1500.0;
pub(crate) const WHITE_HZ: f64 = 2300.0;
#[must_use]
pub(crate) fn lum_to_freq(lum: u8) -> f64 {
BLACK_HZ + (WHITE_HZ - BLACK_HZ) * f64::from(lum) / 255.0
}
pub(crate) struct ToneWriter {
out: Vec<f32>,
phase: f64,
}
impl ToneWriter {
pub fn new() -> Self {
Self {
out: Vec::new(),
phase: 0.0,
}
}
pub fn with_pre_silence_samples(n: usize) -> Self {
Self {
out: vec![0.0; n],
phase: 0.0,
}
}
#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
pub fn fill_to(&mut self, freq_hz: f64, target_n: usize) {
let dphi = 2.0 * PI * freq_hz / f64::from(WORKING_SAMPLE_RATE_HZ);
while self.out.len() < target_n {
self.out.push(self.phase.sin() as f32);
self.phase += dphi;
if self.phase > 2.0 * PI {
self.phase -= 2.0 * PI;
}
}
}
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
pub fn fill_secs(&mut self, freq_hz: f64, secs: f64) {
let n = (secs * f64::from(WORKING_SAMPLE_RATE_HZ)).round() as usize;
let target = self.out.len() + n;
self.fill_to(freq_hz, target);
}
#[allow(dead_code)]
#[must_use]
pub fn len(&self) -> usize {
self.out.len()
}
#[must_use]
pub fn into_vec(self) -> Vec<f32> {
self.out
}
}
impl Default for ToneWriter {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
#[allow(clippy::float_cmp, clippy::cast_precision_loss)]
mod tests {
use super::*;
#[test]
fn lum_to_freq_endpoints_match_black_and_white() {
assert_eq!(lum_to_freq(0), BLACK_HZ);
assert_eq!(lum_to_freq(255), WHITE_HZ);
let mid = lum_to_freq(128);
let target = f64::midpoint(BLACK_HZ, WHITE_HZ);
assert!((mid - target).abs() < 5.0, "mid={mid} ≉ {target}");
}
#[test]
fn fill_to_advances_to_exact_target() {
let mut tone = ToneWriter::new();
tone.fill_to(1200.0, 100);
assert_eq!(tone.len(), 100);
tone.fill_to(1500.0, 250);
assert_eq!(tone.len(), 250);
}
#[test]
fn fill_to_and_fill_secs_are_equivalent_for_matching_durations() {
let secs = 100.0 / f64::from(WORKING_SAMPLE_RATE_HZ);
let mut a = ToneWriter::new();
a.fill_secs(1200.0, secs);
let mut b = ToneWriter::new();
b.fill_to(1200.0, 100);
let av = a.into_vec();
let bv = b.into_vec();
assert_eq!(av.len(), bv.len());
for (i, (&x, &y)) in av.iter().zip(bv.iter()).enumerate() {
assert!((x - y).abs() < 1e-6, "sample {i}: {x} vs {y}");
}
}
#[test]
fn phase_is_continuous_across_tone_boundaries() {
let mut tone = ToneWriter::new();
tone.fill_to(1200.0, 100);
tone.fill_to(1500.0, 200);
let v = tone.into_vec();
for w in v.windows(2) {
let delta = (w[1] - w[0]).abs();
assert!(
delta < 1.0,
"sample-to-sample delta {delta} > 1.0 — phase discontinuity"
);
}
}
}