Harden voice recording initialization (#698)
Some checks failed
Build and Release / Read metadata (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg updater) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis updater) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage deb updater) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg updater) (push) Has been cancelled
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) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage deb updater) (push) Has been cancelled
Build and Release / Prepare & create release (push) Has been cancelled
Build and Release / Publish release (push) Has been cancelled

This commit is contained in:
Thorsten Sommer 2026-03-16 22:36:51 +01:00 committed by GitHub
parent 56da27372c
commit dac0b74145
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 242 additions and 69 deletions

View File

@ -2725,6 +2725,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T428040679"] = "Content creation"
-- Useful assistants -- Useful assistants
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T586430036"] = "Useful assistants" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T586430036"] = "Useful assistants"
-- Voice recording has been disabled for this session because audio playback could not be initialized on the client.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T1123032432"] = "Voice recording has been disabled for this session because audio playback could not be initialized on the client."
-- Failed to create the transcription provider. -- Failed to create the transcription provider.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T1689988905"] = "Failed to create the transcription provider." UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T1689988905"] = "Failed to create the transcription provider."
@ -2734,6 +2737,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T2144994226"] = "Failed to
-- Stop recording and start transcription -- Stop recording and start transcription
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T224155287"] = "Stop recording and start transcription" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T224155287"] = "Stop recording and start transcription"
-- Voice recording is unavailable because the client could not initialize audio playback.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T2260302339"] = "Voice recording is unavailable because the client could not initialize audio playback."
-- Start recording your voice for a transcription -- Start recording your voice for a transcription
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T2372624045"] = "Start recording your voice for a transcription" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T2372624045"] = "Start recording your voice for a transcription"

View File

@ -1,9 +1,7 @@
@using AIStudio.Settings.DataModel
@namespace AIStudio.Components @namespace AIStudio.Components
@inherits MSGComponentBase @inherits MSGComponentBase
@if (PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.SettingsManager) && !string.IsNullOrWhiteSpace(this.SettingsManager.ConfigurationData.App.UseTranscriptionProvider)) @if (this.ShouldRenderVoiceRecording)
{ {
<MudTooltip Text="@this.Tooltip"> <MudTooltip Text="@this.Tooltip">
@if (this.isTranscribing || this.isPreparing) @if (this.isTranscribing || this.isPreparing)
@ -16,6 +14,7 @@
ToggledChanged="@this.OnRecordingToggled" ToggledChanged="@this.OnRecordingToggled"
Icon="@Icons.Material.Filled.Mic" Icon="@Icons.Material.Filled.Mic"
ToggledIcon="@Icons.Material.Filled.Stop" ToggledIcon="@Icons.Material.Filled.Stop"
Disabled="@(!this.IsVoiceRecordingAvailable)"
Color="Color.Primary" Color="Color.Primary"
ToggledColor="Color.Error"/> ToggledColor="Color.Error"/>
} }

View File

