Skip to content
Open
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
10 changes: 5 additions & 5 deletions src/Beutl.Extensions.Voice/Models/VoiceMetadata.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ namespace Beutl.Extensions.Voice.Models;
public class VoiceMetadata
{
[JsonPropertyName("name")]
public string Name { get; init; }
public required string Name { get; init; }

[JsonPropertyName("version")]
public string Version { get; init; }
public required string Version { get; init; }

[JsonPropertyName("speaker_uuid")]
public string SpeakerUuid { get; init; }
public required string SpeakerUuid { get; init; }

[JsonPropertyName("styles")]
public VoiceStyle[] Styles { get; init; }
}
public required VoiceStyle[] Styles { get; init; }
}
4 changes: 2 additions & 2 deletions src/Beutl.Extensions.Voice/Models/VoiceStyle.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@ public class VoiceStyle
public uint Id { get; init; }

[JsonPropertyName("name")]
public string Name { get; init; }
}
public required string Name { get; init; }
}
11 changes: 8 additions & 3 deletions src/Beutl.Extensions.Voice/Services/SimpleWavePlayer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ public SimpleWavePlayer(byte[] waveData)

public WdlResamplingSampleProvider Resampler { get; }

private static short[] ToPcm16(float[] buf)
{
return buf.Select(i => (short)(Math.Clamp(i, -1f, 1f) * short.MaxValue)).ToArray();
}

