From 324ea9eb7365ca16247d2c2167ea3f46bc317fc5 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Wed, 7 Jan 2026 12:56:11 +0100 Subject: [PATCH] Added audio recording (#615) --- app/MindWork AI Studio.sln.DotSettings | 3 + .../Assistants/I18N/allTexts.lua | 9 + .../Chat/IImageSourceExtensions.cs | 51 +++-- .../Components/VoiceRecorder.razor | 16 ++ .../Components/VoiceRecorder.razor.cs | 197 ++++++++++++++++++ .../Layout/MainLayout.razor | 20 +- .../Layout/MainLayout.razor.cs | 2 +- .../plugin.lua | 9 + .../plugin.lua | 9 + app/MindWork AI Studio/Program.cs | 1 - .../Settings/DataModel/PreviewFeatures.cs | 1 + .../DataModel/PreviewFeaturesExtensions.cs | 1 + .../DataModel/PreviewVisibilityExtensions.cs | 1 + .../Tools/MIME/ApplicationBuilder.cs | 67 ++++++ .../Tools/MIME/ApplicationSubtype.cs | 21 ++ .../Tools/MIME/AudioBuilder.cs | 51 +++++ .../Tools/MIME/AudioSubtype.cs | 16 ++ app/MindWork AI Studio/Tools/MIME/BaseType.cs | 10 + app/MindWork AI Studio/Tools/MIME/Builder.cs | 58 ++++++ app/MindWork AI Studio/Tools/MIME/ISubtype.cs | 6 + .../Tools/MIME/ImageBuilder.cs | 48 +++++ .../Tools/MIME/ImageSubtype.cs | 12 ++ app/MindWork AI Studio/Tools/MIME/MIMEType.cs | 16 ++ .../Tools/MIME/MIMETypeExtensions.cs | 15 ++ .../Tools/MIME/TextBuilder.cs | 49 +++++ .../Tools/MIME/TextSubtype.cs | 13 ++ .../Tools/MIME/VideoBuilder.cs | 46 ++++ .../Tools/MIME/VideoSubtype.cs | 11 + app/MindWork AI Studio/wwwroot/app.js | 131 +++++++++++- .../wwwroot/changelog/v26.1.1.md | 1 + .../wwwroot/sounds/start_recording.ogg | Bin 0 -> 9859 bytes .../wwwroot/sounds/stop_recording.ogg | Bin 0 -> 10431 bytes .../wwwroot/sounds/transcription_done.ogg | Bin 0 -> 10171 bytes runtime/Info.plist | 8 + 34 files changed, 868 insertions(+), 31 deletions(-) create mode 100644 app/MindWork AI Studio/Components/VoiceRecorder.razor create mode 100644 app/MindWork AI Studio/Components/VoiceRecorder.razor.cs create mode 100644 app/MindWork AI Studio/Tools/MIME/ApplicationBuilder.cs create mode 100644 app/MindWork AI Studio/Tools/MIME/ApplicationSubtype.cs create mode 100644 app/MindWork AI Studio/Tools/MIME/AudioBuilder.cs create mode 100644 app/MindWork AI Studio/Tools/MIME/AudioSubtype.cs create mode 100644 app/MindWork AI Studio/Tools/MIME/BaseType.cs create mode 100644 app/MindWork AI Studio/Tools/MIME/Builder.cs create mode 100644 app/MindWork AI Studio/Tools/MIME/ISubtype.cs create mode 100644 app/MindWork AI Studio/Tools/MIME/ImageBuilder.cs create mode 100644 app/MindWork AI Studio/Tools/MIME/ImageSubtype.cs create mode 100644 app/MindWork AI Studio/Tools/MIME/MIMEType.cs create mode 100644 app/MindWork AI Studio/Tools/MIME/MIMETypeExtensions.cs create mode 100644 app/MindWork AI Studio/Tools/MIME/TextBuilder.cs create mode 100644 app/MindWork AI Studio/Tools/MIME/TextSubtype.cs create mode 100644 app/MindWork AI Studio/Tools/MIME/VideoBuilder.cs create mode 100644 app/MindWork AI Studio/Tools/MIME/VideoSubtype.cs create mode 100644 app/MindWork AI Studio/wwwroot/sounds/start_recording.ogg create mode 100644 app/MindWork AI Studio/wwwroot/sounds/stop_recording.ogg create mode 100644 app/MindWork AI Studio/wwwroot/sounds/transcription_done.ogg create mode 100644 runtime/Info.plist diff --git a/app/MindWork AI Studio.sln.DotSettings b/app/MindWork AI Studio.sln.DotSettings index faaedb6b..86bd9eb3 100644 --- a/app/MindWork AI Studio.sln.DotSettings +++ b/app/MindWork AI Studio.sln.DotSettings @@ -6,6 +6,7 @@ GWDG HF IERI + IMIME LLM LM MSG @@ -18,10 +19,12 @@ URL I18N True + True True True True True + True True True True \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index 5bbe39b8..c2429c15 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -2323,6 +2323,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T428040679"] = "Content creation" -- Useful assistants UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T586430036"] = "Useful assistants" +-- Stop recording and start transcription +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T224155287"] = "Stop recording and start transcription" + +-- Start recording your voice for a transcription +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T2372624045"] = "Start recording your voice for a transcription" + -- Are you sure you want to delete the chat '{0}' in the workspace '{1}'? UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1016188706"] = "Are you sure you want to delete the chat '{0}' in the workspace '{1}'?" @@ -5368,6 +5374,9 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T1848 -- Plugins: Preview of our plugin system where you can extend the functionality of the app UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T2056842933"] = "Plugins: Preview of our plugin system where you can extend the functionality of the app" +-- Speech to Text: Preview of our speech to text system where you can transcribe recordings and audio files into text +UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T221133923"] = "Speech to Text: Preview of our speech to text system where you can transcribe recordings and audio files into text" + -- RAG: Preview of our RAG implementation where you can refer your files or integrate enterprise data within your company UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T2708939138"] = "RAG: Preview of our RAG implementation where you can refer your files or integrate enterprise data within your company" diff --git a/app/MindWork AI Studio/Chat/IImageSourceExtensions.cs b/app/MindWork AI Studio/Chat/IImageSourceExtensions.cs index 41706047..c6461643 100644 --- a/app/MindWork AI Studio/Chat/IImageSourceExtensions.cs +++ b/app/MindWork AI Studio/Chat/IImageSourceExtensions.cs @@ -1,3 +1,4 @@ +using AIStudio.Tools.MIME; using AIStudio.Tools.PluginSystem; namespace AIStudio.Chat; @@ -6,7 +7,7 @@ public static class IImageSourceExtensions { private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(IImageSourceExtensions).Namespace, nameof(IImageSourceExtensions)); - public static string DetermineMimeType(this IImageSource image) + public static MIMEType DetermineMimeType(this IImageSource image) { switch (image.SourceType) { @@ -18,13 +19,11 @@ public static class IImageSourceExtensions { var mimeEnd = base64Data.IndexOf(';'); if (mimeEnd > 5) - { - return base64Data[5..mimeEnd]; - } + return Builder.FromTextRepresentation(base64Data[5..mimeEnd]); } // Fallback: - return "application/octet-stream"; + return Builder.Create().UseApplication().UseSubtype(ApplicationSubtype.OCTET_STREAM).Build(); } case ContentImageSource.URL: @@ -32,38 +31,36 @@ public static class IImageSourceExtensions // Try to detect the mime type from the URL extension: var uri = new Uri(image.Source); var extension = Path.GetExtension(uri.AbsolutePath).ToLowerInvariant(); - return extension switch - { - ".png" => "image/png", - ".jpg" or ".jpeg" => "image/jpeg", - ".gif" => "image/gif", - ".bmp" => "image/bmp", - ".webp" => "image/webp", - - _ => "application/octet-stream" - }; + return DeriveMIMETypeFromExtension(extension); } case ContentImageSource.LOCAL_PATH: { var extension = Path.GetExtension(image.Source).ToLowerInvariant(); - return extension switch - { - ".png" => "image/png", - ".jpg" or ".jpeg" => "image/jpeg", - ".gif" => "image/gif", - ".bmp" => "image/bmp", - ".webp" => "image/webp", - - _ => "application/octet-stream" - }; + return DeriveMIMETypeFromExtension(extension); } default: - return "application/octet-stream"; + return Builder.Create().UseApplication().UseSubtype(ApplicationSubtype.OCTET_STREAM).Build(); } } - + + private static MIMEType DeriveMIMETypeFromExtension(string extension) + { + var imageBuilder = Builder.Create().UseImage(); + return extension switch + { + ".png" => imageBuilder.UseSubtype(ImageSubtype.PNG).Build(), + ".jpg" or ".jpeg" => imageBuilder.UseSubtype(ImageSubtype.JPEG).Build(), + ".gif" => imageBuilder.UseSubtype(ImageSubtype.GIF).Build(), + ".webp" => imageBuilder.UseSubtype(ImageSubtype.WEBP).Build(), + ".tiff" or ".tif" => imageBuilder.UseSubtype(ImageSubtype.TIFF).Build(), + ".heic" or ".heif" => imageBuilder.UseSubtype(ImageSubtype.HEIC).Build(), + + _ => Builder.Create().UseApplication().UseSubtype(ApplicationSubtype.OCTET_STREAM).Build() + }; + } + /// /// Read the image content as a base64 string. /// diff --git a/app/MindWork AI Studio/Components/VoiceRecorder.razor b/app/MindWork AI Studio/Components/VoiceRecorder.razor new file mode 100644 index 00000000..a3866719 --- /dev/null +++ b/app/MindWork AI Studio/Components/VoiceRecorder.razor @@ -0,0 +1,16 @@ +@using AIStudio.Settings.DataModel + +@namespace AIStudio.Components +@inherits MSGComponentBase + +@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..3cfa787b --- /dev/null +++ b/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs @@ -0,0 +1,197 @@ +using AIStudio.Tools.MIME; +using AIStudio.Tools.Services; + +using Microsoft.AspNetCore.Components; + +namespace AIStudio.Components; + +public partial class VoiceRecorder : MSGComponentBase +{ + [Inject] + private ILogger Logger { get; init; } = null!; + + [Inject] + private IJSRuntime JsRuntime { get; init; } = null!; + + [Inject] + private RustService RustService { get; init; } = null!; + + private uint numReceivedChunks; + private bool isRecording; + private FileStream? currentRecordingStream; + private string? currentRecordingPath; + private string? currentRecordingMimeType; + private DotNetObjectReference? dotNetReference; + + private string Tooltip => this.isRecording ? T("Stop recording and start transcription") : T("Start recording your voice for a transcription"); + + 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() + { + this.numReceivedChunks = 0; + 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 + { + this.numReceivedChunks++; + 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 over {NumChunks} streamed audio chunks to the file '{RecordingPath}'.", this.numReceivedChunks, 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; } + } + + #region Overrides of MSGComponentBase + + protected override void DisposeResources() + { + // 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; + base.DisposeResources(); + } + + #endregion +} diff --git a/app/MindWork AI Studio/Layout/MainLayout.razor b/app/MindWork AI Studio/Layout/MainLayout.razor index 23937719..908411f9 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,12 +22,20 @@ } + + + + + + + + } else { - + @foreach (var navBarItem in this.navItems) { @@ -41,6 +51,14 @@ } } + + + + + + + + } } diff --git a/app/MindWork AI Studio/Layout/MainLayout.razor.cs b/app/MindWork AI Studio/Layout/MainLayout.razor.cs index 064313cf..fc89f248 100644 --- a/app/MindWork AI Studio/Layout/MainLayout.razor.cs +++ b/app/MindWork AI Studio/Layout/MainLayout.razor.cs @@ -341,7 +341,7 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan await this.MessageBus.SendMessage(this, Event.COLOR_THEME_CHANGED); this.StateHasChanged(); } - + #region Implementation of IDisposable public void Dispose() diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua index 3e95cd2a..e2ad8227 100644 --- a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua @@ -2325,6 +2325,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T428040679"] = "Erstellung von In -- Useful assistants UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T586430036"] = "Nützliche Assistenten" +-- Stop recording and start transcription +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T224155287"] = "Aufnahme beenden und Transkription starten" + +-- Start recording your voice for a transcription +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T2372624045"] = "Beginnen Sie mit der Aufnahme Ihrer Stimme für eine Transkription" + -- Are you sure you want to delete the chat '{0}' in the workspace '{1}'? UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1016188706"] = "Möchten Sie den Chat „{0}“ im Arbeitsbereich „{1}“ wirklich löschen?" @@ -5370,6 +5376,9 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T1848 -- Plugins: Preview of our plugin system where you can extend the functionality of the app UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T2056842933"] = "Plugins: Vorschau auf unser Pluginsystems, mit dem Sie die Funktionalität der App erweitern können" +-- Speech to Text: Preview of our speech to text system where you can transcribe recordings and audio files into text +UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T221133923"] = "Sprache zu Text: Vorschau unseres Sprache-zu-Text-Systems, mit dem Sie Aufnahmen und Audiodateien in Text transkribieren können." + -- RAG: Preview of our RAG implementation where you can refer your files or integrate enterprise data within your company UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T2708939138"] = "RAG: Vorschau auf unsere RAG-Implementierung, mit der Sie auf ihre Dateien zugreifen oder Unternehmensdaten in ihrem Unternehmen integrieren können" diff --git a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua index 9ea4cc1b..1057ad86 100644 --- a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua @@ -2325,6 +2325,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T428040679"] = "Content creation" -- Useful assistants UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T586430036"] = "Useful assistants" +-- Stop recording and start transcription +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T224155287"] = "Stop recording and start transcription" + +-- Start recording your voice for a transcription +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T2372624045"] = "Start recording your voice for a transcription" + -- Are you sure you want to delete the chat '{0}' in the workspace '{1}'? UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1016188706"] = "Are you sure you want to delete the chat '{0}' in the workspace '{1}'?" @@ -5370,6 +5376,9 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T1848 -- Plugins: Preview of our plugin system where you can extend the functionality of the app UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T2056842933"] = "Plugins: Preview of our plugin system where you can extend the functionality of the app" +-- Speech to Text: Preview of our speech to text system where you can transcribe recordings and audio files into text +UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T221133923"] = "Speech to Text: Preview of our speech to text system where you can transcribe recordings and audio files into text" + -- RAG: Preview of our RAG implementation where you can refer your files or integrate enterprise data within your company UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T2708939138"] = "RAG: Preview of our RAG implementation where you can refer your files or integrate enterprise data within your company" diff --git a/app/MindWork AI Studio/Program.cs b/app/MindWork AI Studio/Program.cs index cc185180..fa7927b1 100644 --- a/app/MindWork AI Studio/Program.cs +++ b/app/MindWork AI Studio/Program.cs @@ -83,7 +83,6 @@ internal sealed class Program } var builder = WebApplication.CreateBuilder(); - builder.WebHost.ConfigureKestrel(kestrelServerOptions => { kestrelServerOptions.ConfigureEndpointDefaults(listenOptions => diff --git a/app/MindWork AI Studio/Settings/DataModel/PreviewFeatures.cs b/app/MindWork AI Studio/Settings/DataModel/PreviewFeatures.cs index 49aad8d0..d74898dd 100644 --- a/app/MindWork AI Studio/Settings/DataModel/PreviewFeatures.cs +++ b/app/MindWork AI Studio/Settings/DataModel/PreviewFeatures.cs @@ -12,4 +12,5 @@ public enum PreviewFeatures PRE_PLUGINS_2025, PRE_READ_PDF_2025, PRE_DOCUMENT_ANALYSIS_2025, + PRE_SPEECH_TO_TEXT_2026, } \ No newline at end of file diff --git a/app/MindWork AI Studio/Settings/DataModel/PreviewFeaturesExtensions.cs b/app/MindWork AI Studio/Settings/DataModel/PreviewFeaturesExtensions.cs index e80495b2..0433119c 100644 --- a/app/MindWork AI Studio/Settings/DataModel/PreviewFeaturesExtensions.cs +++ b/app/MindWork AI Studio/Settings/DataModel/PreviewFeaturesExtensions.cs @@ -14,6 +14,7 @@ public static class PreviewFeaturesExtensions PreviewFeatures.PRE_PLUGINS_2025 => TB("Plugins: Preview of our plugin system where you can extend the functionality of the app"), PreviewFeatures.PRE_READ_PDF_2025 => TB("Read PDF: Preview of our PDF reading system where you can read and extract text from PDF files"), PreviewFeatures.PRE_DOCUMENT_ANALYSIS_2025 => TB("Document Analysis: Preview of our document analysis system where you can analyze and extract information from documents"), + PreviewFeatures.PRE_SPEECH_TO_TEXT_2026 => TB("Speech to Text: Preview of our speech to text system where you can transcribe recordings and audio files into text"), _ => TB("Unknown preview feature") }; diff --git a/app/MindWork AI Studio/Settings/DataModel/PreviewVisibilityExtensions.cs b/app/MindWork AI Studio/Settings/DataModel/PreviewVisibilityExtensions.cs index bd648b24..53612acc 100644 --- a/app/MindWork AI Studio/Settings/DataModel/PreviewVisibilityExtensions.cs +++ b/app/MindWork AI Studio/Settings/DataModel/PreviewVisibilityExtensions.cs @@ -21,6 +21,7 @@ public static class PreviewVisibilityExtensions { features.Add(PreviewFeatures.PRE_RAG_2024); features.Add(PreviewFeatures.PRE_DOCUMENT_ANALYSIS_2025); + features.Add(PreviewFeatures.PRE_SPEECH_TO_TEXT_2026); } if (visibility >= PreviewVisibility.EXPERIMENTAL) diff --git a/app/MindWork AI Studio/Tools/MIME/ApplicationBuilder.cs b/app/MindWork AI Studio/Tools/MIME/ApplicationBuilder.cs new file mode 100644 index 00000000..2f452274 --- /dev/null +++ b/app/MindWork AI Studio/Tools/MIME/ApplicationBuilder.cs @@ -0,0 +1,67 @@ +namespace AIStudio.Tools.MIME; + +public class ApplicationBuilder : ISubtype +{ + private const BaseType BASE_TYPE = BaseType.APPLICATION; + + private ApplicationBuilder() + { + } + + public static ApplicationBuilder Create() => new(); + + private ApplicationSubtype subtype; + + public ApplicationBuilder UseSubtype(string subType) + { + this.subtype = subType.ToLowerInvariant() switch + { + "vnd.ms-excel" => ApplicationSubtype.EXCEL_OLD, + "vnd.ms-word" => ApplicationSubtype.WORD_OLD, + "vnd.ms-powerpoint" => ApplicationSubtype.POWERPOINT_OLD, + + "vnd.openxmlformats-officedocument.spreadsheetml.sheet" => ApplicationSubtype.EXCEL, + "vnd.openxmlformats-officedocument.wordprocessingml.document" => ApplicationSubtype.WORD, + "vnd.openxmlformats-officedocument.presentationml.presentation" => ApplicationSubtype.POWERPOINT, + + "octet-stream" => ApplicationSubtype.OCTET_STREAM, + + "json" => ApplicationSubtype.JSON, + "xml" => ApplicationSubtype.XML, + "pdf" => ApplicationSubtype.PDF, + "zip" => ApplicationSubtype.ZIP, + + "x-www-form-urlencoded" => ApplicationSubtype.X_WWW_FORM_URLENCODED, + _ => throw new ArgumentOutOfRangeException(nameof(subType), "Unsupported MIME application subtype.") + }; + + return this; + } + + public ApplicationBuilder UseSubtype(ApplicationSubtype subType) + { + this.subtype = subType; + return this; + } + + #region Implementation of IMIMESubtype + + public MIMEType Build() => new() + { + Type = this, + TextRepresentation = this.subtype switch + { + ApplicationSubtype.EXCEL_OLD => $"{BASE_TYPE}/vnd.ms-excel".ToLowerInvariant(), + ApplicationSubtype.WORD_OLD => $"{BASE_TYPE}/vnd.ms-word".ToLowerInvariant(), + ApplicationSubtype.POWERPOINT_OLD => $"{BASE_TYPE}/vnd.ms-powerpoint".ToLowerInvariant(), + + ApplicationSubtype.EXCEL => $"{BASE_TYPE}/vnd.openxmlformats-officedocument.spreadsheetml.sheet".ToLowerInvariant(), + ApplicationSubtype.WORD => $"{BASE_TYPE}/vnd.openxmlformats-officedocument.wordprocessingml.document".ToLowerInvariant(), + ApplicationSubtype.POWERPOINT => $"{BASE_TYPE}/vnd.openxmlformats-officedocument.presentationml.presentation".ToLowerInvariant(), + + _ => $"{BASE_TYPE}/{this.subtype}".ToLowerInvariant() + } + }; + + #endregion +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/MIME/ApplicationSubtype.cs b/app/MindWork AI Studio/Tools/MIME/ApplicationSubtype.cs new file mode 100644 index 00000000..4224815e --- /dev/null +++ b/app/MindWork AI Studio/Tools/MIME/ApplicationSubtype.cs @@ -0,0 +1,21 @@ +namespace AIStudio.Tools.MIME; + +public enum ApplicationSubtype +{ + OCTET_STREAM, + + JSON, + XML, + PDF, + ZIP, + X_WWW_FORM_URLENCODED, + + WORD_OLD, + WORD, + + EXCEL_OLD, + EXCEL, + + POWERPOINT_OLD, + POWERPOINT, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/MIME/AudioBuilder.cs b/app/MindWork AI Studio/Tools/MIME/AudioBuilder.cs new file mode 100644 index 00000000..86e371fb --- /dev/null +++ b/app/MindWork AI Studio/Tools/MIME/AudioBuilder.cs @@ -0,0 +1,51 @@ +namespace AIStudio.Tools.MIME; + +public class AudioBuilder : ISubtype +{ + private const BaseType BASE_TYPE = BaseType.AUDIO; + + private AudioBuilder() + { + } + + public static AudioBuilder Create() => new(); + + private AudioSubtype subtype; + + public AudioBuilder UseSubtype(string subType) + { + this.subtype = subType.ToLowerInvariant() switch + { + "mpeg" => AudioSubtype.MPEG, + "wav" => AudioSubtype.WAV, + "ogg" => AudioSubtype.OGG, + "aac" => AudioSubtype.AAC, + "flac" => AudioSubtype.FLAC, + "webm" => AudioSubtype.WEBM, + "mp4" => AudioSubtype.MP4, + "mp3" => AudioSubtype.MP3, + "m4a" => AudioSubtype.M4A, + "aiff" => AudioSubtype.AIFF, + + _ => throw new ArgumentException("Unsupported MIME audio subtype.", nameof(subType)) + }; + + return this; + } + + public AudioBuilder UseSubtype(AudioSubtype subType) + { + this.subtype = subType; + return this; + } + + #region Implementation of IMIMESubtype + + public MIMEType Build() => new() + { + Type = this, + TextRepresentation = $"{BASE_TYPE}/{this.subtype}".ToLowerInvariant() + }; + + #endregion +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/MIME/AudioSubtype.cs b/app/MindWork AI Studio/Tools/MIME/AudioSubtype.cs new file mode 100644 index 00000000..80ccba24 --- /dev/null +++ b/app/MindWork AI Studio/Tools/MIME/AudioSubtype.cs @@ -0,0 +1,16 @@ +namespace AIStudio.Tools.MIME; + +public enum AudioSubtype +{ + WAV, + MP3, + OGG, + AAC, + FLAC, + // ReSharper disable once InconsistentNaming + M4A, + MPEG, + MP4, + WEBM, + AIFF +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/MIME/BaseType.cs b/app/MindWork AI Studio/Tools/MIME/BaseType.cs new file mode 100644 index 00000000..76443f82 --- /dev/null +++ b/app/MindWork AI Studio/Tools/MIME/BaseType.cs @@ -0,0 +1,10 @@ +namespace AIStudio.Tools.MIME; + +public enum BaseType +{ + APPLICATION, + AUDIO, + IMAGE, + VIDEO, + TEXT, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/MIME/Builder.cs b/app/MindWork AI Studio/Tools/MIME/Builder.cs new file mode 100644 index 00000000..3a45b8db --- /dev/null +++ b/app/MindWork AI Studio/Tools/MIME/Builder.cs @@ -0,0 +1,58 @@ +namespace AIStudio.Tools.MIME; + +public class Builder +{ + private Builder() + { + } + + public static Builder Create() => new(); + + public static MIMEType FromTextRepresentation(string textRepresentation) + { + var parts = textRepresentation.Split('/'); + if (parts.Length != 2) + throw new ArgumentException("Invalid MIME type format.", nameof(textRepresentation)); + + var baseType = parts[0].ToLowerInvariant(); + var subType = parts[1].ToLowerInvariant(); + + var builder = Create(); + + switch (baseType) + { + case "application": + var appBuilder = builder.UseApplication(); + return appBuilder.UseSubtype(subType).Build(); + + case "text": + var textBuilder = builder.UseText(); + return textBuilder.UseSubtype(subType).Build(); + + case "audio": + var audioBuilder = builder.UseAudio(); + return audioBuilder.UseSubtype(subType).Build(); + + case "image": + var imageBuilder = builder.UseImage(); + return imageBuilder.UseSubtype(subType).Build(); + + case "video": + var videoBuilder = builder.UseVideo(); + return videoBuilder.UseSubtype(subType).Build(); + + default: + throw new ArgumentException("Unsupported base type.", nameof(textRepresentation)); + } + } + + public ApplicationBuilder UseApplication() => ApplicationBuilder.Create(); + + public TextBuilder UseText() => TextBuilder.Create(); + + public AudioBuilder UseAudio() => AudioBuilder.Create(); + + public ImageBuilder UseImage() => ImageBuilder.Create(); + + public VideoBuilder UseVideo() => VideoBuilder.Create(); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/MIME/ISubtype.cs b/app/MindWork AI Studio/Tools/MIME/ISubtype.cs new file mode 100644 index 00000000..517f6a3e --- /dev/null +++ b/app/MindWork AI Studio/Tools/MIME/ISubtype.cs @@ -0,0 +1,6 @@ +namespace AIStudio.Tools.MIME; + +public interface ISubtype +{ + public MIMEType Build(); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/MIME/ImageBuilder.cs b/app/MindWork AI Studio/Tools/MIME/ImageBuilder.cs new file mode 100644 index 00000000..b59cca4f --- /dev/null +++ b/app/MindWork AI Studio/Tools/MIME/ImageBuilder.cs @@ -0,0 +1,48 @@ +namespace AIStudio.Tools.MIME; + +public class ImageBuilder : ISubtype +{ + private const BaseType BASE_TYPE = BaseType.IMAGE; + + private ImageBuilder() + { + } + + public static ImageBuilder Create() => new(); + + private ImageSubtype subtype; + + public ImageBuilder UseSubtype(string subType) + { + this.subtype = subType.ToLowerInvariant() switch + { + "jpeg" or "jpg" => ImageSubtype.JPEG, + "png" => ImageSubtype.PNG, + "gif" => ImageSubtype.GIF, + "webp" => ImageSubtype.WEBP, + "tiff" or "tif" => ImageSubtype.TIFF, + "svg+xml" or "svg" => ImageSubtype.SVG, + "heic" => ImageSubtype.HEIC, + + _ => throw new ArgumentException("Unsupported MIME image subtype.", nameof(subType)) + }; + + return this; + } + + public ImageBuilder UseSubtype(ImageSubtype subType) + { + this.subtype = subType; + return this; + } + + #region Implementation of IMIMESubtype + + public MIMEType Build() => new() + { + Type = this, + TextRepresentation = $"{BASE_TYPE}/{this.subtype}".ToLowerInvariant() + }; + + #endregion +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/MIME/ImageSubtype.cs b/app/MindWork AI Studio/Tools/MIME/ImageSubtype.cs new file mode 100644 index 00000000..73b11896 --- /dev/null +++ b/app/MindWork AI Studio/Tools/MIME/ImageSubtype.cs @@ -0,0 +1,12 @@ +namespace AIStudio.Tools.MIME; + +public enum ImageSubtype +{ + JPEG, + PNG, + GIF, + TIFF, + WEBP, + SVG, + HEIC, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/MIME/MIMEType.cs b/app/MindWork AI Studio/Tools/MIME/MIMEType.cs new file mode 100644 index 00000000..adf45e6d --- /dev/null +++ b/app/MindWork AI Studio/Tools/MIME/MIMEType.cs @@ -0,0 +1,16 @@ +namespace AIStudio.Tools.MIME; + +public record MIMEType +{ + public required ISubtype Type { get; init; } + + public required string TextRepresentation { get; init; } + + #region Overrides of Object + + public override string ToString() => this.TextRepresentation; + + #endregion + + public static implicit operator string(MIMEType mimeType) => mimeType.TextRepresentation; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/MIME/MIMETypeExtensions.cs b/app/MindWork AI Studio/Tools/MIME/MIMETypeExtensions.cs new file mode 100644 index 00000000..cfcd9053 --- /dev/null +++ b/app/MindWork AI Studio/Tools/MIME/MIMETypeExtensions.cs @@ -0,0 +1,15 @@ +namespace AIStudio.Tools.MIME; + +public static class MIMETypeExtensions +{ + public static string[] ToStringArray(this MIMEType[] mimeTypes) + { + var result = new string[mimeTypes.Length]; + for (var i = 0; i < mimeTypes.Length; i++) + { + result[i] = mimeTypes[i]; + } + + return result; + } +} diff --git a/app/MindWork AI Studio/Tools/MIME/TextBuilder.cs b/app/MindWork AI Studio/Tools/MIME/TextBuilder.cs new file mode 100644 index 00000000..c4848dbf --- /dev/null +++ b/app/MindWork AI Studio/Tools/MIME/TextBuilder.cs @@ -0,0 +1,49 @@ +namespace AIStudio.Tools.MIME; + +public class TextBuilder : ISubtype +{ + private const BaseType BASE_TYPE = BaseType.TEXT; + + private TextBuilder() + { + } + + public static TextBuilder Create() => new(); + + private TextSubtype subtype; + + public TextBuilder UseSubtype(string subType) + { + this.subtype = subType.ToLowerInvariant() switch + { + "plain" => TextSubtype.PLAIN, + "html" => TextSubtype.HTML, + "css" => TextSubtype.CSS, + "csv" => TextSubtype.CSV, + "javascript" => TextSubtype.JAVASCRIPT, + "xml" => TextSubtype.XML, + "markdown" => TextSubtype.MARKDOWN, + "json" => TextSubtype.JSON, + + _ => throw new ArgumentException("Unsupported MIME text subtype.", nameof(subType)) + }; + + return this; + } + + public TextBuilder UseSubtype(TextSubtype subType) + { + this.subtype = subType; + return this; + } + + #region Implementation of IMIMESubtype + + public MIMEType Build() => new() + { + Type = this, + TextRepresentation = $"{BASE_TYPE}/{this.subtype}".ToLowerInvariant() + }; + + #endregion +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/MIME/TextSubtype.cs b/app/MindWork AI Studio/Tools/MIME/TextSubtype.cs new file mode 100644 index 00000000..c3d34829 --- /dev/null +++ b/app/MindWork AI Studio/Tools/MIME/TextSubtype.cs @@ -0,0 +1,13 @@ +namespace AIStudio.Tools.MIME; + +public enum TextSubtype +{ + PLAIN, + HTML, + CSS, + CSV, + JAVASCRIPT, + XML, + JSON, + MARKDOWN, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/MIME/VideoBuilder.cs b/app/MindWork AI Studio/Tools/MIME/VideoBuilder.cs new file mode 100644 index 00000000..6d23d8b3 --- /dev/null +++ b/app/MindWork AI Studio/Tools/MIME/VideoBuilder.cs @@ -0,0 +1,46 @@ +namespace AIStudio.Tools.MIME; + +public class VideoBuilder : ISubtype +{ + private const BaseType BASE_TYPE = BaseType.VIDEO; + + private VideoBuilder() + { + } + + public static VideoBuilder Create() => new(); + + private VideoSubtype subtype; + + public VideoBuilder UseSubtype(string subType) + { + this.subtype = subType.ToLowerInvariant() switch + { + "mp4" => VideoSubtype.MP4, + "webm" => VideoSubtype.WEBM, + "avi" => VideoSubtype.AVI, + "mov" => VideoSubtype.MOV, + "mkv" => VideoSubtype.MKV, + + _ => throw new ArgumentException("Unsupported MIME video subtype.", nameof(subType)) + }; + + return this; + } + + public VideoBuilder UseSubtype(VideoSubtype subType) + { + this.subtype = subType; + return this; + } + + #region Implementation of IMIMESubtype + + public MIMEType Build() => new() + { + Type = this, + TextRepresentation = $"{BASE_TYPE}/{this.subtype}".ToLowerInvariant() + }; + + #endregion +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/MIME/VideoSubtype.cs b/app/MindWork AI Studio/Tools/MIME/VideoSubtype.cs new file mode 100644 index 00000000..cf152b1b --- /dev/null +++ b/app/MindWork AI Studio/Tools/MIME/VideoSubtype.cs @@ -0,0 +1,11 @@ +namespace AIStudio.Tools.MIME; + +public enum VideoSubtype +{ + MP4, + AVI, + MOV, + MKV, + WEBM, + MPEG, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/wwwroot/app.js b/app/MindWork AI Studio/wwwroot/app.js index aa6b8e2b..2dd43e5c 100644 --- a/app/MindWork AI Studio/wwwroot/app.js +++ b/app/MindWork AI Studio/wwwroot/app.js @@ -25,4 +25,133 @@ window.clearDiv = function (divName) { window.scrollToBottom = function(element) { element.scrollIntoView({ behavior: 'smooth', block: 'end', inline: 'nearest' }); -} \ No newline at end of file +} + +let mediaRecorder; +let actualRecordingMimeType; +let changedMimeType = false; +let pendingChunkUploads = 0; + +window.audioRecorder = { + playSound: function(soundPath) { + try { + const audio = new Audio(soundPath); + audio.play().catch(error => { + console.warn('Failed to play sound effect:', error); + }); + } catch (error) { + console.warn('Error creating audio element:', error); + } + }, + + start: async function (dotnetRef, desiredMimeTypes = []) { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + + // Play start recording sound effect: + this.playSound('/sounds/start_recording.ogg'); + + // When only one mime type is provided as a string, convert it to an array: + if (typeof desiredMimeTypes === 'string') { + desiredMimeTypes = [desiredMimeTypes]; + } + + // Log sent mime types for debugging: + console.log('Audio recording - requested mime types: ', desiredMimeTypes); + + let mimeTypes = desiredMimeTypes.filter(type => typeof type === 'string' && type.trim() !== ''); + + // Next, we have to ensure that we have some default mime types to check as well. + // In case the provided list does not contain these, we append them: + // Use provided mime types or fallback to a default list: + const defaultMimeTypes = [ + 'audio/webm', + 'audio/ogg', + 'audio/mp4', + 'audio/mpeg', + ''// Fallback to browser default + ]; + + defaultMimeTypes.forEach(type => { + if (!mimeTypes.includes(type)) { + mimeTypes.push(type); + } + }); + + console.log('Audio recording - final mime types to check (included defaults): ', mimeTypes); + + // Find the first supported mime type: + actualRecordingMimeType = mimeTypes.find(type => + type === '' || MediaRecorder.isTypeSupported(type) + ) || ''; + + console.log('Audio recording - the browser selected the following mime type for recording: ', actualRecordingMimeType); + const options = actualRecordingMimeType ? { mimeType: actualRecordingMimeType } : {}; + mediaRecorder = new MediaRecorder(stream, options); + + // In case the browser changed the mime type: + actualRecordingMimeType = mediaRecorder.mimeType; + console.log('Audio recording - actual mime type used by the browser: ', actualRecordingMimeType); + + // Check the list of desired mime types against the actual one: + if (!desiredMimeTypes.includes(actualRecordingMimeType)) { + 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: + pendingChunkUploads = 0; + + // Stream each chunk directly to .NET as it becomes available: + mediaRecorder.ondataavailable = async (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--; + } + } + }; + + mediaRecorder.start(3000); // read the recorded data in 3-second chunks + return actualRecordingMimeType; + }, + + stop: async function () { + return new Promise((resolve) => { + + // 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 + } + + console.log('Audio recording - all chunks uploaded, finalizing.'); + + // Play stop recording sound effect: + window.audioRecorder.playSound('/sounds/stop_recording.ogg'); + + // Stop all tracks to release the microphone: + mediaRecorder.stream.getTracks().forEach(track => track.stop()); + + // No need to process data here anymore, just signal completion: + resolve({ + mimeType: actualRecordingMimeType, + changedMimeType: changedMimeType, + }); + }; + + // Finally, stop the recording (which will actually trigger the onstop event): + mediaRecorder.stop(); + }); + } +}; \ No newline at end of file diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.1.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.1.1.md index ec35c4ec..a1b23c7d 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.1.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.1.1.md @@ -1,4 +1,5 @@ # v26.1.1, build 231 (2026-01-xx xx:xx UTC) - Added the option to attach files, including images, to chat templates. You can also define templates with file attachments through a configuration plugin. These file attachments aren’t copied—they’re re-read every time. That means the AI will pick up any updates you make to those files. - Added the option to use source code files in chats and document analysis. This supports software development workflows. +- Added a preview feature that lets you record your own voice in preparation for the transcription feature. The feature remains in development and appears only when the preview feature is enabled. - Improved the app versioning. Starting in 2026, each version number includes the year, followed by the month. The last digit shows the release number for that month. For example, version `26.1.1` is the first release in January 2026. \ No newline at end of file diff --git a/app/MindWork AI Studio/wwwroot/sounds/start_recording.ogg b/app/MindWork AI Studio/wwwroot/sounds/start_recording.ogg new file mode 100644 index 0000000000000000000000000000000000000000..b67bb65eae995da726283dff1d55ad0f13dde362 GIT binary patch literal 9859 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 literal 0 HcmV?d00001 diff --git a/app/MindWork AI Studio/wwwroot/sounds/stop_recording.ogg b/app/MindWork AI Studio/wwwroot/sounds/stop_recording.ogg new file mode 100644 index 0000000000000000000000000000000000000000..c233240835f0e3ac071032e8dd3c6f9fa7287ed0 GIT binary patch literal 10431 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 literal 0 HcmV?d00001 diff --git a/runtime/Info.plist b/runtime/Info.plist new file mode 100644 index 00000000..61967ac4 --- /dev/null +++ b/runtime/Info.plist @@ -0,0 +1,8 @@ + + + + + NSMicrophoneUsageDescription + Request microphone access for voice recording + +