@ -1,4 +1,5 @@
using AIStudio.Provider; using AIStudio.Provider;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.MIME; using AIStudio.Tools.MIME;
using AIStudio.Tools.Rust; using AIStudio.Tools.Rust;
using AIStudio.Tools.Services; using AIStudio.Tools.Services;
@ -21,24 +22,25 @@ public partial class VoiceRecorder : MSGComponentBase
[Inject] [Inject]
private ISnackbar Snackbar { get; init; } = null!; private ISnackbar Snackbar { get; init; } = null!;
[Inject]
private VoiceRecordingAvailabilityService VoiceRecordingAvailabilityService { get; init; } = null!;
#region Overrides of MSGComponentBase #region Overrides of MSGComponentBase
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
// Register for global shortcut events: // Register for global shortcut events:
this.ApplyFilters([], [Event.TAURI_EVENT_RECEIVED]); this.ApplyFilters([], [Event.TAURI_EVENT_RECEIVED, Event.VOICE_RECORDING_AVAILABILITY_CHANGED]);
await base.OnInitializedAsync(); await base.OnInitializedAsync();
}
try protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
// Initialize sound effects. This "warms up" the AudioContext and preloads all sounds for reliable playback: if (firstRender && this.ShouldRenderVoiceRecording)
await this.JsRuntime.InvokeVoidAsync("initSoundEffects"); await this.EnsureSoundEffectsAvailableAsync("during the first interactive render");
}
catch (Exception ex) await base.OnAfterRenderAsync(firstRender);
{
this.Logger.LogError(ex, "Failed to initialize sound effects.");
}
} }
protected override async Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default protected override async Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
@ -54,6 +56,10 @@ public partial class VoiceRecorder : MSGComponentBase
} }
break; break;
case Event.VOICE_RECORDING_AVAILABILITY_CHANGED:
this.StateHasChanged();
break;
} }
} }
@ -62,6 +68,12 @@ public partial class VoiceRecorder : MSGComponentBase
/// </summary> /// </summary>
private async Task ToggleRecordingFromShortcut() private async Task ToggleRecordingFromShortcut()
{ {
if (!this.IsVoiceRecordingAvailable)
{
this.Logger.LogDebug("Ignoring shortcut: voice recording is unavailable in the current session.");
return;
}
// Don't allow toggle if transcription is in progress or preparing: // Don't allow toggle if transcription is in progress or preparing:
if (this.isTranscribing || this.isPreparing) if (this.isTranscribing || this.isPreparing)
{ {
@ -85,27 +97,38 @@ public partial class VoiceRecorder : MSGComponentBase
private string? finalRecordingPath; private string? finalRecordingPath;
private DotNetObjectReference<VoiceRecorder>? dotNetReference; private DotNetObjectReference<VoiceRecorder>? dotNetReference;
private string Tooltip => this.isTranscribing private bool ShouldRenderVoiceRecording => PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.SettingsManager)
? T("Transcription in progress...") && !string.IsNullOrWhiteSpace(this.SettingsManager.ConfigurationData.App.UseTranscriptionProvider);
: this.isRecording
? T("Stop recording and start transcription") private bool IsVoiceRecordingAvailable => this.ShouldRenderVoiceRecording
: T("Start recording your voice for a transcription"); && this.VoiceRecordingAvailabilityService.IsAvailable;
private string Tooltip => !this.VoiceRecordingAvailabilityService.IsAvailable
? T("Voice recording is unavailable because the client could not initialize audio playback.")
: this.isTranscribing
? T("Transcription in progress...")
: this.isRecording
? T("Stop recording and start transcription")
: T("Start recording your voice for a transcription");
private async Task OnRecordingToggled(bool toggled) private async Task OnRecordingToggled(bool toggled)
{ {
if (toggled) if (toggled)
{ {
if (!this.IsVoiceRecordingAvailable)
{
this.Logger.LogDebug("Ignoring recording start: voice recording is unavailable in the current session.");
return;
}
this.isPreparing = true; this.isPreparing = true;
this.StateHasChanged(); this.StateHasChanged();
try if (!await this.EnsureSoundEffectsAvailableAsync("before starting audio recording"))
{ {
// Warm up sound effects: this.isPreparing = false;
await this.JsRuntime.InvokeVoidAsync("initSoundEffects"); this.StateHasChanged();
} return;
catch (Exception ex)
{
this.Logger.LogError(ex, "Failed to initialize sound effects.");
} }
var mimeTypes = GetPreferredMimeTypes( var mimeTypes = GetPreferredMimeTypes(
@ -416,11 +439,66 @@ public partial class VoiceRecorder : MSGComponentBase
} }
} }
private sealed class AudioRecordingResult private async Task<bool> EnsureSoundEffectsAvailableAsync(string context)
{ {
public string MimeType { get; init; } = string.Empty; if (!this.ShouldRenderVoiceRecording)
return false;
public bool ChangedMimeType { get; init; } if (!this.VoiceRecordingAvailabilityService.IsAvailable)
return false;
try
{
var result = await this.JsRuntime.InvokeAsync<SoundEffectsInitializationResult>("initSoundEffects");
if (result.Success)
return true;
var failureDetails = BuildSoundEffectsFailureDetails(result);
this.Logger.LogError("Failed to initialize sound effects {Context}. {FailureDetails}", context, failureDetails);
await this.DisableVoiceRecordingAsync(failureDetails);
}
catch (JSDisconnectedException ex)
{
this.Logger.LogError(ex, "Failed to initialize sound effects {Context}. The JS runtime disconnected.", context);
await this.DisableVoiceRecordingAsync("The JS runtime disconnected while initializing audio playback.");
}
catch (OperationCanceledException ex)
{
this.Logger.LogError(ex, "Failed to initialize sound effects {Context}. The interop call was canceled.", context);
await this.DisableVoiceRecordingAsync("The interop call for audio playback initialization was canceled.");
}
catch (Exception ex)
{
this.Logger.LogError(ex, "Failed to initialize sound effects {Context}.", context);
await this.DisableVoiceRecordingAsync(ex.Message);
}
return false;
}
private async Task DisableVoiceRecordingAsync(string reason)
{
if (!this.VoiceRecordingAvailabilityService.TryDisable(reason))
return;
this.Logger.LogWarning("Voice recording was disabled for the current session. Reason: {Reason}", reason);
await this.MessageBus.SendWarning(new(Icons.Material.Filled.MicOff, this.T("Voice recording has been disabled for this session because audio playback could not be initialized on the client.")));
await this.SendMessage(Event.VOICE_RECORDING_AVAILABILITY_CHANGED, reason);
this.StateHasChanged();
}
private static string BuildSoundEffectsFailureDetails(SoundEffectsInitializationResult result)
{
var details = new List<string>();
if (result.FailedPaths.Length > 0)
details.Add($"Failed sound files: {string.Join(", ", result.FailedPaths)}.");
if (!string.IsNullOrWhiteSpace(result.ErrorMessage))
details.Add($"Client error: {result.ErrorMessage}");
return details.Count > 0
? string.Join(" ", details)
: "The client did not provide additional details.";
} }
#region Overrides of MSGComponentBase #region Overrides of MSGComponentBase

View File

@ -2727,6 +2727,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T428040679"] = "Erstellung von In
-- Useful assistants -- Useful assistants
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T586430036"] = "Nützliche Assistenten" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T586430036"] = "Nützliche Assistenten"
-- Voice recording has been disabled for this session because audio playback could not be initialized on the client.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T1123032432"] = "Die Sprachaufnahme wurde für diese Sitzung deaktiviert, da die Audiowiedergabe auf dem Client nicht initialisiert werden konnte."
-- Failed to create the transcription provider. -- Failed to create the transcription provider.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T1689988905"] = "Der Anbieter für die Transkription konnte nicht erstellt werden." UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T1689988905"] = "Der Anbieter für die Transkription konnte nicht erstellt werden."
@ -2736,6 +2739,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T2144994226"] = "Audioaufn
-- Stop recording and start transcription -- Stop recording and start transcription
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T224155287"] = "Aufnahme beenden und Transkription starten" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T224155287"] = "Aufnahme beenden und Transkription starten"
-- Voice recording is unavailable because the client could not initialize audio playback.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T2260302339"] = "Die Sprachaufnahme ist nicht verfügbar, da der Client die Audiowiedergabe nicht initialisieren konnte."
-- Start recording your voice for a transcription -- Start recording your voice for a transcription
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T2372624045"] = "Beginnen Sie mit der Aufnahme Ihrer Stimme für eine Transkription" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T2372624045"] = "Beginnen Sie mit der Aufnahme Ihrer Stimme für eine Transkription"

View File

@ -2727,6 +2727,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T428040679"] = "Content creation"
-- Useful assistants -- Useful assistants
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T586430036"] = "Useful assistants" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T586430036"] = "Useful assistants"
-- Voice recording has been disabled for this session because audio playback could not be initialized on the client.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T1123032432"] = "Voice recording has been disabled for this session because audio playback could not be initialized on the client."
-- Failed to create the transcription provider. -- Failed to create the transcription provider.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T1689988905"] = "Failed to create the transcription provider." UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T1689988905"] = "Failed to create the transcription provider."
@ -2736,6 +2739,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T2144994226"] = "Failed to
-- Stop recording and start transcription -- Stop recording and start transcription
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T224155287"] = "Stop recording and start transcription" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T224155287"] = "Stop recording and start transcription"
-- Voice recording is unavailable because the client could not initialize audio playback.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T2260302339"] = "Voice recording is unavailable because the client could not initialize audio playback."
-- Start recording your voice for a transcription -- Start recording your voice for a transcription
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T2372624045"] = "Start recording your voice for a transcription" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T2372624045"] = "Start recording your voice for a transcription"

