Added a configurable Opus bitrate for audio transcription (#981)
Some checks are pending
Build and Release / Verify (push) Waiting to run
Build and Release / Determine run mode (push) Waiting to run
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 }}) (-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 / 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

Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
Dominic Neuburg 2026-09-19 19:13:33 +02:00 committed by GitHub
parent f9c6075c50
commit 1379ff6aab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 304 additions and 23 deletions

View File

@ -4561,6 +4561,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1723256298"]
-- Select a transcription provider for transcribing your voice. Without a selected provider, dictation and transcription features will be disabled. -- Select a transcription provider for transcribing your voice. Without a selected provider, dictation and transcription features will be disabled.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1834486728"] = "Select a transcription provider for transcribing your voice. Without a selected provider, dictation and transcription features will be disabled." UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1834486728"] = "Select a transcription provider for transcribing your voice. Without a selected provider, dictation and transcription features will be disabled."
-- 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::SETTINGSPANELAPP::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."
-- Select the language behavior for the app. The default is to use the system language. You might want to choose a language manually? -- Select the language behavior for the app. The default is to use the system language. You might want to choose a language manually?
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T186780842"] = "Select the language behavior for the app. The default is to use the system language. You might want to choose a language manually?" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T186780842"] = "Select the language behavior for the app. The default is to use the system language. You might want to choose a language manually?"
@ -4627,6 +4630,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2960110864"]
-- Save energy? -- Save energy?
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3100928009"] = "Save energy?" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3100928009"] = "Save energy?"
-- Transcription audio quality
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3103106744"] = "Transcription audio quality"
-- Development builds do not install updates. -- Development builds do not install updates.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3138812562"] = "Development builds do not install updates." UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3138812562"] = "Development builds do not install updates."
@ -10489,6 +10495,21 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::THEMESEXTENSIONS::T4107955313"]
-- Always use light theme -- Always use light theme
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::THEMESEXTENSIONS::T534715610"] = "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 -- Use no profile
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::PROFILE::T2205839602"] = "Use no profile" UI_TEXT_CONTENT["AISTUDIO::SETTINGS::PROFILE::T2205839602"] = "Use no profile"

View File

@ -50,6 +50,7 @@
</ItemTemplate> </ItemTemplate>
</ConfigurationSelect> </ConfigurationSelect>
<ConfigurationShortcut Data="@this.VoiceRecordingShortcut" OptionDescription="@T("Voice recording shortcut")" OptionHelp="@T("The global keyboard shortcut for toggling voice recording. This shortcut works system-wide, even when the app is not focused.")" IsLocked="() => ManagedConfiguration.TryGet(x => x.App, x => x.ShortcutVoiceRecording, out var meta) && meta.IsLocked"/> <ConfigurationShortcut Data="@this.VoiceRecordingShortcut" OptionDescription="@T("Voice recording shortcut")" OptionHelp="@T("The global keyboard shortcut for toggling voice recording. This shortcut works system-wide, even when the app is not focused.")" IsLocked="() => ManagedConfiguration.TryGet(x => x.App, x => x.ShortcutVoiceRecording, out var meta) && meta.IsLocked"/>
<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="@IsOpusBitrateLocked"/>
} }
@if (this.SettingsManager.ConfigurationData.App.ShowAdminSettings) @if (this.SettingsManager.ConfigurationData.App.ShowAdminSettings)

View File

