use anyhow::{Context, Result, bail};
use maolan_pitch::{F0Frame, FcpeDetector, FcpeWeights};
use std::path::{Path, PathBuf};
use std::time::Instant;
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
match args.first().map(String::as_str) {
Some("detect") => run_detect(&args[1..]),
Some("vocoder") => run_vocoder(),
_ => run_validation(),
}
}
fn default_bpk() -> PathBuf {
std::env::var("FCPE_BPK")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/home/meka/repos/FCPE-burn/fcpe.bpk"))
}
fn parse_flag<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
let mut it = args.iter();
while let Some(a) = it.next() {
if a == flag {
return it.next().map(String::as_str);
}
}
None
}
struct LoadedDetector {
inner: Box<FcpeDetector>,
}
impl LoadedDetector {
fn load(path: &Path) -> Result<Self> {
let device = maolan_pitch::wgpu_device();
let weights = FcpeWeights::from_burnpack(path, &device)?;
Ok(Self {
inner: Box::new(FcpeDetector::new(weights)),
})
}
fn detect_f0(
&self,
audio: &[f32],
sample_rate: u32,
progress: impl FnMut(f32),
) -> Vec<F0Frame> {
self.inner.detect_f0(audio, sample_rate, progress)
}
}
fn run_detect(args: &[String]) -> Result<()> {
let model = parse_flag(args, "--model").context("missing --model <fcpe.bpk>")?;
let wav = parse_flag(args, "--wav").context("missing --wav <file>")?;
let (audio, sample_rate, channels) = read_wav_mono(Path::new(wav))?;
eprintln!(
"loaded {} ({} Hz, {} ch, {} samples)",
wav,
sample_rate,
channels,
audio.len()
);
let detector = LoadedDetector::load(Path::new(model))?;
let frames = detector.detect_f0(&audio, sample_rate, |p| {
eprint!("\rprogress: {:.0}%", p * 100.0);
});
eprintln!();
println!("time_samples\thz\tconfidence");
for frame in &frames {
match frame.frequency {
Some(f0) => println!("{}\t{f0:.2}\t{:.3}", frame.time_samples, frame.confidence),
None => println!("{}\tunvoiced\t{:.3}", frame.time_samples, frame.confidence),
}
}
Ok(())
}
fn run_validation() -> Result<()> {
if std::env::var("PITCH_HARMONIC").is_ok() {
return run_harmonic_glide();
}
let bpk = default_bpk();
if !bpk.exists() {
bail!("FCPE weights not found at {} (set FCPE_BPK)", bpk.display());
}
eprintln!("backend: wgpu (Vulkan)");
let sr = 16_000_usize;
let seg = 12 * sr / 10; let hold = 3 * sr / 10; let gap = sr / 20; let total = 2 * seg + hold + 2 * gap;
let freq_at = |sample: usize| -> Option<f32> {
if sample < seg {
Some(220.0 + 220.0 * sample as f32 / seg as f32)
} else if sample >= seg + gap && sample < seg + gap + seg {
Some(440.0 - 110.0 * (sample - seg - gap) as f32 / seg as f32)
} else if sample >= 2 * (seg + gap) && sample < 2 * (seg + gap) + hold {
Some(330.0)
} else {
None
}
};
let amp: f32 = std::env::var("PITCH_AMP")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(0.5);
let mut audio = vec![0.0_f32; total];
let mut phase = 0.0_f32;
for (i, slot) in audio.iter_mut().enumerate() {
if let Some(f) = freq_at(i) {
phase += f / sr as f32;
phase -= phase.floor();
*slot = amp * (2.0 * std::f32::consts::PI * phase).sin();
}
}
let detector = LoadedDetector::load(&bpk)?;
eprintln!("running detector on {} samples...", audio.len());
let start = Instant::now();
let frames = detector.detect_f0(&audio, sr as u32, |p| {
eprint!("\rprogress: {:.0}% ", p * 100.0);
});
let elapsed = start.elapsed();
eprintln!("\ndetection wall time: {elapsed:?} on wgpu (Vulkan)");
let mut voiced = 0;
let mut max_err = 0.0_f32;
let mut sum_err = 0.0_f32;
let mut silent_voiced = 0;
let settle = 12; let (mut ex, mut de, mut ex2, mut de2, mut cr) = (0.0_f64, 0.0_f64, 0.0_f64, 0.0_f64, 0.0_f64);
println!("\nsegment summary (frames outside the {settle}-frame settle zones):");
for (i, frame) in frames.iter().enumerate() {
let sample = i * 160;
match (freq_at(sample), frame.frequency) {
(Some(target), Some(f0)) => {
voiced += 1;
let err = (f0 - target).abs() / target;
sum_err += err;
ex += target as f64;
de += f0 as f64;
ex2 += (target as f64).powi(2);
de2 += (f0 as f64).powi(2);
cr += target as f64 * f0 as f64;
let in_settle = (sample < seg
&& (sample / 160 < settle || (seg - sample) / 160 <= settle))
|| (sample >= seg + gap
&& sample < seg + gap + seg
&& ((sample - seg - gap) / 160 < settle
|| (seg + gap + seg - sample) / 160 <= settle))
|| (sample >= 2 * (seg + gap)
&& ((sample - 2 * (seg + gap)) / 160 < settle
|| (2 * (seg + gap) + hold - sample) / 160 <= settle));
if !in_settle {
assert!(
err < 0.05,
"frame {i}: expected {target:.1}, detected {f0:.1} (err {err:.4})"
);
max_err = max_err.max(err);
}
}
(None, Some(f0)) => {
silent_voiced += 1;
println!(
" silence frame at sample {} detected voiced: {f0:.1} Hz (conf {:.3})",
frame.time_samples, frame.confidence
);
}
_ => {}
}
}
let n = voiced as f64;
let corr = (cr - ex * de / n) / ((ex2 - ex * ex / n) * (de2 - de * de / n)).sqrt();
println!("\nvoiced frames: {voiced}");
println!("max rel error: {max_err:.5} (hard limit 0.05, settle zones excluded)");
println!(
"mean rel error: {:.5} (reported)",
sum_err / voiced as f32
);
println!("expected/detected correlation: {corr:.6} (requirement > 0.99)");
println!("silence frames called voiced: {silent_voiced}");
assert!(corr > 0.99, "correlation {corr} below 0.99");
assert!(silent_voiced <= 8, "too many silence frames voiced");
if let Some(wav) = find_fixture() {
println!("\nfixture: {}", wav.display());
let (audio, sample_rate, _) = read_wav_mono(&wav)?;
let start = Instant::now();
let frames = detector.detect_f0(&audio, sample_rate, |_| {});
let voiced: Vec<f32> = frames.iter().filter_map(|f| f.frequency).collect();
if voiced.is_empty() {
println!("no voiced frames detected");
} else {
let mean = voiced.iter().sum::<f32>() / voiced.len() as f32;
let min = voiced.iter().fold(f32::INFINITY, |a, &b| a.min(b));
let max = voiced.iter().fold(0.0_f32, |a, &b| a.max(b));
println!(
"f0 track: {} voiced / {} frames, mean = {mean:.1} Hz, range = [{min:.1}, {max:.1}] Hz ({:?})",
voiced.len(),
frames.len(),
start.elapsed()
);
}
} else {
println!("\nno .wav fixtures found under maolan/ or engine/, skipping real-sample check");
}
Ok(())
}
fn find_fixture() -> Option<PathBuf> {
for root in ["../maolan", "../engine", "."] {
let root = Path::new(root);
if !root.is_dir() {
continue;
}
let mut candidates: Vec<PathBuf> = Vec::new();
collect_wavs(root, 4, &mut candidates);
candidates.sort_by_key(|p| {
let name = p.to_string_lossy().to_lowercase();
(!name.contains("vocal") && !name.contains("voice")) as u8
});
if let Some(p) = candidates.first() {
return Some(p.clone());
}
}
None
}
fn collect_wavs(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
if depth == 0 {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let name = entry.file_name().to_string_lossy().into_owned();
if !matches!(name.as_str(), "target" | ".git" | "node_modules") {
collect_wavs(&path, depth - 1, out);
}
} else if path.extension().is_some_and(|e| e == "wav") {
out.push(path);
}
}
}
fn read_wav_mono(path: &Path) -> Result<(Vec<f32>, u32, u16)> {
let bytes =
std::fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
if bytes.len() < 12 || &bytes[0..4] != b"RIFF" || &bytes[8..12] != b"WAVE" {
bail!("{} is not a RIFF/WAVE file", path.display());
}
let mut pos = 12;
let (mut format, mut channels, mut sample_rate, mut bits) = (0_u16, 0_u16, 0_u32, 0_u16);
let mut data: Option<&[u8]> = None;
while pos + 8 <= bytes.len() {
let id = &bytes[pos..pos + 4];
let size = u32::from_le_bytes(bytes[pos + 4..pos + 8].try_into()?) as usize;
let body_start = pos + 8;
let body_end = (body_start + size).min(bytes.len());
match id {
b"fmt " => {
format = u16::from_le_bytes(bytes[body_start..body_start + 2].try_into()?);
channels = u16::from_le_bytes(bytes[body_start + 2..body_start + 4].try_into()?);
sample_rate = u32::from_le_bytes(bytes[body_start + 4..body_start + 8].try_into()?);
bits = if body_start + 16 <= body_end {
u16::from_le_bytes(bytes[body_start + 14..body_start + 16].try_into()?)
} else {
16
};
}
b"data" => data = Some(&bytes[body_start..body_end]),
_ => {}
}
pos = body_start + size + (size & 1);
}
let data = data.context("missing data chunk")?;
let channels = channels.max(1) as usize;
let frames: Vec<f32> = match (format, bits) {
(1, 16) => data
.as_chunks::<2>()
.0
.iter()
.map(|c| i16::from_le_bytes(*c) as f32 / 32768.0)
.collect(),
(1, 24) => data
.as_chunks::<3>()
.0
.iter()
.map(|c| {
let v =
i32::from_le_bytes([c[0], c[1], c[2], if c[2] & 0x80 != 0 { 0xFF } else { 0 }]);
v as f32 / 8_388_608.0
})
.collect(),
(1, 32) => data
.as_chunks::<4>()
.0
.iter()
.map(|c| i32::from_le_bytes(*c) as f32 / 2_147_483_648.0)
.collect(),
(3, 32) => data
.as_chunks::<4>()
.0
.iter()
.map(|c| f32::from_le_bytes(*c))
.collect(),
other => bail!("unsupported wav format {other:?}"),
};
let mono: Vec<f32> = if channels == 1 {
frames
} else {
frames
.chunks(channels)
.map(|frame| frame.iter().sum::<f32>() / channels as f32)
.collect()
};
Ok((mono, sample_rate, channels as u16))
}
fn run_harmonic_glide() -> Result<()> {
let bpk = default_bpk();
let detector = LoadedDetector::load(&bpk)?;
let sr = 16_000_usize;
let seg = 12 * sr / 10;
let hold = 3 * sr / 10;
let gap = sr / 20;
let total = 2 * seg + hold + 2 * gap;
let freq_at = |sample: usize| -> f32 {
if sample < seg {
220.0 + 220.0 * sample as f32 / seg as f32
} else if sample >= seg + gap && sample < seg + gap + seg {
440.0 - 110.0 * (sample - seg - gap) as f32 / seg as f32
} else {
330.0
}
};
let mut audio = vec![0.0_f32; total];
let mut phase = 0.0_f32;
for (i, slot) in audio.iter_mut().enumerate() {
let f = freq_at(i);
if (i + 1) % (seg + gap) <= gap && i > seg {
continue;
}
phase += f / sr as f32;
phase -= phase.floor();
let mut x = 0.0;
let mut k = 1;
while (k as f32) * f < 7500.0 && k < 200 {
x += (2.0 * std::f32::consts::PI * k as f32 * phase).sin() / k as f32;
k += 1;
}
*slot = 0.4 * x;
}
let frames = detector.detect_f0(&audio, sr as u32, |_| {});
let settle = 12;
let mut voiced = 0;
let mut max_err = 0.0_f32;
let (mut ex, mut de, mut ex2, mut de2, mut cr) = (0.0_f64, 0.0_f64, 0.0_f64, 0.0_f64, 0.0_f64);
for (i, frame) in frames.iter().enumerate() {
let sample = i * 160;
let in_gap = (sample >= seg && sample < seg + gap)
|| (sample >= 2 * seg + gap && sample < 2 * seg + 2 * gap);
if in_gap {
continue;
}
let f = freq_at(sample);
if let Some(f0) = frame.frequency {
voiced += 1;
let err = (f0 - f).abs() / f;
let in_settle = (sample < seg
&& (sample / 160 < settle || (seg - sample) / 160 <= settle))
|| (sample >= seg + gap
&& sample < seg + gap + seg
&& ((sample - seg - gap) / 160 < settle
|| (seg + gap + seg - sample) / 160 <= settle))
|| (sample >= 2 * (seg + gap)
&& ((sample - 2 * (seg + gap)) / 160 < settle
|| (2 * (seg + gap) + hold - sample) / 160 <= settle));
if !in_settle {
max_err = max_err.max(err);
if err > 0.015 {
println!(" frame {i}: expected {f:.1}, detected {f0:.1} (err {err:.4})");
}
}
ex += f as f64;
de += f0 as f64;
ex2 += (f as f64).powi(2);
de2 += (f0 as f64).powi(2);
cr += f as f64 * f0 as f64;
}
}
let n = voiced as f64;
let corr = (cr - ex * de / n) / ((ex2 - ex * ex / n) * (de2 - de * de / n)).sqrt();
println!("harmonic glide: voiced {voiced}, max err {max_err:.5}, corr {corr:.6}");
assert!(max_err < 0.01, "max err {max_err} above 1%");
assert!(corr > 0.99);
Ok(())
}
fn default_vocoder_bpk() -> PathBuf {
std::env::var(maolan_pitch::VOCODER_BPK_ENV)
.map(PathBuf::from)
.unwrap_or_else(|_| {
PathBuf::from("/home/meka/repos/pc-nsf-hifigan-burn/pc_nsf_hifigan.bpk")
})
}
fn run_vocoder() -> Result<()> {
use maolan_pitch::{PcNsfHifiGan, load_pc_nsf_hifigan_weights, vmel};
let vocoder_bpk = default_vocoder_bpk();
if !vocoder_bpk.exists() {
bail!(
"vocoder weights not found at {} (set {})",
vocoder_bpk.display(),
maolan_pitch::VOCODER_BPK_ENV
);
}
let fcpe_bpk = default_bpk();
if !fcpe_bpk.exists() {
bail!(
"FCPE weights not found at {} (set FCPE_BPK)",
fcpe_bpk.display()
);
}
let sr = vmel::SAMPLE_RATE as usize;
let seconds = 2;
let target_f0 = 220.0f32;
let mut source = vec![0.0_f32; sr * seconds];
let mut phase = 0.0f32;
for sample in &mut source {
phase += target_f0 / sr as f32;
phase -= phase.floor();
let mut x = 0.0f32;
let mut k = 1;
while (k as f32) * target_f0 < 14000.0 && k < 300 {
x += (2.0 * std::f32::consts::PI * k as f32 * phase).sin() / k as f32;
k += 1;
}
*sample = 0.5 * x;
}
let mel = vmel::wav_to_mel(&source);
let f0_contour = vec![target_f0; mel.len()];
println!(
"vocoder input: {} mel frames, f0 = {} Hz constant",
mel.len(),
target_f0
);
let weights = load_pc_nsf_hifigan_weights(&vocoder_bpk)?;
let vocoder = PcNsfHifiGan::new(weights);
let start = Instant::now();
let pcm = vocoder.synthesize(&mel, &f0_contour);
let synth_time = start.elapsed();
eprintln!(
"vocoder: {} samples ({:.2} s audio) in {:?} (debug)",
pcm.len(),
pcm.len() as f32 / sr as f32,
synth_time
);
let expected = mel.len() * 512;
assert!(
pcm.len().abs_diff(expected) <= 512,
"output length {} differs from expected {expected}",
pcm.len()
);
let rms = (pcm.iter().map(|v| v * v).sum::<f32>() / pcm.len() as f32).sqrt();
assert!(rms > 0.01, "output too quiet: RMS {rms}");
assert!(pcm.iter().all(|v| v.is_finite()), "non-finite output");
println!("output RMS: {rms:.4}, length: {} samples", pcm.len());
let fcpe_weights = maolan_pitch::load_fcpe_weights(&fcpe_bpk)?;
let detector = FcpeDetector::new(fcpe_weights);
let mean_detected = |audio: &[f32], label: &str| -> f32 {
let start = Instant::now();
let frames = detector.detect_f0(audio, sr as u32, |_| {});
let voiced: Vec<f32> = frames.iter().filter_map(|f| f.frequency).collect();
assert!(!voiced.is_empty(), "no voiced frames in {label}");
let mean = voiced.iter().sum::<f32>() / voiced.len() as f32;
eprintln!("fcpe on {label}: {mean:.1} Hz ({:?})", start.elapsed());
mean
};
let reference_f0 = mean_detected(&source, "ground-truth source");
let mean_f0 = mean_detected(&pcm, "vocoder output");
println!(
"tracked f0: source = {reference_f0:.1} Hz, vocoder = {mean_f0:.1} Hz (target {target_f0})"
);
assert!(
(mean_f0 - reference_f0).abs() / reference_f0 < 0.02,
"vocoder f0 {mean_f0} deviates from FCPE reference {reference_f0} by more than 2%"
);
assert!(
(mean_f0 - target_f0).abs() / target_f0 < 0.1,
"vocoder f0 {mean_f0} implausibly far from the {target_f0} Hz target"
);
let wav_path = std::path::Path::new("/tmp/vocoder_out.wav");
if write_wav_mono(wav_path, &pcm, sr as u32).is_ok() {
println!("wrote {}", wav_path.display());
}
Ok(())
}
fn write_wav_mono(path: &std::path::Path, samples: &[f32], sample_rate: u32) -> Result<()> {
use std::io::Write;
let mut file = std::fs::File::create(path)?;
let data_bytes = samples.len() * 2;
file.write_all(b"RIFF")?;
file.write_all(&(36 + data_bytes as u32).to_le_bytes())?;
file.write_all(b"WAVEfmt ")?;
file.write_all(&16u32.to_le_bytes())?;
file.write_all(&1u16.to_le_bytes())?; file.write_all(&1u16.to_le_bytes())?; file.write_all(&sample_rate.to_le_bytes())?;
file.write_all(&(sample_rate * 2).to_le_bytes())?;
file.write_all(&2u16.to_le_bytes())?;
file.write_all(&16u16.to_le_bytes())?;
file.write_all(b"data")?;
file.write_all(&(data_bytes as u32).to_le_bytes())?;
for &s in samples {
let v = (s.clamp(-1.0, 1.0) * 32767.0) as i16;
file.write_all(&v.to_le_bytes())?;
}
Ok(())
}