Added a configurable Opus bitrate for audio transcription

This commit is contained in:
Dominic Neuburg 2026-09-18 09:54:33 +02:00
parent 557d0b1409
commit 6be958eb92
14 changed files with 140 additions and 20 deletions

View File

@ -4993,6 +4993,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T16
-- Uses the provider-configured model
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T1760715963"] = "Uses the provider-configured model"
-- Higher bitrates can improve transcription accuracy, especially for quiet or noisy recordings, at the cost of a larger upload to the transcription provider. 128 kbps is recommended.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T1859657826"] = "Higher bitrates can improve transcription accuracy, especially for quiet or noisy recordings, at the cost of a larger upload to the transcription provider. 128 kbps is recommended."
-- Add Transcription Provider
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T2066315685"] = "Add Transcription Provider"
@ -5005,6 +5008,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T21
-- Name
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T266367750"] = "Name"
-- Transcription audio quality
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T3103106744"] = "Transcription audio quality"
-- Edit
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T3267849393"] = "Edit"
@ -10492,6 +10498,21 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::THEMESEXTENSIONS::T4107955313"]
-- Always use light theme
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::THEMESEXTENSIONS::T534715610"] = "Always use light theme"
-- 128 kbps (recommended)
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::TRANSCRIPTIONOPUSBITRATEEXTENSIONS::T2152168180"] = "128 kbps (recommended)"
-- 256 kbps (largest upload, highest accuracy)
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::TRANSCRIPTIONOPUSBITRATEEXTENSIONS::T3092489829"] = "256 kbps (largest upload, highest accuracy)"
-- Unknown
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::TRANSCRIPTIONOPUSBITRATEEXTENSIONS::T3424652889"] = "Unknown"
-- 64 kbps
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::TRANSCRIPTIONOPUSBITRATEEXTENSIONS::T3501477553"] = "64 kbps"
-- 32 kbps (smallest upload, lowest accuracy)
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::TRANSCRIPTIONOPUSBITRATEEXTENSIONS::T767394292"] = "32 kbps (smallest upload, lowest accuracy)"
-- Use no profile
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::PROFILE::T2205839602"] = "Use no profile"

View File

@ -12,7 +12,7 @@
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
@T("With the support of transcription models, MindWork AI Studio can convert human speech into text. This is useful, for example, when you need to dictate text. You can choose from dedicated transcription models, but not multimodal LLMs (large language models) that can handle both speech and text. The configuration of multimodal models is done in the 'Configure providers' section.")
</MudJustifiedText>
<MudTable Items="@this.SettingsManager.GetAllTranscriptionProviders()" Hover="@true" GroupBy="@GROUP_CONFIG" Class="border-dashed border rounded-lg">
<ColGroup>
<col style="width: 12em;"/>
@ -84,5 +84,9 @@
}
<LockableButton Text="@T("Add transcription provider")" IsLocked="@(() => !this.SettingsManager.ConfigurationData.App.AllowUserToAddProvider || !this.SettingsManager.ConfigurationData.App.AllowUserToAddTranscriptionProvider)" Icon="@Icons.Material.Filled.AddRoad" OnClickAsync="@this.AddTranscriptionProvider" Class="mt-3" />
<MudDivider Class="mt-6 mb-3"/>
<ConfigurationSelect OptionDescription="@T("Transcription audio quality")" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.OpusBitrate)" Data="@ConfigurationSelectDataFactory.GetTranscriptionOpusBitrateData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.App.OpusBitrate = selectedValue)" OptionHelp="@this.OpusBitrateHelp" IsLocked="@this.IsOpusBitrateLocked"/>
</ExpansionPanel>
}

View File

@ -36,7 +36,11 @@ public partial class SettingsPanelTranscription : SettingsPanelProviderBase
var modelName = provider.Model.ToString();
return modelName.Length > MAX_LENGTH ? "[...] " + modelName[^Math.Min(MAX_LENGTH, modelName.Length)..] : modelName;
}
private string OpusBitrateHelp => T("Higher bitrates can improve transcription accuracy, especially for quiet or noisy recordings, at the cost of a larger upload to the transcription provider. 128 kbps is recommended.");
private bool IsOpusBitrateLocked() => ManagedConfiguration.TryGet(x => x.App, x => x.OpusBitrate, out var meta) && meta.IsLocked;
#region Overrides of ComponentBase
protected override async Task OnInitializedAsync()

View File