@ -79,6 +79,10 @@ public partial class SettingsPanelApp : SettingsPanelBase
DisplayUpdate = this.UpdateShortcutVoiceRecordingDisplay, DisplayUpdate = this.UpdateShortcutVoiceRecordingDisplay,
}; };
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 static bool IsOpusBitrateLocked() => ManagedConfiguration.TryGet(x => x.App, x => x.OpusBitrate, out var meta) && meta.IsLocked;
private async Task GenerateEncryptionSecret() private async Task GenerateEncryptionSecret()
{ {
var secret = EnterpriseEncryption.GenerateSecret(); var secret = EnterpriseEncryption.GenerateSecret();

View File

@ -12,7 +12,7 @@
<MudJustifiedText Typo="Typo.body1" Class="mb-3"> <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.") @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> </MudJustifiedText>
<MudTable Items="@this.SettingsManager.GetAllTranscriptionProviders()" Hover="@true" GroupBy="@GROUP_CONFIG" Class="border-dashed border rounded-lg"> <MudTable Items="@this.SettingsManager.GetAllTranscriptionProviders()" Hover="@true" GroupBy="@GROUP_CONFIG" Class="border-dashed border rounded-lg">
<ColGroup> <ColGroup>
<col style="width: 12em;"/> <col style="width: 12em;"/>

View File

@ -36,7 +36,7 @@ public partial class SettingsPanelTranscription : SettingsPanelProviderBase
var modelName = provider.Model.ToString(); var modelName = provider.Model.ToString();
return modelName.Length > MAX_LENGTH ? "[...] " + modelName[^Math.Min(MAX_LENGTH, modelName.Length)..] : modelName; return modelName.Length > MAX_LENGTH ? "[...] " + modelName[^Math.Min(MAX_LENGTH, modelName.Length)..] : modelName;
} }
#region Overrides of ComponentBase #region Overrides of ComponentBase
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()

View File

@ -578,6 +578,18 @@ CONFIG["SETTINGS"] = {}
-- Please note: using an empty string ("") will lock the selection and disable dictation/transcription. -- Please note: using an empty string ("") will lock the selection and disable dictation/transcription.
-- CONFIG["SETTINGS"]["DataApp.UseTranscriptionProvider"] = "00000000-0000-0000-0000-000000000000" -- 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.
-- Please note: this bitrate applies whenever a recording has to be re-encoded. A file which already
-- is a single mono 48 kHz Opus track in a WebM container and stays below 25 MiB is forwarded to the
-- transcription provider unchanged, keeping the bitrate it was created with.
-- 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. -- Configure which assistants should be hidden from the UI.
-- Allowed values are: -- Allowed values are:
-- GRAMMAR_SPELLING_ASSISTANT, ICON_FINDER_ASSISTANT, REWRITE_ASSISTANT, -- GRAMMAR_SPELLING_ASSISTANT, ICON_FINDER_ASSISTANT, REWRITE_ASSISTANT,

View File

@ -4563,6 +4563,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1723256298"]
-- Select a transcription provider for transcribing your voice. Without a selected provider, dictation and transcription features will be disabled. -- Select a transcription provider for transcribing your voice. Without a selected provider, dictation and transcription features will be disabled.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1834486728"] = "Wählen Sie für die Transkription Ihrer Stimme einen Anbieter für Transkriptionen aus. Ohne einen ausgewählten Anbieter wird die Diktier- und Transkriptions-Funktion deaktiviert." UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1834486728"] = "Wählen Sie für die Transkription Ihrer Stimme einen Anbieter für Transkriptionen aus. Ohne einen ausgewählten Anbieter wird die Diktier- und Transkriptions-Funktion deaktiviert."
-- 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::SETTINGSPANELAPP::T1859657826"] = "Höhere Bitraten können die Genauigkeit der Transkription verbessern, insbesondere bei leisen oder verrauschten Aufnahmen. Dafür wird eine größere Datei an den Anbieter der Transkription übertragen. Empfohlen werden 128 kbit/s."
-- Select the language behavior for the app. The default is to use the system language. You might want to choose a language manually? -- Select the language behavior for the app. The default is to use the system language. You might want to choose a language manually?
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T186780842"] = "Wählen Sie das Sprachverhalten für die App aus. Standardmäßig wird die Systemsprache verwendet. Möchten Sie die Sprache manuell einstellen?" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T186780842"] = "Wählen Sie das Sprachverhalten für die App aus. Standardmäßig wird die Systemsprache verwendet. Möchten Sie die Sprache manuell einstellen?"
@ -4629,6 +4632,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2960110864"]
-- Save energy? -- Save energy?
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3100928009"] = "Energie sparen?" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3100928009"] = "Energie sparen?"
-- Transcription audio quality
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3103106744"] = "Audioqualität der Transkription"
-- Development builds do not install updates. -- Development builds do not install updates.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3138812562"] = "Entwicklerversionen installieren keine Updates." UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3138812562"] = "Entwicklerversionen installieren keine Updates."
@ -10491,6 +10497,21 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::THEMESEXTENSIONS::T4107955313"]
-- Always use light theme -- Always use light theme
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::THEMESEXTENSIONS::T534715610"] = "Immer das helle Design verwenden" 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 kbit/s (empfohlen)"
-- 256 kbps (largest upload, highest accuracy)
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::TRANSCRIPTIONOPUSBITRATEEXTENSIONS::T3092489829"] = "256 kbit/s (größte Datei, höchste Genauigkeit)"
-- Unknown
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::TRANSCRIPTIONOPUSBITRATEEXTENSIONS::T3424652889"] = "Unbekannt"
-- 64 kbps
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::TRANSCRIPTIONOPUSBITRATEEXTENSIONS::T3501477553"] = "64 kbit/s"
-- 32 kbps (smallest upload, lowest accuracy)
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::TRANSCRIPTIONOPUSBITRATEEXTENSIONS::T767394292"] = "32 kbit/s (kleinste Datei, geringste Genauigkeit)"
-- Use no profile -- Use no profile
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::PROFILE::T2205839602"] = "Kein Profil verwenden" UI_TEXT_CONTENT["AISTUDIO::SETTINGS::PROFILE::T2205839602"] = "Kein Profil verwenden"

