mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-05 13:09:40 +00:00
Added a configurable Opus bitrate for audio transcription (#981)
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
Build and Release / Verify (push) Waiting to run
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
Build and Release / Verify (push) Waiting to run
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
1 parent
f9c6075c50
commit
1379ff6aab
19 files changed
+304
-23
No files matched your search
+78
-17
@@ -44,9 +44,6 @@ const OUTPUT_SAMPLE_RATE: u32 = 48_000;
|
||||
/// Number of samples in one 20 ms Opus frame at 48 kHz.
|
||||
const OPUS_FRAME_SAMPLES: usize = 960;
|
||||
|
||||
/// Target bitrate for mono speech-oriented Opus output.
|
||||
const OPUS_BITRATE: u32 = 32_000;
|
||||
|
||||
/// Stable normalized container name returned to upload clients.
|
||||
const OUTPUT_FORMAT: &str = "webm";
|
||||
|
||||
@@ -62,6 +59,14 @@ const OPUS_PRE_SKIP: u16 = 312;
|
||||
/// Default size ceiling for copying an already-normalized file unchanged.
|
||||
const DEFAULT_MAX_PASS_THROUGH_BYTES: u64 = 25 * 1024 * 1024;
|
||||
|
||||
/// Default target bitrate for mono speech-oriented Opus output, used when a request omits it.
|
||||
///
|
||||
/// AI Studio always states a bitrate, so this default only covers requests which leave it out. It is
|
||||
/// 128 kbps because the 32 kbps this encoder used to be fixed at cost transcription models whole
|
||||
/// quiet passages: a softly spoken greeting at the start of a recording never reached the transcript,
|
||||
/// while the same recording at 128 kbps came back complete.
|
||||
const DEFAULT_OPUS_BITRATE_BPS: u32 = 128_000;
|
||||
|
||||
/// Bounded input block used for streaming resampling.
|
||||
const RESAMPLE_INPUT_BLOCK_SAMPLES: usize = 2_048;
|
||||
|
||||
@@ -94,6 +99,9 @@ pub struct CreateMediaJobRequest {
|
||||
|
||||
/// Optional size ceiling for pass-through files.
|
||||
pub max_pass_through_bytes: Option<u64>,
|
||||
|
||||
/// Optional target Opus encoder bitrate in bits per second.
|
||||
pub opus_bitrate_bps: Option<u32>,
|
||||
}
|
||||
|
||||
/// Response returned immediately after a media job has been registered.
|
||||
@@ -348,8 +356,9 @@ pub async fn create_job(
|
||||
let started_at = Instant::now();
|
||||
log::info!("media job registered: job_id={completed_job_id}");
|
||||
let max_pass_through_bytes = request.max_pass_through_bytes.unwrap_or(DEFAULT_MAX_PASS_THROUGH_BYTES);
|
||||
let opus_bitrate_bps = request.opus_bitrate_bps.unwrap_or(DEFAULT_OPUS_BITRATE_BPS);
|
||||
let task_job = Arc::clone(&job);
|
||||
let result = tokio::task::spawn_blocking(move || normalize_media(&input_path, &output_path, max_pass_through_bytes, &task_job)).await;
|
||||
let result = tokio::task::spawn_blocking(move || normalize_media(&input_path, &output_path, max_pass_through_bytes, opus_bitrate_bps, &task_job)).await;
|
||||
match result {
|
||||
Ok(Ok(result)) => {
|
||||
log::info!("media job completed: job_id={completed_job_id}, elapsed_ms={}", started_at.elapsed().as_millis());
|
||||
@@ -515,7 +524,7 @@ impl MediaSource for CancellationMediaSource {
|
||||
}
|
||||
|
||||
/// Probes, normalizes, and atomically commits one media file.
|
||||
fn normalize_media(input_path: &FilePath, output_path: &FilePath, max_pass_through_bytes: u64, job: &MediaJob) -> Result<MediaJobResult, MediaError> {
|
||||
fn normalize_media(input_path: &FilePath, output_path: &FilePath, max_pass_through_bytes: u64, opus_bitrate_bps: u32, job: &MediaJob) -> Result<MediaJobResult, MediaError> {
|
||||
check_cancelled(job)?;
|
||||
|
||||
let detected = FileFormat::from_file(input_path)
|
||||
@@ -623,7 +632,10 @@ fn normalize_media(input_path: &FilePath, output_path: &FilePath, max_pass_throu
|
||||
&& params.sample_rate == Some(OUTPUT_SAMPLE_RATE)
|
||||
&& channels == 1
|
||||
&& input_path.metadata().map(|metadata| metadata.len() <= max_pass_through_bytes).unwrap_or(false);
|
||||
log::info!("media normalization decision: track_id={track_id}, pass_through={pass_through}, codec={detected_codec}, channels={channels}");
|
||||
// A pass-through never reaches the encoder, so no bitrate is applied to it. Logging the one we
|
||||
// would have used anyway would send support looking for an encoding which never happened:
|
||||
let applied_opus_bitrate_bps = (!pass_through).then_some(opus_bitrate_bps);
|
||||
log::info!("media normalization decision: track_id={track_id}, pass_through={pass_through}, codec={detected_codec}, channels={channels}, opus_bitrate_bps={applied_opus_bitrate_bps:?}");
|
||||
|
||||
let partial_path = partial_path(output_path);
|
||||
if let Some(parent) = partial_path.parent() {
|
||||
@@ -670,6 +682,7 @@ fn normalize_media(input_path: &FilePath, output_path: &FilePath, max_pass_throu
|
||||
time_base: track_time_base,
|
||||
source_progress,
|
||||
job,
|
||||
opus_bitrate_bps,
|
||||
};
|
||||
transcode(&mut *format, context)
|
||||
};
|
||||
@@ -892,6 +905,9 @@ struct TranscodeContext<'a> {
|
||||
|
||||
/// Cancellation and progress state for the job.
|
||||
job: &'a MediaJob,
|
||||
|
||||
/// Target Opus encoder bitrate in bits per second.
|
||||
opus_bitrate_bps: u32,
|
||||
}
|
||||
|
||||
/// Decodes a selected track and writes timestamp-aligned 20 ms mono Opus frames.
|
||||
@@ -901,7 +917,7 @@ fn transcode(
|
||||
) -> Result<MediaJobResult, MediaError> {
|
||||
let mut decoder = StreamDecoder::new(&context.params, context.track_delay)?;
|
||||
let mut opus_encoder = OpusEncoder::builder(OUTPUT_SAMPLE_RATE, OpusChannels::Mono, Application::Audio)
|
||||
.bitrate(Bitrate::Bits(OPUS_BITRATE))
|
||||
.bitrate(Bitrate::Bits(context.opus_bitrate_bps))
|
||||
.vbr(true)
|
||||
.build()
|
||||
.map_err(|error| MediaError::new(MediaErrorCode::EncoderInitFailed, error.to_string()))?;
|
||||
@@ -1647,7 +1663,7 @@ mod tests {
|
||||
/// Creates a temporary output and normalizes one checked-in fixture.
|
||||
fn normalize_fixture(name: &str) -> Result<(MediaJobResult, PathBuf), MediaError> {
|
||||
let output = std::env::temp_dir().join(format!("ai-studio-fixture-{}.webm", rand::random::<u64>()));
|
||||
let result = normalize_media(&fixtures().join(name), &output, DEFAULT_MAX_PASS_THROUGH_BYTES, &MediaJob::new())?;
|
||||
let result = normalize_media(&fixtures().join(name), &output, DEFAULT_MAX_PASS_THROUGH_BYTES, DEFAULT_OPUS_BITRATE_BPS, &MediaJob::new())?;
|
||||
Ok((result, output))
|
||||
}
|
||||
|
||||
@@ -1804,7 +1820,7 @@ mod tests {
|
||||
let output = directory.join("output.webm");
|
||||
fs::write(&input, wav_silence(44_100, 4_410)).unwrap();
|
||||
let job = MediaJob::new();
|
||||
let result = normalize_media(&input, &output, DEFAULT_MAX_PASS_THROUGH_BYTES, &job).unwrap();
|
||||
let result = normalize_media(&input, &output, DEFAULT_MAX_PASS_THROUGH_BYTES, DEFAULT_OPUS_BITRATE_BPS, &job).unwrap();
|
||||
assert!(!result.pass_through);
|
||||
assert_eq!(result.output_format, OUTPUT_FORMAT);
|
||||
assert_eq!(result.output_codec, OUTPUT_CODEC);
|
||||
@@ -1822,6 +1838,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::<u64>()));
|
||||
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() {
|
||||
@@ -1832,7 +1872,7 @@ mod tests {
|
||||
fs::write(&input, wav_silence(48_000, 960)).unwrap();
|
||||
let job = MediaJob::new();
|
||||
job.cancelled.store(true, Ordering::Relaxed);
|
||||
let error = normalize_media(&input, &output, DEFAULT_MAX_PASS_THROUGH_BYTES, &job).unwrap_err();
|
||||
let error = normalize_media(&input, &output, DEFAULT_MAX_PASS_THROUGH_BYTES, DEFAULT_OPUS_BITRATE_BPS, &job).unwrap_err();
|
||||
assert_eq!(error.code, MediaErrorCode::Cancelled);
|
||||
assert!(!output.exists());
|
||||
assert!(!partial_path(&output).exists());
|
||||
@@ -1850,7 +1890,7 @@ mod tests {
|
||||
writer.write_packet(&[0xf8, 0xff, 0xfe], 0).unwrap();
|
||||
writer.finish().unwrap();
|
||||
let job = MediaJob::new();
|
||||
let result = normalize_media(&input, &output, DEFAULT_MAX_PASS_THROUGH_BYTES, &job).unwrap();
|
||||
let result = normalize_media(&input, &output, DEFAULT_MAX_PASS_THROUGH_BYTES, DEFAULT_OPUS_BITRATE_BPS, &job).unwrap();
|
||||
assert!(result.pass_through);
|
||||
assert_eq!(result.output_format, OUTPUT_FORMAT);
|
||||
assert_eq!(result.output_codec, OUTPUT_CODEC);
|
||||
@@ -1867,7 +1907,7 @@ mod tests {
|
||||
let input = directory.join("input.wav");
|
||||
let output = directory.join("output.webm");
|
||||
fs::write(&input, wav_constant(48_000, 960, 1_000)).unwrap();
|
||||
let result = normalize_media(&input, &output, DEFAULT_MAX_PASS_THROUGH_BYTES, &MediaJob::new()).unwrap();
|
||||
let result = normalize_media(&input, &output, DEFAULT_MAX_PASS_THROUGH_BYTES, DEFAULT_OPUS_BITRATE_BPS, &MediaJob::new()).unwrap();
|
||||
assert!(result.has_audible_signal);
|
||||
let _ = fs::remove_dir_all(directory);
|
||||
}
|
||||
@@ -1914,15 +1954,15 @@ mod tests {
|
||||
#[test]
|
||||
fn fixture_errors_are_stable() {
|
||||
let damaged_output = std::env::temp_dir().join(format!("ai-studio-damaged-{}.webm", rand::random::<u64>()));
|
||||
let damaged = normalize_media(&fixtures().join("damaged.bin"), &damaged_output, DEFAULT_MAX_PASS_THROUGH_BYTES, &MediaJob::new()).unwrap_err();
|
||||
let damaged = normalize_media(&fixtures().join("damaged.bin"), &damaged_output, DEFAULT_MAX_PASS_THROUGH_BYTES, DEFAULT_OPUS_BITRATE_BPS, &MediaJob::new()).unwrap_err();
|
||||
assert!(matches!(damaged.code, MediaErrorCode::UnknownFormat | MediaErrorCode::NotMedia | MediaErrorCode::DamagedContainer));
|
||||
|
||||
let no_audio_output = std::env::temp_dir().join(format!("ai-studio-no-audio-{}.webm", rand::random::<u64>()));
|
||||
let no_audio = normalize_media(&fixtures().join("no-audio.webm"), &no_audio_output, DEFAULT_MAX_PASS_THROUGH_BYTES, &MediaJob::new()).unwrap_err();
|
||||
let no_audio = normalize_media(&fixtures().join("no-audio.webm"), &no_audio_output, DEFAULT_MAX_PASS_THROUGH_BYTES, DEFAULT_OPUS_BITRATE_BPS, &MediaJob::new()).unwrap_err();
|
||||
assert_eq!(no_audio.code, MediaErrorCode::NoAudioTrack);
|
||||
|
||||
let unknown_output = std::env::temp_dir().join(format!("ai-studio-unknown-{}.webm", rand::random::<u64>()));
|
||||
let unknown = normalize_media(&fixtures().join("unknown-codec.mkv"), &unknown_output, DEFAULT_MAX_PASS_THROUGH_BYTES, &MediaJob::new()).unwrap_err();
|
||||
let unknown = normalize_media(&fixtures().join("unknown-codec.mkv"), &unknown_output, DEFAULT_MAX_PASS_THROUGH_BYTES, DEFAULT_OPUS_BITRATE_BPS, &MediaJob::new()).unwrap_err();
|
||||
assert_eq!(unknown.code, MediaErrorCode::UnsupportedCodec);
|
||||
}
|
||||
|
||||
@@ -1949,7 +1989,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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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());
|
||||
@@ -1963,7 +2024,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
|
||||
|
||||
Reference in new issue
Block a user