mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 12:22:47 +00:00
Moves filmcraft-mp3enc (storytold/filmcraft@4bec22b) into craft_audio::mp3, on craft-media's BitWriter. `encode` turns the feature on, mp3::Error converts into AudioError::Encode, and a test covers every sample rate and bit rate with extreme signals.
163 lines
7.1 KiB
Rust
163 lines
7.1 KiB
Rust
//! The encoder against ffmpeg as an external decoder (skipped without ffmpeg): ffprobe must see
|
|
//! an MP3 stream with our rate, channels and duration, ffmpeg must decode every frame without a
|
|
//! complaint, and its decode, aligned by [`DELAY`], must match the input within a signal-to-noise
|
|
//! ratio per bit rate. White noise loses what lies above the bit rate's low-pass, so its bound is
|
|
//! low; the bounds sit about 6 dB under what the encoder reaches today.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Command, Stdio};
|
|
|
|
use craft_audio::mp3::{Config, DELAY, Encoder, StereoMode};
|
|
|
|
fn fixtures() -> PathBuf {
|
|
crate::testkit::fixtures_dir("mp3enc")
|
|
}
|
|
|
|
fn chords(rate: u32, n: usize, detune: f32) -> Vec<f32> {
|
|
let f = [220.0, 277.18, 329.63, 440.0, 1318.5, 3520.0];
|
|
let a = [0.2, 0.15, 0.12, 0.1, 0.05, 0.02];
|
|
(0..n)
|
|
.map(|i| {
|
|
let t = i as f32 / rate as f32;
|
|
f.iter().zip(a).map(|(f, a)| a * (2.0 * std::f32::consts::PI * f * (1.0 + detune) * t).sin()).sum::<f32>()
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn noise(n: usize, amp: f32, seed: u64) -> Vec<f32> {
|
|
let mut s = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1;
|
|
(0..n)
|
|
.map(|_| {
|
|
s ^= s << 13;
|
|
s ^= s >> 7;
|
|
s ^= s << 17;
|
|
((s >> 40) as f32 / (1u64 << 24) as f32 * 2.0 - 1.0) * amp
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn sweep(rate: u32, n: usize) -> Vec<f32> {
|
|
let secs = n as f32 / rate as f32;
|
|
let (f0, f1) = (40.0f32, rate as f32 * 0.4);
|
|
let k = (f1 / f0).ln() / secs;
|
|
(0..n).map(|i| 0.4 * (2.0 * std::f32::consts::PI * f0 * (((i as f32 / rate as f32) * k).exp() - 1.0) / k).sin()).collect()
|
|
}
|
|
|
|
fn encode(cfg: Config, input: &[Vec<f32>]) -> Vec<u8> {
|
|
let mut enc = Encoder::new(cfg).unwrap();
|
|
let mut out = Vec::new();
|
|
let n = input[0].len();
|
|
let mut pos = 0;
|
|
while pos < n {
|
|
let end = (pos + 1000).min(n);
|
|
let planes: Vec<&[f32]> = input.iter().map(|c| &c[pos..end]).collect();
|
|
out.extend(enc.encode(&planes));
|
|
pos = end;
|
|
}
|
|
out.extend(enc.flush());
|
|
out
|
|
}
|
|
|
|
fn ffmpeg_decode(ff: &Path, path: &Path, channels: usize) -> (Vec<Vec<f32>>, String) {
|
|
let out = Command::new(ff)
|
|
.args(["-v", "error", "-i"])
|
|
.arg(path)
|
|
.args(["-f", "f32le", "-acodec", "pcm_f32le", "-"])
|
|
.stdin(Stdio::null())
|
|
.output()
|
|
.unwrap();
|
|
let samples: Vec<f32> = out.stdout.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect();
|
|
let planar = (0..channels).map(|c| samples.iter().skip(c).step_by(channels).copied().collect()).collect();
|
|
(planar, String::from_utf8_lossy(&out.stderr).into_owned())
|
|
}
|
|
|
|
/// SNR in dB of `dec` (aligned) against `input`.
|
|
fn snr(input: &[f32], dec: &[f32]) -> f64 {
|
|
let (mut s, mut n) = (0f64, 0f64);
|
|
for (a, b) in input.iter().zip(dec) {
|
|
s += f64::from(*a) * f64::from(*a);
|
|
n += f64::from(a - b) * f64::from(a - b);
|
|
}
|
|
10.0 * (s / n.max(1e-30)).log10()
|
|
}
|
|
|
|
struct Case {
|
|
name: &'static str,
|
|
rate: u32,
|
|
channels: usize,
|
|
kbps: u32,
|
|
stereo: StereoMode,
|
|
signal: fn(u32, usize, usize) -> Vec<f32>,
|
|
min_snr: f64,
|
|
}
|
|
|
|
fn sig_chords(rate: u32, n: usize, c: usize) -> Vec<f32> {
|
|
chords(rate, n, 0.003 * c as f32)
|
|
}
|
|
fn sig_noise(_rate: u32, n: usize, c: usize) -> Vec<f32> {
|
|
noise(n, 0.25, 11 + c as u64)
|
|
}
|
|
fn sig_sweep(rate: u32, n: usize, c: usize) -> Vec<f32> {
|
|
sweep(rate, n).iter().map(|v| v * (1.0 - 0.2 * c as f32)).collect()
|
|
}
|
|
|
|
fn run(ff: &Path, c: &Case) -> f64 {
|
|
let n = c.rate as usize * 2 + 777;
|
|
let input: Vec<Vec<f32>> = (0..c.channels).map(|ch| (c.signal)(c.rate, n, ch)).collect();
|
|
let cfg = Config { sample_rate: c.rate, channels: c.channels, bitrate_kbps: c.kbps, stereo: c.stereo };
|
|
let bytes = encode(cfg, &input);
|
|
let path = fixtures().join(format!("{}_{}_{}ch_{}k.mp3", c.name, c.rate, c.channels, c.kbps));
|
|
std::fs::write(&path, &bytes).unwrap();
|
|
let (dec, err) = ffmpeg_decode(ff, &path, c.channels);
|
|
assert!(err.trim().is_empty(), "{}: ffmpeg reported: {err}", path.display());
|
|
let d = DELAY as usize;
|
|
assert!(dec[0].len() >= n + d, "{}: decoded {} samples, need {}", c.name, dec[0].len(), n + d);
|
|
// the bit rate is exact: every frame has the nominal size
|
|
let frame_samples = if c.rate >= 32_000 { 1152 } else { 576 };
|
|
let frames = dec[0].len() / frame_samples;
|
|
let nominal = c.kbps as f64 * 1000.0 / 8.0 * (frames * frame_samples) as f64 / c.rate as f64;
|
|
assert!((bytes.len() as f64 / nominal - 1.0).abs() < 0.01, "{}: {} bytes vs {nominal}", c.name, bytes.len());
|
|
if let Some(probe) = crate::testkit::oracle::find_tool("ffprobe") {
|
|
let out = Command::new(probe)
|
|
.args(["-v", "error", "-show_entries", "stream=codec_name,sample_rate,channels:format=duration", "-of", "default=nw=1"])
|
|
.arg(&path)
|
|
.output()
|
|
.unwrap();
|
|
let text = String::from_utf8_lossy(&out.stdout);
|
|
let field = |k: &str| text.lines().find_map(|l| l.strip_prefix(&format!("{k}="))).unwrap_or("").to_string();
|
|
assert_eq!(field("codec_name"), "mp3", "{text}");
|
|
assert_eq!(field("sample_rate"), c.rate.to_string(), "{text}");
|
|
assert_eq!(field("channels"), c.channels.to_string(), "{text}");
|
|
let dur: f64 = field("duration").parse().unwrap();
|
|
let expect = (frames * frame_samples) as f64 / c.rate as f64;
|
|
assert!((dur - expect).abs() < 2.0 * frame_samples as f64 / c.rate as f64, "{}: duration {dur} vs {expect}", c.name);
|
|
}
|
|
let s = (0..c.channels).map(|ch| snr(&input[ch], &dec[ch][d..d + n])).sum::<f64>() / c.channels as f64;
|
|
println!("{:<8} {:>5} Hz {}ch {:>3} kbps {:?}: SNR {s:.2} dB", c.name, c.rate, c.channels, c.kbps, c.stereo);
|
|
s
|
|
}
|
|
|
|
#[test]
|
|
fn ffmpeg_decodes_our_streams_at_every_rate() {
|
|
let ff = require_ffmpeg!();
|
|
use StereoMode::*;
|
|
let cases = [
|
|
Case { name: "chords", rate: 44_100, channels: 2, kbps: 128, stereo: JointStereo, signal: sig_chords, min_snr: 42.0 },
|
|
Case { name: "chords", rate: 44_100, channels: 2, kbps: 320, stereo: JointStereo, signal: sig_chords, min_snr: 60.0 },
|
|
Case { name: "sweep", rate: 48_000, channels: 2, kbps: 192, stereo: Stereo, signal: sig_sweep, min_snr: 22.0 },
|
|
Case { name: "noise", rate: 48_000, channels: 2, kbps: 128, stereo: JointStereo, signal: sig_noise, min_snr: 2.5 },
|
|
Case { name: "chords", rate: 32_000, channels: 1, kbps: 64, stereo: JointStereo, signal: sig_chords, min_snr: 40.0 },
|
|
Case { name: "chords", rate: 22_050, channels: 2, kbps: 64, stereo: JointStereo, signal: sig_chords, min_snr: 33.0 },
|
|
Case { name: "sweep", rate: 24_000, channels: 1, kbps: 48, stereo: JointStereo, signal: sig_sweep, min_snr: 24.0 },
|
|
Case { name: "chords", rate: 16_000, channels: 1, kbps: 32, stereo: JointStereo, signal: sig_chords, min_snr: 30.0 },
|
|
];
|
|
let mut failed = Vec::new();
|
|
for c in &cases {
|
|
let s = run(&ff, c);
|
|
if s < c.min_snr {
|
|
failed.push(format!("{} {} Hz {} kbps: {s:.2} < {}", c.name, c.rate, c.kbps, c.min_snr));
|
|
}
|
|
}
|
|
assert!(failed.is_empty(), "{failed:#?}");
|
|
}
|