View File

@ -4563,6 +4563,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1723256298"]
-- Select a transcription provider for transcribing your voice. Without a selected provider, dictation and transcription features will be disabled. -- Select a transcription provider for transcribing your voice. Without a selected provider, dictation and transcription features will be disabled.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1834486728"] = "Select a transcription provider for transcribing your voice. Without a selected provider, dictation and transcription features will be disabled." UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1834486728"] = "Select a transcription provider for transcribing your voice. Without a selected provider, dictation and transcription features will be disabled."
-- 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::SETTINGSPANELAPP::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."
-- Select the language behavior for the app. The default is to use the system language. You might want to choose a language manually? -- Select the language behavior for the app. The default is to use the system language. You might want to choose a language manually?
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T186780842"] = "Select the language behavior for the app. The default is to use the system language. You might want to choose a language manually?" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T186780842"] = "Select the language behavior for the app. The default is to use the system language. You might want to choose a language manually?"
@ -4629,6 +4632,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2960110864"]
-- Save energy? -- Save energy?
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3100928009"] = "Save energy?" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3100928009"] = "Save energy?"
-- Transcription audio quality
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3103106744"] = "Transcription audio quality"
-- Development builds do not install updates. -- Development builds do not install updates.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3138812562"] = "Development builds do not install updates." UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3138812562"] = "Development builds do not install updates."
@ -10491,6 +10497,21 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::THEMESEXTENSIONS::T4107955313"]
-- Always use light theme -- Always use light theme
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::THEMESEXTENSIONS::T534715610"] = "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 -- Use no profile
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::PROFILE::T2205839602"] = "Use no profile" UI_TEXT_CONTENT["AISTUDIO::SETTINGS::PROFILE::T2205839602"] = "Use no profile"

View File

