mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-04 22:47:21 +00:00
Added transcription of audio and video files to local data sources (#1081)
Co-authored-by: Paul Koudelka <106623909+PaulKoudelka@users.noreply.github.com>
This commit is contained in:
1 parent
8642ce2d81
commit
752f7d828a
82 files changed
+3231
-302
No files matched your search
@@ -0,0 +1,209 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
using AIStudio.Settings;
|
||||
|
||||
namespace AIStudio.Tools.Media;
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the transcripts of the audio and video files of one data source below the data directory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A data source never writes into the folders of the user: they may be read-only, a network share,
|
||||
/// or the read-only home folder of the Flatpak, and a transcript which cannot be stored would be paid
|
||||
/// for again with every run. The transcripts live in 'mediaTranscripts/<data source id>/<key>.md'
|
||||
/// instead, where the key is the SHA-256 hash of the content of the media file. Renaming or moving a
|
||||
/// recording, or a sync tool which only touches its write time, finds the transcript again. The path
|
||||
/// follows from the id and the key, so nothing stores it.
|
||||
/// </remarks>
|
||||
/// <param name="dataDirectory">The data directory of AI Studio.</param>
|
||||
/// <param name="dataSourceId">The id of the data source, a GUID.</param>
|
||||
internal sealed partial class DataSourceTranscriptStore(string dataDirectory, string dataSourceId) : IMediaTranscriptLocation
|
||||
{
|
||||
/// <summary>
|
||||
/// The folder below the data directory which holds the transcripts of all data sources.
|
||||
/// </summary>
|
||||
public const string FOLDER_NAME = "mediaTranscripts";
|
||||
|
||||
private const string FILE_EXTENSION = ".md";
|
||||
|
||||
private readonly string directoryPath = Path.Join(dataDirectory, FOLDER_NAME, NormalizeId(dataSourceId));
|
||||
|
||||
/// <summary>
|
||||
/// The store of a data source in the data directory of the running app.
|
||||
/// </summary>
|
||||
/// <param name="dataSourceId">The id of the data source.</param>
|
||||
/// <returns>The store.</returns>
|
||||
public static DataSourceTranscriptStore ForDataSource(string dataSourceId) => new(SettingsManager.DataDirectory ?? throw new InvalidOperationException("The data directory is not known yet."), dataSourceId);
|
||||
|
||||
/// <summary>
|
||||
/// The store of a data source in the data directory of the running app, or null when there is none to clean up.
|
||||
/// </summary>
|
||||
/// <param name="dataSourceId">The id of the data source.</param>
|
||||
/// <returns>The store, or null while the data directory is not known or for an id which is no GUID.</returns>
|
||||
public static DataSourceTranscriptStore? TryForDataSource(string dataSourceId) => string.IsNullOrWhiteSpace(SettingsManager.DataDirectory) || !Guid.TryParse(dataSourceId, out _)
|
||||
? null
|
||||
: new(SettingsManager.DataDirectory, dataSourceId);
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Reads the whole media file to hash it, in pieces. Callers ask only for files which are about
|
||||
/// to be read anyway because they are new or changed.
|
||||
/// </remarks>
|
||||
public async Task<MediaTranscriptSlot> ResolveAsync(string mediaPath, CancellationToken token)
|
||||
{
|
||||
var key = await FileContentHash.ComputeAsync(mediaPath, token);
|
||||
return new(this.GetPath(key), key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a text is a key of this store, i.e. a lowercase SHA-256 hash.
|
||||
/// </summary>
|
||||
/// <param name="key">The text to check.</param>
|
||||
/// <returns>True for a key.</returns>
|
||||
public static bool IsKey(string key) => KeyPattern().IsMatch(key);
|
||||
|
||||
/// <summary>
|
||||
/// The path of the transcript with the given key.
|
||||
/// </summary>
|
||||
/// <param name="key">The key of the transcript.</param>
|
||||
/// <returns>The path, whether or not the transcript exists.</returns>
|
||||
public string GetPath(string key)
|
||||
{
|
||||
if (!IsKey(key))
|
||||
throw new ArgumentException("The key of a media transcript has to be a lowercase SHA-256 hash.", nameof(key));
|
||||
|
||||
return Path.Join(this.directoryPath, key + FILE_EXTENSION);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The keys of all transcripts of this data source.
|
||||
/// </summary>
|
||||
/// <returns>The keys; empty when the data source has no transcripts.</returns>
|
||||
public IReadOnlyList<string> ListKeys()
|
||||
{
|
||||
return this.EnumerateOwnFiles()
|
||||
.Select(file => Path.GetFileName(file.FullName))
|
||||
.Where(fileName => TranscriptFileNamePattern().IsMatch(fileName))
|
||||
.Select(Path.GetFileNameWithoutExtension)
|
||||
.OfType<string>()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the transcript with the given key.
|
||||
/// </summary>
|
||||
/// <param name="key">The key of the transcript.</param>
|
||||
/// <returns>True when a transcript was deleted.</returns>
|
||||
public bool Delete(string key)
|
||||
{
|
||||
var file = new FileInfo(this.GetPath(key));
|
||||
if (!file.Exists || file.Attributes.HasFlag(FileAttributes.ReparsePoint))
|
||||
return false;
|
||||
|
||||
file.Delete();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the temporary files which an interrupted write left behind.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Call it only while nothing writes a transcript of this data source, e.g. at the end of a run.
|
||||
/// </remarks>
|
||||
/// <returns>The number of deleted files.</returns>
|
||||
public int DeleteTemporaryFiles()
|
||||
{
|
||||
var deleted = 0;
|
||||
foreach (var file in this.EnumerateOwnFiles().Where(file => TemporaryFileNamePattern().IsMatch(file.Name)))
|
||||
{
|
||||
file.Delete();
|
||||
deleted++;
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes every transcript of this data source, together with temporary files a crash left behind.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Removes only what this store wrote. Anything else in the folder stays, and the folder with it.
|
||||
/// </remarks>
|
||||
public void DeleteAll()
|
||||
{
|
||||
foreach (var file in this.EnumerateOwnFiles())
|
||||
file.Delete();
|
||||
|
||||
var directory = new DirectoryInfo(this.directoryPath);
|
||||
if (directory.Exists && !directory.Attributes.HasFlag(FileAttributes.ReparsePoint) && !directory.EnumerateFileSystemInfos().Any())
|
||||
directory.Delete();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies the transcripts of all data sources, e.g. for a backup.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Copies only transcripts, file by file and in pieces. Temporary files of an interrupted write,
|
||||
/// links, and anything else in the folders stay behind.
|
||||
/// </remarks>
|
||||
/// <param name="dataDirectory">The data directory to copy from.</param>
|
||||
/// <param name="targetDirectory">The folder which receives the transcripts below 'mediaTranscripts/', in the same layout.</param>
|
||||
/// <param name="token">The cancellation token.</param>
|
||||
/// <returns>The number of copied transcripts.</returns>
|
||||
public static async Task<int> CopyAllAsync(string dataDirectory, string targetDirectory, CancellationToken token)
|
||||
{
|
||||
var root = new DirectoryInfo(Path.Join(dataDirectory, FOLDER_NAME));
|
||||
if (!root.Exists || root.Attributes.HasFlag(FileAttributes.ReparsePoint))
|
||||
return 0;
|
||||
|
||||
var copied = 0;
|
||||
foreach (var dataSourceFolder in root.EnumerateDirectories("*", new EnumerationOptions { RecurseSubdirectories = false, AttributesToSkip = FileAttributes.ReparsePoint, IgnoreInaccessible = false }))
|
||||
{
|
||||
if (!Guid.TryParse(dataSourceFolder.Name, out _))
|
||||
continue;
|
||||
|
||||
var store = new DataSourceTranscriptStore(dataDirectory, dataSourceFolder.Name);
|
||||
foreach (var file in store.EnumerateOwnFiles().Where(file => TranscriptFileNamePattern().IsMatch(file.Name)))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var targetPath = Path.Join(targetDirectory, FOLDER_NAME, dataSourceFolder.Name, file.Name);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
|
||||
|
||||
await using var source = new FileStream(file.FullName, FileMode.Open, FileAccess.Read, FileShare.Read, 65_536, FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
await using var target = new FileStream(targetPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 65_536, FileOptions.Asynchronous);
|
||||
await source.CopyToAsync(target, token);
|
||||
copied++;
|
||||
}
|
||||
}
|
||||
|
||||
return copied;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The files this store wrote: transcripts and the temporary files of interrupted writes. Links are left out.
|
||||
/// </summary>
|
||||
private IEnumerable<FileInfo> EnumerateOwnFiles()
|
||||
{
|
||||
var directory = new DirectoryInfo(this.directoryPath);
|
||||
if (!directory.Exists || directory.Attributes.HasFlag(FileAttributes.ReparsePoint))
|
||||
return [];
|
||||
|
||||
return directory
|
||||
.EnumerateFiles("*", new EnumerationOptions { RecurseSubdirectories = false, AttributesToSkip = FileAttributes.ReparsePoint, IgnoreInaccessible = true })
|
||||
.Where(file => TranscriptFileNamePattern().IsMatch(file.Name) || TemporaryFileNamePattern().IsMatch(file.Name))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string NormalizeId(string dataSourceId) => Guid.TryParse(dataSourceId, out var id)
|
||||
? id.ToString("D")
|
||||
: throw new ArgumentException("The id of a data source with media transcripts has to be a GUID.", nameof(dataSourceId));
|
||||
|
||||
[GeneratedRegex("^[0-9a-f]{64}$")]
|
||||
private static partial Regex KeyPattern();
|
||||
|
||||
[GeneratedRegex(@"^[0-9a-f]{64}\.md$")]
|
||||
private static partial Regex TranscriptFileNamePattern();
|
||||
|
||||
[GeneratedRegex(@"^[0-9a-f]{64}\.md\.[0-9a-f]{32}\.tmp$")]
|
||||
private static partial Regex TemporaryFileNamePattern();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace AIStudio.Tools.Media;
|
||||
|
||||
/// <summary>
|
||||
/// Where the persistent transcript of an audio or video file lives, cf. PersistentMediaTranscript.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A location only names the place. Reading and writing the transcript is left to
|
||||
/// PersistentMediaTranscript, so every location keeps the same rules against paying twice.
|
||||
/// </remarks>
|
||||
internal interface IMediaTranscriptLocation
|
||||
{
|
||||
/// <summary>
|
||||
/// Finds the place of the transcript of a media file.
|
||||
/// </summary>
|
||||
/// <param name="mediaPath">The audio or video file.</param>
|
||||
/// <param name="token">The cancellation token.</param>
|
||||
/// <returns>The place of the transcript, whether or not a transcript is there yet.</returns>
|
||||
Task<MediaTranscriptSlot> ResolveAsync(string mediaPath, CancellationToken token);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
namespace AIStudio.Tools.Media;
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the transcript of a media file next to it, as '<media file>.transcript.md'.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The Batch Processing assistant writes its results into the folder the user chose, and its
|
||||
/// transcripts belong there as well, where people find and reuse them. Data sources must not write
|
||||
/// into the folders of the user and keep their transcripts below the data directory instead.
|
||||
/// </remarks>
|
||||
internal sealed class MediaSidecarTranscripts : IMediaTranscriptLocation
|
||||
{
|
||||
public const string FILE_SUFFIX = ".transcript.md";
|
||||
|
||||
public static readonly MediaSidecarTranscripts INSTANCE = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<MediaTranscriptSlot> ResolveAsync(string mediaPath, CancellationToken token)
|
||||
{
|
||||
var path = GetFilePath(mediaPath);
|
||||
return Task.FromResult(new MediaTranscriptSlot(path, path));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The path of the transcript next to a media file.
|
||||
/// </summary>
|
||||
/// <param name="mediaPath">The audio or video file.</param>
|
||||
/// <returns>The path of its transcript.</returns>
|
||||
public static string GetFilePath(string mediaPath) => mediaPath + FILE_SUFFIX;
|
||||
|
||||
/// <summary>
|
||||
/// Whether a file is a transcript next to a media file, or a temporary file left while one was stored.
|
||||
/// </summary>
|
||||
/// <param name="path">The path of the file.</param>
|
||||
/// <returns>True for transcripts and their temporary files.</returns>
|
||||
public static bool IsArtifactPath(string path)
|
||||
{
|
||||
var fileName = Path.GetFileName(path);
|
||||
if (fileName.EndsWith(FILE_SUFFIX, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
var suffixIndex = fileName.LastIndexOf(FILE_SUFFIX + ".", StringComparison.OrdinalIgnoreCase);
|
||||
return suffixIndex >= 0 && fileName.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a file is the transcript of a media file which the selection of a folder data source selects as well.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Such a transcript holds the same text the data source gets from transcribing the media file,
|
||||
/// so indexing it as a document of its own would only duplicate that text. A transcript whose
|
||||
/// media file is gone, or not selected, stays a document like every other Markdown file.
|
||||
/// </remarks>
|
||||
/// <param name="path">The path of the file.</param>
|
||||
/// <param name="selection">The files the folder data source selects.</param>
|
||||
/// <returns>True when the data source indexes the media file of this transcript.</returns>
|
||||
public static bool IsCoveredArtifact(string path, FolderFileSelection selection)
|
||||
{
|
||||
if (!TryGetMediaPath(path, out var mediaPath))
|
||||
return false;
|
||||
|
||||
return PersistentMediaTranscript.IsMediaPath(mediaPath) && File.Exists(mediaPath) && selection.Selects(mediaPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a media file has a transcript next to it, so no transcription provider is needed for it.
|
||||
/// </summary>
|
||||
/// <param name="mediaPath">The audio or video file.</param>
|
||||
/// <returns>True when a transcript which is not empty exists.</returns>
|
||||
public static bool HasReusableTranscript(string mediaPath)
|
||||
{
|
||||
var transcriptFilePath = GetFilePath(mediaPath);
|
||||
try
|
||||
{
|
||||
return File.Exists(transcriptFilePath) && new FileInfo(transcriptFilePath).Length > 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// The concrete read error is reported when the affected file is
|
||||
// processed. Here we only decide whether a provider is required.
|
||||
return File.Exists(transcriptFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The media file a transcript or one of its temporary files belongs to.
|
||||
/// </summary>
|
||||
private static bool TryGetMediaPath(string path, out string mediaPath)
|
||||
{
|
||||
var suffixIndex = path.LastIndexOf(FILE_SUFFIX, StringComparison.OrdinalIgnoreCase);
|
||||
if (suffixIndex <= 0)
|
||||
{
|
||||
mediaPath = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
var remainder = path[(suffixIndex + FILE_SUFFIX.Length)..];
|
||||
if (remainder.Length > 0 && !(remainder[0] is '.' && remainder.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
mediaPath = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
mediaPath = path[..suffixIndex];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace AIStudio.Tools.Media;
|
||||
|
||||
/// <summary>
|
||||
/// The place of the persistent transcript of one audio or video file.
|
||||
/// </summary>
|
||||
/// <param name="Path">The absolute path of the Markdown transcript.</param>
|
||||
/// <param name="Key">What identifies the transcript at its location, e.g. the hash of the media content.</param>
|
||||
internal sealed record MediaTranscriptSlot(string Path, string Key);
|
||||
@@ -9,7 +9,8 @@ namespace AIStudio.Tools.Media;
|
||||
/// <param name="Text">Transcript text for a successful operation.</param>
|
||||
/// <param name="UserMessage">Localized message suitable for display after a warning or failure.</param>
|
||||
/// <param name="ErrorCode">Optional stable runtime failure category.</param>
|
||||
public sealed record MediaTranscriptionResult(MediaTranscriptionResultStatus Status, string Text, string UserMessage, MediaJobErrorCode? ErrorCode = null)
|
||||
/// <param name="FileRejected">Whether the transcription provider refused the media file itself, so that it would refuse the unchanged file again.</param>
|
||||
public sealed record MediaTranscriptionResult(MediaTranscriptionResultStatus Status, string Text, string UserMessage, MediaJobErrorCode? ErrorCode = null, bool FileRejected = false)
|
||||
{
|
||||
/// <summary>Creates a successful result.</summary>
|
||||
/// <param name="text">Provider transcript.</param>
|
||||
@@ -20,6 +21,10 @@ public sealed record MediaTranscriptionResult(MediaTranscriptionResultStatus Sta
|
||||
/// <param name="errorCode">Optional runtime error category.</param>
|
||||
public static MediaTranscriptionResult Failed(string userMessage, MediaJobErrorCode? errorCode = null) => new(MediaTranscriptionResultStatus.FAILED, string.Empty, userMessage, errorCode);
|
||||
|
||||
/// <summary>Creates a failed result for a media file which the transcription provider refused.</summary>
|
||||
/// <param name="userMessage">Localized visible message.</param>
|
||||
public static MediaTranscriptionResult Rejected(string userMessage) => new(MediaTranscriptionResultStatus.FAILED, string.Empty, userMessage, FileRejected: true);
|
||||
|
||||
/// <summary>Creates a warning result for media without an audible signal.</summary>
|
||||
/// <param name="userMessage">Localized visible warning.</param>
|
||||
public static MediaTranscriptionResult NoAudibleSignal(string userMessage) => new(
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
using System.Text;
|
||||
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
using AIStudio.Tools.Rust;
|
||||
|
||||
namespace AIStudio.Tools.Media;
|
||||
|
||||
/// <summary>
|
||||
/// Reads or creates the persistent Markdown transcript of an audio or video file.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every transcript which AI Studio keeps comes from here, so the rules which keep a paid
|
||||
/// transcription from being paid twice hold for every caller: an existing transcript is used as it
|
||||
/// is, a transcript which arrived is stored even when the caller cancelled meanwhile, and nothing is
|
||||
/// stored for a file which changed during its transcription or for a transcription without text.
|
||||
/// Where a transcript lives is up to the location, e.g. next to the media file or below the data
|
||||
/// directory.
|
||||
/// </remarks>
|
||||
internal static class PersistentMediaTranscript
|
||||
{
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(PersistentMediaTranscript).Namespace, nameof(PersistentMediaTranscript));
|
||||
|
||||
/// <summary>
|
||||
/// Whether a file is an audio or video file, which holds no text until it is transcribed.
|
||||
/// </summary>
|
||||
/// <param name="path">The path of the file.</param>
|
||||
/// <returns>True for audio and video files.</returns>
|
||||
public static bool IsMediaPath(string path) => FileTypes.IsAllowedPath(path, FileTypes.AUDIO, FileTypes.VIDEO);
|
||||
|
||||
/// <summary>
|
||||
/// Returns where the transcript of a media file is, transcribing the file only when its location holds no transcript yet.
|
||||
/// </summary>
|
||||
/// <param name="location">Where the transcript lives.</param>
|
||||
/// <param name="mediaPath">The audio or video file.</param>
|
||||
/// <param name="provider">The transcription provider the caller allows, or null when no provider may hear the file.</param>
|
||||
/// <param name="transcribe">Transcribes the media file with the given provider, usually MediaTranscriptionService.TranscribeAsync.</param>
|
||||
/// <param name="logger">The logger of the caller.</param>
|
||||
/// <param name="token">The cancellation token.</param>
|
||||
/// <returns>Where the transcript is, or why there is none.</returns>
|
||||
public static async Task<PersistentMediaTranscriptResult> ReadOrCreateAsync(IMediaTranscriptLocation location, string mediaPath, TranscriptionProvider? provider, Func<string, TranscriptionProvider, CancellationToken, Task<MediaTranscriptionResult>> transcribe, ILogger logger, CancellationToken token)
|
||||
{
|
||||
//
|
||||
// The stamp comes first: a location may read the whole file to find the place of its
|
||||
// transcript, and a change from then on must keep a transcript out of that place.
|
||||
//
|
||||
FileStamp sourceBefore;
|
||||
MediaTranscriptSlot slot;
|
||||
try
|
||||
{
|
||||
sourceBefore = FileStamp.Read(mediaPath);
|
||||
slot = await location.ResolveAsync(mediaPath, token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return PersistentMediaTranscriptResult.Cancelled();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Could not inspect the media file '{MediaFilePath}' before its transcription.", mediaPath);
|
||||
return PersistentMediaTranscriptResult.Unavailable(PersistentMediaTranscriptStatus.SOURCE_UNREADABLE, null, TB("The media file could not be read."));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (await HoldsTextAsync(slot.Path, token))
|
||||
{
|
||||
logger.LogInformation("Reusing the transcript '{TranscriptFilePath}' of the media file '{MediaFilePath}'.", slot.Path, mediaPath);
|
||||
return PersistentMediaTranscriptResult.Ready(slot, reused: true);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return PersistentMediaTranscriptResult.Cancelled();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
//
|
||||
// Transcribing again would mean paying for a transcript which exists, and storing it
|
||||
// would most likely fail for the same reason:
|
||||
//
|
||||
logger.LogWarning(exception, "Could not read the transcript '{TranscriptFilePath}' of the media file '{MediaFilePath}'.", slot.Path, mediaPath);
|
||||
return PersistentMediaTranscriptResult.Unavailable(PersistentMediaTranscriptStatus.TRANSCRIPT_UNREADABLE, slot, string.Format(TB("Was not able to read the existing transcript: {0}"), exception.Message));
|
||||
}
|
||||
|
||||
if (provider is null)
|
||||
return PersistentMediaTranscriptResult.Unavailable(PersistentMediaTranscriptStatus.NO_PROVIDER, slot, TB("No usable transcription provider is configured."));
|
||||
|
||||
MediaTranscriptionResult transcription;
|
||||
try
|
||||
{
|
||||
transcription = await transcribe(mediaPath, provider, token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return PersistentMediaTranscriptResult.Cancelled();
|
||||
}
|
||||
|
||||
switch (transcription.Status)
|
||||
{
|
||||
case MediaTranscriptionResultStatus.CANCELLED:
|
||||
return PersistentMediaTranscriptResult.Cancelled();
|
||||
|
||||
case MediaTranscriptionResultStatus.NO_AUDIBLE_SIGNAL:
|
||||
return PersistentMediaTranscriptResult.Unavailable(PersistentMediaTranscriptStatus.NO_AUDIBLE_SIGNAL, slot, transcription.UserMessage);
|
||||
|
||||
case MediaTranscriptionResultStatus.FAILED when transcription.FileRejected:
|
||||
return PersistentMediaTranscriptResult.Unavailable(PersistentMediaTranscriptStatus.REJECTED, slot, transcription.UserMessage);
|
||||
|
||||
case MediaTranscriptionResultStatus.FAILED:
|
||||
return PersistentMediaTranscriptResult.Unavailable(PersistentMediaTranscriptStatus.TRANSCRIPTION_FAILED, slot, transcription.UserMessage, transcription.ErrorCode);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(transcription.Text))
|
||||
return PersistentMediaTranscriptResult.Unavailable(PersistentMediaTranscriptStatus.EMPTY, slot, TB("The transcription provider returned an empty transcript."));
|
||||
|
||||
if (!sourceBefore.StillMatches(mediaPath))
|
||||
return SourceChanged(slot);
|
||||
|
||||
var temporaryPath = $"{slot.Path}.{Guid.NewGuid():N}.tmp";
|
||||
try
|
||||
{
|
||||
var directory = Path.GetDirectoryName(slot.Path);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
//
|
||||
// The transcription is paid for at this point. Storing it is quick, so it is completed
|
||||
// even when the caller cancelled meanwhile, and the next attempt can use it:
|
||||
//
|
||||
await File.WriteAllTextAsync(temporaryPath, transcription.Text, new UTF8Encoding(false), CancellationToken.None);
|
||||
if (!sourceBefore.StillMatches(mediaPath))
|
||||
return SourceChanged(slot);
|
||||
|
||||
File.Move(temporaryPath, slot.Path, true);
|
||||
logger.LogInformation("Stored the transcript '{TranscriptFilePath}' of the media file '{MediaFilePath}'.", slot.Path, mediaPath);
|
||||
return PersistentMediaTranscriptResult.Ready(slot, reused: false);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Could not store the transcript '{TranscriptFilePath}' of the media file '{MediaFilePath}'.", slot.Path, mediaPath);
|
||||
return PersistentMediaTranscriptResult.Unavailable(PersistentMediaTranscriptStatus.STORE_FAILED, slot, string.Format(TB("Was not able to store the transcript: {0}"), exception.Message));
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(temporaryPath))
|
||||
File.Delete(temporaryPath);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Could not remove the temporary transcript '{TempFilePath}'.", temporaryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static PersistentMediaTranscriptResult SourceChanged(MediaTranscriptSlot slot) => PersistentMediaTranscriptResult.Unavailable(PersistentMediaTranscriptStatus.SOURCE_CHANGED, slot, TB("The media file changed while it was being transcribed, so its transcript was not kept."));
|
||||
|
||||
/// <summary>
|
||||
/// Whether a transcript exists and holds more than white space.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reads only as far as the first character which is not white space, so a large file is
|
||||
/// never loaded as a whole.
|
||||
/// </remarks>
|
||||
private static async Task<bool> HoldsTextAsync(string path, CancellationToken token)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
return false;
|
||||
|
||||
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
|
||||
var buffer = new char[1024];
|
||||
int read;
|
||||
while ((read = await reader.ReadAsync(buffer.AsMemory(), token)) > 0)
|
||||
{
|
||||
for (var index = 0; index < read; index++)
|
||||
if (!char.IsWhiteSpace(buffer[index]))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The size and write time of a media file, to notice a change during its transcription.
|
||||
/// </summary>
|
||||
private readonly record struct FileStamp(long Length, DateTime LastWriteTimeUtc)
|
||||
{
|
||||
public static FileStamp Read(string path)
|
||||
{
|
||||
var file = new FileInfo(path);
|
||||
file.Refresh();
|
||||
if (!file.Exists)
|
||||
throw new FileNotFoundException("The media file does not exist.", path);
|
||||
|
||||
return new(file.Length, file.LastWriteTimeUtc);
|
||||
}
|
||||
|
||||
public bool StillMatches(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Read(path) == this;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using AIStudio.Tools.Rust;
|
||||
|
||||
namespace AIStudio.Tools.Media;
|
||||
|
||||
/// <summary>
|
||||
/// The outcome of a request for a persistent transcript, cf. PersistentMediaTranscript.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The transcript itself is not part of it: whoever needs the text reads the file at
|
||||
/// TranscriptPath, so a transcript reaches the AI by the same path no matter whether it was just
|
||||
/// made or stored long ago.
|
||||
/// </remarks>
|
||||
/// <param name="Status">How the request ended.</param>
|
||||
/// <param name="TranscriptPath">The path of the transcript; empty when the place could not be determined.</param>
|
||||
/// <param name="Key">What identifies the transcript at its location; empty when the place could not be determined.</param>
|
||||
/// <param name="Reused">Whether an existing transcript was used instead of a new transcription.</param>
|
||||
/// <param name="UserMessage">A localized explanation for every status but READY and CANCELLED.</param>
|
||||
/// <param name="ErrorCode">Why the media pipeline failed, for TRANSCRIPTION_FAILED, when it is known.</param>
|
||||
internal sealed record PersistentMediaTranscriptResult(PersistentMediaTranscriptStatus Status, string TranscriptPath, string Key, bool Reused, string UserMessage, MediaJobErrorCode? ErrorCode = null)
|
||||
{
|
||||
public static PersistentMediaTranscriptResult Ready(MediaTranscriptSlot slot, bool reused) => new(PersistentMediaTranscriptStatus.READY, slot.Path, slot.Key, reused, string.Empty);
|
||||
|
||||
public static PersistentMediaTranscriptResult Cancelled() => new(PersistentMediaTranscriptStatus.CANCELLED, string.Empty, string.Empty, false, string.Empty);
|
||||
|
||||
public static PersistentMediaTranscriptResult Unavailable(PersistentMediaTranscriptStatus status, MediaTranscriptSlot? slot, string userMessage, MediaJobErrorCode? errorCode = null) => new(status, slot?.Path ?? string.Empty, slot?.Key ?? string.Empty, false, userMessage, errorCode);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
namespace AIStudio.Tools.Media;
|
||||
|
||||
/// <summary>
|
||||
/// How the request for a persistent transcript ended, cf. PersistentMediaTranscript.
|
||||
/// </summary>
|
||||
public enum PersistentMediaTranscriptStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// The transcript is stored at its place and holds text.
|
||||
/// </summary>
|
||||
READY,
|
||||
|
||||
/// <summary>
|
||||
/// There is no transcript yet, and the caller allows no transcription provider.
|
||||
/// </summary>
|
||||
NO_PROVIDER,
|
||||
|
||||
/// <summary>
|
||||
/// The media file could not be read before it was transcribed.
|
||||
/// </summary>
|
||||
SOURCE_UNREADABLE,
|
||||
|
||||
/// <summary>
|
||||
/// A transcript exists, but it could not be read.
|
||||
/// </summary>
|
||||
TRANSCRIPT_UNREADABLE,
|
||||
|
||||
/// <summary>
|
||||
/// The transcription failed. The error code of the media pipeline tells why, when it is known.
|
||||
/// </summary>
|
||||
TRANSCRIPTION_FAILED,
|
||||
|
||||
/// <summary>
|
||||
/// The transcription provider refused the media file itself, e.g., because it is too large
|
||||
/// or damaged. It would refuse the unchanged file again.
|
||||
/// </summary>
|
||||
REJECTED,
|
||||
|
||||
/// <summary>
|
||||
/// The media file holds no audible signal, so there was nothing to transcribe.
|
||||
/// </summary>
|
||||
NO_AUDIBLE_SIGNAL,
|
||||
|
||||
/// <summary>
|
||||
/// The transcription provider returned no text. Nothing was stored.
|
||||
/// </summary>
|
||||
EMPTY,
|
||||
|
||||
/// <summary>
|
||||
/// The media file changed while it was transcribed. Nothing was stored.
|
||||
/// </summary>
|
||||
SOURCE_CHANGED,
|
||||
|
||||
/// <summary>
|
||||
/// The transcript could not be stored at its place.
|
||||
/// </summary>
|
||||
STORE_FAILED,
|
||||
|
||||
/// <summary>
|
||||
/// The caller cancelled before a transcript was available.
|
||||
/// </summary>
|
||||
CANCELLED,
|
||||
}
|
||||
Reference in new issue
Block a user