@ -578,6 +578,15 @@ CONFIG["SETTINGS"] = {}
-- Please note: using an empty string ("") will lock the selection and disable dictation/transcription.
-- CONFIG["SETTINGS"]["DataApp.UseTranscriptionProvider"] = "00000000-0000-0000-0000-000000000000"
-- Configure the Opus bitrate used when normalizing uploaded audio/video for transcription.
-- Allowed values are: KBPS_32, KBPS_64, KBPS_128, KBPS_256
-- Higher bitrates improve transcription accuracy on noisy or quiet recordings, at the cost of
-- a larger upload to the transcription provider. KBPS_128 is recommended.
-- CONFIG["SETTINGS"]["DataApp.OpusBitrate"] = "KBPS_32"
--
-- Allow the user to change the Opus bitrate even though your organization set a default above:
-- CONFIG["SETTINGS"]["DataApp.OpusBitrate.AllowUserOverride"] = true
-- Configure which assistants should be hidden from the UI.
-- Allowed values are:
-- GRAMMAR_SPELLING_ASSISTANT, ICON_FINDER_ASSISTANT, REWRITE_ASSISTANT,

View File

@ -4995,6 +4995,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T16
-- Uses the provider-configured model
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T1760715963"] = "Verwendet das vom Anbieter konfigurierte Modell"
-- Higher bitrates can improve transcription accuracy, especially for quiet or noisy recordings, at the cost of a larger upload to the transcription provider. 128 kbps is recommended.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T1859657826"] = "Höhere Bitraten können die Transkriptionsgenauigkeit verbessern, besonders bei ruhigen oder lauten Aufnahmen, zum Nachteil eines größeren Uploads an den Transkriptionsdienstleister. 128 kbps wird empfohlen."
-- Add Transcription Provider
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T2066315685"] = "Anbieter für Transkriptionen hinzufügen"
@ -5007,6 +5010,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T21
-- Name
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T266367750"] = "Name"
-- Transcription audio quality
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T3103106744"] = "Aufzeichnungsqualität für die Transkription"
-- Edit
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T3267849393"] = "Bearbeiten"
@ -10494,6 +10500,21 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::THEMESEXTENSIONS::T4107955313"]
-- Always use light theme
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::THEMESEXTENSIONS::T534715610"] = "Immer das helle Design verwenden"
-- 128 kbps (recommended)
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::TRANSCRIPTIONOPUSBITRATEEXTENSIONS::T2152168180"] = "128 kbps (empfohlen)"
-- 256 kbps (largest upload, highest accuracy)
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::TRANSCRIPTIONOPUSBITRATEEXTENSIONS::T3092489829"] = "256 kbps (größter Upload, höchste Genauigkeit)"
-- Unknown
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::TRANSCRIPTIONOPUSBITRATEEXTENSIONS::T3424652889"] = "Bitte geben Sie den zu übersetzenden Text an."
-- 64 kbps
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::TRANSCRIPTIONOPUSBITRATEEXTENSIONS::T3501477553"] = "64 kbps"
-- 32 kbps (smallest upload, lowest accuracy)
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::TRANSCRIPTIONOPUSBITRATEEXTENSIONS::T767394292"] = "32 kbps (kleinster Upload, geringste Genauigkeit)"
-- Use no profile
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::PROFILE::T2205839602"] = "Kein Profil verwenden"

View File

@ -323,4 +323,10 @@ public static class ConfigurationSelectDataFactory
yield return new(level.GetName(), level);
}
}
public static IEnumerable<ConfigurationSelectData<TranscriptionOpusBitrate>> GetTranscriptionOpusBitrateData()
{
foreach (var bitrate in Enum.GetValues<TranscriptionOpusBitrate>())
yield return new(bitrate.GetName(), bitrate);
}
}

View File