@ -323,4 +323,12 @@ public static class ConfigurationSelectDataFactory
yield return new(level.GetName(), level); 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,18 @@ public sealed class DataApp(Expression<Func<Data, DataApp>>? configSelection = n
/// </summary> /// </summary>
public string UseTranscriptionProvider { get; set; } = ManagedConfiguration.Register(configSelection, n => n.UseTranscriptionProvider, string.Empty); 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>
/// <remarks>
/// Every recording is re-encoded to mono Opus before it goes to the transcription provider. That
/// encoding used to be fixed at 32 kbps, which cost the transcription models entire quiet passages:
/// a greeting spoken softly at the start of a recording was simply missing from the transcript. The
/// same recording compared at 64 and 128 kbps came back complete, which is why 128 kbps is the
/// default here. Users trading accuracy for a smaller upload can still pick a lower bitrate.
/// </remarks>
public TranscriptionOpusBitrate OpusBitrate { get; set; } = ManagedConfiguration.Register(configSelection, n => n.OpusBitrate, TranscriptionOpusBitrate.KBPS_128);
/// <summary> /// <summary>
/// The global keyboard shortcut for toggling voice recording. /// 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). /// Uses Tauri's shortcut format, e.g., "CmdOrControl+1" (Cmd+1 on macOS, Ctrl+1 on Windows/Linux).

View File

@ -0,0 +1,14 @@
namespace AIStudio.Settings.DataModel;
public enum TranscriptionOpusBitrate
{
// The recommended bitrate is deliberately the member with the underlying value 0: when the
// settings file holds a value TolerantEnumConverter cannot read, it falls back to that member.
// Landing on the lowest bitrate there would silently reintroduce the very defect this setting
// exists to prevent -- transcripts losing what was said quietly.
KBPS_128 = 0,
KBPS_32,
KBPS_64,
KBPS_256,
}

View File

@ -0,0 +1,29 @@
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,
// A value we do not know must never mean the lowest quality: that is how quiet passages
// went missing from transcripts in the first place. Fall back to the recommended bitrate.
_ => 128_000,
};
}

View File

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

View File

@ -4,4 +4,5 @@ namespace AIStudio.Tools.Rust;
/// <param name="InputPath">Absolute source media path.</param> /// <param name="InputPath">Absolute source media path.</param>
/// <param name="OutputPath">Absolute operation-owned output path.</param> /// <param name="OutputPath">Absolute operation-owned output path.</param>
/// <param name="MaxPassThroughBytes">Optional pass-through size ceiling.</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.Chat;
using AIStudio.Provider; using AIStudio.Provider;
using AIStudio.Settings; using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.Media; using AIStudio.Tools.Media;
using AIStudio.Tools.PluginSystem; using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Rust; using AIStudio.Tools.Rust;
@ -646,9 +647,23 @@ public sealed class MediaTranscriptionService(
MediaOperation operation, MediaOperation operation,
bool updateImportState) bool updateImportState)
{ {
// The bitrate governs re-encoding, not every upload: the runtime hands a file through
// unchanged when it already is a single mono 48 kHz Opus track in a WebM container and small
// enough. Such a file keeps whatever bitrate it was made with, which is the better outcome --
// re-encoding it could only take quality away, never add any.
var opusBitrateBps = settingsManager.ConfigurationData.App.OpusBitrate.GetBitsPerSecond();
// Which quality an upload was produced with is the first question to ask when a transcript
// comes back missing something, so it has to be in the log of the job it belongs to:
logger.LogInformation(
"Normalizing media for operation {OperationId}; re-encoding uses the Opus bitrate {OpusBitrate} ({OpusBitrateBps} bps).",
operation.Id,
settingsManager.ConfigurationData.App.OpusBitrate,
opusBitrateBps);
// The quick POST is intentionally not cancelled: losing its response could orphan a job // 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. // whose ID the client never received. Cancellation is applied immediately after ownership.
var jobId = await rustService.StartMediaJobAsync(mediaPath, normalizedPath, CancellationToken.None); var jobId = await rustService.StartMediaJobAsync(mediaPath, normalizedPath, opusBitrateBps, CancellationToken.None);
operation.JobId = jobId; operation.JobId = jobId;
try try

View File

@ -10,13 +10,14 @@ public partial class RustService
/// <summary>Starts a Rust media normalization job.</summary> /// <summary>Starts a Rust media normalization job.</summary>
/// <param name="inputPath">Absolute source path.</param> /// <param name="inputPath">Absolute source path.</param>
/// <param name="outputPath">Absolute operation-owned output 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> /// <param name="token">Request cancellation token.</param>
/// <returns>The opaque runtime job identifier.</returns> /// <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( using var response = await this.http.PostAsJsonAsync(
"/media/jobs", "/media/jobs",
new CreateMediaJobRequest(inputPath, outputPath), new CreateMediaJobRequest(inputPath, outputPath, OpusBitrateBps: opusBitrateBps),
this.jsonRustSerializerOptions, this.jsonRustSerializerOptions,
token); token);