View File

@ -169,6 +169,7 @@ internal sealed class Program
builder.Services.AddMudMarkdownClipboardService<MarkdownClipboardService>(); builder.Services.AddMudMarkdownClipboardService<MarkdownClipboardService>();
builder.Services.AddSingleton<SettingsManager>(); builder.Services.AddSingleton<SettingsManager>();
builder.Services.AddSingleton<ThreadSafeRandom>(); builder.Services.AddSingleton<ThreadSafeRandom>();
builder.Services.AddSingleton<VoiceRecordingAvailabilityService>();
builder.Services.AddSingleton<DataSourceService>(); builder.Services.AddSingleton<DataSourceService>();
builder.Services.AddScoped<PandocAvailabilityService>(); builder.Services.AddScoped<PandocAvailabilityService>();
builder.Services.AddTransient<HTMLParser>(); builder.Services.AddTransient<HTMLParser>();

View File

@ -0,0 +1,8 @@
namespace AIStudio.Tools;
public sealed class AudioRecordingResult
{
public string MimeType { get; init; } = string.Empty;
public bool ChangedMimeType { get; init; }
}

View File

@ -17,6 +17,7 @@ public enum Event
SHOW_SUCCESS, SHOW_SUCCESS,
TAURI_EVENT_RECEIVED, TAURI_EVENT_RECEIVED,
RUST_SERVICE_UNAVAILABLE, RUST_SERVICE_UNAVAILABLE,
VOICE_RECORDING_AVAILABILITY_CHANGED,
// Update events: // Update events:
USER_SEARCH_FOR_UPDATE, USER_SEARCH_FOR_UPDATE,

