From 43a508b4de05654681c5e36a7c1e09a745599ac7 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Tue, 14 Jul 2026 19:28:02 +0200 Subject: [PATCH] Improved audio recording error handling and chunk processing --- .../Components/VoiceRecorder.razor.cs | 73 +++++++++++++------ app/MindWork AI Studio/wwwroot/audio.js | 62 +++++++++++----- .../wwwroot/changelog/v26.7.3.md | 2 +- 3 files changed, 97 insertions(+), 40 deletions(-) diff --git a/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs b/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs index b98ace9f..f6ece656 100644 --- a/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs +++ b/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs @@ -137,6 +137,7 @@ public partial class VoiceRecorder : MSGComponentBase var mimeTypes = GetPreferredMimeTypes( Builder.Create().UseAudio().UseSubtype(AudioSubtype.WEBM).Build(), Builder.Create().UseAudio().UseSubtype(AudioSubtype.OGG).Build(), + Builder.Create().UseAudio().UseSubtype(AudioSubtype.MP4).Build(), Builder.Create().UseAudio().UseSubtype(AudioSubtype.AAC).Build(), Builder.Create().UseAudio().UseSubtype(AudioSubtype.MP3).Build(), Builder.Create().UseAudio().UseSubtype(AudioSubtype.AIFF).Build(), @@ -171,6 +172,7 @@ public partial class VoiceRecorder : MSGComponentBase // Clean up the recording stream if starting failed: await this.FinalizeRecordingStream(); + await this.ReleaseMicrophoneAsync(); } finally { @@ -179,11 +181,13 @@ public partial class VoiceRecorder : MSGComponentBase } else { + var recordingStoppedSuccessfully = false; try { var result = await this.JsRuntime.InvokeAsync("audioRecorder.stop"); if (result.ChangedMimeType) this.Logger.LogWarning("The recorded audio MIME type was changed to '{ResultMimeType}'.", result.MimeType); + recordingStoppedSuccessfully = true; } catch (Exception e) { @@ -197,9 +201,20 @@ public partial class VoiceRecorder : MSGComponentBase this.isRecording = false; this.StateHasChanged(); - // Start transcription if we have a recording and a configured provider: - if (this.finalRecordingPath is not null) - await this.TranscribeRecordingAsync(); + if (!recordingStoppedSuccessfully || this.finalRecordingPath is null) + { + if (recordingStoppedSuccessfully) + { + this.Logger.LogWarning("The audio recorder did not produce any data."); + await this.MessageBus.SendError(new(Icons.Material.Filled.MicOff, this.T("Failed to stop audio recording."))); + } + + this.DeleteFinalRecording(); + await this.ReleaseMicrophoneAsync(); + return; + } + + await this.TranscribeRecordingAsync(); } } @@ -256,6 +271,7 @@ public partial class VoiceRecorder : MSGComponentBase catch (Exception ex) { this.Logger.LogError(ex, "Error writing audio chunk to stream."); + throw; } } @@ -268,17 +284,23 @@ public partial class VoiceRecorder : MSGComponentBase await this.currentRecordingStream.DisposeAsync(); this.currentRecordingStream = null; - // Rename the file with the correct extension based on MIME type: - if (this.currentRecordingPath is not null && this.currentRecordingMimeType is not null) + if (this.currentRecordingPath is not null && File.Exists(this.currentRecordingPath)) { - var extension = GetFileExtension(this.currentRecordingMimeType); - var newPath = Path.ChangeExtension(this.currentRecordingPath, extension); + var fileSize = new FileInfo(this.currentRecordingPath).Length; - if (File.Exists(this.currentRecordingPath)) + // Rename non-empty recordings with the correct extension based on MIME type: + if (fileSize > 0 && this.currentRecordingMimeType is not null) { + var extension = GetFileExtension(this.currentRecordingMimeType); + var newPath = Path.ChangeExtension(this.currentRecordingPath, extension); File.Move(this.currentRecordingPath, newPath, overwrite: true); this.finalRecordingPath = newPath; - this.Logger.LogInformation("Finalized audio recording over {NumChunks} streamed audio chunks to the file '{RecordingPath}'.", this.numReceivedChunks, newPath); + this.Logger.LogInformation("Finalized audio recording over {NumChunks} streamed audio chunks to the file '{RecordingPath}' with {FileSize} bytes.", this.numReceivedChunks, newPath, fileSize); + } + else + { + this.Logger.LogWarning("Discarding an audio recording with {FileSize} bytes and MIME type '{MimeType}'.", fileSize, this.currentRecordingMimeType); + File.Delete(this.currentRecordingPath); } } } @@ -375,22 +397,31 @@ public partial class VoiceRecorder : MSGComponentBase finally { await this.ReleaseMicrophoneAsync(); - - try - { - if (File.Exists(this.finalRecordingPath)) - File.Delete(this.finalRecordingPath); - } - catch (Exception ex) - { - this.Logger.LogError(ex, "Failed to delete the recording file '{RecordingPath}'.", this.finalRecordingPath); - } - this.finalRecordingPath = null; + this.DeleteFinalRecording(); this.isTranscribing = false; this.StateHasChanged(); } } + private void DeleteFinalRecording() + { + var recordingPath = this.finalRecordingPath; + this.finalRecordingPath = null; + + if (recordingPath is null) + return; + + try + { + if (File.Exists(recordingPath)) + File.Delete(recordingPath); + } + catch (Exception ex) + { + this.Logger.LogError(ex, "Failed to delete the recording file '{RecordingPath}'.", recordingPath); + } + } + private async Task ReleaseMicrophoneAsync() { // Wait a moment for any queued sounds to finish playing, then release the microphone. @@ -486,4 +517,4 @@ public partial class VoiceRecorder : MSGComponentBase } #endregion -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/wwwroot/audio.js b/app/MindWork AI Studio/wwwroot/audio.js index 4e9f40b5..61ccc7b6 100644 --- a/app/MindWork AI Studio/wwwroot/audio.js +++ b/app/MindWork AI Studio/wwwroot/audio.js @@ -184,6 +184,9 @@ let mediaRecorder; let actualRecordingMimeType; let changedMimeType = false; let pendingChunkUploads = 0; +let chunkUploadPromise = Promise.resolve(); +let chunkUploadError = null; +let recordingError = null; // Store the media stream so we can close the microphone later: let activeMediaStream = null; @@ -246,48 +249,65 @@ window.audioRecorder = { actualRecordingMimeType = mediaRecorder.mimeType; console.log('Audio recording - actual mime type used by the browser: ', actualRecordingMimeType); + const actualBaseMimeType = actualRecordingMimeType.split(';')[0].trim().toLowerCase(); + // Check the list of desired mime types against the actual one: - if (!desiredMimeTypes.includes(actualRecordingMimeType)) { + if (!desiredMimeTypes.some(type => type.split(';')[0].trim().toLowerCase() === actualBaseMimeType)) { changedMimeType = true; console.warn(`Audio recording - requested mime types ('${desiredMimeTypes.join(', ')}') do not include the actual mime type used by the browser ('${actualRecordingMimeType}').`); } else { changedMimeType = false; } - // Reset the pending uploads counter: + // Reset the upload and recorder state: pendingChunkUploads = 0; + chunkUploadPromise = Promise.resolve(); + chunkUploadError = null; + recordingError = null; - // Stream each chunk directly to .NET as it becomes available: - mediaRecorder.ondataavailable = async (event) => { + // Stream each chunk directly to .NET in recording order as it becomes available: + mediaRecorder.ondataavailable = (event) => { if (event.data.size > 0) { pendingChunkUploads++; - try { - const arrayBuffer = await event.data.arrayBuffer(); - const uint8Array = new Uint8Array(arrayBuffer); - await dotnetRef.invokeMethodAsync('OnAudioChunkReceived', uint8Array); - } catch (error) { - console.error('Error sending audio chunk to .NET:', error); - } finally { - pendingChunkUploads--; - } + chunkUploadPromise = chunkUploadPromise + .then(async () => { + const arrayBuffer = await event.data.arrayBuffer(); + const uint8Array = new Uint8Array(arrayBuffer); + await dotnetRef.invokeMethodAsync('OnAudioChunkReceived', uint8Array); + }) + .catch(error => { + chunkUploadError ??= error; + console.error('Error sending audio chunk to .NET:', error); + }) + .finally(() => pendingChunkUploads--); } }; - mediaRecorder.start(3000); // read the recorded data in 3-second chunks + mediaRecorder.onerror = event => { + recordingError ??= event.error || new Error('The media recorder failed.'); + console.error('Audio recording - MediaRecorder error:', recordingError); + }; + + if (actualBaseMimeType === 'audio/mp4') { + // WebKitGTK does not reliably produce MP4 data when a timeslice is used. Let it + // finalize one complete M4A blob when stop() is called instead. + mediaRecorder.start(); + } else { + mediaRecorder.start(3000); // read the recorded data in 3-second chunks + } + return actualRecordingMimeType; }, stop: async function () { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { // Add an event listener to handle the stop event: mediaRecorder.onstop = async () => { // Wait for all pending chunk uploads to complete before finalizing: console.log(`Audio recording - waiting for ${pendingChunkUploads} pending uploads.`); - while (pendingChunkUploads > 0) { - await new Promise(r => setTimeout(r, 10)); // wait 10 ms before checking again - } + await chunkUploadPromise; console.log('Audio recording - all chunks uploaded, finalizing.'); @@ -303,6 +323,12 @@ window.audioRecorder = { // Call window.audioRecorder.releaseMicrophone() after the last sound has played. // + const error = recordingError || chunkUploadError; + if (error) { + reject(error); + return; + } + // No need to process data here anymore, just signal completion: resolve({ mimeType: actualRecordingMimeType, diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.7.3.md b/app/MindWork AI Studio/wwwroot/changelog/v26.7.3.md index 0bed101c..628ef17b 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.7.3.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.7.3.md @@ -5,7 +5,7 @@ - Improved update guidance for Flatpak installations and added an enterprise option that lets organizations manage updates entirely through their IT department. - Fixed an issue that could leave AI Studio unresponsive after waking the computer from sleep. Yes, we know this was an annoying bug, and we apologize for the inconvenience. - Fixed enterprise configuration plugins from Windows-created ZIP files may not load correctly on Linux when the ZIP contained plugin files inside a folder. -- Fixed voice recording not starting on Linux. +- Fixed voice recording and transcription on Linux. - Fixed being able to switch document analysis policies while an analysis or media transcription was still in progress. - Upgraded Rust to v1.97.0. - Upgraded Tauri to v2.11.5.