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
87 changes: 83 additions & 4 deletions src/ui/Logic/Download/OmniVoiceDownloadService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@ public class OmniVoiceDownloadService : IOmniVoiceDownloadService
public const string WindowsVariantVulkan = "vulkan";
public const string WindowsVariantCuda = "cuda";

private const string ModelBaseUrl = "https://huggingface.co/Serveurperso/OmniVoice-GGUF/resolve/main/omnivoice-base-Q8_0.gguf";
private const string ModelTokenizerUrl = "https://huggingface.co/Serveurperso/OmniVoice-GGUF/resolve/main/omnivoice-tokenizer-F32.gguf";
internal const string ModelRepoRevision = "017094167b5c9ed565a5076ac9b3b93c5ecf5c73";
internal const string ModelBaseSha256 = "2882d887921798aea13d45236556bdf8012842ab6f8cd2690943eead6289f298";
internal const string ModelTokenizerSha256 = "83820c6316da023076af7c1d06de5e38dcd09ae9f42203675bf8b3bd9a58e330";
private const string ModelRepoBaseUrl = "https://huggingface.co/Serveurperso/OmniVoice-GGUF/resolve/" + ModelRepoRevision + "/";

// omnivoice.cpp release pin. Bump in lockstep with the hashes in DownloadHashManager.OmniVoice
// (each new release: prepend the new SHA-256 at index 0, keep the previous one for "update available").
Expand Down Expand Up @@ -63,13 +65,90 @@ public async Task DownloadModels(string modelsFolder, IProgress<float>? progress
{
step++;
titleProgress?.Invoke($"Downloading OmniVoice TTS models ({step}/{total}): {ModelBaseFileName}");
await DownloadHelper.DownloadFileAsync(_httpClient, ModelBaseUrl, basePath, progress, cancellationToken);
await DownloadAndPublishModelAsync(
_httpClient,
GetModelUrl(ModelBaseFileName),
basePath,
ModelBaseSha256,
progress,
cancellationToken);
}
if (needTokenizer)
{
step++;
titleProgress?.Invoke($"Downloading OmniVoice TTS models ({step}/{total}): {ModelTokenizerFileName}");
await DownloadHelper.DownloadFileAsync(_httpClient, ModelTokenizerUrl, tokenizerPath, progress, cancellationToken);
await DownloadAndPublishModelAsync(
_httpClient,
GetModelUrl(ModelTokenizerFileName),
tokenizerPath,
ModelTokenizerSha256,
progress,
cancellationToken);
}
}

internal static string GetModelUrl(string fileName) => ModelRepoBaseUrl + fileName;

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 OmniVoice 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(
$"OmniVoice model {Path.GetFileName(destinationFileName)} failed integrity check " +
$"(expected SHA-256 {expectedSha256}, got {actual}).");
}

cancellationToken.ThrowIfCancellationRequested();
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
155 changes: 155 additions & 0 deletions tests/UI/Logic/Download/OmniVoiceDownloadServiceModelTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
using System.Net;
using System.Security.Cryptography;
using System.Text;
using Nikse.SubtitleEdit.Logic.Download;

namespace UITests.Logic.Download;

public class OmniVoiceDownloadServiceModelTests
{
[Fact]
public void ModelMetadata_PinsImmutableRevisionAndPublishedDigests()
{
Assert.Equal(
"017094167b5c9ed565a5076ac9b3b93c5ecf5c73",
OmniVoiceDownloadService.ModelRepoRevision);
Assert.Equal(
"2882d887921798aea13d45236556bdf8012842ab6f8cd2690943eead6289f298",
OmniVoiceDownloadService.ModelBaseSha256);
Assert.Equal(
"83820c6316da023076af7c1d06de5e38dcd09ae9f42203675bf8b3bd9a58e330",
OmniVoiceDownloadService.ModelTokenizerSha256);
}

[Fact]
public void ModelUrls_DoNotUseMutableMain()
{
var baseUrl = OmniVoiceDownloadService.GetModelUrl(OmniVoiceDownloadService.ModelBaseFileName);
var tokenizerUrl = OmniVoiceDownloadService.GetModelUrl(OmniVoiceDownloadService.ModelTokenizerFileName);

Assert.Equal(
"https://huggingface.co/Serveurperso/OmniVoice-GGUF/resolve/" +
"017094167b5c9ed565a5076ac9b3b93c5ecf5c73/" +
"omnivoice-base-Q8_0.gguf",
baseUrl);
Assert.Equal(
"https://huggingface.co/Serveurperso/OmniVoice-GGUF/resolve/" +
"017094167b5c9ed565a5076ac9b3b93c5ecf5c73/" +
"omnivoice-tokenizer-F32.gguf",
tokenizerUrl);
Assert.DoesNotContain("/resolve/main/", baseUrl, StringComparison.Ordinal);
Assert.DoesNotContain("/resolve/main/", tokenizerUrl, StringComparison.Ordinal);
}

[Fact]
public async Task DownloadAndPublishModelAsync_ValidPayload_PublishesVerifiedBytes()
{
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.gguf");

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

await OmniVoiceDownloadService.DownloadAndPublishModelAsync(
httpClient,
"https://example.test/model.gguf",
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_PreservesExistingDestination()
{
var expectedPayload = Encoding.ASCII.GetBytes("expected");
var expected = Convert.ToHexString(SHA256.HashData(expectedPayload)).ToLowerInvariant();
var existingPayload = Encoding.ASCII.GetBytes("existing-model");
var folder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(folder);
var destination = Path.Combine(folder, "model.gguf");
await File.WriteAllBytesAsync(destination, existingPayload, TestContext.Current.CancellationToken);

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

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

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

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

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

Assert.Equal(0, handler.RequestCount);
Assert.False(File.Exists(destination));
Assert.False(File.Exists(destination + ".part"));
}
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),
});
}
}
}