@ -112,6 +112,11 @@ public sealed class DataApp(Expression<Func<Data, DataApp>>? configSelection = n
/// </summary>
public string UseTranscriptionProvider { get; set; } = ManagedConfiguration.Register(configSelection, n => n.UseTranscriptionProvider, string.Empty);
/// <summary>
/// The Opus bitrate used when normalizing uploaded audio/video for transcription.
/// </summary>
public TranscriptionOpusBitrate OpusBitrate { get; set; } = ManagedConfiguration.Register(configSelection, n => n.OpusBitrate, TranscriptionOpusBitrate.KBPS_128);
/// <summary>
/// The global keyboard shortcut for toggling voice recording.
/// Uses Tauri's shortcut format, e.g., "CmdOrControl+1" (Cmd+1 on macOS, Ctrl+1 on Windows/Linux).

View File

@ -0,0 +1,9 @@
namespace AIStudio.Settings.DataModel;
public enum TranscriptionOpusBitrate
{
KBPS_32,
KBPS_64,
KBPS_128,
KBPS_256,
}

View File

@ -0,0 +1,26 @@
using AIStudio.Tools.PluginSystem;
namespace AIStudio.Settings.DataModel;
public static class TranscriptionOpusBitrateExtensions
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(TranscriptionOpusBitrateExtensions).Namespace, nameof(TranscriptionOpusBitrateExtensions));
public static string GetName(this TranscriptionOpusBitrate bitrate) => bitrate switch
{
TranscriptionOpusBitrate.KBPS_32 => TB("32 kbps (smallest upload, lowest accuracy)"),
TranscriptionOpusBitrate.KBPS_64 => TB("64 kbps"),
TranscriptionOpusBitrate.KBPS_128 => TB("128 kbps (recommended)"),
TranscriptionOpusBitrate.KBPS_256 => TB("256 kbps (largest upload, highest accuracy)"),
_ => TB("Unknown"),
};
public static uint GetBitsPerSecond(this TranscriptionOpusBitrate bitrate) => bitrate switch
{
TranscriptionOpusBitrate.KBPS_32 => 32_000,
TranscriptionOpusBitrate.KBPS_64 => 64_000,
TranscriptionOpusBitrate.KBPS_128 => 128_000,
TranscriptionOpusBitrate.KBPS_256 => 256_000,
_ => 32_000,
};
}

View File

@ -398,6 +398,9 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
// Config: transcription provider?
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.UseTranscriptionProvider, Guid.Empty, this.Id, settingsTable, dryRun);
// Config: transcription Opus bitrate?
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.OpusBitrate, this.Id, settingsTable, dryRun);
message = string.Empty;
return true;
}

View File

@ -4,4 +4,5 @@ namespace AIStudio.Tools.Rust;
/// <param name="InputPath">Absolute source media path.</param>
/// <param name="OutputPath">Absolute operation-owned output path.</param>
/// <param name="MaxPassThroughBytes">Optional pass-through size ceiling.</param>
public sealed record CreateMediaJobRequest(string InputPath, string OutputPath, ulong? MaxPassThroughBytes = null);
/// <param name="OpusBitrateBps">Optional target Opus encoder bitrate in bits per second.</param>
public sealed record CreateMediaJobRequest(string InputPath, string OutputPath, ulong? MaxPassThroughBytes = null, uint? OpusBitrateBps = null);

View File

@ -1,6 +1,7 @@
using AIStudio.Chat;
using AIStudio.Provider;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.Media;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Rust;
@ -648,7 +649,8 @@ public sealed class MediaTranscriptionService(
{
// The quick POST is intentionally not cancelled: losing its response could orphan a job
// whose ID the client never received. Cancellation is applied immediately after ownership.
var jobId = await rustService.StartMediaJobAsync(mediaPath, normalizedPath, CancellationToken.None);
var opusBitrateBps = settingsManager.ConfigurationData.App.OpusBitrate.GetBitsPerSecond();
var jobId = await rustService.StartMediaJobAsync(mediaPath, normalizedPath, opusBitrateBps, CancellationToken.None);
operation.JobId = jobId;
try

View File

@ -10,13 +10,14 @@ public partial class RustService
/// <summary>Starts a Rust media normalization job.</summary>
/// <param name="inputPath">Absolute source path.</param>
/// <param name="outputPath">Absolute operation-owned output path.</param>
/// <param name="opusBitrateBps">Target Opus encoder bitrate in bits per second.</param>
/// <param name="token">Request cancellation token.</param>
/// <returns>The opaque runtime job identifier.</returns>
public async Task<string> StartMediaJobAsync(string inputPath, string outputPath, CancellationToken token = default)
public async Task<string> StartMediaJobAsync(string inputPath, string outputPath, uint opusBitrateBps, CancellationToken token = default)
{
using var response = await this.http.PostAsJsonAsync(
"/media/jobs",
new CreateMediaJobRequest(inputPath, outputPath),
new CreateMediaJobRequest(inputPath, outputPath, OpusBitrateBps: opusBitrateBps),
this.jsonRustSerializerOptions,
token);

View File

@ -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,9 @@ 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.
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 +94,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 +351,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 +519,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)
@ -670,6 +674,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 +897,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 +909,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 +1655,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 +1812,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);
@ -1832,7 +1840,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 +1858,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 +1875,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 +1922,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);
}