diff --git a/app/MindWork AI Studio/Components/VoiceRecorder.razor b/app/MindWork AI Studio/Components/VoiceRecorder.razor new file mode 100644 index 00000000..e205e8fc --- /dev/null +++ b/app/MindWork AI Studio/Components/VoiceRecorder.razor @@ -0,0 +1,14 @@ +@using AIStudio.Settings.DataModel + +@namespace AIStudio.Components +@inherits ComponentBase + +@if (PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.SettingsManager)) +{ + +} diff --git a/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs b/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs new file mode 100644 index 00000000..380efc50 --- /dev/null +++ b/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs @@ -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 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? 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(", ", 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("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("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; + } +} diff --git a/app/MindWork AI Studio/Layout/MainLayout.razor b/app/MindWork AI Studio/Layout/MainLayout.razor index f98c1ce1..6aef66dc 100644 --- a/app/MindWork AI Studio/Layout/MainLayout.razor +++ b/app/MindWork AI Studio/Layout/MainLayout.razor @@ -1,4 +1,6 @@ @using AIStudio.Settings.DataModel +@using AIStudio.Components + @using Microsoft.AspNetCore.Components.Routing @using MudBlazor @@ -20,16 +22,7 @@ } - - @if (PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.SettingsManager)) - { - - } + } @@ -51,16 +44,7 @@ } } - - @if (PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.SettingsManager)) - { - - } + } } diff --git a/app/MindWork AI Studio/Layout/MainLayout.razor.cs b/app/MindWork AI Studio/Layout/MainLayout.razor.cs index 1252e5e5..89fcd418 100644 --- a/app/MindWork AI Studio/Layout/MainLayout.razor.cs +++ b/app/MindWork AI Studio/Layout/MainLayout.razor.cs @@ -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? dotNetReference; private IReadOnlyCollection 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(", ", 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("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("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