mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-09-13 16:53:36 +00:00
Switched voice recording to use PCM/WAV format
This commit is contained in:
parent
322a02490d
commit
169d474892
@ -1,6 +1,7 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
|
||||||
using AIStudio.Settings.DataModel;
|
using AIStudio.Settings.DataModel;
|
||||||
using AIStudio.Tools.Media;
|
using AIStudio.Tools.Media;
|
||||||
using AIStudio.Tools.MIME;
|
|
||||||
using AIStudio.Tools.Rust;
|
using AIStudio.Tools.Rust;
|
||||||
using AIStudio.Tools.Services;
|
using AIStudio.Tools.Services;
|
||||||
|
|
||||||
@ -10,6 +11,8 @@ namespace AIStudio.Components;
|
|||||||
|
|
||||||
public partial class VoiceRecorder : MSGComponentBase
|
public partial class VoiceRecorder : MSGComponentBase
|
||||||
{
|
{
|
||||||
|
private const int PCM_WAV_HEADER_SIZE = 44;
|
||||||
|
|
||||||
[Inject]
|
[Inject]
|
||||||
private ILogger<VoiceRecorder> Logger { get; init; } = null!;
|
private ILogger<VoiceRecorder> Logger { get; init; } = null!;
|
||||||
|
|
||||||
@ -96,7 +99,6 @@ public partial class VoiceRecorder : MSGComponentBase
|
|||||||
private bool isTranscribing;
|
private bool isTranscribing;
|
||||||
private FileStream? currentRecordingStream;
|
private FileStream? currentRecordingStream;
|
||||||
private string? currentRecordingPath;
|
private string? currentRecordingPath;
|
||||||
private string? currentRecordingMimeType;
|
|
||||||
private string? finalRecordingPath;
|
private string? finalRecordingPath;
|
||||||
private DotNetObjectReference<VoiceRecorder>? dotNetReference;
|
private DotNetObjectReference<VoiceRecorder>? dotNetReference;
|
||||||
|
|
||||||
@ -134,18 +136,7 @@ public partial class VoiceRecorder : MSGComponentBase
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var mimeTypes = GetPreferredMimeTypes(
|
this.Logger.LogInformation("Starting PCM/WAV audio recording.");
|
||||||
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(),
|
|
||||||
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:
|
// Create a DotNetObjectReference to pass to JavaScript:
|
||||||
this.dotNetReference = DotNetObjectReference.Create(this);
|
this.dotNetReference = DotNetObjectReference.Create(this);
|
||||||
@ -155,13 +146,8 @@ public partial class VoiceRecorder : MSGComponentBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
string[] mimeTypeStrings = ["audio/webm;codecs=opus", .. mimeTypes.ToStringArray()];
|
await this.JsRuntime.InvokeVoidAsync("audioRecorder.start", this.dotNetReference);
|
||||||
var actualMimeType = await this.JsRuntime.InvokeAsync<string>("audioRecorder.start", this.dotNetReference, mimeTypeStrings);
|
this.Logger.LogInformation("PCM/WAV audio recording started.");
|
||||||
|
|
||||||
// Store the MIME type for later use:
|
|
||||||
this.currentRecordingMimeType = actualMimeType;
|
|
||||||
|
|
||||||
this.Logger.LogInformation("Audio recording started with MIME type: '{ActualMimeType}'.", actualMimeType);
|
|
||||||
this.isPreparing = false;
|
this.isPreparing = false;
|
||||||
this.isRecording = true;
|
this.isRecording = true;
|
||||||
}
|
}
|
||||||
@ -184,9 +170,7 @@ public partial class VoiceRecorder : MSGComponentBase
|
|||||||
var recordingStoppedSuccessfully = false;
|
var recordingStoppedSuccessfully = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await this.JsRuntime.InvokeAsync<AudioRecordingResult>("audioRecorder.stop");
|
await this.JsRuntime.InvokeVoidAsync("audioRecorder.stop");
|
||||||
if (result.ChangedMimeType)
|
|
||||||
this.Logger.LogWarning("The recorded audio MIME type was changed to '{ResultMimeType}'.", result.MimeType);
|
|
||||||
recordingStoppedSuccessfully = true;
|
recordingStoppedSuccessfully = true;
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
@ -218,24 +202,6 @@ public partial class VoiceRecorder : MSGComponentBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
private async Task InitializeRecordingStream()
|
||||||
{
|
{
|
||||||
this.numReceivedChunks = 0;
|
this.numReceivedChunks = 0;
|
||||||
@ -244,7 +210,7 @@ public partial class VoiceRecorder : MSGComponentBase
|
|||||||
if (!Directory.Exists(recordingDirectory))
|
if (!Directory.Exists(recordingDirectory))
|
||||||
Directory.CreateDirectory(recordingDirectory);
|
Directory.CreateDirectory(recordingDirectory);
|
||||||
|
|
||||||
var fileName = $"recording_{DateTime.UtcNow:yyyyMMdd_HHmmss}.audio";
|
var fileName = $"recording_{DateTime.UtcNow:yyyyMMdd_HHmmss}.wav";
|
||||||
this.currentRecordingPath = Path.Combine(recordingDirectory, fileName);
|
this.currentRecordingPath = Path.Combine(recordingDirectory, fileName);
|
||||||
this.currentRecordingStream = new FileStream(this.currentRecordingPath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 8192, useAsync: true);
|
this.currentRecordingStream = new FileStream(this.currentRecordingPath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 8192, useAsync: true);
|
||||||
|
|
||||||
@ -281,6 +247,7 @@ public partial class VoiceRecorder : MSGComponentBase
|
|||||||
if (this.currentRecordingStream is not null)
|
if (this.currentRecordingStream is not null)
|
||||||
{
|
{
|
||||||
await this.currentRecordingStream.FlushAsync();
|
await this.currentRecordingStream.FlushAsync();
|
||||||
|
var hasPcmAudioData = await this.FinalizePcmWavHeaderAsync(this.currentRecordingStream);
|
||||||
await this.currentRecordingStream.DisposeAsync();
|
await this.currentRecordingStream.DisposeAsync();
|
||||||
this.currentRecordingStream = null;
|
this.currentRecordingStream = null;
|
||||||
|
|
||||||
@ -288,44 +255,48 @@ public partial class VoiceRecorder : MSGComponentBase
|
|||||||
{
|
{
|
||||||
var fileSize = new FileInfo(this.currentRecordingPath).Length;
|
var fileSize = new FileInfo(this.currentRecordingPath).Length;
|
||||||
|
|
||||||
// Rename non-empty recordings with the correct extension based on MIME type:
|
if (hasPcmAudioData)
|
||||||
if (fileSize > 0 && this.currentRecordingMimeType is not null)
|
|
||||||
{
|
{
|
||||||
var extension = GetFileExtension(this.currentRecordingMimeType);
|
this.finalRecordingPath = this.currentRecordingPath;
|
||||||
var newPath = Path.ChangeExtension(this.currentRecordingPath, extension);
|
this.Logger.LogInformation("Finalized audio recording over {NumChunks} streamed audio chunks to the file '{RecordingPath}' with {FileSize} bytes.", this.numReceivedChunks, this.currentRecordingPath, fileSize);
|
||||||
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}' with {FileSize} bytes.", this.numReceivedChunks, newPath, fileSize);
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
this.Logger.LogWarning("Discarding an audio recording with {FileSize} bytes and MIME type '{MimeType}'.", fileSize, this.currentRecordingMimeType);
|
this.Logger.LogWarning("Discarding a PCM/WAV audio recording without audio data ({FileSize} bytes).", fileSize);
|
||||||
File.Delete(this.currentRecordingPath);
|
File.Delete(this.currentRecordingPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.currentRecordingPath = null;
|
this.currentRecordingPath = null;
|
||||||
this.currentRecordingMimeType = null;
|
|
||||||
|
|
||||||
// Dispose the .NET reference:
|
// Dispose the .NET reference:
|
||||||
this.dotNetReference?.Dispose();
|
this.dotNetReference?.Dispose();
|
||||||
this.dotNetReference = null;
|
this.dotNetReference = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetFileExtension(string mimeType)
|
private async Task<bool> FinalizePcmWavHeaderAsync(FileStream recordingStream)
|
||||||
{
|
{
|
||||||
var baseMimeType = mimeType.Split(';')[0].Trim().ToLowerInvariant();
|
if (recordingStream.Length <= PCM_WAV_HEADER_SIZE)
|
||||||
return baseMimeType switch
|
return false;
|
||||||
{
|
|
||||||
"audio/webm" => ".webm",
|
var pcmDataSize = recordingStream.Length - PCM_WAV_HEADER_SIZE;
|
||||||
"audio/ogg" => ".ogg",
|
if (pcmDataSize > uint.MaxValue - 36)
|
||||||
"audio/mp4" => ".m4a",
|
throw new InvalidDataException("The streamed PCM recording exceeds the WAV size limit.");
|
||||||
"audio/mpeg" => ".mp3",
|
|
||||||
"audio/wav" => ".wav",
|
var valueBuffer = new byte[sizeof(uint)];
|
||||||
"audio/x-wav" => ".wav",
|
BinaryPrimitives.WriteUInt32LittleEndian(valueBuffer, checked((uint)(36 + pcmDataSize)));
|
||||||
_ => ".audio" // Fallback
|
recordingStream.Seek(4, SeekOrigin.Begin);
|
||||||
};
|
await recordingStream.WriteAsync(valueBuffer);
|
||||||
|
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(valueBuffer, checked((uint)pcmDataSize));
|
||||||
|
recordingStream.Seek(40, SeekOrigin.Begin);
|
||||||
|
await recordingStream.WriteAsync(valueBuffer);
|
||||||
|
recordingStream.Seek(0, SeekOrigin.End);
|
||||||
|
await recordingStream.FlushAsync();
|
||||||
|
|
||||||
|
this.Logger.LogInformation("Finalized a streamed PCM/WAV header for {PcmDataSize} bytes of audio data.", pcmDataSize);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task TranscribeRecordingAsync()
|
private async Task TranscribeRecordingAsync()
|
||||||
|
|||||||
@ -1070,6 +1070,10 @@ public abstract class BaseProvider : IProvider, ISecretId
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.logger.LogInformation("Uploading transcription media '{FileName}' with content type '{ContentType}' and {FileSize} bytes.",
|
||||||
|
Path.GetFileName(audioFilePath),
|
||||||
|
mimeType.TextRepresentation,
|
||||||
|
fileStream.Length);
|
||||||
using var response = await this.HttpClient.SendAsync(request, token);
|
using var response = await this.HttpClient.SendAsync(request, token);
|
||||||
var responseBody = await response.Content.ReadAsStringAsync(token);
|
var responseBody = await response.Content.ReadAsStringAsync(token);
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +0,0 @@
|
|||||||
namespace AIStudio.Tools;
|
|
||||||
|
|
||||||
public sealed class AudioRecordingResult
|
|
||||||
{
|
|
||||||
public string MimeType { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
public bool ChangedMimeType { get; init; }
|
|
||||||
}
|
|
||||||
@ -2,6 +2,8 @@ namespace AIStudio.Tools.Rust;
|
|||||||
|
|
||||||
/// <summary>Successful terminal result returned by Rust media normalization.</summary>
|
/// <summary>Successful terminal result returned by Rust media normalization.</summary>
|
||||||
/// <param name="OutputPath">Committed normalized output path.</param>
|
/// <param name="OutputPath">Committed normalized output path.</param>
|
||||||
|
/// <param name="OutputFormat">Stable normalized container used for provider uploads.</param>
|
||||||
|
/// <param name="OutputCodec">Stable normalized audio codec used for provider uploads.</param>
|
||||||
/// <param name="DetectedFormat">Detected container diagnostic.</param>
|
/// <param name="DetectedFormat">Detected container diagnostic.</param>
|
||||||
/// <param name="DetectedCodec">Selected codec diagnostic.</param>
|
/// <param name="DetectedCodec">Selected codec diagnostic.</param>
|
||||||
/// <param name="DurationMs">Normalized duration in milliseconds.</param>
|
/// <param name="DurationMs">Normalized duration in milliseconds.</param>
|
||||||
@ -9,6 +11,8 @@ namespace AIStudio.Tools.Rust;
|
|||||||
/// <param name="HasAudibleSignal">Whether the normalized audio exceeds the practical-silence threshold.</param>
|
/// <param name="HasAudibleSignal">Whether the normalized audio exceeds the practical-silence threshold.</param>
|
||||||
public sealed record MediaJobResult(
|
public sealed record MediaJobResult(
|
||||||
string OutputPath,
|
string OutputPath,
|
||||||
|
string OutputFormat,
|
||||||
|
string OutputCodec,
|
||||||
string DetectedFormat,
|
string DetectedFormat,
|
||||||
string DetectedCodec,
|
string DetectedCodec,
|
||||||
ulong DurationMs,
|
ulong DurationMs,
|
||||||
|
|||||||
@ -12,6 +12,11 @@ namespace AIStudio.Tools.Services;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class MediaTranscriptionService(RustService rustService, SettingsManager settingsManager, ILogger<MediaTranscriptionService> logger) : IDisposable
|
public sealed class MediaTranscriptionService(RustService rustService, SettingsManager settingsManager, ILogger<MediaTranscriptionService> logger) : IDisposable
|
||||||
{
|
{
|
||||||
|
private const string NORMALIZED_OUTPUT_EXTENSION = ".webm";
|
||||||
|
private const string NORMALIZED_OUTPUT_FORMAT = "webm";
|
||||||
|
private const string NORMALIZED_OUTPUT_CODEC = "opus";
|
||||||
|
private static readonly byte[] WEBM_EBML_SIGNATURE = [0x1A, 0x45, 0xDF, 0xA3];
|
||||||
|
|
||||||
/// <summary>Serializes attachment and file-content imports.</summary>
|
/// <summary>Serializes attachment and file-content imports.</summary>
|
||||||
private readonly SemaphoreSlim importQueue = new(1, 1);
|
private readonly SemaphoreSlim importQueue = new(1, 1);
|
||||||
|
|
||||||
@ -446,6 +451,13 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM
|
|||||||
? MediaTranscriptionResult.Failed(TB("The media pipeline ended without an output file."))
|
? MediaTranscriptionResult.Failed(TB("The media pipeline ended without an output file."))
|
||||||
: MediaTranscriptionResult.Failed(UserMessageFor(normalized.Error.Code), normalized.Error.Code);
|
: MediaTranscriptionResult.Failed(UserMessageFor(normalized.Error.Code), normalized.Error.Code);
|
||||||
|
|
||||||
|
var uploadContractError = await ValidateNormalizedProviderUploadAsync(normalized.Result, normalizedPath, operation.Cancellation.Token);
|
||||||
|
if (uploadContractError is not null)
|
||||||
|
{
|
||||||
|
logger.LogError("Refusing the transcription provider upload because the normalized media contract validation failed: {Diagnostic}", uploadContractError);
|
||||||
|
return MediaTranscriptionResult.Failed(TB("The media pipeline ended without an output file."));
|
||||||
|
}
|
||||||
|
|
||||||
if (!normalized.Result.HasAudibleSignal)
|
if (!normalized.Result.HasAudibleSignal)
|
||||||
{
|
{
|
||||||
logger.LogInformation("Skipping transcription for '{MediaPath}' because its maximum audio peak does not exceed the practical-silence threshold.", mediaPath);
|
logger.LogInformation("Skipping transcription for '{MediaPath}' because its maximum audio peak does not exceed the practical-silence threshold.", mediaPath);
|
||||||
@ -463,12 +475,21 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM
|
|||||||
if (provider.Provider is LLMProviders.NONE)
|
if (provider.Provider is LLMProviders.NONE)
|
||||||
return MediaTranscriptionResult.Failed(TB("The configured transcription provider could not be created."));
|
return MediaTranscriptionResult.Failed(TB("The configured transcription provider could not be created."));
|
||||||
|
|
||||||
logger.LogInformation("Transcribing normalized media '{MediaPath}' with provider '{Provider}' and model '{Model}'.",
|
var sourceSize = File.Exists(mediaPath) ? new FileInfo(mediaPath).Length : 0;
|
||||||
|
var normalizedSize = new FileInfo(normalizedPath).Length;
|
||||||
|
var reductionPercent = sourceSize > 0
|
||||||
|
? (1.0 - (double)normalizedSize / sourceSize) * 100.0
|
||||||
|
: 0.0;
|
||||||
|
logger.LogInformation("Transcribing normalized WebM/Opus media '{NormalizedPath}' ({NormalizedSize} bytes; source '{SourcePath}' {SourceSize} bytes; size reduction {ReductionPercent:F1}%) with provider '{Provider}' and model '{Model}'.",
|
||||||
|
normalizedPath,
|
||||||
|
normalizedSize,
|
||||||
mediaPath,
|
mediaPath,
|
||||||
|
sourceSize,
|
||||||
|
reductionPercent,
|
||||||
providerSettings.UsedLLMProvider,
|
providerSettings.UsedLLMProvider,
|
||||||
providerSettings.Model);
|
providerSettings.Model);
|
||||||
|
|
||||||
var providerResult = await provider.TranscribeAudioAsync(providerSettings.Model, normalized.Result.OutputPath, settingsManager, operation.Cancellation.Token);
|
var providerResult = await provider.TranscribeAudioAsync(providerSettings.Model, normalizedPath, settingsManager, operation.Cancellation.Token);
|
||||||
operation.Cancellation.Token.ThrowIfCancellationRequested();
|
operation.Cancellation.Token.ThrowIfCancellationRequested();
|
||||||
if (!providerResult.Success)
|
if (!providerResult.Success)
|
||||||
{
|
{
|
||||||
@ -498,6 +519,72 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Validates the fail-closed WebM/Opus contract before provider upload.</summary>
|
||||||
|
private static async Task<string?> ValidateNormalizedProviderUploadAsync(MediaJobResult result, string expectedOutputPath, CancellationToken token)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(result.OutputPath))
|
||||||
|
return "Rust returned an empty normalized output path.";
|
||||||
|
|
||||||
|
string actualFullPath;
|
||||||
|
string expectedFullPath;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
actualFullPath = Path.GetFullPath(result.OutputPath);
|
||||||
|
expectedFullPath = Path.GetFullPath(expectedOutputPath);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
return $"The normalized output path is invalid: {exception.Message}";
|
||||||
|
}
|
||||||
|
|
||||||
|
var pathComparison = OperatingSystem.IsWindows()
|
||||||
|
? StringComparison.OrdinalIgnoreCase
|
||||||
|
: StringComparison.Ordinal;
|
||||||
|
|
||||||
|
if (!string.Equals(actualFullPath, expectedFullPath, pathComparison))
|
||||||
|
return $"Rust returned the unexpected output path '{result.OutputPath}' instead of '{expectedOutputPath}'.";
|
||||||
|
|
||||||
|
if (!string.Equals(Path.GetExtension(actualFullPath), NORMALIZED_OUTPUT_EXTENSION, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return $"The normalized output path '{actualFullPath}' does not use the required '{NORMALIZED_OUTPUT_EXTENSION}' extension.";
|
||||||
|
|
||||||
|
if (!string.Equals(result.OutputFormat, NORMALIZED_OUTPUT_FORMAT, StringComparison.Ordinal))
|
||||||
|
return $"Rust returned output format '{result.OutputFormat}' instead of '{NORMALIZED_OUTPUT_FORMAT}'.";
|
||||||
|
|
||||||
|
if (!string.Equals(result.OutputCodec, NORMALIZED_OUTPUT_CODEC, StringComparison.Ordinal))
|
||||||
|
return $"Rust returned output codec '{result.OutputCodec}' instead of '{NORMALIZED_OUTPUT_CODEC}'.";
|
||||||
|
|
||||||
|
if (!File.Exists(actualFullPath))
|
||||||
|
return $"The normalized output file '{actualFullPath}' does not exist.";
|
||||||
|
|
||||||
|
var header = new byte[WEBM_EBML_SIGNATURE.Length];
|
||||||
|
var bytesRead = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var stream = File.OpenRead(actualFullPath);
|
||||||
|
while (bytesRead < header.Length)
|
||||||
|
{
|
||||||
|
var count = await stream.ReadAsync(header.AsMemory(bytesRead), token);
|
||||||
|
if (count is 0)
|
||||||
|
break;
|
||||||
|
|
||||||
|
bytesRead += count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (IOException exception)
|
||||||
|
{
|
||||||
|
return $"The normalized output file '{actualFullPath}' could not be read: {exception.Message}";
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException exception)
|
||||||
|
{
|
||||||
|
return $"The normalized output file '{actualFullPath}' could not be read: {exception.Message}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bytesRead != header.Length || !header.AsSpan().SequenceEqual(WEBM_EBML_SIGNATURE))
|
||||||
|
return $"The normalized output file '{actualFullPath}' does not begin with the WebM/Matroska EBML signature.";
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Runs the Rust normalization job and drains cancellation to a terminal event.</summary>
|
/// <summary>Runs the Rust normalization job and drains cancellation to a terminal event.</summary>
|
||||||
/// <param name="mediaPath">Source media path.</param>
|
/// <param name="mediaPath">Source media path.</param>
|
||||||
/// <param name="normalizedPath">Owned temporary output path.</param>
|
/// <param name="normalizedPath">Owned temporary output path.</param>
|
||||||
|
|||||||
61
app/MindWork AI Studio/wwwroot/audio-recorder-worklet.js
Normal file
61
app/MindWork AI Studio/wwwroot/audio-recorder-worklet.js
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
class PCMRecorderProcessor extends AudioWorkletProcessor {
|
||||||
|
constructor(options) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
const chunkDurationSeconds = options.processorOptions?.chunkDurationSeconds || 3;
|
||||||
|
this.chunkSamples = Math.max(128, Math.round(sampleRate * chunkDurationSeconds));
|
||||||
|
this.samples = new Int16Array(this.chunkSamples);
|
||||||
|
this.numSamples = 0;
|
||||||
|
|
||||||
|
this.port.onmessage = event => {
|
||||||
|
if (event.data?.type === 'flush') {
|
||||||
|
this.flush();
|
||||||
|
this.port.postMessage({ type: 'flushed' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
process(inputs) {
|
||||||
|
const channels = inputs[0];
|
||||||
|
if (!channels || channels.length === 0)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
const numFrames = channels[0].length;
|
||||||
|
for (let frame = 0; frame < numFrames; frame++) {
|
||||||
|
let monoSample = 0;
|
||||||
|
for (const channel of channels) {
|
||||||
|
monoSample += channel[frame] || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
monoSample = Math.max(-1, Math.min(1, monoSample / channels.length));
|
||||||
|
this.samples[this.numSamples++] = monoSample < 0
|
||||||
|
? Math.round(monoSample * 0x8000)
|
||||||
|
: Math.round(monoSample * 0x7fff);
|
||||||
|
|
||||||
|
if (this.numSamples === this.chunkSamples)
|
||||||
|
this.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
flush() {
|
||||||
|
if (this.numSamples === 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const buffer = new ArrayBuffer(this.numSamples * 2);
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
for (let index = 0; index < this.numSamples; index++) {
|
||||||
|
view.setInt16(index * 2, this.samples[index], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.port.postMessage({
|
||||||
|
type: 'chunk',
|
||||||
|
buffer: buffer,
|
||||||
|
sampleCount: this.numSamples,
|
||||||
|
}, [buffer]);
|
||||||
|
this.numSamples = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerProcessor('pcm-recorder-processor', PCMRecorderProcessor);
|
||||||
@ -180,25 +180,208 @@ window.playSound = async function(soundPath) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mediaRecorder;
|
|
||||||
let actualRecordingMimeType;
|
|
||||||
let changedMimeType = false;
|
|
||||||
let pendingChunkUploads = 0;
|
let pendingChunkUploads = 0;
|
||||||
let chunkUploadPromise = Promise.resolve();
|
let chunkUploadPromise = Promise.resolve();
|
||||||
let chunkUploadError = null;
|
let chunkUploadError = null;
|
||||||
let recordingError = null;
|
let recordingError = null;
|
||||||
|
let captureAudioContext = null;
|
||||||
|
let captureSourceNode = null;
|
||||||
|
let captureWorkletNode = null;
|
||||||
|
let captureSilentGainNode = null;
|
||||||
|
let pcmFlushResolve = null;
|
||||||
|
let pcmSamplesReceived = 0;
|
||||||
|
|
||||||
// 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;
|
||||||
|
|
||||||
// Delay in milliseconds to wait after getUserMedia() for Bluetooth profile switch (A2DP → HFP):
|
// Delay in milliseconds to wait after getUserMedia() for Bluetooth profile switch (A2DP → HFP):
|
||||||
const BLUETOOTH_PROFILE_SWITCH_DELAY_MS = 1_600;
|
const BLUETOOTH_PROFILE_SWITCH_DELAY_MS = 1_600;
|
||||||
|
const PCM_SAMPLE_RATE = 48_000;
|
||||||
|
const PCM_CHUNK_DURATION_SECONDS = 3;
|
||||||
|
const PCM_FLUSH_TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
|
function queueAudioChunkUpload(upload) {
|
||||||
|
pendingChunkUploads++;
|
||||||
|
chunkUploadPromise = chunkUploadPromise
|
||||||
|
.then(upload)
|
||||||
|
.catch(error => {
|
||||||
|
chunkUploadError ??= error;
|
||||||
|
console.error('Error sending audio chunk to .NET:', error);
|
||||||
|
})
|
||||||
|
.finally(() => pendingChunkUploads--);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForAudioChunkUploads() {
|
||||||
|
let observedUploadPromise;
|
||||||
|
do {
|
||||||
|
observedUploadPromise = chunkUploadPromise;
|
||||||
|
await observedUploadPromise;
|
||||||
|
} while (pendingChunkUploads > 0 || observedUploadPromise !== chunkUploadPromise);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPcmWavHeader(sampleRate) {
|
||||||
|
const buffer = new ArrayBuffer(44);
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
|
||||||
|
const writeAscii = (offset, value) => {
|
||||||
|
for (let index = 0; index < value.length; index++) {
|
||||||
|
view.setUint8(offset + index, value.charCodeAt(index));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
writeAscii(0, 'RIFF');
|
||||||
|
view.setUint32(4, 0, true); // Finalized by .NET after all PCM data was written.
|
||||||
|
writeAscii(8, 'WAVE');
|
||||||
|
writeAscii(12, 'fmt ');
|
||||||
|
view.setUint32(16, 16, true);
|
||||||
|
view.setUint16(20, 1, true); // PCM
|
||||||
|
view.setUint16(22, 1, true); // Mono
|
||||||
|
view.setUint32(24, sampleRate, true);
|
||||||
|
view.setUint32(28, sampleRate * 2, true);
|
||||||
|
view.setUint16(32, 2, true);
|
||||||
|
view.setUint16(34, 16, true);
|
||||||
|
writeAscii(36, 'data');
|
||||||
|
view.setUint32(40, 0, true); // Finalized by .NET after all PCM data was written.
|
||||||
|
|
||||||
|
return new Uint8Array(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function observeAudioTrack(track) {
|
||||||
|
console.log('Audio recording - microphone track state:', {
|
||||||
|
label: track.label,
|
||||||
|
enabled: track.enabled,
|
||||||
|
muted: track.muted,
|
||||||
|
readyState: track.readyState,
|
||||||
|
settings: typeof track.getSettings === 'function' ? track.getSettings() : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
track.addEventListener('mute', () => console.warn('Audio recording - microphone track was muted.'));
|
||||||
|
track.addEventListener('unmute', () => console.log('Audio recording - microphone track was unmuted.'));
|
||||||
|
track.addEventListener('ended', () => console.warn('Audio recording - microphone track ended.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startPcmRecording(stream, dotnetRef) {
|
||||||
|
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
|
||||||
|
if (!AudioContextClass || typeof AudioWorkletNode === 'undefined') {
|
||||||
|
throw new Error('PCM audio capture is unavailable because AudioWorklet is not supported.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
captureAudioContext = new AudioContextClass({
|
||||||
|
latencyHint: 'interactive',
|
||||||
|
sampleRate: PCM_SAMPLE_RATE,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!captureAudioContext.audioWorklet) {
|
||||||
|
throw new Error('PCM audio capture is unavailable because AudioWorklet is not supported.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await captureAudioContext.audioWorklet.addModule('/audio-recorder-worklet.js');
|
||||||
|
|
||||||
|
const actualSampleRate = captureAudioContext.sampleRate;
|
||||||
|
console.log(`Audio recording - starting PCM/WAV capture at ${actualSampleRate} Hz mono.`);
|
||||||
|
|
||||||
|
if (captureAudioContext.state === 'suspended') {
|
||||||
|
await captureAudioContext.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
captureSourceNode = captureAudioContext.createMediaStreamSource(stream);
|
||||||
|
captureWorkletNode = new AudioWorkletNode(captureAudioContext, 'pcm-recorder-processor', {
|
||||||
|
numberOfInputs: 1,
|
||||||
|
numberOfOutputs: 1,
|
||||||
|
outputChannelCount: [1],
|
||||||
|
processorOptions: {
|
||||||
|
chunkDurationSeconds: PCM_CHUNK_DURATION_SECONDS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
captureSilentGainNode = captureAudioContext.createGain();
|
||||||
|
captureSilentGainNode.gain.value = 0;
|
||||||
|
|
||||||
|
captureWorkletNode.port.onmessage = event => {
|
||||||
|
if (event.data?.type === 'chunk') {
|
||||||
|
const chunkBytes = new Uint8Array(event.data.buffer);
|
||||||
|
pcmSamplesReceived += event.data.sampleCount;
|
||||||
|
console.debug(`Audio recording - received ${event.data.sampleCount} PCM samples from AudioWorklet.`);
|
||||||
|
queueAudioChunkUpload(() => dotnetRef.invokeMethodAsync('OnAudioChunkReceived', chunkBytes));
|
||||||
|
} else if (event.data?.type === 'flushed') {
|
||||||
|
pcmFlushResolve?.();
|
||||||
|
pcmFlushResolve = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
captureWorkletNode.onprocessorerror = event => {
|
||||||
|
recordingError ??= event.error || new Error('The PCM audio processor failed.');
|
||||||
|
console.error('Audio recording - AudioWorklet error:', recordingError);
|
||||||
|
};
|
||||||
|
|
||||||
|
captureSourceNode.connect(captureWorkletNode);
|
||||||
|
captureWorkletNode.connect(captureSilentGainNode);
|
||||||
|
captureSilentGainNode.connect(captureAudioContext.destination);
|
||||||
|
queueAudioChunkUpload(() => dotnetRef.invokeMethodAsync('OnAudioChunkReceived', createPcmWavHeader(actualSampleRate)));
|
||||||
|
} catch (error) {
|
||||||
|
await cleanupPcmCapture();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushPcmRecording() {
|
||||||
|
if (!captureWorkletNode) {
|
||||||
|
throw new Error('The PCM audio processor is unavailable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
pcmFlushResolve = null;
|
||||||
|
reject(new Error('Timed out while flushing PCM audio data.'));
|
||||||
|
}, PCM_FLUSH_TIMEOUT_MS);
|
||||||
|
|
||||||
|
pcmFlushResolve = () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
captureWorkletNode.port.postMessage({ type: 'flush' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupPcmCapture() {
|
||||||
|
captureSourceNode?.disconnect();
|
||||||
|
captureWorkletNode?.disconnect();
|
||||||
|
captureSilentGainNode?.disconnect();
|
||||||
|
captureSourceNode = null;
|
||||||
|
captureWorkletNode = null;
|
||||||
|
captureSilentGainNode = null;
|
||||||
|
pcmFlushResolve = null;
|
||||||
|
|
||||||
|
if (captureAudioContext && captureAudioContext.state !== 'closed') {
|
||||||
|
await captureAudioContext.close();
|
||||||
|
}
|
||||||
|
captureAudioContext = null;
|
||||||
|
}
|
||||||
|
|
||||||
window.audioRecorder = {
|
window.audioRecorder = {
|
||||||
start: async function (dotnetRef, desiredMimeTypes = []) {
|
start: async function (dotnetRef) {
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
// Reset the upload and recorder state:
|
||||||
|
pendingChunkUploads = 0;
|
||||||
|
chunkUploadPromise = Promise.resolve();
|
||||||
|
chunkUploadError = null;
|
||||||
|
recordingError = null;
|
||||||
|
pcmSamplesReceived = 0;
|
||||||
|
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: {
|
||||||
|
sampleRate: { ideal: PCM_SAMPLE_RATE },
|
||||||
|
channelCount: { ideal: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
activeMediaStream = stream;
|
activeMediaStream = stream;
|
||||||
|
|
||||||
|
const audioTracks = stream.getAudioTracks();
|
||||||
|
if (audioTracks.length === 0) {
|
||||||
|
throw new Error('The microphone stream does not contain an audio track.');
|
||||||
|
}
|
||||||
|
observeAudioTrack(audioTracks[0]);
|
||||||
|
|
||||||
// Wait for Bluetooth headsets to complete the profile switch from A2DP to HFP.
|
// Wait for Bluetooth headsets to complete the profile switch from A2DP to HFP.
|
||||||
// This prevents the first sound from being cut off during the switch:
|
// This prevents the first sound from being cut off during the switch:
|
||||||
console.log('Audio recording - waiting for Bluetooth profile switch...');
|
console.log('Audio recording - waiting for Bluetooth profile switch...');
|
||||||
@ -207,144 +390,55 @@ window.audioRecorder = {
|
|||||||
// Play start recording sound effect:
|
// Play start recording sound effect:
|
||||||
await window.playSound('/sounds/start_recording.ogg');
|
await window.playSound('/sounds/start_recording.ogg');
|
||||||
|
|
||||||
// When only one mime type is provided as a string, convert it to an array:
|
await startPcmRecording(stream, dotnetRef);
|
||||||
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);
|
|
||||||
|
|
||||||
const actualBaseMimeType = actualRecordingMimeType.split(';')[0].trim().toLowerCase();
|
|
||||||
|
|
||||||
// Check the list of desired mime types against the actual one:
|
|
||||||
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 upload and recorder state:
|
|
||||||
pendingChunkUploads = 0;
|
|
||||||
chunkUploadPromise = Promise.resolve();
|
|
||||||
chunkUploadError = null;
|
|
||||||
recordingError = null;
|
|
||||||
|
|
||||||
// Stream each chunk directly to .NET in recording order as it becomes available:
|
|
||||||
mediaRecorder.ondataavailable = (event) => {
|
|
||||||
if (event.data.size > 0) {
|
|
||||||
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.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 () {
|
stop: async function () {
|
||||||
return new Promise((resolve, reject) => {
|
let stopError = null;
|
||||||
|
|
||||||
// Add an event listener to handle the stop event:
|
try {
|
||||||
mediaRecorder.onstop = async () => {
|
try {
|
||||||
|
await flushPcmRecording();
|
||||||
|
} finally {
|
||||||
|
await cleanupPcmCapture();
|
||||||
|
}
|
||||||
|
|
||||||
// Wait for all pending chunk uploads to complete before finalizing:
|
console.log(`Audio recording - PCM/WAV capture produced ${pcmSamplesReceived} samples.`);
|
||||||
console.log(`Audio recording - waiting for ${pendingChunkUploads} pending uploads.`);
|
if (pcmSamplesReceived === 0) {
|
||||||
await chunkUploadPromise;
|
throw new Error('The microphone did not produce any PCM audio samples.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
stopError = error;
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Audio recording - all chunks uploaded, finalizing.');
|
console.log(`Audio recording - waiting for ${pendingChunkUploads} pending uploads.`);
|
||||||
|
await waitForAudioChunkUploads();
|
||||||
|
console.log('Audio recording - all chunks uploaded, finalizing.');
|
||||||
|
|
||||||
// Play stop recording sound effect:
|
// Play stop recording sound effect:
|
||||||
await window.playSound('/sounds/stop_recording.ogg');
|
await window.playSound('/sounds/stop_recording.ogg');
|
||||||
|
|
||||||
//
|
//
|
||||||
// IMPORTANT: Do NOT release the microphone here!
|
// IMPORTANT: Do NOT release the microphone here!
|
||||||
// Bluetooth headsets switch profiles (HFP → A2DP) when the microphone is released,
|
// Bluetooth headsets switch profiles (HFP → A2DP) when the microphone is released,
|
||||||
// which causes audio to be interrupted. We keep the microphone open so that the
|
// which causes audio to be interrupted. We keep the microphone open so that the
|
||||||
// stop_recording and transcription_done sounds can play without interruption.
|
// stop_recording and transcription_done sounds can play without interruption.
|
||||||
//
|
//
|
||||||
// Call window.audioRecorder.releaseMicrophone() after the last sound has played.
|
// Call window.audioRecorder.releaseMicrophone() after the last sound has played.
|
||||||
//
|
//
|
||||||
|
|
||||||
const error = recordingError || chunkUploadError;
|
const error = stopError || recordingError || chunkUploadError;
|
||||||
if (error) {
|
if (error) {
|
||||||
reject(error);
|
throw error;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Release the microphone after all sounds have been played.
|
// Release the microphone after all sounds have been played.
|
||||||
// This should be called after the transcription_done sound to allow
|
// This should be called after the transcription_done sound to allow
|
||||||
// Bluetooth headsets to switch back to A2DP profile without interrupting audio:
|
// Bluetooth headsets to switch back to A2DP profile without interrupting audio:
|
||||||
releaseMicrophone: function () {
|
releaseMicrophone: async function () {
|
||||||
|
await cleanupPcmCapture();
|
||||||
|
|
||||||
if (activeMediaStream) {
|
if (activeMediaStream) {
|
||||||
console.log('Audio recording - releasing microphone (Bluetooth will switch back to A2DP)');
|
console.log('Audio recording - releasing microphone (Bluetooth will switch back to A2DP)');
|
||||||
activeMediaStream.getTracks().forEach(track => track.stop());
|
activeMediaStream.getTracks().forEach(track => track.stop());
|
||||||
|
|||||||
@ -47,6 +47,12 @@ const OPUS_FRAME_SAMPLES: usize = 960;
|
|||||||
/// Target bitrate for mono speech-oriented Opus output.
|
/// Target bitrate for mono speech-oriented Opus output.
|
||||||
const OPUS_BITRATE: u32 = 32_000;
|
const OPUS_BITRATE: u32 = 32_000;
|
||||||
|
|
||||||
|
/// Stable normalized container name returned to upload clients.
|
||||||
|
const OUTPUT_FORMAT: &str = "webm";
|
||||||
|
|
||||||
|
/// Stable normalized codec name returned to upload clients.
|
||||||
|
const OUTPUT_CODEC: &str = "opus";
|
||||||
|
|
||||||
/// Maximum duration of a WebM cluster before rotating it.
|
/// Maximum duration of a WebM cluster before rotating it.
|
||||||
const CLUSTER_DURATION_MS: u64 = 30_000;
|
const CLUSTER_DURATION_MS: u64 = 30_000;
|
||||||
|
|
||||||
@ -219,6 +225,12 @@ pub struct MediaJobResult {
|
|||||||
/// Path at which the normalized output was committed.
|
/// Path at which the normalized output was committed.
|
||||||
pub output_path: String,
|
pub output_path: String,
|
||||||
|
|
||||||
|
/// Stable container produced for provider uploads.
|
||||||
|
pub output_format: String,
|
||||||
|
|
||||||
|
/// Stable audio codec produced for provider uploads.
|
||||||
|
pub output_codec: String,
|
||||||
|
|
||||||
/// Human-readable detected container description for diagnostics.
|
/// Human-readable detected container description for diagnostics.
|
||||||
pub detected_format: String,
|
pub detected_format: String,
|
||||||
|
|
||||||
@ -634,6 +646,8 @@ fn normalize_media(input_path: &FilePath, output_path: &FilePath, max_pass_throu
|
|||||||
copy_with_cancellation(input_path, &partial_path, job)?;
|
copy_with_cancellation(input_path, &partial_path, job)?;
|
||||||
Ok(MediaJobResult {
|
Ok(MediaJobResult {
|
||||||
output_path: output_path.to_string_lossy().into_owned(),
|
output_path: output_path.to_string_lossy().into_owned(),
|
||||||
|
output_format: OUTPUT_FORMAT.to_string(),
|
||||||
|
output_codec: OUTPUT_CODEC.to_string(),
|
||||||
detected_format: detected_format.clone(),
|
detected_format: detected_format.clone(),
|
||||||
detected_codec,
|
detected_codec,
|
||||||
duration_ms,
|
duration_ms,
|
||||||
@ -1043,6 +1057,8 @@ fn transcode(
|
|||||||
|
|
||||||
Ok(MediaJobResult {
|
Ok(MediaJobResult {
|
||||||
output_path: context.output_path.to_string_lossy().into_owned(),
|
output_path: context.output_path.to_string_lossy().into_owned(),
|
||||||
|
output_format: OUTPUT_FORMAT.to_string(),
|
||||||
|
output_codec: OUTPUT_CODEC.to_string(),
|
||||||
detected_format: context.detected_format,
|
detected_format: context.detected_format,
|
||||||
detected_codec: context.detected_codec,
|
detected_codec: context.detected_codec,
|
||||||
duration_ms: output_duration_ms,
|
duration_ms: output_duration_ms,
|
||||||
@ -1790,6 +1806,8 @@ mod tests {
|
|||||||
let job = MediaJob::new();
|
let job = MediaJob::new();
|
||||||
let result = normalize_media(&input, &output, DEFAULT_MAX_PASS_THROUGH_BYTES, &job).unwrap();
|
let result = normalize_media(&input, &output, DEFAULT_MAX_PASS_THROUGH_BYTES, &job).unwrap();
|
||||||
assert!(!result.pass_through);
|
assert!(!result.pass_through);
|
||||||
|
assert_eq!(result.output_format, OUTPUT_FORMAT);
|
||||||
|
assert_eq!(result.output_codec, OUTPUT_CODEC);
|
||||||
assert!(!result.has_audible_signal);
|
assert!(!result.has_audible_signal);
|
||||||
assert!(result.duration_ms.abs_diff(100) <= 20);
|
assert!(result.duration_ms.abs_diff(100) <= 20);
|
||||||
|
|
||||||
@ -1834,6 +1852,8 @@ mod tests {
|
|||||||
let job = MediaJob::new();
|
let job = MediaJob::new();
|
||||||
let result = normalize_media(&input, &output, DEFAULT_MAX_PASS_THROUGH_BYTES, &job).unwrap();
|
let result = normalize_media(&input, &output, DEFAULT_MAX_PASS_THROUGH_BYTES, &job).unwrap();
|
||||||
assert!(result.pass_through);
|
assert!(result.pass_through);
|
||||||
|
assert_eq!(result.output_format, OUTPUT_FORMAT);
|
||||||
|
assert_eq!(result.output_codec, OUTPUT_CODEC);
|
||||||
assert_eq!(fs::read(input).unwrap(), fs::read(output).unwrap());
|
assert_eq!(fs::read(input).unwrap(), fs::read(output).unwrap());
|
||||||
assert!(!result.has_audible_signal);
|
assert!(!result.has_audible_signal);
|
||||||
let _ = fs::remove_dir_all(directory);
|
let _ = fs::remove_dir_all(directory);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user