View File

@ -28,6 +28,8 @@
- Added drag and drop to the input and output folder of the Batch Processing assistant: drop a folder onto either field to choose it. - Added drag and drop to the input and output folder of the Batch Processing assistant: drop a folder onto either field to choose it.
- Added ways to load text from a file and drop zones for them, throughout the assistants and dialogs. We went through them one by one, so many fields that used to accept typed text only now take the content of a file as well. - Added ways to load text from a file and drop zones for them, throughout the assistants and dialogs. We went through them one by one, so many fields that used to accept typed text only now take the content of a file as well.
- Added an optional API key to every server you host yourself, among them LM Studio, llama.cpp, and whisper.cpp. Such a server may ask for one itself or sit behind a login your organization placed in front of it. So far, only Ollama and vLLM could be given a key. - Added an optional API key to every server you host yourself, among them LM Studio, llama.cpp, and whisper.cpp. Such a server may ask for one itself or sit behind a login your organization placed in front of it. So far, only Ollama and vLLM could be given a key.
- Added a setting for the audio quality used when your speech and your audio and video files are transcribed. AI Studio prepares every recording before it goes to your transcription provider, and you now decide how much detail it keeps: a lower quality travels faster, a higher one gives the transcription model more to work with. You find it in the app settings, right below your transcription provider. Thanks, Dominic Neuburg (`donework`), for this contribution.
- Added organization-wide management for the audio quality used when transcribing. IT departments can set the quality their organization works with and lock it, or leave it as a default their colleagues are free to change.
- Improved loading web content in the assistants: it now uses the same reader as the Read Web Page tool, which extracts the main content of a page more reliably and skips navigation and boilerplate. Pages from your own network, including local servers, keep working as before. When a page cannot be read, AI Studio now says why instead of leaving the field empty. - Improved loading web content in the assistants: it now uses the same reader as the Read Web Page tool, which extracts the main content of a page more reliably and skips navigation and boilerplate. Pages from your own network, including local servers, keep working as before. When a page cannot be read, AI Studio now says why instead of leaving the field empty.
- Improved the app icon. The previous one was generated by an image model; the new one was created based on it and keeps the familiar green landscape with the chat bubble. Because it is now a vector drawing, it stays sharp everywhere it appears: in your taskbar or dock, in the window list, and on the start screen while AI Studio is loading. - Improved the app icon. The previous one was generated by an image model; the new one was created based on it and keeps the familiar green landscape with the chat bubble. Because it is now a vector drawing, it stays sharp everywhere it appears: in your taskbar or dock, in the window list, and on the start screen while AI Studio is loading.
- Improved how AI Studio works out what a model can do. Every model family now stands on its own, together with the page it was read from, and our build refuses rules which contradict each other or name no source. That way, mistakes are caught before they ever reach you. - Improved how AI Studio works out what a model can do. Every model family now stands on its own, together with the page it was read from, and our build refuses rules which contradict each other or name no source. That way, mistakes are caught before they ever reach you.
@ -69,5 +71,6 @@
- Fixed the list of models staying empty at a server you host yourself, which made the model you had picked look as if it had vanished. Your key was there all along, it just was not read when the settings opened. - Fixed the list of models staying empty at a server you host yourself, which made the model you had picked look as if it had vanished. Your key was there all along, it just was not read when the settings opened.
- Fixed AI Studio asking such a server for its models with an empty key attached when you had stored none at all. Servers behind a login turn those requests down. - Fixed AI Studio asking such a server for its models with an empty key attached when you had stored none at all. Servers behind a login turn those requests down.
- Fixed a key that could not be saved going unmentioned for the servers you host yourself. You are now told what went wrong, instead of the settings simply staying open. - Fixed a key that could not be saved going unmentioned for the servers you host yourself. You are now told what went wrong, instead of the settings simply staying open.
- Fixed transcripts are quietly losing what was said softly, such as a greeting at the very beginning of a recording. AI Studio compressed recordings so far before sending them to your transcription provider that the model could no longer make out those passages. Recordings now keep enough details for the whole of what you said to arrive.
- Upgraded the Visual Briefing Assistant (in preview) from the prototype to the beta state. The assistant is now completely implemented and is undergoing a deeper testing phase in preparation for release. To try it, open the app settings, allow preview features down to beta, and then enable the Visual Briefing Assistant there. - Upgraded the Visual Briefing Assistant (in preview) from the prototype to the beta state. The assistant is now completely implemented and is undergoing a deeper testing phase in preparation for release. To try it, open the app settings, allow preview features down to beta, and then enable the Visual Briefing Assistant there.
- Upgraded the vector database behind local RAG (Qdrant Edge) to version 0.8.0. - Upgraded the vector database behind local RAG (Qdrant Edge) to version 0.8.0.