View File

@ -15,6 +15,7 @@ public sealed class GlobalShortcutService : BackgroundService, IMessageBusReceiv
CONFIGURATION_CHANGED, CONFIGURATION_CHANGED,
STARTUP_COMPLETED, STARTUP_COMPLETED,
PLUGINS_RELOADED, PLUGINS_RELOADED,
VOICE_RECORDING_AVAILABILITY_CHANGED,
} }
private readonly SemaphoreSlim registrationSemaphore = new(1, 1); private readonly SemaphoreSlim registrationSemaphore = new(1, 1);
@ -22,20 +23,23 @@ public sealed class GlobalShortcutService : BackgroundService, IMessageBusReceiv
private readonly SettingsManager settingsManager; private readonly SettingsManager settingsManager;
private readonly MessageBus messageBus; private readonly MessageBus messageBus;
private readonly RustService rustService; private readonly RustService rustService;
private readonly VoiceRecordingAvailabilityService voiceRecordingAvailabilityService;
public GlobalShortcutService( public GlobalShortcutService(
ILogger<GlobalShortcutService> logger, ILogger<GlobalShortcutService> logger,
SettingsManager settingsManager, SettingsManager settingsManager,
MessageBus messageBus, MessageBus messageBus,
RustService rustService) RustService rustService,
VoiceRecordingAvailabilityService voiceRecordingAvailabilityService)
{ {
this.logger = logger; this.logger = logger;
this.settingsManager = settingsManager; this.settingsManager = settingsManager;
this.messageBus = messageBus; this.messageBus = messageBus;
this.rustService = rustService; this.rustService = rustService;
this.voiceRecordingAvailabilityService = voiceRecordingAvailabilityService;
this.messageBus.RegisterComponent(this); this.messageBus.RegisterComponent(this);
this.ApplyFilters([], [Event.CONFIGURATION_CHANGED, Event.PLUGINS_RELOADED, Event.STARTUP_COMPLETED]); this.ApplyFilters([], [Event.CONFIGURATION_CHANGED, Event.PLUGINS_RELOADED, Event.STARTUP_COMPLETED, Event.VOICE_RECORDING_AVAILABILITY_CHANGED]);
} }
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@ -75,6 +79,13 @@ public sealed class GlobalShortcutService : BackgroundService, IMessageBusReceiv
await this.RegisterAllShortcuts(ShortcutSyncSource.PLUGINS_RELOADED); await this.RegisterAllShortcuts(ShortcutSyncSource.PLUGINS_RELOADED);
break; break;
case Event.VOICE_RECORDING_AVAILABILITY_CHANGED:
if (!IS_STARTUP_COMPLETED)
return;
await this.RegisterAllShortcuts(ShortcutSyncSource.VOICE_RECORDING_AVAILABILITY_CHANGED);
break;
} }
} }
@ -152,7 +163,8 @@ public sealed class GlobalShortcutService : BackgroundService, IMessageBusReceiv
private bool IsShortcutAllowed(Shortcut name) => name switch private bool IsShortcutAllowed(Shortcut name) => name switch
{ {
// Voice recording is a preview feature: // Voice recording is a preview feature:
Shortcut.VOICE_RECORDING_TOGGLE => PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.settingsManager), Shortcut.VOICE_RECORDING_TOGGLE => PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.settingsManager)
&& this.voiceRecordingAvailabilityService.IsAvailable,
// Other shortcuts are always allowed: // Other shortcuts are always allowed:
_ => true, _ => true,

