mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-02-13 03:21:37 +00:00
Add VoiceRecorder component for audio recording functionality
This commit is contained in:
parent
fcbcaeb20a
commit
2df9d363c8
14
app/MindWork AI Studio/Components/VoiceRecorder.razor
Normal file
14
app/MindWork AI Studio/Components/VoiceRecorder.razor
Normal file
@ -0,0 +1,14 @@
|
||||
@using AIStudio.Settings.DataModel
|
||||
|
||||
@namespace AIStudio.Components
|
||||
@inherits ComponentBase
|
||||
|
||||
@if (PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.SettingsManager))
|
||||
{
|
||||
<MudToggleIconButton Toggled="@this.isRecording"
|
||||
ToggledChanged="@this.OnRecordingToggled"
|
||||
Icon="@Icons.Material.Filled.Mic"
|
||||
ToggledIcon="@Icons.Material.Filled.Stop"
|
||||
Color="Color.Primary"
|
||||
ToggledColor="Color.Error"/>
|
||||
}
|
||||
191
app/MindWork AI Studio/Components/VoiceRecorder.razor.cs
Normal file
191
app/MindWork AI Studio/Components/VoiceRecorder.razor.cs
Normal file
@ -0,0 +1,191 @@
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools.MIME;
|
||||
using AIStudio.Tools.Services;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
public partial class VoiceRecorder : IDisposable
|
||||
{
|
||||
[Inject]
|
||||
private ILogger<VoiceRecorder> Logger { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private IJSRuntime JsRuntime { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private RustService RustService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private SettingsManager SettingsManager { get; set; } = null!;
|
||||
|
||||
private bool isRecording;
|
||||
private FileStream? currentRecordingStream;
|
||||
private string? currentRecordingPath;
|
||||
private string? currentRecordingMimeType;
|
||||
private DotNetObjectReference<VoiceRecorder>? dotNetReference;
|
||||
|
||||
private async Task OnRecordingToggled(bool toggled)
|
||||
{
|
||||
if (toggled)
|
||||
{
|
||||
var mimeTypes = GetPreferredMimeTypes(
|
||||
Builder.Create().UseAudio().UseSubtype(AudioSubtype.OGG).Build(),
|
||||
Builder.Create().UseAudio().UseSubtype(AudioSubtype.AAC).Build(),
|
||||
Builder.Create().UseAudio().UseSubtype(AudioSubtype.MP3).Build(),
|
||||
Builder.Create().UseAudio().UseSubtype(AudioSubtype.AIFF).Build(),
|
||||
Builder.Create().UseAudio().UseSubtype(AudioSubtype.WAV).Build(),
|
||||
Builder.Create().UseAudio().UseSubtype(AudioSubtype.FLAC).Build()
|
||||
);
|
||||
|
||||
this.Logger.LogInformation("Starting audio recording with preferred MIME types: {PreferredMimeTypes}", string.Join<MIMEType>(", ", mimeTypes));
|
||||
|
||||
// Create a DotNetObjectReference to pass to JavaScript:
|
||||
this.dotNetReference = DotNetObjectReference.Create(this);
|
||||
|
||||
// Initialize the file stream for writing chunks:
|
||||
await this.InitializeRecordingStream();
|
||||
|
||||
var mimeTypeStrings = mimeTypes.ToStringArray();
|
||||
var actualMimeType = await this.JsRuntime.InvokeAsync<string>("audioRecorder.start", this.dotNetReference, mimeTypeStrings);
|
||||
|
||||
// Store the MIME type for later use:
|
||||
this.currentRecordingMimeType = actualMimeType;
|
||||
|
||||
this.Logger.LogInformation("Audio recording started with MIME type: {ActualMimeType}", actualMimeType);
|
||||
this.isRecording = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
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);
|
||||
|
||||
// Close and finalize the recording stream:
|
||||
await this.FinalizeRecordingStream();
|
||||
|
||||
this.isRecording = false;
|
||||
this.StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private static MIMEType[] GetPreferredMimeTypes(params MIMEType[] mimeTypes)
|
||||
{
|
||||
// Default list if no parameters provided:
|
||||
if (mimeTypes.Length is 0)
|
||||
{
|
||||
var audioBuilder = Builder.Create().UseAudio();
|
||||
return
|
||||
[
|
||||
audioBuilder.UseSubtype(AudioSubtype.WEBM).Build(),
|
||||
audioBuilder.UseSubtype(AudioSubtype.OGG).Build(),
|
||||
audioBuilder.UseSubtype(AudioSubtype.MP4).Build(),
|
||||
audioBuilder.UseSubtype(AudioSubtype.MPEG).Build(),
|
||||
];
|
||||
}
|
||||
|
||||
return mimeTypes;
|
||||
}
|
||||
|
||||
private async Task InitializeRecordingStream()
|
||||
{
|
||||
var dataDirectory = await this.RustService.GetDataDirectory();
|
||||
var recordingDirectory = Path.Combine(dataDirectory, "audioRecordings");
|
||||
if (!Directory.Exists(recordingDirectory))
|
||||
Directory.CreateDirectory(recordingDirectory);
|
||||
|
||||
var fileName = $"recording_{DateTime.UtcNow:yyyyMMdd_HHmmss}.audio";
|
||||
this.currentRecordingPath = Path.Combine(recordingDirectory, fileName);
|
||||
this.currentRecordingStream = new FileStream(this.currentRecordingPath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 8192, useAsync: true);
|
||||
|
||||
this.Logger.LogInformation("Initialized audio recording stream: {RecordingPath}", this.currentRecordingPath);
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task OnAudioChunkReceived(byte[] chunkBytes)
|
||||
{
|
||||
if (this.currentRecordingStream is null)
|
||||
{
|
||||
this.Logger.LogWarning("Received audio chunk but no recording stream is active.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await this.currentRecordingStream.WriteAsync(chunkBytes);
|
||||
await this.currentRecordingStream.FlushAsync();
|
||||
|
||||
this.Logger.LogDebug("Wrote {ByteCount} bytes to recording stream.", chunkBytes.Length);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.Logger.LogError(ex, "Error writing audio chunk to stream.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FinalizeRecordingStream()
|
||||
{
|
||||
if (this.currentRecordingStream is not null)
|
||||
{
|
||||
await this.currentRecordingStream.FlushAsync();
|
||||
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)
|
||||
{
|
||||
var extension = GetFileExtension(this.currentRecordingMimeType);
|
||||
var newPath = Path.ChangeExtension(this.currentRecordingPath, extension);
|
||||
|
||||
if (File.Exists(this.currentRecordingPath))
|
||||
{
|
||||
File.Move(this.currentRecordingPath, newPath, overwrite: true);
|
||||
this.Logger.LogInformation("Finalized audio recording: {RecordingPath}", newPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.currentRecordingPath = null;
|
||||
this.currentRecordingMimeType = null;
|
||||
|
||||
// Dispose the .NET reference:
|
||||
this.dotNetReference?.Dispose();
|
||||
this.dotNetReference = null;
|
||||
}
|
||||
|
||||
private static string GetFileExtension(string mimeType)
|
||||
{
|
||||
var baseMimeType = mimeType.Split(';')[0].Trim().ToLowerInvariant();
|
||||
return baseMimeType switch
|
||||
{
|
||||
"audio/webm" => ".webm",
|
||||
"audio/ogg" => ".ogg",
|
||||
"audio/mp4" => ".m4a",
|
||||
"audio/mpeg" => ".mp3",
|
||||
"audio/wav" => ".wav",
|
||||
"audio/x-wav" => ".wav",
|
||||
_ => ".audio" // Fallback
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class AudioRecordingResult
|
||||
{
|
||||
public string MimeType { get; init; } = string.Empty;
|
||||
|
||||
public bool ChangedMimeType { get; init; }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Clean up recording resources if still active:
|
||||
if (this.currentRecordingStream is not null)
|
||||
{
|
||||
this.currentRecordingStream.Dispose();
|
||||
this.currentRecordingStream = null;
|
||||
}
|
||||
|
||||
this.dotNetReference?.Dispose();
|
||||
this.dotNetReference = null;
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,6 @@
|
||||
@using AIStudio.Settings.DataModel
|
||||
@using AIStudio.Components
|
||||
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using MudBlazor
|
||||
|
||||
@ -20,16 +22,7 @@
|
||||
</MudNavLink>
|
||||
}
|
||||
</MudNavMenu>
|
||||
|
||||
@if (PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.SettingsManager))
|
||||
{
|
||||
<MudToggleIconButton Toggled="@this.isRecording"
|
||||
ToggledChanged="@this.OnRecordingToggled"
|
||||
Icon="@Icons.Material.Filled.Mic"
|
||||
ToggledIcon="@Icons.Material.Filled.Stop"
|
||||
Color="Color.Primary"
|
||||
ToggledColor="Color.Error"/>
|
||||
}
|
||||
<VoiceRecorder />
|
||||
</MudDrawer>
|
||||
</MudDrawerContainer>
|
||||
}
|
||||
@ -51,16 +44,7 @@
|
||||
}
|
||||
}
|
||||
</MudNavMenu>
|
||||
|
||||
@if (PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.SettingsManager))
|
||||
{
|
||||
<MudToggleIconButton Toggled="@this.isRecording"
|
||||
ToggledChanged="@this.OnRecordingToggled"
|
||||
Icon="@Icons.Material.Filled.Mic"
|
||||
ToggledIcon="@Icons.Material.Filled.Stop"
|
||||
Color="Color.Primary"
|
||||
ToggledColor="Color.Error"/>
|
||||
}
|
||||
<VoiceRecorder />
|
||||
</MudPaper>
|
||||
}
|
||||
}
|
||||
|
||||
@ -57,11 +57,6 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
|
||||
private UpdateResponse? currentUpdateResponse;
|
||||
private MudThemeProvider themeProvider = null!;
|
||||
private bool useDarkMode;
|
||||
private bool isRecording;
|
||||
private FileStream? currentRecordingStream;
|
||||
private string? currentRecordingPath;
|
||||
private string? currentRecordingMimeType;
|
||||
private DotNetObjectReference<MainLayout>? dotNetReference;
|
||||
|
||||
private IReadOnlyCollection<NavBarItem> navItems = [];
|
||||
|
||||
@ -351,174 +346,11 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
|
||||
this.StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task OnRecordingToggled(bool toggled)
|
||||
{
|
||||
if (toggled)
|
||||
{
|
||||
var mimeTypes = GetPreferredMimeTypes(
|
||||
Builder.Create().UseAudio().UseSubtype(AudioSubtype.OGG).Build(),
|
||||
Builder.Create().UseAudio().UseSubtype(AudioSubtype.AAC).Build(),
|
||||
Builder.Create().UseAudio().UseSubtype(AudioSubtype.MP3).Build(),
|
||||
Builder.Create().UseAudio().UseSubtype(AudioSubtype.AIFF).Build(),
|
||||
Builder.Create().UseAudio().UseSubtype(AudioSubtype.WAV).Build(),
|
||||
Builder.Create().UseAudio().UseSubtype(AudioSubtype.FLAC).Build()
|
||||
);
|
||||
|
||||
this.Logger.LogInformation("Starting audio recording with preferred MIME types: {PreferredMimeTypes}", string.Join<MIMEType>(", ", mimeTypes));
|
||||
|
||||
// Create a DotNetObjectReference to pass to JavaScript:
|
||||
this.dotNetReference = DotNetObjectReference.Create(this);
|
||||
|
||||
// Initialize the file stream for writing chunks:
|
||||
await this.InitializeRecordingStream();
|
||||
|
||||
var mimeTypeStrings = mimeTypes.ToStringArray();
|
||||
var actualMimeType = await this.JsRuntime.InvokeAsync<string>("audioRecorder.start", this.dotNetReference, mimeTypeStrings);
|
||||
|
||||
// Store the MIME type for later use:
|
||||
this.currentRecordingMimeType = actualMimeType;
|
||||
|
||||
this.Logger.LogInformation("Audio recording started with MIME type: {ActualMimeType}", actualMimeType);
|
||||
this.isRecording = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
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);
|
||||
|
||||
// Close and finalize the recording stream:
|
||||
await this.FinalizeRecordingStream();
|
||||
|
||||
this.isRecording = false;
|
||||
this.StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private static MIMEType[] GetPreferredMimeTypes(params MIMEType[] mimeTypes)
|
||||
{
|
||||
// Default list if no parameters provided:
|
||||
if (mimeTypes.Length is 0)
|
||||
{
|
||||
var audioBuilder = Builder.Create().UseAudio();
|
||||
return
|
||||
[
|
||||
audioBuilder.UseSubtype(AudioSubtype.WEBM).Build(),
|
||||
audioBuilder.UseSubtype(AudioSubtype.OGG).Build(),
|
||||
audioBuilder.UseSubtype(AudioSubtype.MP4).Build(),
|
||||
audioBuilder.UseSubtype(AudioSubtype.MPEG).Build(),
|
||||
];
|
||||
}
|
||||
|
||||
return mimeTypes;
|
||||
}
|
||||
|
||||
private async Task InitializeRecordingStream()
|
||||
{
|
||||
var dataDirectory = await this.RustService.GetDataDirectory();
|
||||
var recordingDirectory = Path.Combine(dataDirectory, "audioRecordings");
|
||||
if(!Directory.Exists(recordingDirectory))
|
||||
Directory.CreateDirectory(recordingDirectory);
|
||||
|
||||
var fileName = $"recording_{DateTime.UtcNow:yyyyMMdd_HHmmss}.audio";
|
||||
this.currentRecordingPath = Path.Combine(recordingDirectory, fileName);
|
||||
this.currentRecordingStream = new FileStream(this.currentRecordingPath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 8192, useAsync: true);
|
||||
|
||||
this.Logger.LogInformation("Initialized audio recording stream: {RecordingPath}", this.currentRecordingPath);
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task OnAudioChunkReceived(byte[] chunkBytes)
|
||||
{
|
||||
if (this.currentRecordingStream is null)
|
||||
{
|
||||
this.Logger.LogWarning("Received audio chunk but no recording stream is active.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await this.currentRecordingStream.WriteAsync(chunkBytes);
|
||||
await this.currentRecordingStream.FlushAsync();
|
||||
|
||||
this.Logger.LogDebug("Wrote {ByteCount} bytes to recording stream.", chunkBytes.Length);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.Logger.LogError(ex, "Error writing audio chunk to stream.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FinalizeRecordingStream()
|
||||
{
|
||||
if (this.currentRecordingStream is not null)
|
||||
{
|
||||
await this.currentRecordingStream.FlushAsync();
|
||||
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)
|
||||
{
|
||||
var extension = GetFileExtension(this.currentRecordingMimeType);
|
||||
var newPath = Path.ChangeExtension(this.currentRecordingPath, extension);
|
||||
|
||||
if (File.Exists(this.currentRecordingPath))
|
||||
{
|
||||
File.Move(this.currentRecordingPath, newPath, overwrite: true);
|
||||
this.Logger.LogInformation("Finalized audio recording: {RecordingPath}", newPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.currentRecordingPath = null;
|
||||
this.currentRecordingMimeType = null;
|
||||
|
||||
// Dispose the .NET reference:
|
||||
this.dotNetReference?.Dispose();
|
||||
this.dotNetReference = null;
|
||||
}
|
||||
|
||||
private static string GetFileExtension(string mimeType)
|
||||
{
|
||||
// Codec-Parameter entfernen für Matching
|
||||
var baseMimeType = mimeType.Split(';')[0].Trim().ToLowerInvariant();
|
||||
|
||||
return baseMimeType switch
|
||||
{
|
||||
"audio/webm" => ".webm",
|
||||
"audio/ogg" => ".ogg",
|
||||
"audio/mp4" => ".m4a",
|
||||
"audio/mpeg" => ".mp3",
|
||||
"audio/wav" => ".wav",
|
||||
"audio/x-wav" => ".wav",
|
||||
_ => ".audio" // Fallback
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class AudioRecordingResult
|
||||
{
|
||||
|
||||
public string MimeType { get; init; } = string.Empty;
|
||||
|
||||
public bool ChangedMimeType { get; init; }
|
||||
}
|
||||
|
||||
#region Implementation of IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
this.MessageBus.Unregister(this);
|
||||
|
||||
// Clean up recording resources if still active:
|
||||
if (this.currentRecordingStream is not null)
|
||||
{
|
||||
this.currentRecordingStream.Dispose();
|
||||
this.currentRecordingStream = null;
|
||||
}
|
||||
|
||||
this.dotNetReference?.Dispose();
|
||||
this.dotNetReference = null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
Loading…
Reference in New Issue
Block a user