View File

@ -0,0 +1,54 @@
using AIStudio.Settings.DataModel;
namespace AIStudio.Tests.Settings;
/// <summary>
/// Checks which bitrate the transcription pipeline ends up asking the Opus encoder for.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[TestFixture]
public sealed class TranscriptionOpusBitrateTests
{
private static readonly Dictionary<TranscriptionOpusBitrate, uint> 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<TranscriptionOpusBitrate>())
{
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.");
}
}

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. /// Number of samples in one 20 ms Opus frame at 48 kHz.
const OPUS_FRAME_SAMPLES: usize = 960; 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. /// Stable normalized container name returned to upload clients.
const OUTPUT_FORMAT: &str = "webm"; 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. /// Default size ceiling for copying an already-normalized file unchanged.
const DEFAULT_MAX_PASS_THROUGH_BYTES: u64 = 25 * 1024 * 1024; 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. /// Bounded input block used for streaming resampling.
const RESAMPLE_INPUT_BLOCK_SAMPLES: usize = 2_048; const RESAMPLE_INPUT_BLOCK_SAMPLES: usize = 2_048;
@ -94,6 +99,9 @@ pub struct CreateMediaJobRequest {
/// Optional size ceiling for pass-through files. /// Optional size ceiling for pass-through files.
pub max_pass_through_bytes: Option<u64>, 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. /// Response returned immediately after a media job has been registered.
@ -348,8 +356,9 @@ pub async fn create_job(
let started_at = Instant::now(); let started_at = Instant::now();
log::info!("media job registered: job_id={completed_job_id}"); 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 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 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 { match result {
Ok(Ok(result)) => { Ok(Ok(result)) => {
log::info!("media job completed: job_id={completed_job_id}, elapsed_ms={}", started_at.elapsed().as_millis()); 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. /// 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)?; check_cancelled(job)?;
let detected = FileFormat::from_file(input_path) 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) && params.sample_rate == Some(OUTPUT_SAMPLE_RATE)
&& channels == 1 && channels == 1
&& input_path.metadata().map(|metadata| metadata.len() <= max_pass_through_bytes).unwrap_or(false); && 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); let partial_path = partial_path(output_path);
if let Some(parent) = partial_path.parent() { 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, time_base: track_time_base,
source_progress, source_progress,
job, job,
opus_bitrate_bps,
}; };
transcode(&mut *format, context) transcode(&mut *format, context)
}; };
@ -892,6 +905,9 @@ struct TranscodeContext<'a> {
/// Cancellation and progress state for the job. /// Cancellation and progress state for the job.
job: &'a MediaJob, 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. /// Decodes a selected track and writes timestamp-aligned 20 ms mono Opus frames.
@ -901,7 +917,7 @@ fn transcode(
) -> Result<MediaJobResult, MediaError> { ) -> Result<MediaJobResult, MediaError> {
let mut decoder = StreamDecoder::new(&context.params, context.track_delay)?; let mut decoder = StreamDecoder::new(&context.params, context.track_delay)?;
let mut opus_encoder = OpusEncoder::builder(OUTPUT_SAMPLE_RATE, OpusChannels::Mono, Application::Audio) 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) .vbr(true)
.build() .build()
.map_err(|error| MediaError::new(MediaErrorCode::EncoderInitFailed, error.to_string()))?; .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. /// Creates a temporary output and normalizes one checked-in fixture.
fn normalize_fixture(name: &str) -> Result<(MediaJobResult, PathBuf), MediaError> { 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 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)) Ok((result, output))
} }
@ -1804,7 +1820,7 @@ mod tests {
let output = directory.join("output.webm"); let output = directory.join("output.webm");
fs::write(&input, wav_silence(44_100, 4_410)).unwrap(); fs::write(&input, wav_silence(44_100, 4_410)).unwrap();
let job = MediaJob::new(); 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!(!result.pass_through);
assert_eq!(result.output_format, OUTPUT_FORMAT); assert_eq!(result.output_format, OUTPUT_FORMAT);
assert_eq!(result.output_codec, OUTPUT_CODEC); assert_eq!(result.output_codec, OUTPUT_CODEC);
@ -1822,6 +1838,30 @@ mod tests {
let _ = fs::remove_dir_all(directory); 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. /// Verifies cancellation removes both final and partial outputs.
#[test] #[test]
fn cancellation_does_not_leave_an_output_file() { fn cancellation_does_not_leave_an_output_file() {
@ -1832,7 +1872,7 @@ mod tests {
fs::write(&input, wav_silence(48_000, 960)).unwrap(); fs::write(&input, wav_silence(48_000, 960)).unwrap();
let job = MediaJob::new(); let job = MediaJob::new();
job.cancelled.store(true, Ordering::Relaxed); 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_eq!(error.code, MediaErrorCode::Cancelled);
assert!(!output.exists()); assert!(!output.exists());
assert!(!partial_path(&output).exists()); assert!(!partial_path(&output).exists());
@ -1850,7 +1890,7 @@ mod tests {
writer.write_packet(&[0xf8, 0xff, 0xfe], 0).unwrap(); writer.write_packet(&[0xf8, 0xff, 0xfe], 0).unwrap();
writer.finish().unwrap(); writer.finish().unwrap();
let job = MediaJob::new(); 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!(result.pass_through);
assert_eq!(result.output_format, OUTPUT_FORMAT); assert_eq!(result.output_format, OUTPUT_FORMAT);
assert_eq!(result.output_codec, OUTPUT_CODEC); assert_eq!(result.output_codec, OUTPUT_CODEC);
@ -1867,7 +1907,7 @@ mod tests {
let input = directory.join("input.wav"); let input = directory.join("input.wav");
let output = directory.join("output.webm"); let output = directory.join("output.webm");
fs::write(&input, wav_constant(48_000, 960, 1_000)).unwrap(); 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); assert!(result.has_audible_signal);
let _ = fs::remove_dir_all(directory); let _ = fs::remove_dir_all(directory);
} }
@ -1914,15 +1954,15 @@ mod tests {
#[test] #[test]
fn fixture_errors_are_stable() { fn fixture_errors_are_stable() {
let damaged_output = std::env::temp_dir().join(format!("ai-studio-damaged-{}.webm", rand::random::<u64>())); 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)); 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_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); 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_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); 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. /// 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> { 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); let mut wav = Vec::with_capacity(44 + data_size as usize);
wav.extend_from_slice(b"RIFF"); wav.extend_from_slice(b"RIFF");
wav.extend_from_slice(&(36 + data_size).to_le_bytes()); 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(&16u16.to_le_bytes());
wav.extend_from_slice(b"data"); wav.extend_from_slice(b"data");
wav.extend_from_slice(&data_size.to_le_bytes()); 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.extend_from_slice(&sample.to_le_bytes());
} }
wav wav