|
| 1 | +// Symphonia Check Tool |
| 2 | +// Copyright (c) 2019-2022 The Project Symphonia Developers. |
| 3 | +// |
| 4 | +// This Source Code Form is subject to the terms of the Mozilla Public |
| 5 | +// License, v. 2.0. If a copy of the MPL was not distributed with this |
| 6 | +// file, You can obtain one at https://mozilla.org/MPL/2.0/. |
| 7 | + |
| 8 | +#![warn(rust_2018_idioms)] |
| 9 | +#![forbid(unsafe_code)] |
| 10 | +// Justification: Fields on DecoderOptions and FormatOptions may change at any time, but |
| 11 | +// symphonia-check doesn't want to be updated every time those fields change, therefore always fill |
| 12 | +// in the remaining fields with default values. |
| 13 | +#![allow(clippy::needless_update)] |
| 14 | + |
| 15 | +use std::fs::File; |
| 16 | +use std::path::Path; |
| 17 | +use std::process::{Command, Stdio}; |
| 18 | + |
| 19 | +use log::warn; |
| 20 | +use symphonia::core::audio::GenericAudioBufferRef; |
| 21 | +use symphonia::core::codecs::audio::{AudioDecoder, AudioDecoderOptions}; |
| 22 | +use symphonia::core::codecs::CodecParameters; |
| 23 | +use symphonia::core::errors::{unsupported_error, Error, Result}; |
| 24 | +use symphonia::core::formats::probe::Hint; |
| 25 | +use symphonia::core::formats::{FormatOptions, FormatReader, TrackType}; |
| 26 | +use symphonia::core::io::{MediaSourceStream, ReadOnlySource}; |
| 27 | +use symphonia::core::meta::MetadataOptions; |
| 28 | + |
| 29 | +use crate::{AudioTestDecoder, AudioTestOptions, RefProcess}; |
| 30 | + |
| 31 | +/// The absolute maximum allowable sample delta. Around 2^-17 (-102.4dB). |
| 32 | +const ABS_MAX_ALLOWABLE_SAMPLE_DELTA: f32 = 0.00001; |
| 33 | + |
| 34 | +// The absolute maximum allowable sample delta for a fully compliant MP3 decoder as specified by the |
| 35 | +// ISO. Around 2^-14 (-84.2dB). |
| 36 | +// const ABS_MAX_ALLOWABLE_SAMPLE_DELTA_MP3: f32 = 0.00006104; |
| 37 | + |
| 38 | +#[derive(Default)] |
| 39 | +struct AudioTestResult { |
| 40 | + n_frames: u64, |
| 41 | + n_samples: u64, |
| 42 | + n_failed_samples: u64, |
| 43 | + n_packets: u64, |
| 44 | + n_failed_packets: u64, |
| 45 | + abs_max_delta: f32, |
| 46 | + tgt_unchecked_samples: u64, |
| 47 | + ref_unchecked_samples: u64, |
| 48 | +} |
| 49 | + |
| 50 | +fn build_ffmpeg_command(path: &str, gapless: bool) -> Command { |
| 51 | + let mut cmd = Command::new("ffmpeg"); |
| 52 | + |
| 53 | + // Gapless argument must come before everything else. |
| 54 | + if !gapless { |
| 55 | + cmd.arg("-flags2").arg("skip_manual"); |
| 56 | + } |
| 57 | + |
| 58 | + cmd.arg("-nostats") // Quiet command. |
| 59 | + .arg("-hide_banner") |
| 60 | + .arg("-i") // Input path. |
| 61 | + .arg(path) |
| 62 | + .arg("-map") // Select the first audio track. |
| 63 | + .arg("0:a:0") |
| 64 | + .arg("-c:a") // Encode audio to pcm_s32le. |
| 65 | + .arg("pcm_f32le") |
| 66 | + .arg("-f") // Output in WAVE format. |
| 67 | + .arg("wav") |
| 68 | + .arg("-") // Pipe output to stdout. |
| 69 | + .stdout(Stdio::piped()) |
| 70 | + .stderr(Stdio::null()); // Pipe errors to null. |
| 71 | + |
| 72 | + cmd |
| 73 | +} |
| 74 | + |
| 75 | +fn build_flac_command(path: &str) -> Command { |
| 76 | + let mut cmd = Command::new("flac"); |
| 77 | + |
| 78 | + cmd.arg("--stdout").arg("-d").arg(path).stdout(Stdio::piped()).stderr(Stdio::null()); |
| 79 | + |
| 80 | + cmd |
| 81 | +} |
| 82 | + |
| 83 | +fn build_mpg123_command(path: &str, gapless: bool) -> Command { |
| 84 | + let mut cmd = Command::new("mpg123"); |
| 85 | + |
| 86 | + if !gapless { |
| 87 | + cmd.arg("--no-gapless"); |
| 88 | + } |
| 89 | + |
| 90 | + cmd.arg("--wav").arg("-").arg("--float").arg(path).stdout(Stdio::piped()).stderr(Stdio::null()); |
| 91 | + |
| 92 | + cmd |
| 93 | +} |
| 94 | + |
| 95 | +fn build_oggdec_command(path: &str) -> Command { |
| 96 | + let mut cmd = Command::new("oggdec"); |
| 97 | + cmd.arg(path).arg("-o").arg("-").stdout(Stdio::piped()).stderr(Stdio::null()); |
| 98 | + cmd |
| 99 | +} |
| 100 | + |
| 101 | +#[derive(Default)] |
| 102 | +struct FlushStats { |
| 103 | + n_packets: u64, |
| 104 | + n_samples: u64, |
| 105 | +} |
| 106 | + |
| 107 | +struct DecoderInstance { |
| 108 | + format: Box<dyn FormatReader>, |
| 109 | + decoder: Box<dyn AudioDecoder>, |
| 110 | + track_id: u32, |
| 111 | +} |
| 112 | + |
| 113 | +impl DecoderInstance { |
| 114 | + fn try_open( |
| 115 | + mss: MediaSourceStream<'static>, |
| 116 | + fmt_opts: FormatOptions, |
| 117 | + ) -> Result<DecoderInstance> { |
| 118 | + // Use the default options for metadata and format readers, and the decoder. |
| 119 | + let meta_opts: MetadataOptions = Default::default(); |
| 120 | + let dec_opts: AudioDecoderOptions = Default::default(); |
| 121 | + |
| 122 | + let hint = Hint::new(); |
| 123 | + |
| 124 | + let format = symphonia::default::get_probe().probe(&hint, mss, fmt_opts, meta_opts)?; |
| 125 | + |
| 126 | + let track = format.default_track(TrackType::Audio).unwrap(); |
| 127 | + |
| 128 | + let codec_params = match &track.codec_params { |
| 129 | + Some(CodecParameters::Audio(params)) => params, |
| 130 | + _ => return unsupported_error("only audio tracks are supported"), |
| 131 | + }; |
| 132 | + |
| 133 | + let decoder = |
| 134 | + symphonia::default::get_codecs().make_audio_decoder(codec_params, &dec_opts)?; |
| 135 | + |
| 136 | + let track_id = track.id; |
| 137 | + |
| 138 | + Ok(DecoderInstance { format, decoder, track_id }) |
| 139 | + } |
| 140 | + |
| 141 | + fn samples_per_frame(&self) -> Option<u64> { |
| 142 | + self.decoder.codec_params().channels.as_ref().map(|ch| ch.count() as u64) |
| 143 | + } |
| 144 | + |
| 145 | + fn next_audio_buf(&mut self, keep_going: bool) -> Result<Option<GenericAudioBufferRef<'_>>> { |
| 146 | + loop { |
| 147 | + // Get the next packet. |
| 148 | + let packet = match self.format.next_packet() { |
| 149 | + Ok(Some(packet)) => packet, |
| 150 | + Ok(None) => return Ok(None), |
| 151 | + Err(Error::IoError(err)) if err.kind() == std::io::ErrorKind::UnexpectedEof => { |
| 152 | + // WavReader will always return an UnexpectedEof when it ends because the |
| 153 | + // reference decoder is piping the decoded audio and cannot write out the |
| 154 | + // actual length of the media. Treat UnexpectedEof as the end of the stream. |
| 155 | + return Ok(None); |
| 156 | + } |
| 157 | + Err(err) => return Err(err), |
| 158 | + }; |
| 159 | + |
| 160 | + // Skip packets that do not belong to the track being decoded. |
| 161 | + if packet.track_id() != self.track_id { |
| 162 | + continue; |
| 163 | + } |
| 164 | + |
| 165 | + // Decode the packet, ignoring decode errors if `keep_going` is true. |
| 166 | + match self.decoder.decode(&packet) { |
| 167 | + Ok(_) => break, |
| 168 | + Err(Error::DecodeError(err)) if keep_going => warn!("{}", err), |
| 169 | + Err(err) => return Err(err), |
| 170 | + } |
| 171 | + } |
| 172 | + |
| 173 | + Ok(Some(self.decoder.last_decoded())) |
| 174 | + } |
| 175 | + |
| 176 | + fn flush(&mut self, keep_going: bool) -> Result<FlushStats> { |
| 177 | + let mut stats: FlushStats = Default::default(); |
| 178 | + |
| 179 | + while let Some(buf) = self.next_audio_buf(keep_going)? { |
| 180 | + stats.n_packets += 1; |
| 181 | + stats.n_samples += buf.samples_interleaved() as u64; |
| 182 | + } |
| 183 | + |
| 184 | + Ok(stats) |
| 185 | + } |
| 186 | +} |
| 187 | + |
| 188 | +fn run_check( |
| 189 | + ref_inst: &mut DecoderInstance, |
| 190 | + tgt_inst: &mut DecoderInstance, |
| 191 | + opts: &AudioTestOptions, |
| 192 | + acct: &mut AudioTestResult, |
| 193 | +) -> Result<()> { |
| 194 | + // Reference |
| 195 | + let mut ref_sample_buf: Vec<f32> = Default::default(); |
| 196 | + let mut ref_sample_cnt = 0; |
| 197 | + let mut ref_sample_pos = 0; |
| 198 | + |
| 199 | + // Target |
| 200 | + let mut tgt_sample_buf: Vec<f32> = Default::default(); |
| 201 | + let mut tgt_sample_cnt = 0; |
| 202 | + let mut tgt_sample_pos = 0; |
| 203 | + |
| 204 | + let samples_per_frame = tgt_inst.samples_per_frame().unwrap_or(1); |
| 205 | + |
| 206 | + // Samples/frame must match for both decoders. |
| 207 | + if samples_per_frame != ref_inst.samples_per_frame().unwrap_or(1) { |
| 208 | + return unsupported_error("target and reference decoder samples per frame mismatch"); |
| 209 | + } |
| 210 | + |
| 211 | + let early_fail = 'outer: loop { |
| 212 | + // Decode the next target audio buffer and copy it to the target sample buffer. |
| 213 | + match tgt_inst.next_audio_buf(opts.keep_going)? { |
| 214 | + Some(buf) => buf.copy_to_vec_interleaved(&mut tgt_sample_buf), |
| 215 | + None => break 'outer false, |
| 216 | + }; |
| 217 | + |
| 218 | + tgt_sample_cnt = tgt_sample_buf.len(); |
| 219 | + tgt_sample_pos = 0; |
| 220 | + |
| 221 | + // The number of frames previously read & compared. |
| 222 | + let frame_num_base = acct.n_frames; |
| 223 | + |
| 224 | + // The number of failed samples in the target packet. |
| 225 | + let mut n_failed_pkt_samples = 0; |
| 226 | + |
| 227 | + while tgt_sample_pos < tgt_sample_cnt { |
| 228 | + // Need to read a decode a new reference buffer. |
| 229 | + if ref_sample_pos == ref_sample_cnt { |
| 230 | + // Get the next reference audio buffer and copy it to the reference sample buffer. |
| 231 | + match ref_inst.next_audio_buf(true)? { |
| 232 | + Some(buf) => buf.copy_to_vec_interleaved(&mut ref_sample_buf), |
| 233 | + None => break 'outer false, |
| 234 | + } |
| 235 | + |
| 236 | + ref_sample_cnt = ref_sample_buf.len(); |
| 237 | + ref_sample_pos = 0; |
| 238 | + } |
| 239 | + |
| 240 | + // Get a slice of the remaining samples in the reference and target sample buffers. |
| 241 | + let ref_samples = &ref_sample_buf[ref_sample_pos..]; |
| 242 | + let tgt_samples = &tgt_sample_buf[tgt_sample_pos..]; |
| 243 | + |
| 244 | + // The number of samples that can be compared given the current length of the reference |
| 245 | + // and target sample buffers. |
| 246 | + let n_test_samples = std::cmp::min(ref_samples.len(), tgt_samples.len()); |
| 247 | + |
| 248 | + // Perform the comparison. |
| 249 | + for (&t, &r) in tgt_samples[..n_test_samples].iter().zip(&ref_samples[..n_test_samples]) |
| 250 | + { |
| 251 | + // Clamp the reference and target samples between [-1.0, 1.0] and find the |
| 252 | + // difference. |
| 253 | + let delta = t.clamp(-1.0, 1.0) - r.clamp(-1.0, 1.0); |
| 254 | + |
| 255 | + if delta.abs() > ABS_MAX_ALLOWABLE_SAMPLE_DELTA { |
| 256 | + // Print per-sample or per-packet failure nessage based on selected options. |
| 257 | + if !opts.is_quiet && (opts.is_per_sample || n_failed_pkt_samples == 0) { |
| 258 | + println!( |
| 259 | + "[FAIL] packet={:>8}, frame={:>10} ({:>4}), plane={:>3}, dec={:+.8}, ref={:+.8} ({:+.8})", |
| 260 | + acct.n_packets, |
| 261 | + acct.n_frames, |
| 262 | + acct.n_frames - frame_num_base, |
| 263 | + acct.n_samples % samples_per_frame, |
| 264 | + t, |
| 265 | + r, |
| 266 | + r - t |
| 267 | + ); |
| 268 | + } |
| 269 | + |
| 270 | + n_failed_pkt_samples += 1; |
| 271 | + } |
| 272 | + |
| 273 | + acct.abs_max_delta = acct.abs_max_delta.max(delta.abs()); |
| 274 | + acct.n_samples += 1; |
| 275 | + |
| 276 | + if acct.n_samples % samples_per_frame == 0 { |
| 277 | + acct.n_frames += 1; |
| 278 | + } |
| 279 | + } |
| 280 | + |
| 281 | + // Update position in reference and target buffers. |
| 282 | + ref_sample_pos += n_test_samples; |
| 283 | + tgt_sample_pos += n_test_samples; |
| 284 | + } |
| 285 | + |
| 286 | + acct.n_failed_samples += n_failed_pkt_samples; |
| 287 | + acct.n_failed_packets += u64::from(n_failed_pkt_samples > 0); |
| 288 | + acct.n_packets += 1; |
| 289 | + |
| 290 | + if opts.stop_after_fail && acct.n_failed_packets > 0 { |
| 291 | + break true; |
| 292 | + } |
| 293 | + }; |
| 294 | + |
| 295 | + // Count how many samples were remaining for both the target and references if the loop did not |
| 296 | + // break out early due to a failed sample. |
| 297 | + if !early_fail { |
| 298 | + let tgt_stats = tgt_inst.flush(true)?; |
| 299 | + let ref_stats = ref_inst.flush(true)?; |
| 300 | + |
| 301 | + acct.n_packets += tgt_stats.n_packets; |
| 302 | + acct.tgt_unchecked_samples = (tgt_sample_cnt - tgt_sample_pos) as u64 + tgt_stats.n_samples; |
| 303 | + acct.ref_unchecked_samples = (ref_sample_cnt - ref_sample_pos) as u64 + ref_stats.n_samples; |
| 304 | + } |
| 305 | + |
| 306 | + Ok(()) |
| 307 | +} |
| 308 | + |
| 309 | +fn run_test(path: &str, opts: &AudioTestOptions, result: &mut AudioTestResult) -> Result<()> { |
| 310 | + let command = match opts.ref_decoder { |
| 311 | + AudioTestDecoder::Ffmpeg => build_ffmpeg_command(path, opts.gapless), |
| 312 | + AudioTestDecoder::Flac => build_flac_command(path), |
| 313 | + AudioTestDecoder::Mpg123 => build_mpg123_command(path, opts.gapless), |
| 314 | + AudioTestDecoder::Oggdec => build_oggdec_command(path), |
| 315 | + }; |
| 316 | + |
| 317 | + // 1. Start the reference decoder process. |
| 318 | + let mut ref_process = RefProcess::try_spawn(command)?; |
| 319 | + |
| 320 | + // 2. Instantiate a Symphonia decoder for the reference process output. |
| 321 | + let ref_ms = Box::new(ReadOnlySource::new(ref_process.child.stdout.take().unwrap())); |
| 322 | + let ref_mss = MediaSourceStream::new(ref_ms, Default::default()); |
| 323 | + |
| 324 | + let mut ref_inst = DecoderInstance::try_open(ref_mss, Default::default())?; |
| 325 | + |
| 326 | + // 3. Instantiate a Symphonia decoder for the test target. |
| 327 | + let tgt_ms = Box::new(File::open(Path::new(path))?); |
| 328 | + let tgt_mss = MediaSourceStream::new(tgt_ms, Default::default()); |
| 329 | + |
| 330 | + let tgt_fmt_opts = FormatOptions { enable_gapless: opts.gapless, ..Default::default() }; |
| 331 | + |
| 332 | + let mut tgt_inst = DecoderInstance::try_open(tgt_mss, tgt_fmt_opts)?; |
| 333 | + |
| 334 | + // 4. Begin check. |
| 335 | + run_check(&mut ref_inst, &mut tgt_inst, opts, result) |
| 336 | +} |
| 337 | + |
| 338 | +pub fn run_audio(opts: AudioTestOptions) -> Result<()> { |
| 339 | + let mut res: AudioTestResult = Default::default(); |
| 340 | + |
| 341 | + run_test(&opts.input, &opts, &mut res)?; |
| 342 | + |
| 343 | + if !opts.is_quiet { |
| 344 | + println!(); |
| 345 | + } |
| 346 | + |
| 347 | + println!("Test Results"); |
| 348 | + println!("================================================="); |
| 349 | + println!(); |
| 350 | + println!(" Failed/Total Packets: {:>12}/{:>12}", res.n_failed_packets, res.n_packets); |
| 351 | + println!(" Failed/Total Samples: {:>12}/{:>12}", res.n_failed_samples, res.n_samples); |
| 352 | + println!(); |
| 353 | + println!(" Remaining Target Samples: {:>12}", res.tgt_unchecked_samples); |
| 354 | + println!(" Remaining Reference Samples: {:>12}", res.ref_unchecked_samples); |
| 355 | + println!(); |
| 356 | + println!(" Absolute Maximum Sample Delta: {:.8}", res.abs_max_delta); |
| 357 | + println!(); |
| 358 | + |
| 359 | + if res.n_failed_samples == 0 { |
| 360 | + Ok(()) |
| 361 | + } |
| 362 | + else { |
| 363 | + unsupported_error("Some samples didn't pass validation") |
| 364 | + } |
| 365 | +} |
0 commit comments