Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 78 additions & 2 deletions src/ui/Logic/Download/KokoroTtsCppDownloadService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ public class KokoroTtsCppDownloadService : IKokoroTtsCppDownloadService
private const string TtsModelFileName = "kokoro-v1.1-zh.onnx";
private const string VoicesModelFileName = "voices-v1.1-zh.bin";
private const string TtsModelUrl = "https://github.com/koth/kokoro.cpp/releases/download/voices_model_files/kokoro-v1.1-zh.onnx";
internal const string TtsModelSha256 = "eefec708cbc7aba8e8129b5c2f7cb92e1fe7d281af1e1dd451592d9ff0714a0d";
private const string VoicesModelUrl = "https://github.com/koth/kokoro.cpp/releases/download/voices_model_files/voices-v1.1-zh.bin";
internal const string VoicesModelSha256 = "e678019845e6cfe3b7c34531779396b28f509451b91e6535d5dc09bbf11a4be5";

public KokoroTtsCppDownloadService(HttpClient httpClient)
{
Expand Down Expand Up @@ -84,13 +86,87 @@ public async Task DownloadModels(string modelsFolder, IProgress<float>? progress
{
step++;
titleProgress?.Invoke($"Downloading Kokoro TTS models ({step}/{total}): {TtsModelFileName}");
await DownloadHelper.DownloadFileAsync(_httpClient, TtsModelUrl, ttsPath, progress, cancellationToken);
await DownloadAndPublishModelAsync(
_httpClient,
TtsModelUrl,
ttsPath,
TtsModelSha256,
progress,
cancellationToken);
}
if (needVoices)
{
step++;
titleProgress?.Invoke($"Downloading Kokoro TTS models ({step}/{total}): {VoicesModelFileName}");
await DownloadHelper.DownloadFileAsync(_httpClient, VoicesModelUrl, voicesPath, progress, cancellationToken);
await DownloadAndPublishModelAsync(
_httpClient,
VoicesModelUrl,
voicesPath,
VoicesModelSha256,
progress,
cancellationToken);
}
}

internal static async Task DownloadAndPublishModelAsync(
HttpClient httpClient,
string url,
string destinationFileName,
string expectedSha256,
IProgress<float>? progress,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(expectedSha256))
{
throw new InvalidOperationException(
$"No SHA-256 is registered for Kokoro TTS model '{Path.GetFileName(destinationFileName)}'.");
}

var tempFileName = destinationFileName + ".part";
try
{
if (File.Exists(tempFileName))
{
File.Delete(tempFileName);
}

await DownloadHelper.DownloadFileAsync(
httpClient,
url,
tempFileName,
progress,
cancellationToken);

string actual;
await using (var stream = File.OpenRead(tempFileName))
{
actual = await Sha256Util.ComputeSha256Async(stream, cancellationToken);
}

if (!string.Equals(expectedSha256, actual, StringComparison.OrdinalIgnoreCase))
{
throw new IOException(
$"Kokoro TTS model {Path.GetFileName(destinationFileName)} failed integrity check " +
$"(expected SHA-256 {expectedSha256}, got {actual}).");
}

File.Move(tempFileName, destinationFileName, true);
}
catch
{
try
{
if (File.Exists(tempFileName))
{
File.Delete(tempFileName);
}
}
catch
{
// Preserve the original download/integrity error; cleanup is best-effort.
}

throw;
}
}

Expand Down
128 changes: 128 additions & 0 deletions tests/UI/Logic/Download/KokoroTtsCppDownloadServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
using System.Net;
using System.Security.Cryptography;
using System.Text;
using Nikse.SubtitleEdit.Logic.Download;

namespace UITests.Logic.Download;

public class KokoroTtsCppDownloadServiceTests
{
[Fact]
public void ModelDigests_MatchPublishedReleaseAssets()
{
Assert.Equal(
"eefec708cbc7aba8e8129b5c2f7cb92e1fe7d281af1e1dd451592d9ff0714a0d",
KokoroTtsCppDownloadService.TtsModelSha256);
Assert.Equal(
"e678019845e6cfe3b7c34531779396b28f509451b91e6535d5dc09bbf11a4be5",
KokoroTtsCppDownloadService.VoicesModelSha256);
}

[Fact]
public async Task DownloadAndPublishModelAsync_ValidPayload_PublishesAtomically()
{
var payload = Encoding.ASCII.GetBytes("valid-model");
var expected = Convert.ToHexString(SHA256.HashData(payload)).ToLowerInvariant();
var folder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(folder);
var destination = Path.Combine(folder, "model.bin");

try
{
using var httpClient = new HttpClient(new StaticResponseHandler(payload));

await KokoroTtsCppDownloadService.DownloadAndPublishModelAsync(
httpClient,
"https://example.test/model.bin",
destination,
expected,
progress: null,
TestContext.Current.CancellationToken);

Assert.True(File.Exists(destination));
Assert.False(File.Exists(destination + ".part"));
Assert.Equal(payload, await File.ReadAllBytesAsync(destination, TestContext.Current.CancellationToken));
}
finally
{
Directory.Delete(folder, true);
}
}

[Fact]
public async Task DownloadAndPublishModelAsync_TamperedPayload_RejectsWithoutPublishing()
{
var expectedPayload = Encoding.ASCII.GetBytes("expected");
var expected = Convert.ToHexString(SHA256.HashData(expectedPayload)).ToLowerInvariant();
var folder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(folder);
var destination = Path.Combine(folder, "model.bin");

try
{
using var httpClient = new HttpClient(new StaticResponseHandler(Encoding.ASCII.GetBytes("tampered")));

await Assert.ThrowsAsync<IOException>(() =>
KokoroTtsCppDownloadService.DownloadAndPublishModelAsync(
httpClient,
"https://example.test/model.bin",
destination,
expected,
progress: null,
TestContext.Current.CancellationToken));

Assert.False(File.Exists(destination));
Assert.False(File.Exists(destination + ".part"));
}
finally
{
Directory.Delete(folder, true);
}
}

[Fact]
public async Task DownloadAndPublishModelAsync_MissingDigest_FailsBeforeHttp()
{
var folder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(folder);
var destination = Path.Combine(folder, "model.bin");
var handler = new StaticResponseHandler(Encoding.ASCII.GetBytes("payload"));

try
{
using var httpClient = new HttpClient(handler);

await Assert.ThrowsAsync<InvalidOperationException>(() =>
KokoroTtsCppDownloadService.DownloadAndPublishModelAsync(
httpClient,
"https://example.test/model.bin",
destination,
string.Empty,
progress: null,
TestContext.Current.CancellationToken));

Assert.Equal(0, handler.RequestCount);
Assert.False(File.Exists(destination));
}
finally
{
Directory.Delete(folder, true);
}
}

private sealed class StaticResponseHandler(byte[] payload) : HttpMessageHandler
{
public int RequestCount { get; private set; }

protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
RequestCount++;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(payload),
});
}
}
}
Loading