mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-09-13 17:13:38 +00:00
Improved audio recording error handling and chunk processing
This commit is contained in:
parent
c159875351
commit
43a508b4de
@ -137,6 +137,7 @@ public partial class VoiceRecorder : MSGComponentBase
|
|||||||
var mimeTypes = GetPreferredMimeTypes(
|
var mimeTypes = GetPreferredMimeTypes(
|
||||||
Builder.Create().UseAudio().UseSubtype(AudioSubtype.WEBM).Build(),
|
Builder.Create().UseAudio().UseSubtype(AudioSubtype.WEBM).Build(),
|
||||||
Builder.Create().UseAudio().UseSubtype(AudioSubtype.OGG).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.AAC).Build(),
|
||||||
Builder.Create().UseAudio().UseSubtype(AudioSubtype.MP3).Build(),
|
Builder.Create().UseAudio().UseSubtype(AudioSubtype.MP3).Build(),
|
||||||
Builder.Create().UseAudio().UseSubtype(AudioSubtype.AIFF).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:
|
// Clean up the recording stream if starting failed:
|
||||||
await this.FinalizeRecordingStream();
|
await this.FinalizeRecordingStream();
|
||||||
|
await this.ReleaseMicrophoneAsync();
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@ -179,11 +181,13 @@ public partial class VoiceRecorder : MSGComponentBase
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
var recordingStoppedSuccessfully = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await this.JsRuntime.InvokeAsync<AudioRecordingResult>("audioRecorder.stop");
|
var result = await this.JsRuntime.InvokeAsync<AudioRecordingResult>("audioRecorder.stop");
|
||||||
if (result.ChangedMimeType)
|
if (result.ChangedMimeType)
|
||||||
this.Logger.LogWarning("The recorded audio MIME type was changed to '{ResultMimeType}'.", result.MimeType);
|
this.Logger.LogWarning("The recorded audio MIME type was changed to '{ResultMimeType}'.", result.MimeType);
|
||||||
|
recordingStoppedSuccessfully = true;
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
@ -197,8 +201,19 @@ public partial class VoiceRecorder : MSGComponentBase
|
|||||||
this.isRecording = false;
|
this.isRecording = false;
|
||||||
this.StateHasChanged();
|
this.StateHasChanged();
|
||||||
|
|
||||||
// Start transcription if we have a recording and a configured provider:
|
if (!recordingStoppedSuccessfully || this.finalRecordingPath is null)
|
||||||
if (this.finalRecordingPath is not 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();
|
await this.TranscribeRecordingAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -256,6 +271,7 @@ public partial class VoiceRecorder : MSGComponentBase
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
this.Logger.LogError(ex, "Error writing audio chunk to stream.");
|
this.Logger.LogError(ex, "Error writing audio chunk to stream.");
|
||||||
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -268,17 +284,23 @@ public partial class VoiceRecorder : MSGComponentBase
|
|||||||
await this.currentRecordingStream.DisposeAsync();
|
await this.currentRecordingStream.DisposeAsync();
|
||||||
this.currentRecordingStream = null;
|
this.currentRecordingStream = null;
|
||||||
|
|
||||||
// Rename the file with the correct extension based on MIME type:
|
if (this.currentRecordingPath is not null && File.Exists(this.currentRecordingPath))
|
||||||
if (this.currentRecordingPath is not null && this.currentRecordingMimeType is not null)
|
{
|
||||||
|
var fileSize = new FileInfo(this.currentRecordingPath).Length;
|
||||||
|
|
||||||
|
// 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 extension = GetFileExtension(this.currentRecordingMimeType);
|
||||||
var newPath = Path.ChangeExtension(this.currentRecordingPath, extension);
|
var newPath = Path.ChangeExtension(this.currentRecordingPath, extension);
|
||||||
|
|
||||||
if (File.Exists(this.currentRecordingPath))
|
|
||||||
{
|
|
||||||
File.Move(this.currentRecordingPath, newPath, overwrite: true);
|
File.Move(this.currentRecordingPath, newPath, overwrite: true);
|
||||||
this.finalRecordingPath = newPath;
|
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,19 +397,28 @@ public partial class VoiceRecorder : MSGComponentBase
|
|||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
await this.ReleaseMicrophoneAsync();
|
await this.ReleaseMicrophoneAsync();
|
||||||
|
this.DeleteFinalRecording();
|
||||||
|
this.isTranscribing = false;
|
||||||
|
this.StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DeleteFinalRecording()
|
||||||
|
{
|
||||||
|
var recordingPath = this.finalRecordingPath;
|
||||||
|
this.finalRecordingPath = null;
|
||||||
|
|
||||||
|
if (recordingPath is null)
|
||||||
|
return;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (File.Exists(this.finalRecordingPath))
|
if (File.Exists(recordingPath))
|
||||||
File.Delete(this.finalRecordingPath);
|
File.Delete(recordingPath);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
this.Logger.LogError(ex, "Failed to delete the recording file '{RecordingPath}'.", this.finalRecordingPath);
|
this.Logger.LogError(ex, "Failed to delete the recording file '{RecordingPath}'.", recordingPath);
|
||||||
}
|
|
||||||
this.finalRecordingPath = null;
|
|
||||||
this.isTranscribing = false;
|
|
||||||
this.StateHasChanged();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -184,6 +184,9 @@ let mediaRecorder;
|
|||||||
let actualRecordingMimeType;
|
let actualRecordingMimeType;
|
||||||
let changedMimeType = false;
|
let changedMimeType = false;
|
||||||
let pendingChunkUploads = 0;
|
let pendingChunkUploads = 0;
|
||||||
|
let chunkUploadPromise = Promise.resolve();
|
||||||
|
let chunkUploadError = null;
|
||||||
|
let recordingError = null;
|
||||||
|
|
||||||
// Store the media stream so we can close the microphone later:
|
// Store the media stream so we can close the microphone later:
|
||||||
let activeMediaStream = null;
|
let activeMediaStream = null;
|
||||||
@ -246,48 +249,65 @@ window.audioRecorder = {
|
|||||||
actualRecordingMimeType = mediaRecorder.mimeType;
|
actualRecordingMimeType = mediaRecorder.mimeType;
|
||||||
console.log('Audio recording - actual mime type used by the browser: ', actualRecordingMimeType);
|
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:
|
// 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;
|
changedMimeType = true;
|
||||||
console.warn(`Audio recording - requested mime types ('${desiredMimeTypes.join(', ')}') do not include the actual mime type used by the browser ('${actualRecordingMimeType}').`);
|
console.warn(`Audio recording - requested mime types ('${desiredMimeTypes.join(', ')}') do not include the actual mime type used by the browser ('${actualRecordingMimeType}').`);
|
||||||
} else {
|
} else {
|
||||||
changedMimeType = false;
|
changedMimeType = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset the pending uploads counter:
|
// Reset the upload and recorder state:
|
||||||
pendingChunkUploads = 0;
|
pendingChunkUploads = 0;
|
||||||
|
chunkUploadPromise = Promise.resolve();
|
||||||
|
chunkUploadError = null;
|
||||||
|
recordingError = null;
|
||||||
|
|
||||||
// Stream each chunk directly to .NET as it becomes available:
|
// Stream each chunk directly to .NET in recording order as it becomes available:
|
||||||
mediaRecorder.ondataavailable = async (event) => {
|
mediaRecorder.ondataavailable = (event) => {
|
||||||
if (event.data.size > 0) {
|
if (event.data.size > 0) {
|
||||||
pendingChunkUploads++;
|
pendingChunkUploads++;
|
||||||
try {
|
chunkUploadPromise = chunkUploadPromise
|
||||||
|
.then(async () => {
|
||||||
const arrayBuffer = await event.data.arrayBuffer();
|
const arrayBuffer = await event.data.arrayBuffer();
|
||||||
const uint8Array = new Uint8Array(arrayBuffer);
|
const uint8Array = new Uint8Array(arrayBuffer);
|
||||||
await dotnetRef.invokeMethodAsync('OnAudioChunkReceived', uint8Array);
|
await dotnetRef.invokeMethodAsync('OnAudioChunkReceived', uint8Array);
|
||||||
} catch (error) {
|
})
|
||||||
|
.catch(error => {
|
||||||
|
chunkUploadError ??= error;
|
||||||
console.error('Error sending audio chunk to .NET:', error);
|
console.error('Error sending audio chunk to .NET:', error);
|
||||||
} finally {
|
})
|
||||||
pendingChunkUploads--;
|
.finally(() => pendingChunkUploads--);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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
|
mediaRecorder.start(3000); // read the recorded data in 3-second chunks
|
||||||
|
}
|
||||||
|
|
||||||
return actualRecordingMimeType;
|
return actualRecordingMimeType;
|
||||||
},
|
},
|
||||||
|
|
||||||
stop: async function () {
|
stop: async function () {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|
||||||
// Add an event listener to handle the stop event:
|
// Add an event listener to handle the stop event:
|
||||||
mediaRecorder.onstop = async () => {
|
mediaRecorder.onstop = async () => {
|
||||||
|
|
||||||
// Wait for all pending chunk uploads to complete before finalizing:
|
// Wait for all pending chunk uploads to complete before finalizing:
|
||||||
console.log(`Audio recording - waiting for ${pendingChunkUploads} pending uploads.`);
|
console.log(`Audio recording - waiting for ${pendingChunkUploads} pending uploads.`);
|
||||||
while (pendingChunkUploads > 0) {
|
await chunkUploadPromise;
|
||||||
await new Promise(r => setTimeout(r, 10)); // wait 10 ms before checking again
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Audio recording - all chunks uploaded, finalizing.');
|
console.log('Audio recording - all chunks uploaded, finalizing.');
|
||||||
|
|
||||||
@ -303,6 +323,12 @@ window.audioRecorder = {
|
|||||||
// Call window.audioRecorder.releaseMicrophone() after the last sound has played.
|
// 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:
|
// No need to process data here anymore, just signal completion:
|
||||||
resolve({
|
resolve({
|
||||||
mimeType: actualRecordingMimeType,
|
mimeType: actualRecordingMimeType,
|
||||||
|
|||||||
@ -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.
|
- 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 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 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.
|
- 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 Rust to v1.97.0.
|
||||||
- Upgraded Tauri to v2.11.5.
|
- Upgraded Tauri to v2.11.5.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user