From 8049067fb3a136fc7a634ef3f3682ce3a4f2c39f Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sat, 19 Sep 2026 19:01:39 +0200 Subject: [PATCH] Added tests for the configurable transcription Opus bitrate --- .../Settings/TranscriptionOpusBitrateTests.cs | 54 +++++++++++++++++++ runtime/src/media.rs | 49 ++++++++++++++++- 2 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 app/Tests/Settings/TranscriptionOpusBitrateTests.cs diff --git a/app/Tests/Settings/TranscriptionOpusBitrateTests.cs b/app/Tests/Settings/TranscriptionOpusBitrateTests.cs new file mode 100644 index 00000000..33a75682 --- /dev/null +++ b/app/Tests/Settings/TranscriptionOpusBitrateTests.cs @@ -0,0 +1,54 @@ +using AIStudio.Settings.DataModel; + +namespace AIStudio.Tests.Settings; + +/// +/// Checks which bitrate the transcription pipeline ends up asking the Opus encoder for. +/// +/// +/// This number is the whole reason the setting exists. Until v26.9.1 the encoder was fixed at +/// 32 kbps, and transcription models silently dropped what had been spoken quietly -- a greeting at +/// the start of a recording never reached the transcript. Two ways back to that value have to stay +/// closed: the arm catching a bitrate nobody declared, and the member a settings file the app cannot +/// read falls back to. Both are one edit away from pointing at the lowest quality again. +/// +[TestFixture] +public sealed class TranscriptionOpusBitrateTests +{ + private static readonly Dictionary EXPECTED_BITS_PER_SECOND = new() + { + [TranscriptionOpusBitrate.KBPS_32] = 32_000, + [TranscriptionOpusBitrate.KBPS_64] = 64_000, + [TranscriptionOpusBitrate.KBPS_128] = 128_000, + [TranscriptionOpusBitrate.KBPS_256] = 256_000, + }; + + [Test] + public void EveryOfferedBitrateAsksForWhatItsNameSays() + { + foreach (var bitrate in Enum.GetValues()) + { + Assert.That(EXPECTED_BITS_PER_SECOND.ContainsKey(bitrate), Is.True, $"The selection offers {bitrate}, so this test has to state what that is worth in bits per second."); + Assert.That(bitrate.GetBitsPerSecond(), Is.EqualTo(EXPECTED_BITS_PER_SECOND[bitrate]), $"{bitrate} is what the user picked; anything else travels to the encoder behind their back."); + } + } + + [Test] + public void ABitrateNobodyDeclaredFallsBackToTheRecommendedOne() + { + var undeclared = (TranscriptionOpusBitrate)999; + + Assert.That(Enum.IsDefined(undeclared), Is.False, "The point of this test is a value outside the enum; a declared one would prove nothing."); + Assert.That(undeclared.GetBitsPerSecond(), Is.EqualTo(128_000u), "Not knowing which quality was meant is no reason to pick the worst one available."); + } + + [Test] + public void AnUnreadableSettingsValueLandsOnTheRecommendedBitrate() + { + // + // TolerantEnumConverter answers a value it cannot parse with the member whose underlying + // value is zero. Which member that is decides what a damaged settings file transcribes with: + // + Assert.That(default(TranscriptionOpusBitrate), Is.EqualTo(TranscriptionOpusBitrate.KBPS_128), "The member with the underlying value zero is what a settings file the app cannot read falls back to, so it has to be the recommended bitrate."); + } +} \ No newline at end of file diff --git a/runtime/src/media.rs b/runtime/src/media.rs index d41282f9..1378fe7d 100644 --- a/runtime/src/media.rs +++ b/runtime/src/media.rs @@ -1835,6 +1835,30 @@ mod tests { let _ = fs::remove_dir_all(directory); } + /// Verifies the requested bitrate reaches the encoder rather than a fixed one. + #[test] + fn the_requested_bitrate_reaches_the_opus_encoder() { + let directory = std::env::temp_dir().join(format!("ai-studio-media-test-{}", rand::random::())); + fs::create_dir_all(&directory).unwrap(); + let input = directory.join("input.wav"); + fs::write(&input, wav_noise(OUTPUT_SAMPLE_RATE, OUTPUT_SAMPLE_RATE)).unwrap(); + + let mut sizes = Vec::new(); + for bitrate in [32_000u32, 256_000] { + let output = directory.join(format!("output-{bitrate}.webm")); + let result = normalize_media(&input, &output, DEFAULT_MAX_PASS_THROUGH_BYTES, bitrate, &MediaJob::new()).unwrap(); + assert!(!result.pass_through); + sizes.push(fs::metadata(&output).unwrap().len()); + } + + // One second of noise cannot be squeezed into a comparable size at both ends of the scale, + // so the higher bitrate has to produce a markedly larger file. Two outputs of roughly equal + // size would mean the requested bitrate never arrived and the encoder kept its own: + assert!(sizes[1] > sizes[0] * 2, "the higher bitrate did not grow the output: {sizes:?}"); + + let _ = fs::remove_dir_all(directory); + } + /// Verifies cancellation removes both final and partial outputs. #[test] fn cancellation_does_not_leave_an_output_file() { @@ -1962,7 +1986,28 @@ mod tests { /// Constructs a minimal mono 16-bit PCM WAV containing one constant sample value. fn wav_constant(sample_rate: u32, samples: u32, sample: i16) -> Vec { - let data_size = samples * 2; + wav_samples(sample_rate, &vec![sample; samples as usize]) + } + + /// Constructs a minimal mono 16-bit PCM WAV filled with deterministic pseudo-random noise. + /// + /// Noise is what a bitrate can be measured with: it barely compresses, so the encoder has to + /// spend whatever it was given on it. A tone would not do -- variable bitrate encodes one at + /// nearly the same size no matter which target it was asked for. + fn wav_noise(sample_rate: u32, samples: u32) -> Vec { + let mut state = 0x2545_F491_4F6C_DD1Du64; + let mut noise = Vec::with_capacity(samples as usize); + for _ in 0..samples { + state = state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); + noise.push((state >> 48) as i16); + } + + wav_samples(sample_rate, &noise) + } + + /// Wraps mono 16-bit PCM samples in a minimal WAV container. + fn wav_samples(sample_rate: u32, samples: &[i16]) -> Vec { + let data_size = samples.len() as u32 * 2; let mut wav = Vec::with_capacity(44 + data_size as usize); wav.extend_from_slice(b"RIFF"); wav.extend_from_slice(&(36 + data_size).to_le_bytes()); @@ -1976,7 +2021,7 @@ mod tests { wav.extend_from_slice(&16u16.to_le_bytes()); wav.extend_from_slice(b"data"); wav.extend_from_slice(&data_size.to_le_bytes()); - for _ in 0..samples { + for sample in samples { wav.extend_from_slice(&sample.to_le_bytes()); } wav