← All source files

V11 / ca6f28eff240

KokoroSetup.cs

Lilith/Core/Kokoro/KokoroSetup.cs

Unchanged from the preceding version
CS
using System.Text.Json;
using Lilith.Core.Logging;

namespace Lilith.Core.Kokoro;

public static class KokoroSetup
{
    public const string ModelUrl = "https://github.com/Lyrcaxis/KokoroSharpBinaries/releases/download/v2.0.0/kokoro.onnx";

    public static async Task DownloadAsync(KokoroTtsOptions options, Logger logger,
        CancellationToken ct = default, HttpClient? client = null)
        => await DownloadCoreAsync(options, logger, TimeSpan.FromMinutes(30), ct, client);

    internal static async Task DownloadCoreAsync(KokoroTtsOptions options, Logger logger, TimeSpan timeout,
        CancellationToken ct = default, HttpClient? client = null)
    {
        ct.ThrowIfCancellationRequested();
        string path = options.ResolvedModelPath;
        if (await KokoroModelFiles.IsUsableAsync(path)) return;
        KokoroModelFiles.Quarantine(path);
        Directory.CreateDirectory(Path.GetDirectoryName(path)!);
        string temporary = path + "." + Guid.NewGuid().ToString("N") + ".partial";
        using var ownedClient = client is null ? new HttpClient { Timeout = TimeSpan.FromMinutes(30) } : null;
        using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct);
        deadline.CancelAfter(timeout);
        ct = deadline.Token;
        try
        {
            using var response = await (client ?? ownedClient!).GetAsync(ModelUrl, HttpCompletionOption.ResponseHeadersRead, ct);
            response.EnsureSuccessStatusCode();
            await using var source = await response.Content.ReadAsStreamAsync(ct);
            long total = response.Content.Headers.ContentLength ?? 0;
            long received = 0;
            await using (var destination = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, true))
            {
                var buffer = new byte[81920];
                int count;
                while ((count = await source.ReadAsync(buffer, ct)) > 0)
                {
                    await destination.WriteAsync(buffer.AsMemory(0, count), ct);
                    received += count;
                    if (total > 0) logger.WriteProgress("TTS", received, total);
                    else logger.WriteProgress("TTS", $"Downloaded {received / 1048576.0:F1} MB");
                }
            }
            if (received == 0 || (total > 0 && received != total)) throw new InvalidDataException("Kokoro download is incomplete.");
            ct.ThrowIfCancellationRequested();
            File.Move(temporary, path, overwrite: true);
            logger.WriteLine("TTS", "Kokoro model download complete.");
        }
        finally { if (File.Exists(temporary)) File.Delete(temporary); }
    }

    /// <summary>Stores preferences before downloading, so an interrupted first boot can resume.</summary>
    public static async Task<KokoroTtsOptions> RunAsync(TextReader input, TextWriter output, Logger logger,
        string settingsPath, CancellationToken ct = default)
        => await RunCoreAsync(input, output, logger, settingsPath, KokoroModelFiles.IsUsableAsync, ct);

    internal static async Task<KokoroTtsOptions> RunCoreAsync(TextReader input, TextWriter output, Logger logger,
        string settingsPath, Func<string, Task<bool>> validateModel, CancellationToken ct = default)
    {
        // Automated or redirected sessions keep their existing input protocol and never download implicitly.
        string? enabled = Environment.GetEnvironmentVariable("LILITH_TTS");
        if (enabled is "0" or "false") return new();
        KokoroTtsOptions? options = null;
        if (File.Exists(settingsPath))
        {
            try { options = JsonSettings.Load<KokoroTtsOptions>(settingsPath, ValidOptions); }
            catch (JsonException) { logger.WriteWarning("TTS", "TTS settings are invalid; configure speech again."); }
        }
        if (options is null)
        {
            if (!await input.ConfirmAsync(output, "Enable Kokoro voice replies? [y/N]: ", ct)) options = new();
            else
            {
                await output.WriteAsync("Voice (af_heart, af_bella, am_adam, bf_emma; or custom ID) [af_heart]: ");
                string voice = (await input.ReadLineAsync(ct))?.Trim() ?? "";
                voice = voice.Length == 0 ? "af_heart" : voice;
                KokoroTts.ValidateVoice(voice);
                bool wait = await input.ConfirmAsync(output, "Wait for speech before accepting the next message? [y/N]: ", ct);
                options = new(true, voice, wait);
            }
            ct.ThrowIfCancellationRequested();
            JsonSettings.Save(settingsPath, options);
        }
        if (!options.Enabled) return options;
        KokoroTts.ValidateVoice(options.VoiceId);
        if (!await validateModel(options.ResolvedModelPath))
        {
            if (File.Exists(options.ResolvedModelPath))
            {
                KokoroModelFiles.Quarantine(options.ResolvedModelPath);
                logger.WriteWarning("TTS", "The Kokoro model is damaged or invalid; download it again to repair speech.");
            }
            if (!await input.ConfirmAsync(output, "Download Kokoro model (about 320 MB)? [y/N]: ", ct))
                return options with { Enabled = false };
            await logger.TimeStageAsync("Kokoro model download", () => DownloadAsync(options, logger, ct));
            if (!await validateModel(options.ResolvedModelPath))
            {
                KokoroModelFiles.Quarantine(options.ResolvedModelPath);
                throw new InvalidDataException("The downloaded Kokoro model is invalid. Relaunch setup to retry.");
            }
        }
        return options;
    }

    private static bool ValidOptions(KokoroTtsOptions options)
    {
        try
        {
            _ = options.ResolvedModelPath;
            KokoroTts.ValidateVoice(options.VoiceId);
            return true;
        }
        catch (Exception ex) when (ex is ArgumentException or NotSupportedException) { return false; }
    }
}