2026-07-15 12:53:30 +02:00
using AIStudio.Chat ;
using AIStudio.Provider ;
using AIStudio.Settings ;
using AIStudio.Tools.Media ;
using AIStudio.Tools.PluginSystem ;
using AIStudio.Tools.Rust ;
namespace AIStudio.Tools.Services ;
/// <summary>
/// Coordinates serialized visible media imports and independent voice transcriptions.
/// </summary>
2026-08-02 21:41:12 +02:00
public sealed class MediaTranscriptionService (
RustService rustService ,
SettingsManager settingsManager ,
IEnumerable < IMediaTranscriptStorage > transcriptStorages ,
ILogger < MediaTranscriptionService > logger ) : IDisposable
2026-07-15 12:53:30 +02:00
{
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>
private readonly SemaphoreSlim importQueue = new ( 1 , 1 );
/// <summary>Protects operation ownership and owner-specific import state.</summary>
private readonly Lock stateLock = new ();
/// <summary>All operations retained so disposal can cancel voice and import work.</summary>
private readonly HashSet < MediaOperation > operations = [];
/// <summary>The active or queued import operation for each owner.</summary>
private readonly Dictionary < MediaImportOwner , MediaOperation > currentImports = [];
/// <summary>The latest active or unacknowledged terminal state for each owner.</summary>
private readonly Dictionary < MediaImportOwner , MediaImportSnapshot > snapshots = [];
/// <summary>Successful results waiting for their concrete UI target.</summary>
private readonly Dictionary < MediaImportTarget , PendingDelivery > pendingDeliveries = [];
/// <summary>Terminal notifications waiting for their owner surface to be displayed.</summary>
private readonly Dictionary < MediaImportOwner , MediaImportOutcome > outcomes = [];
/// <summary>Owners whose complete file batches are managed by this service.</summary>
private readonly HashSet < MediaImportOwner > activeBatches = [];
/// <summary>Batch-level cancellation keeps Stop effective between two files.</summary>
private readonly Dictionary < MediaImportOwner , CancellationTokenSource > batchCancellations = [];
/// <summary>Prevents new work after disposal.</summary>
private bool disposed ;
/// <summary>Raised only with the owner whose copied state changed.</summary>
public event Action < MediaImportOwner >? StateChanged ;
/// <summary>Gets whether one owner has queued, running, or canceling media work.</summary>
public bool IsBusy ( MediaImportOwner owner )
{
lock ( this . stateLock )
return this . activeBatches . Contains ( owner );
}
2026-08-11 20:14:21 +02:00
/// <summary>Gets whether the currently configured transcription provider can be used.</summary>
public bool HasUsableTranscriptionProvider => this . ResolveProvider () is not null ;
2026-07-15 12:53:30 +02:00
/// <summary>Gets the last retained state for one owner.</summary>
public MediaImportSnapshot ? GetSnapshot ( MediaImportOwner owner )
{
lock ( this . stateLock )
return this . snapshots . GetValueOrDefault ( owner );
}
/// <summary>Gets copied retained snapshots for navigation indicators.</summary>
public IReadOnlyCollection < MediaImportSnapshot > GetSnapshots ()
{
lock ( this . stateLock )
return [.. this . snapshots . Values ];
}
/// <summary>Gets copied results that have not yet been applied by one target.</summary>
public MediaImportDelivery ? GetPendingDelivery ( MediaImportTarget target )
{
lock ( this . stateLock )
{
if (! this . pendingDeliveries . TryGetValue ( target , out var pending ))
return null ;
return new ()
{
Target = target ,
Attachments = [.. pending . Attachments ],
Text = pending . Text ,
};
}
}
/// <summary>Removes exactly the results that one target applied successfully.</summary>
public void AcknowledgeDelivery ( MediaImportDelivery delivery )
{
lock ( this . stateLock )
{
if (! this . pendingDeliveries . TryGetValue ( delivery . Target , out var pending ))
return ;
var acknowledgedPaths = delivery . Attachments . Select ( attachment => attachment . FilePath ). ToHashSet ( StringComparer . Ordinal );
pending . Attachments . RemoveAll ( attachment => acknowledgedPaths . Contains ( attachment . FilePath ));
if ( delivery . Text is not null && string . Equals ( pending . Text , delivery . Text , StringComparison . Ordinal ))
pending . Text = null ;
if ( pending . Attachments . Count is 0 && pending . Text is null )
this . pendingDeliveries . Remove ( delivery . Target );
}
}
/// <summary>Consumes one terminal notification when its owner surface is displayed.</summary>
public MediaImportOutcome ? TryConsumeOutcome ( MediaImportOwner owner )
{
MediaImportOutcome ? outcome ;
lock ( this . stateLock )
{
if (! this . outcomes . Remove ( owner , out outcome ))
return null ;
if ( this . snapshots . GetValueOrDefault ( owner ) is { IsBusy : false })
this . snapshots . Remove ( owner );
}
this . NotifyStateChanged ( owner );
return outcome ;
}
/// <summary>Discards retained inactive state and deletes unclaimed managed transcript files.</summary>
public void ClearOwnerState ( MediaImportOwner owner )
{
List < FileAttachment > discardedAttachments = [];
lock ( this . stateLock )
{
if ( this . activeBatches . Contains ( owner ))
return ;
this . snapshots . Remove ( owner );
this . outcomes . Remove ( owner );
foreach ( var target in this . pendingDeliveries . Keys . Where ( target => target . Owner == owner ). ToList ())
{
discardedAttachments . AddRange ( this . pendingDeliveries [ target ]. Attachments );
this . pendingDeliveries . Remove ( target );
}
}
foreach ( var attachment in discardedAttachments )
ManagedTranscriptAttachment . TryDeleteOwnedFile ( attachment );
this . NotifyStateChanged ( owner );
}
/// <summary>Starts an owner-managed attachment batch and returns without holding the UI event handler.</summary>
public bool TryStartAttachmentBatch ( IReadOnlyList < string > mediaPaths , MediaImportTarget target , ChatThread ? ownerChat = null )
{
this . ThrowIfDisposed ();
if ( mediaPaths . Count is 0 )
return false ;
lock ( this . stateLock )
{
if (! this . activeBatches . Add ( target . Owner ))
return false ;
this . batchCancellations [ target . Owner ] = new ();
this . outcomes . Remove ( target . Owner );
}
this . UpdateImportState ( target , Path . GetFileName ( mediaPaths [ 0 ]), MediaTranscriptionPhase . QUEUED , null , MediaImportStatus . QUEUED );
2026-08-25 18:53:09 +02:00
Task . Run (() => this . RunAttachmentBatchAsync ( mediaPaths , target , ownerChat )). Observe ( $"{nameof(MediaTranscriptionService)}: running an attachment batch" );
2026-07-15 12:53:30 +02:00
return true ;
}
/// <summary>Starts a reattachable file-content import for one stable assistant field.</summary>
public bool TryStartTextImport ( string mediaPath , MediaImportTarget target )
{
this . ThrowIfDisposed ();
lock ( this . stateLock )
{
if (! this . activeBatches . Add ( target . Owner ))
return false ;
this . batchCancellations [ target . Owner ] = new ();
this . outcomes . Remove ( target . Owner );
}
this . UpdateImportState ( target , Path . GetFileName ( mediaPath ), MediaTranscriptionPhase . QUEUED , null , MediaImportStatus . QUEUED );
2026-08-25 18:53:09 +02:00
Task . Run (() => this . RunTextImportAsync ( mediaPath , target )). Observe ( $"{nameof(MediaTranscriptionService)}: running a text import" );
2026-07-15 12:53:30 +02:00
return true ;
}
/// <summary>Completes a field import independently of the originating Blazor component.</summary>
private async Task RunTextImportAsync ( string mediaPath , MediaImportTarget target )
{
CancellationTokenSource cancellation ;
lock ( this . stateLock )
cancellation = this . batchCancellations [ target . Owner ];
var status = MediaImportStatus . SUCCEEDED ;
List < MediaImportFailure > failures = [];
List < MediaImportWarning > warnings = [];
try
{
var result = await this . TranscribeImportAsync ( mediaPath , target , cancellation . Token );
if ( result . Status is MediaTranscriptionResultStatus . SUCCEEDED )
this . AddCompletedText ( target , result . Text );
else if ( result . Status is MediaTranscriptionResultStatus . CANCELLED )
status = MediaImportStatus . CANCELLED ;
else if ( result . Status is MediaTranscriptionResultStatus . NO_AUDIBLE_SIGNAL )
{
status = MediaImportStatus . WARNING ;
warnings . Add ( new ( Path . GetFileName ( mediaPath ), result . UserMessage ));
}
else
{
status = MediaImportStatus . FAILED ;
failures . Add ( new ( Path . GetFileName ( mediaPath ), result . UserMessage , result . ErrorCode ));
}
}
catch ( OperationCanceledException )
{
status = MediaImportStatus . CANCELLED ;
}
catch ( Exception exception )
{
logger . LogError ( exception , "Owner media text import failed for '{Owner}' and target '{TargetId}'." , target . Owner , target . TargetId );
status = MediaImportStatus . FAILED ;
failures . Add ( new ( Path . GetFileName ( mediaPath ), TB ( "The media file could not be transcribed." )));
}
finally
{
lock ( this . stateLock )
{
this . activeBatches . Remove ( target . Owner );
if ( this . batchCancellations . Remove ( target . Owner , out var ownedCancellation ))
ownedCancellation . Dispose ();
}
this . CompleteImport ( target , Path . GetFileName ( mediaPath ), status , failures , warnings );
}
}
/// <summary>Stores a completed field transcript for reattachment after navigation.</summary>
private void AddCompletedText ( MediaImportTarget target , string text )
{
lock ( this . stateLock )
{
if (! this . pendingDeliveries . TryGetValue ( target , out var pending ))
this . pendingDeliveries [ target ] = pending = new ();
pending . Text = text ;
}
this . NotifyStateChanged ( target . Owner );
}
/// <summary>Serially transcribes a complete owner batch while retaining every successful result.</summary>
private async Task RunAttachmentBatchAsync ( IReadOnlyList < string > mediaPaths , MediaImportTarget target , ChatThread ? ownerChat )
{
CancellationToken batchToken ;
lock ( this . stateLock )
batchToken = this . batchCancellations [ target . Owner ]. Token ;
var status = MediaImportStatus . SUCCEEDED ;
var currentFileName = Path . GetFileName ( mediaPaths [ 0 ]);
List < MediaImportFailure > failures = [];
List < MediaImportWarning > warnings = [];
try
{
foreach ( var mediaPath in mediaPaths )
{
currentFileName = Path . GetFileName ( mediaPath );
batchToken . ThrowIfCancellationRequested ();
var result = await this . TranscribeImportAsync ( mediaPath , target , batchToken );
if ( result . Status is MediaTranscriptionResultStatus . CANCELLED )
{
status = MediaImportStatus . CANCELLED ;
break ;
}
if ( result . Status is MediaTranscriptionResultStatus . NO_AUDIBLE_SIGNAL )
{
if ( status is MediaImportStatus . SUCCEEDED )
status = MediaImportStatus . WARNING ;
warnings . Add ( new ( currentFileName , result . UserMessage ));
continue ;
}
if ( result . Status is not MediaTranscriptionResultStatus . SUCCEEDED )
{
status = MediaImportStatus . FAILED ;
failures . Add ( new ( currentFileName , result . UserMessage , result . ErrorCode ));
continue ;
}
2026-08-02 21:41:12 +02:00
var persistentStorage = transcriptStorages . FirstOrDefault ( storage => storage . CanStore ( target . Owner ));
var isPersistedChat = persistentStorage is null &&
ownerChat is not null &&
WorkspaceBehaviour . IsChatExisting ( new LoadChat ( ownerChat . WorkspaceId , ownerChat . ChatId ));
var attachment = persistentStorage is not null
? await persistentStorage . StoreAsync ( target , mediaPath , result . Text , batchToken )
: isPersistedChat
? await WorkspaceBehaviour . CreateManagedTranscriptAsync ( ownerChat !, mediaPath , result . Text )
: await ManagedTranscriptAttachment . CreateStagedAsync ( mediaPath , result . Text );
if ( persistentStorage is null && ownerChat is not null && attachment is ManagedTranscriptAttachment managed
2026-07-15 12:53:30 +02:00
&& ownerChat . PendingMediaTranscripts . All ( existing => existing . FilePath != managed . FilePath ))
ownerChat . PendingMediaTranscripts . Add ( managed );
if ( isPersistedChat )
await WorkspaceBehaviour . StoreChatAsync ( ownerChat !);
this . AddCompletedAttachment ( target , attachment );
}
}
catch ( OperationCanceledException )
{
status = MediaImportStatus . CANCELLED ;
}
catch ( Exception exception )
{
logger . LogError ( exception , "Owner media batch failed for '{Owner}'." , target . Owner );
status = MediaImportStatus . FAILED ;
failures . Add ( new ( currentFileName , TB ( "The media file could not be transcribed." )));
}
finally
{
lock ( this . stateLock )
{
this . activeBatches . Remove ( target . Owner );
if ( this . batchCancellations . Remove ( target . Owner , out var cancellation ))
cancellation . Dispose ();
}
this . CompleteImport ( target , currentFileName , status , failures , warnings );
}
}
/// <summary>Adds a successful partial result to the retained owner snapshot.</summary>
private void AddCompletedAttachment ( MediaImportTarget target , FileAttachment attachment )
{
lock ( this . stateLock )
{
if (! this . pendingDeliveries . TryGetValue ( target , out var pending ))
this . pendingDeliveries [ target ] = pending = new ();
if ( pending . Attachments . All ( existing => existing . FilePath != attachment . FilePath ))
pending . Attachments . Add ( attachment );
}
this . NotifyStateChanged ( target . Owner );
}
/// <summary>
/// Transcribes an attachment or file-content import on the serialized visible lane.
/// </summary>
/// <param name="mediaPath">Source media path.</param>
/// <param name="target">Media import target.</param>
/// <param name="token">Caller cancellation token.</param>
/// <returns>A typed terminal result.</returns>
private async Task < MediaTranscriptionResult > TranscribeImportAsync ( string mediaPath , MediaImportTarget target , CancellationToken token = default )
{
this . ThrowIfDisposed ();
var operation = this . CreateOperation ( target , token );
lock ( this . stateLock )
{
if (! this . currentImports . TryAdd ( target . Owner , operation ))
throw new InvalidOperationException ( $"Media owner '{target.Owner}' already has an active operation." );
}
this . UpdateImportState ( target , Path . GetFileName ( mediaPath ), MediaTranscriptionPhase . QUEUED , null , MediaImportStatus . QUEUED );
try
{
await this . importQueue . WaitAsync ( operation . Cancellation . Token );
operation . HasQueueLease = true ;
this . UpdateImportState ( target , Path . GetFileName ( mediaPath ), MediaTranscriptionPhase . PROBING , 0.0 , MediaImportStatus . RUNNING );
return await this . TranscribeCoreAsync ( mediaPath , operation , updateImportState : true );
}
catch ( OperationCanceledException )
{
return MediaTranscriptionResult . Cancelled ();
}
finally
{
lock ( this . stateLock )
{
if ( this . currentImports . GetValueOrDefault ( target . Owner ) == operation )
this . currentImports . Remove ( target . Owner );
}
this . ReleaseOperation ( operation );
if ( operation . HasQueueLease )
this . importQueue . Release ();
}
}
/// <summary>
2026-08-11 20:14:21 +02:00
/// Transcribes an audio or video file without starting a visible import operation.
2026-07-15 12:53:30 +02:00
/// </summary>
2026-08-11 20:14:21 +02:00
/// <param name="mediaPath">Audio or video file path.</param>
2026-07-15 12:53:30 +02:00
/// <param name="token">Caller cancellation token.</param>
/// <returns>A typed terminal result.</returns>
2026-08-11 20:14:21 +02:00
public async Task < MediaTranscriptionResult > TranscribeAsync ( string mediaPath , CancellationToken token = default )
2026-07-15 12:53:30 +02:00
{
this . ThrowIfDisposed ();
var operation = this . CreateOperation ( null , token );
try
{
return await this . TranscribeCoreAsync ( mediaPath , operation , updateImportState : false );
}
finally
{
this . ReleaseOperation ( operation );
}
}
2026-08-11 20:14:21 +02:00
/// <summary>
/// Transcribes a voice recording independently of the visible import lane.
/// </summary>
/// <param name="mediaPath">Voice recording path.</param>
/// <param name="token">Caller cancellation token.</param>
/// <returns>A typed terminal result.</returns>
public Task < MediaTranscriptionResult > TranscribeVoiceAsync ( string mediaPath , CancellationToken token = default ) => this . TranscribeAsync ( mediaPath , token );
2026-07-15 12:53:30 +02:00
/// <summary>Cancels only the queued or active operation belonging to one owner.</summary>
public async Task StopAsync ( MediaImportOwner owner )
{
MediaOperation ? operation ;
MediaImportSnapshot ? snapshot ;
lock ( this . stateLock )
{
operation = this . currentImports . GetValueOrDefault ( owner );
this . batchCancellations . GetValueOrDefault ( owner )?. Cancel ();
operation ?. Cancellation . Cancel ();
snapshot = this . snapshots . GetValueOrDefault ( owner );
}
if ( snapshot is not null && this . IsBusy ( owner ))
this . UpdateImportState ( snapshot . Target , snapshot . CurrentFileName , MediaTranscriptionPhase . CANCELING , null , MediaImportStatus . CANCELING );
if (! string . IsNullOrWhiteSpace ( operation ?. JobId ))
await rustService . CancelMediaJobAsync ( operation . JobId );
}
/// <summary>Runs normalization, provider resolution, and upload for one owned operation.</summary>
/// <param name="mediaPath">Source media path.</param>
/// <param name="operation">Operation-specific cancellation and Rust-job state.</param>
/// <param name="updateImportState">Whether progress belongs to the visible import lane.</param>
/// <returns>A typed terminal result.</returns>
private async Task < MediaTranscriptionResult > TranscribeCoreAsync ( string mediaPath , MediaOperation operation , bool updateImportState )
{
var normalizedPath = Path . Combine ( Path . GetTempPath (), "mindwork-ai-studio-media" , $"{operation.Id:N}.webm" );
Directory . CreateDirectory ( Path . GetDirectoryName ( normalizedPath )!);
2026-08-02 21:41:12 +02:00
// Logged next to the operation ID: users recognize the file they picked, whereas an ID only
// helps when correlating log lines. The name alone is enough and keeps full paths out of
// logs that get shared in bug reports.
var fileName = Path . GetFileName ( mediaPath );
2026-07-15 12:53:30 +02:00
try
{
var normalized = await this . NormalizeAsync ( mediaPath , normalizedPath , operation , updateImportState );
if ( normalized . Result is null )
return normalized . Error is null
? MediaTranscriptionResult . Failed ( TB ( "The media pipeline ended without an output file." ))
: MediaTranscriptionResult . Failed ( UserMessageFor ( normalized . Error . Code ), normalized . Error . Code );
var uploadContractError = await ValidateNormalizedProviderUploadAsync ( normalized . Result , normalizedPath , operation . Cancellation . Token );
if ( uploadContractError is not null )
{
2026-08-02 21:41:12 +02:00
logger . LogError (
"Refusing the transcription provider upload for '{FileName}' (operation {OperationId}) because the normalized media contract validation failed: {Diagnostic}" ,
fileName ,
operation . Id ,
uploadContractError );
2026-07-15 12:53:30 +02:00
return MediaTranscriptionResult . Failed ( TB ( "The media pipeline ended without an output file." ));
}
if (! normalized . Result . HasAudibleSignal )
{
2026-08-02 21:41:12 +02:00
logger . LogInformation (
"Skipping media transcription for '{FileName}' (operation {OperationId}) because its maximum audio peak does not exceed the practical-silence threshold." ,
fileName ,
operation . Id );
2026-07-15 12:53:30 +02:00
return MediaTranscriptionResult . NoAudibleSignal ( TB ( "The audio track contains no audible signal, so there is nothing to transcribe." ));
}
var providerSettings = this . ResolveProvider ();
if ( providerSettings is null )
return MediaTranscriptionResult . Failed ( TB ( "No usable transcription provider is configured." ));
if ( updateImportState )
this . UpdateImportState ( operation . Target !. Value , Path . GetFileName ( mediaPath ), MediaTranscriptionPhase . UPLOADING , null , MediaImportStatus . RUNNING );
var provider = providerSettings . CreateProvider ();
if ( provider . Provider is LLMProviders . NONE )
return MediaTranscriptionResult . Failed ( TB ( "The configured transcription provider could not be created." ));
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 ;
2026-08-02 21:41:12 +02:00
logger . LogInformation ( "Transcribing normalized WebM/Opus media '{FileName}' for operation {OperationId} ({NormalizedSize} bytes; source {SourceSize} bytes; size reduction {ReductionPercent:F1}%) with provider '{Provider}' and model '{Model}'." ,
fileName ,
operation . Id ,
2026-07-15 12:53:30 +02:00
normalizedSize ,
sourceSize ,
reductionPercent ,
providerSettings . UsedLLMProvider ,
providerSettings . Model );
var providerResult = await provider . TranscribeAudioAsync ( providerSettings . Model , normalizedPath , settingsManager , operation . Cancellation . Token );
operation . Cancellation . Token . ThrowIfCancellationRequested ();
if (! providerResult . Success )
{
2026-08-02 21:41:12 +02:00
logger . LogWarning (
"The transcription provider failed for '{FileName}' (operation {OperationId}): {Diagnostic}" ,
fileName ,
operation . Id ,
providerResult . ErrorMessage );
2026-08-29 22:25:12 +02:00
//
// When the provider told us why it failed, the user gets to read it. Only that
// message says whether to wait, to check the API key, or to ask the provider for
// a format it can read:
//
return MediaTranscriptionResult . Failed ( string . IsNullOrWhiteSpace ( providerResult . ErrorMessage )
? TB ( "The transcription provider could not transcribe the media file." )
: providerResult . ErrorMessage );
2026-07-15 12:53:30 +02:00
}
return MediaTranscriptionResult . Succeeded ( providerResult . Text . Trim ());
}
catch ( OperationCanceledException )
{
return MediaTranscriptionResult . Cancelled ();
}
catch ( Exception exception )
{
2026-08-02 21:41:12 +02:00
logger . LogError (
"Media transcription failed for '{FileName}' (operation {OperationId}). ExceptionType={ExceptionType}" ,
fileName ,
operation . Id ,
exception . GetType (). Name );
2026-07-15 12:53:30 +02:00
return MediaTranscriptionResult . Failed ( TB ( "The media file could not be transcribed." ));
}
finally
{
// NormalizeAsync does not return from cancellation until Rust has reached a terminal
// phase, so deleting both paths here cannot race a still-writing worker.
if (! this . RetainNormalizedMediaIfRequested ( normalizedPath , operation . Id ))
this . DeleteTemporaryFile ( normalizedPath );
this . DeleteTemporaryFile ( normalizedPath + ".partial" );
}
}
/// <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>
/// <param name="mediaPath">Source media path.</param>
/// <param name="normalizedPath">Owned temporary output path.</param>
/// <param name="operation">Operation-specific state.</param>
/// <param name="updateImportState">Whether progress belongs to the import lane.</param>
/// <returns>The terminal runtime result or error.</returns>
private async Task <( MediaJobResult ? Result , MediaJobError ? Error )> NormalizeAsync (
string mediaPath ,
string normalizedPath ,
MediaOperation operation ,
bool updateImportState )
{
// The quick POST is intentionally not cancelled: losing its response could orphan a job
// whose ID the client never received. Cancellation is applied immediately after ownership.
var jobId = await rustService . StartMediaJobAsync ( mediaPath , normalizedPath , CancellationToken . None );
operation . JobId = jobId ;
try
{
operation . Cancellation . Token . ThrowIfCancellationRequested ();
await foreach ( var mediaEvent in rustService . StreamMediaJobEventsAsync ( jobId , operation . Cancellation . Token ))
{
if ( updateImportState && mediaEvent . Phase is MediaJobPhase . PROBING or MediaJobPhase . TRANSCODING )
{
var phase = mediaEvent . Phase is MediaJobPhase . PROBING
? MediaTranscriptionPhase . PROBING
: MediaTranscriptionPhase . TRANSCODING ;
this . UpdateImportState ( operation . Target !. Value , Path . GetFileName ( mediaPath ), phase , mediaEvent . Progress , MediaImportStatus . RUNNING );
}
switch ( mediaEvent . Phase )
{
case MediaJobPhase . COMPLETED :
return ( mediaEvent . Result , null );
case MediaJobPhase . FAILED :
if ( mediaEvent . Error is not null )
2026-08-02 21:41:12 +02:00
logger . LogWarning (
"Rust media normalization failed for operation {OperationId} with {Code}: {Diagnostic}" ,
operation . Id ,
mediaEvent . Error . Code ,
mediaEvent . Error . Message );
2026-07-15 12:53:30 +02:00
return ( null , mediaEvent . Error );
case MediaJobPhase . CANCELLED :
throw new OperationCanceledException ( operation . Cancellation . Token );
}
}
return ( null , null );
}
catch ( OperationCanceledException )
{
await rustService . CancelMediaJobAsync ( jobId , CancellationToken . None );
await this . DrainTerminalEventAsync ( jobId );
throw ;
}
}
/// <summary>Waits for Rust cleanup after cooperative cancellation.</summary>
/// <param name="jobId">Owned Rust job identifier.</param>
private async Task DrainTerminalEventAsync ( string jobId )
{
await foreach ( var _ in rustService . StreamMediaJobEventsAsync ( jobId , CancellationToken . None ))
{
// The stream itself ends immediately after the first terminal snapshot or event.
}
}
/// <summary>Resolves the configured provider after confidence validation.</summary>
private TranscriptionProvider ? ResolveProvider ()
{
var providerId = settingsManager . ConfigurationData . App . UseTranscriptionProvider ;
if ( string . IsNullOrWhiteSpace ( providerId ))
return null ;
var providerSettings = settingsManager . ConfigurationData . TranscriptionProviders . FirstOrDefault ( x => x . Id == providerId );
if ( providerSettings is null )
return null ;
var minimumLevel = settingsManager . GetMinimumConfidenceLevel ( Components . NONE );
return providerSettings . UsedLLMProvider . GetConfidence ( settingsManager ). Level >= minimumLevel
? providerSettings
: null ;
}
/// <summary>Creates and registers operation-owned cancellation state.</summary>
/// <param name="target">Optional visible media import target.</param>
/// <param name="token">Caller token linked to the operation.</param>
/// <returns>The registered operation.</returns>
private MediaOperation CreateOperation ( MediaImportTarget ? target , CancellationToken token )
{
var operation = new MediaOperation ( target , token );
lock ( this . stateLock )
this . operations . Add ( operation );
return operation ;
}
/// <summary>Unregisters and disposes completed operation state.</summary>
/// <param name="operation">Completed operation.</param>
private void ReleaseOperation ( MediaOperation operation )
{
lock ( this . stateLock )
this . operations . Remove ( operation );
operation . Dispose ();
}
/// <summary>Updates and publishes copied state for exactly one owner.</summary>
private void UpdateImportState ( MediaImportTarget target , string fileName , MediaTranscriptionPhase phase , double? progress , MediaImportStatus status )
{
var snapshot = new MediaImportSnapshot
{
Owner = target . Owner ,
Target = target ,
CurrentFileName = fileName ,
Phase = phase ,
Progress = progress ,
Status = status ,
};
lock ( this . stateLock )
this . snapshots [ target . Owner ] = snapshot ;
this . NotifyStateChanged ( target . Owner );
}
/// <summary>Publishes one retained terminal result after an entire target batch ended.</summary>
private void CompleteImport (
MediaImportTarget target ,
string fileName ,
MediaImportStatus status ,
IReadOnlyList < MediaImportFailure > failures ,
IReadOnlyList < MediaImportWarning > warnings )
{
lock ( this . stateLock )
{
this . snapshots [ target . Owner ] = new ()
{
Owner = target . Owner ,
Target = target ,
CurrentFileName = fileName ,
Phase = MediaTranscriptionPhase . IDLE ,
Progress = null ,
Status = status ,
};
this . outcomes [ target . Owner ] = new ()
{
Owner = target . Owner ,
Status = status ,
Failures = [.. failures ],
Warnings = [.. warnings ],
};
}
this . NotifyStateChanged ( target . Owner );
}
/// <summary>Publishes state changes without allowing one stale UI subscriber to fault a worker.</summary>
private void NotifyStateChanged ( MediaImportOwner owner )
{
if ( this . StateChanged is not { } stateChanged )
return ;
foreach ( var @delegate in stateChanged . GetInvocationList ())
{
var handler = ( Action < MediaImportOwner >) @delegate ;
try
{
handler ( owner );
}
catch ( Exception exception )
{
logger . LogWarning ( exception , "A media state subscriber failed for owner '{Owner}'." , owner );
}
}
}
/// <summary>Maps runtime codes to localized user-facing fallback text.</summary>
private static string UserMessageFor ( MediaJobErrorCode code ) => code switch
{
MediaJobErrorCode . FILE_NOT_FOUND => TB ( "The selected media file no longer exists." ),
MediaJobErrorCode . UNSAFE_FILE or MediaJobErrorCode . NOT_MEDIA => TB ( "The selected file cannot be processed as media." ),
MediaJobErrorCode . NO_AUDIO_TRACK => TB ( "The selected media file does not contain an audio track." ),
MediaJobErrorCode . UNSUPPORTED_CONTAINER or MediaJobErrorCode . UNSUPPORTED_CODEC or MediaJobErrorCode . UNSUPPORTED_OPUS_MAPPING => TB ( "This media format or audio codec is not supported." ),
MediaJobErrorCode . UNKNOWN_FORMAT or MediaJobErrorCode . DAMAGED_CONTAINER => TB ( "The media file is damaged or its format could not be identified." ),
_ => TB ( "The media file could not be prepared for transcription." ),
};
/// <summary>Deletes one operation-owned temporary file on a best-effort basis.</summary>
private void DeleteTemporaryFile ( string path )
{
try
{
if ( File . Exists ( path ))
File . Delete ( path );
}
catch ( Exception exception )
{
2026-08-02 21:41:12 +02:00
logger . LogWarning (
"Could not delete an operation-owned temporary media file. ExceptionType={ExceptionType}" ,
exception . GetType (). Name );
2026-07-15 12:53:30 +02:00
}
}
/// <summary>Retains the exact provider upload only for opt-in debug diagnostics.</summary>
private bool RetainNormalizedMediaIfRequested ( string normalizedPath , Guid operationId )
{
#if DEBUG
if (! string . Equals ( Environment . GetEnvironmentVariable ( "MINDWORK_AI_RETAIN_NORMALIZED_MEDIA" ), "true" , StringComparison . OrdinalIgnoreCase )
|| ! File . Exists ( normalizedPath ))
return false ;
try
{
var diagnosticDirectory = Path . Combine ( Path . GetTempPath (), "mindwork-ai-studio-media" , "diagnostics" );
Directory . CreateDirectory ( diagnosticDirectory );
var diagnosticPath = Path . Combine ( diagnosticDirectory , $"{operationId:N}.webm" );
File . Move ( normalizedPath , diagnosticPath , overwrite : true );
foreach ( var oldPath in new DirectoryInfo ( diagnosticDirectory ). EnumerateFiles ( "*.webm" ). OrderByDescending ( file => file . LastWriteTimeUtc ). Skip ( 10 ))
oldPath . Delete ();
2026-08-02 21:41:12 +02:00
logger . LogInformation ( "Retained normalized media diagnostic for operation {OperationId}." , operationId );
2026-07-15 12:53:30 +02:00
return true ;
}
catch ( Exception exception )
{
2026-08-02 21:41:12 +02:00
logger . LogWarning (
"Could not retain normalized media diagnostic for operation {OperationId}. ExceptionType={ExceptionType}" ,
operationId ,
exception . GetType (). Name );
2026-07-15 12:53:30 +02:00
}
#endif
return false ;
}
/// <summary>Returns localized text while registering the US-English fallback with I18N.</summary>
private static string TB ( string fallbackEN ) => I18N . I . T ( fallbackEN , typeof ( MediaTranscriptionService ). Namespace , nameof ( MediaTranscriptionService ));
/// <summary>Throws when a caller attempts to start work after disposal.</summary>
private void ThrowIfDisposed () => ObjectDisposedException . ThrowIf ( this . disposed , this );
/// <summary>Cancels every active import and voice operation and releases owned resources.</summary>
public void Dispose ()
{
MediaOperation [] active ;
CancellationTokenSource [] batches ;
lock ( this . stateLock )
{
if ( this . disposed )
return ;
this . disposed = true ;
active = [.. this . operations ];
batches = [.. this . batchCancellations . Values ];
}
foreach ( var operation in active )
operation . Cancellation . Cancel ();
foreach ( var batch in batches )
batch . Cancel ();
// The semaphore may still be released by an operation unwinding after cancellation.
}
/// <summary>Cancellation and runtime-job ownership for exactly one media operation.</summary>
private sealed class MediaOperation : IDisposable
{
/// <summary>Creates operation state linked to a caller token.</summary>
/// <param name="target">Optional visible media import target.</param>
/// <param name="token">Caller cancellation token.</param>
public MediaOperation ( MediaImportTarget ? target , CancellationToken token )
{
this . Target = target ;
this . Cancellation = CancellationTokenSource . CreateLinkedTokenSource ( token );
}
/// <summary>Gets the optional visible import target; voice operations have none.</summary>
public MediaImportTarget ? Target { get ; }
/// <summary>Gets the unique temporary-path identifier.</summary>
public Guid Id { get ; } = Guid . NewGuid ();
/// <summary>Gets the operation-owned cancellation source.</summary>
public CancellationTokenSource Cancellation { get ; }
/// <summary>Gets or sets the Rust job after its POST response establishes ownership.</summary>
public string? JobId { get ; set ; }
/// <summary>Gets or sets whether this operation currently owns the serialized lane.</summary>
public bool HasQueueLease { get ; set ; }
/// <summary>Disposes operation-owned cancellation state.</summary>
public void Dispose () => this . Cancellation . Dispose ();
}
/// <summary>Mutable successful results waiting for acknowledgement by one target.</summary>
private sealed class PendingDelivery
{
public List < FileAttachment > Attachments { get ; } = [];
public string? Text { get ; set ; }
}
}