Improved audio recording error handling and chunk processing

This commit is contained in:
Thorsten Sommer 2026-07-14 19:28:02 +02:00
parent c159875351
commit 43a508b4de
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
3 changed files with 97 additions and 40 deletions

View File

@ -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<AudioRecordingResult>("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
}
}

View File

@ -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,

View File

@ -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.