View File

@ -0,0 +1,23 @@
namespace AIStudio.Tools.Services;
public sealed class VoiceRecordingAvailabilityService
{
private readonly Lock stateLock = new();
public bool IsAvailable { get; private set; } = true;
public string? DisableReason { get; private set; }
public bool TryDisable(string reason)
{
lock (this.stateLock)
{
if (!this.IsAvailable)
return false;
this.IsAvailable = false;
this.DisableReason = reason;
return true;
}
}
}

View File

@ -0,0 +1,10 @@
namespace AIStudio.Tools;
public sealed class SoundEffectsInitializationResult
{
public bool Success { get; init; }
public string[] FailedPaths { get; init; } = [];
public string? ErrorMessage { get; init; }
}

View File

@ -21,59 +21,68 @@ const SOUND_EFFECT_PATHS = [
'/sounds/transcription_done.ogg' '/sounds/transcription_done.ogg'
]; ];
function createSoundEffectsInitResult(success, failedPaths = [], errorMessage = null) {
return {
success: success,
failedPaths: failedPaths,
errorMessage: errorMessage
};
}
// Initialize the audio context with low-latency settings. // Initialize the audio context with low-latency settings.
// Should be called from a user interaction (click, keypress) // Should be called from a user interaction (click, keypress)
// to satisfy browser autoplay policies: // to satisfy browser autoplay policies:
window.initSoundEffects = async function() { window.initSoundEffects = async function() {
if (soundEffectContext && soundEffectContext.state !== 'closed') {
// Already initialized, just ensure it's running:
if (soundEffectContext.state === 'suspended') {
await soundEffectContext.resume();
}
return;
}
try { try {
// Create the context with the interactive latency hint for the lowest latency: if (soundEffectContext && soundEffectContext.state !== 'closed') {
soundEffectContext = new (window.AudioContext || window.webkitAudioContext)({ // Already initialized, just ensure it's running:
latencyHint: 'interactive' if (soundEffectContext.state === 'suspended') {
}); await soundEffectContext.resume();
}
} else {
// Create the context with the interactive latency hint for the lowest latency:
soundEffectContext = new (window.AudioContext || window.webkitAudioContext)({
latencyHint: 'interactive'
});
// Resume immediately (needed for Safari/macOS): // Resume immediately (needed for Safari/macOS):
if (soundEffectContext.state === 'suspended') { if (soundEffectContext.state === 'suspended') {
await soundEffectContext.resume(); await soundEffectContext.resume();
}
// Reset the queue timing:
nextAvailablePlayTime = 0;
//
// Play a very short silent buffer to "warm up" the audio pipeline.
// This helps prevent the first real sound from being cut off:
//
const silentBuffer = soundEffectContext.createBuffer(1, 1, soundEffectContext.sampleRate);
const silentSource = soundEffectContext.createBufferSource();
silentSource.buffer = silentBuffer;
silentSource.connect(soundEffectContext.destination);
silentSource.start(0);
console.log('Sound effects - AudioContext initialized with latency:', soundEffectContext.baseLatency);
} }
// Reset the queue timing:
nextAvailablePlayTime = 0;
//
// Play a very short silent buffer to "warm up" the audio pipeline.
// This helps prevent the first real sound from being cut off:
//
const silentBuffer = soundEffectContext.createBuffer(1, 1, soundEffectContext.sampleRate);
const silentSource = soundEffectContext.createBufferSource();
silentSource.buffer = silentBuffer;
silentSource.connect(soundEffectContext.destination);
silentSource.start(0);
console.log('Sound effects - AudioContext initialized with latency:', soundEffectContext.baseLatency);
// Preload all sound effects in parallel: // Preload all sound effects in parallel:
if (!soundEffectsPreloaded) { if (!soundEffectsPreloaded) {
await window.preloadSoundEffects(); return await window.preloadSoundEffects();
} }
return createSoundEffectsInitResult(true);
} catch (error) { } catch (error) {
console.warn('Failed to initialize sound effects:', error); console.warn('Failed to initialize sound effects:', error);
return createSoundEffectsInitResult(false, [], error?.message || String(error));
} }
}; };
// Preload all sound effect files into the cache: // Preload all sound effect files into the cache:
window.preloadSoundEffects = async function() { window.preloadSoundEffects = async function() {
if (soundEffectsPreloaded) { if (soundEffectsPreloaded) {
return; return createSoundEffectsInitResult(true);
} }
// Ensure that the context exists: // Ensure that the context exists:
@ -84,10 +93,15 @@ window.preloadSoundEffects = async function() {
} }
console.log('Sound effects - preloading', SOUND_EFFECT_PATHS.length, 'sound files...'); console.log('Sound effects - preloading', SOUND_EFFECT_PATHS.length, 'sound files...');
const failedPaths = [];
const preloadPromises = SOUND_EFFECT_PATHS.map(async (soundPath) => { const preloadPromises = SOUND_EFFECT_PATHS.map(async (soundPath) => {
try { try {
const response = await fetch(soundPath); const response = await fetch(soundPath);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const arrayBuffer = await response.arrayBuffer(); const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await soundEffectContext.decodeAudioData(arrayBuffer); const audioBuffer = await soundEffectContext.decodeAudioData(arrayBuffer);
soundEffectCache.set(soundPath, audioBuffer); soundEffectCache.set(soundPath, audioBuffer);
@ -95,12 +109,20 @@ window.preloadSoundEffects = async function() {
console.log('Sound effects - preloaded:', soundPath, 'duration:', audioBuffer.duration.toFixed(2), 's'); console.log('Sound effects - preloaded:', soundPath, 'duration:', audioBuffer.duration.toFixed(2), 's');
} catch (error) { } catch (error) {
console.warn('Sound effects - failed to preload:', soundPath, error); console.warn('Sound effects - failed to preload:', soundPath, error);
failedPaths.push(soundPath);
} }
}); });
await Promise.all(preloadPromises); await Promise.all(preloadPromises);
soundEffectsPreloaded = true; soundEffectsPreloaded = failedPaths.length === 0;
console.log('Sound effects - all files preloaded');
if (soundEffectsPreloaded) {
console.log('Sound effects - all files preloaded');
return createSoundEffectsInitResult(true);
}
console.warn('Sound effects - preload finished with failures:', failedPaths);
return createSoundEffectsInitResult(false, failedPaths, 'One or more sound effects could not be loaded.');
}; };
window.playSound = async function(soundPath) { window.playSound = async function(soundPath) {

View File

@ -16,4 +16,5 @@
- Improved the validation of additional API parameters in the advanced provider settings to help catch formatting mistakes earlier. - Improved the validation of additional API parameters in the advanced provider settings to help catch formatting mistakes earlier.
- Improved the app startup resilience by allowing AI Studio to continue without Qdrant if it fails to initialize. - Improved the app startup resilience by allowing AI Studio to continue without Qdrant if it fails to initialize.
- Fixed an issue where assistants hidden via configuration plugins still appear in "Send to ..." menus. Thanks, Gunnar, for reporting this issue. - Fixed an issue where assistants hidden via configuration plugins still appear in "Send to ..." menus. Thanks, Gunnar, for reporting this issue.
- Fixed an issue with voice recording where AI Studio could log errors and keep the feature available even though required parts failed to initialize. Voice recording is now disabled automatically for the current session in that case.
- Fixed an issue where the app could turn white or appear invisible in certain chats after HTML-like content was shown. Thanks, Inga, for reporting this issue and providing some context on how to reproduce it. - Fixed an issue where the app could turn white or appear invisible in certain chats after HTML-like content was shown. Thanks, Inga, for reporting this issue and providing some context on how to reproduce it.