public async Task Play(CancellationToken ct)
{
await Task.Run(async () =>
Expand Down Expand Up @@ -134,7 +139,7 @@ private async Task PlayWithOpenAL(CancellationToken ct)
var buf = new float[Reader.WaveFormat.SampleRate * 2];
_ = Resampler.Read(buf, 0, buf.Length);
cur += Reader.WaveFormat.SampleRate;
var converted = buf.Select(i => (short)(i * short.MaxValue)).ToArray();
var converted = ToPcm16(buf);

audioContext.BufferData(buffer, BufferFormat.Stereo16, converted.AsSpan(), Reader.WaveFormat.SampleRate);

Expand All @@ -153,7 +158,7 @@ private async Task PlayWithOpenAL(CancellationToken ct)
var buf = new float[Reader.WaveFormat.SampleRate * 2];
_ = Resampler.Read(buf, 0, buf.Length);
cur += Reader.WaveFormat.SampleRate;
var converted = buf.Select(i => (short)(i * short.MaxValue)).ToArray();
var converted = ToPcm16(buf);

audioContext.BufferData(buffer, BufferFormat.Stereo16, converted.AsSpan(), Reader.WaveFormat.SampleRate);

Expand Down Expand Up @@ -193,4 +198,4 @@ public void Dispose()
Reader.Dispose();
Stream.Dispose();
}
}
}
185 changes: 162 additions & 23 deletions src/Beutl.Extensions.Voice/Services/VoiceVoxInstaller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ namespace Beutl.Extensions.Voice.Services;
public class VoiceVoxInstaller
{
private readonly ILogger _logger = Log.CreateLogger<VoiceVoxInstaller>();
private static readonly TimeSpan s_httpHeaderTimeout = TimeSpan.FromSeconds(30);
private static readonly TimeSpan s_downloadIdleTimeout = TimeSpan.FromMinutes(2);

public ReactiveProperty<double> Progress { get; } = new(0);

Expand All @@ -28,20 +30,63 @@ public Task Install(CancellationToken ct)
{
return Task.Run(async () =>
{
string? voicevoxHomePath = null;
string? tempVoicevoxHomePath = null;
string? backupVoicevoxHomePath = null;
try
{
var home = BeutlEnvironment.GetHomeDirectoryPath();
var voicevoxHomePath = Path.Combine(home, "voicevox");
voicevoxHomePath = Path.Combine(home, "voicevox");
tempVoicevoxHomePath = Path.Combine(home, $".voicevox-{Guid.NewGuid():N}.tmp");

DeleteDirectoryIfExists(tempVoicevoxHomePath);
Directory.CreateDirectory(tempVoicevoxHomePath);

await InstallVoiceVoxCore(tempVoicevoxHomePath, ct);
await InstallDictionary(tempVoicevoxHomePath, ct);
await InstallOnnxRuntime(tempVoicevoxHomePath, ct);
await InstallVoiceModels(tempVoicevoxHomePath, ct);

if (!VoiceVoxLoader.IsValidInstallation(tempVoicevoxHomePath))
{
throw new InvalidOperationException("VOICEVOXのインストール検証に失敗しました。");
}

backupVoicevoxHomePath = Path.Combine(home, $".voicevox-backup-{Guid.NewGuid():N}.tmp");
DeleteDirectoryIfExists(backupVoicevoxHomePath);

if (Directory.Exists(voicevoxHomePath))
{
Directory.Delete(voicevoxHomePath, true);
Directory.Move(voicevoxHomePath, backupVoicevoxHomePath);
}

try
{
MoveDirectoryCrossDevice(tempVoicevoxHomePath, voicevoxHomePath);
tempVoicevoxHomePath = null;
DeleteDirectoryIfExists(backupVoicevoxHomePath);
backupVoicevoxHomePath = null;
Comment on lines +67 to +68

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep backup until the new install loads

In the replace-existing-install path, the backup is deleted immediately after the filesystem swap, before TtsLoader.StaticLoad() verifies that the new native libraries and models can actually be loaded. If the new installation is structurally valid but Load() fails (for example due to an incompatible native library or bad model metadata), the catch path records the error but can no longer restore the previously working install, leaving the user stuck on a failing replacement. Keep the backup until after StaticLoad() succeeds and restore it on load failure.

Useful? React with 👍 / 👎.

}
catch
{
if (Directory.Exists(backupVoicevoxHomePath))
{
if (Directory.Exists(voicevoxHomePath)
&& !VoiceVoxLoader.IsValidInstallation(voicevoxHomePath))
{
DeleteDirectoryIfExists(voicevoxHomePath);
}

if (!Directory.Exists(voicevoxHomePath))
{
MoveDirectoryCrossDevice(backupVoicevoxHomePath, voicevoxHomePath);
backupVoicevoxHomePath = null;
}
}

throw;
}

Directory.CreateDirectory(voicevoxHomePath);
await InstallVoiceVoxCore(voicevoxHomePath, ct);
await InstallDictionary(voicevoxHomePath, ct);
await InstallOnnxRuntime(voicevoxHomePath, ct);
await InstallVoiceModels(voicevoxHomePath, ct);
Status.Value = "ロード中 (8/8)";
IsIndeterminate.Value = true;
await TtsLoader.StaticLoad();
Expand All @@ -50,6 +95,27 @@ public Task Install(CancellationToken ct)
}
catch (Exception ex)
{
if (tempVoicevoxHomePath != null)
{
DeleteDirectoryIfExists(tempVoicevoxHomePath);
}

if (voicevoxHomePath != null
&& Directory.Exists(voicevoxHomePath)
&& !VoiceVoxLoader.IsValidInstallation(voicevoxHomePath))
{
DeleteDirectoryIfExists(voicevoxHomePath);
}

if (backupVoicevoxHomePath != null
&& Directory.Exists(backupVoicevoxHomePath)
&& voicevoxHomePath != null
&& Directory.Exists(voicevoxHomePath)
&& VoiceVoxLoader.IsValidInstallation(voicevoxHomePath))
{
DeleteDirectoryIfExists(backupVoicevoxHomePath);
}

Error.Value = ex.Message;
_logger.LogError(ex, "Failed to install voicevox_core");
}
Expand All @@ -63,7 +129,7 @@ public Task Install(CancellationToken ct)
private async Task DownloadFile(string url, string path, CancellationToken ct)
{
_logger.LogInformation("Downloading {Url} to {Path}", url, path);
using var client = new HttpClient();
using var client = new HttpClient { Timeout = s_httpHeaderTimeout };
using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
response.EnsureSuccessStatusCode();

Expand All @@ -75,40 +141,98 @@ private async Task DownloadFile(string url, string path, CancellationToken ct)
if (!contentLength.HasValue)
{
IsIndeterminate.Value = true;
await download.CopyToAsync(fs, ct).ConfigureAwait(false);
await CopyDownloadedStream(download, fs, ct).ConfigureAwait(false);
}
else
{
var bufferSize = 81920;
var buffer = new byte[bufferSize];
long totalBytesRead = 0;
int bytesRead;
ProgressMax.Value = 1;
while ((bytesRead = await download.ReadAsync(buffer, ct).ConfigureAwait(false)) != 0)
await CopyDownloadedStream(download, fs, ct, bytesRead =>
{
await fs.WriteAsync(buffer.AsMemory(0, bytesRead), ct).ConfigureAwait(false);
totalBytesRead += bytesRead;

Progress.Value = totalBytesRead / (double)contentLength.Value;
}
}).ConfigureAwait(false);
}

_logger.LogInformation("Downloaded {Url} to {Path}", url, path);
}

private static async Task CopyDownloadedStream(
Stream source,
Stream destination,
CancellationToken ct,
Action<int>? onBytesRead = null)
{
var buffer = new byte[81920];
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);

while (true)
{
timeoutCts.CancelAfter(s_downloadIdleTimeout);
int bytesRead;
try
{
bytesRead = await source.ReadAsync(buffer.AsMemory(0, buffer.Length), timeoutCts.Token)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
throw new TimeoutException($"ダウンロードが{s_downloadIdleTimeout.TotalSeconds:0}秒以上停止しました。");
}
finally
{
timeoutCts.CancelAfter(Timeout.InfiniteTimeSpan);
}

if (bytesRead == 0)
{
break;
}

await destination.WriteAsync(buffer.AsMemory(0, bytesRead), ct).ConfigureAwait(false);
onBytesRead?.Invoke(bytesRead);
}
}

private async Task ExtractZip(string zipPath, string dstDir, CancellationToken ct)
{
_logger.LogInformation("Extracting {ZipPath} to {DstDir}", zipPath, dstDir);
using var source = ZipFile.Open(zipPath, ZipArchiveMode.Read);
var fullDstDir = Path.GetFullPath(dstDir);
if (!fullDstDir.EndsWith(Path.DirectorySeparatorChar))
{
fullDstDir += Path.DirectorySeparatorChar;
}

var pathComparison = OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
ProgressMax.Value = source.Entries.Count;
foreach (var entry in source.Entries)
{
if (entry.Length != 0)
{
var dst = Path.Combine(dstDir,
string.Join(Path.DirectorySeparatorChar, entry.FullName.Split('/')[1..]));
Directory.CreateDirectory(Path.GetDirectoryName(dst)!);
await using var fs = File.OpenWrite(dst);
var entrySegments = entry.FullName.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (entrySegments.Length < 2 || entrySegments.Any(x => x == ".."))
{
throw new IOException($"Invalid zip entry path: {entry.FullName}");
}

var dst = Path.Combine(dstDir, Path.Combine(entrySegments[1..]));
var fullDst = Path.GetFullPath(dst);
if (!fullDst.StartsWith(fullDstDir, pathComparison))
{
throw new IOException($"Zip entry is outside the target directory: {entry.FullName}");
}

var directory = Path.GetDirectoryName(fullDst);
if (string.IsNullOrEmpty(directory))
{
throw new IOException($"Invalid zip entry path: {entry.FullName}");
}

Directory.CreateDirectory(directory);
await using var fs = File.OpenWrite(fullDst);
await using var es = entry.Open();
await es.CopyToAsync(fs, ct).ConfigureAwait(false);
}
Expand Down Expand Up @@ -271,7 +395,7 @@ private async Task InstallVoiceModels(string voicevoxHomePath, CancellationToken
Status.Value = "音声モデル情報を取得中 (7/8)";
IsIndeterminate.Value = true;

using var client = new HttpClient();
using var client = new HttpClient { Timeout = s_httpHeaderTimeout };
client.DefaultRequestHeaders.Add("User-Agent", "Beutl");

var releasesJson = await client.GetStringAsync(
Expand Down Expand Up @@ -337,14 +461,29 @@ private async Task InstallVoiceModels(string voicevoxHomePath, CancellationToken
response.EnsureSuccessStatusCode();
await using var fs = File.OpenWrite(filePath);
await using var download = await response.Content.ReadAsStreamAsync(ct);
await download.CopyToAsync(fs, ct);
await CopyDownloadedStream(download, fs, ct);

Progress.Value = i + 1;
}

_logger.LogInformation("Installed {Count} VVM files to {Dir}", vvmAssets.Count, vvmDir);
}

private void DeleteDirectoryIfExists(string path)
{
try
{
if (Directory.Exists(path))
{
Directory.Delete(path, true);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete directory: {Path}", path);
}
}

private static void MoveDirectoryCrossDevice(string source, string dest)
{
try
Expand Down Expand Up @@ -413,4 +552,4 @@ private static string DetermineOnnxRuntimeUrl()

return $"https://github.com/VOICEVOX/onnxruntime-builder/releases/download/voicevox_onnxruntime-1.17.3/voicevox_onnxruntime-{os}-{arch}-1.17.3.tgz";
}
}
}
Loading