From f5f99f2983063cf7a58471b04a87cd497b898069 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Tue, 23 Jun 2026 14:27:18 +0900 Subject: [PATCH 1/2] fix: harden TTS reliability, installer safety, and error surfacing Address issues found in a full-project review (verified across multiple review/audit passes; build stays at 0 warnings / 0 errors on net10.0). TtsTabViewModel: - Surface synthesis/query/play failures to the user via NotificationService instead of silently logging and returning. - Add an Interlocked reentrancy guard and split out CreateQueryCore so Generate/Play no longer reset IsGenerating mid-run and no longer lose the AudioQuery to a fire-and-forget Dispatcher.Post race (they now decide on a local query value). - Make _initTcs completion idempotent (TrySetResult on all OnLoaded paths) so ReadFromJson never waits forever when VOICEVOX is missing/not loaded. VoiceVoxInstaller: - Install into a temp directory and atomically swap on success, validating the installation first, so a failed/cancelled install never leaves a broken state. - Add Zip Slip containment in ExtractZip (normalized prefix check, reject ".." and single-segment entries). - Add an idle timeout to downloads so a stalled connection cannot hang forever. VoiceVoxLoader: - Validate the installation structure (core/dict/onnxruntime/vvm), not just directory existence. - Skip a single malformed model's metas (JsonException) instead of aborting all model loading. - Centralize native library path resolution. TtsLoader: - Marshal the install notification to the UI thread, observe ContinueWith faults, and avoid the duplicate "not installed" warning on load failure. SimpleWavePlayer: - Clamp the float -> PCM16 conversion to avoid wraparound noise. Models: - Mark required JSON properties as `required` (clears CS8618 warnings). Claude-Session: https://claude.ai/code/session_01F5pRvtNeKb5nagNh83hWRz --- .../Models/VoiceMetadata.cs | 10 +- .../Models/VoiceStyle.cs | 4 +- .../Services/SimpleWavePlayer.cs | 11 +- .../Services/VoiceVoxInstaller.cs | 146 +++++++++++-- .../Services/VoiceVoxLoader.cs | 70 ++++-- src/Beutl.Extensions.Voice/TtsLoader.cs | 25 ++- .../ViewModels/TtsTabViewModel.cs | 205 +++++++++++++----- 7 files changed, 359 insertions(+), 112 deletions(-) diff --git a/src/Beutl.Extensions.Voice/Models/VoiceMetadata.cs b/src/Beutl.Extensions.Voice/Models/VoiceMetadata.cs index 829bcf9..5d33665 100644 --- a/src/Beutl.Extensions.Voice/Models/VoiceMetadata.cs +++ b/src/Beutl.Extensions.Voice/Models/VoiceMetadata.cs @@ -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; } -} \ No newline at end of file + public required VoiceStyle[] Styles { get; init; } +} diff --git a/src/Beutl.Extensions.Voice/Models/VoiceStyle.cs b/src/Beutl.Extensions.Voice/Models/VoiceStyle.cs index 6ad13bd..58b4825 100644 --- a/src/Beutl.Extensions.Voice/Models/VoiceStyle.cs +++ b/src/Beutl.Extensions.Voice/Models/VoiceStyle.cs @@ -8,5 +8,5 @@ public class VoiceStyle public uint Id { get; init; } [JsonPropertyName("name")] - public string Name { get; init; } -} \ No newline at end of file + public required string Name { get; init; } +} diff --git a/src/Beutl.Extensions.Voice/Services/SimpleWavePlayer.cs b/src/Beutl.Extensions.Voice/Services/SimpleWavePlayer.cs index 20b8cf2..a47ff2b 100644 --- a/src/Beutl.Extensions.Voice/Services/SimpleWavePlayer.cs +++ b/src/Beutl.Extensions.Voice/Services/SimpleWavePlayer.cs @@ -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 () => @@ -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); @@ -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); @@ -193,4 +198,4 @@ public void Dispose() Reader.Dispose(); Stream.Dispose(); } -} \ No newline at end of file +} diff --git a/src/Beutl.Extensions.Voice/Services/VoiceVoxInstaller.cs b/src/Beutl.Extensions.Voice/Services/VoiceVoxInstaller.cs index d16c658..d882b93 100644 --- a/src/Beutl.Extensions.Voice/Services/VoiceVoxInstaller.cs +++ b/src/Beutl.Extensions.Voice/Services/VoiceVoxInstaller.cs @@ -11,6 +11,8 @@ namespace Beutl.Extensions.Voice.Services; public class VoiceVoxInstaller { private readonly ILogger _logger = Log.CreateLogger(); + private static readonly TimeSpan s_httpHeaderTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan s_downloadIdleTimeout = TimeSpan.FromMinutes(2); public ReactiveProperty Progress { get; } = new(0); @@ -28,20 +30,35 @@ public Task Install(CancellationToken ct) { return Task.Run(async () => { + string? voicevoxHomePath = null; + string? tempVoicevoxHomePath = 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のインストール検証に失敗しました。"); + } + if (Directory.Exists(voicevoxHomePath)) { Directory.Delete(voicevoxHomePath, true); } - Directory.CreateDirectory(voicevoxHomePath); - await InstallVoiceVoxCore(voicevoxHomePath, ct); - await InstallDictionary(voicevoxHomePath, ct); - await InstallOnnxRuntime(voicevoxHomePath, ct); - await InstallVoiceModels(voicevoxHomePath, ct); + MoveDirectoryCrossDevice(tempVoicevoxHomePath, voicevoxHomePath); + tempVoicevoxHomePath = null; + Status.Value = "ロード中 (8/8)"; IsIndeterminate.Value = true; await TtsLoader.StaticLoad(); @@ -50,6 +67,18 @@ public Task Install(CancellationToken ct) } catch (Exception ex) { + if (tempVoicevoxHomePath != null) + { + DeleteDirectoryIfExists(tempVoicevoxHomePath); + } + + if (voicevoxHomePath != null + && Directory.Exists(voicevoxHomePath) + && !VoiceVoxLoader.IsValidInstallation(voicevoxHomePath)) + { + DeleteDirectoryIfExists(voicevoxHomePath); + } + Error.Value = ex.Message; _logger.LogError(ex, "Failed to install voicevox_core"); } @@ -63,7 +92,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(); @@ -75,40 +104,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? 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); } @@ -271,7 +358,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( @@ -337,7 +424,7 @@ 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; } @@ -345,6 +432,21 @@ private async Task InstallVoiceModels(string voicevoxHomePath, CancellationToken _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 @@ -413,4 +515,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"; } -} \ No newline at end of file +} diff --git a/src/Beutl.Extensions.Voice/Services/VoiceVoxLoader.cs b/src/Beutl.Extensions.Voice/Services/VoiceVoxLoader.cs index b3c49ff..1f898f9 100644 --- a/src/Beutl.Extensions.Voice/Services/VoiceVoxLoader.cs +++ b/src/Beutl.Extensions.Voice/Services/VoiceVoxLoader.cs @@ -33,13 +33,52 @@ public class VoiceVoxLoader(string voicevoxHomePath) public bool IsInstalled { get; private set; } + internal static bool IsValidInstallation(string voicevoxHomePath) + { + try + { + return Directory.Exists(voicevoxHomePath) + && File.Exists(GetVoiceVoxCoreLibraryPath(voicevoxHomePath)) + && HasDirectoryEntries(Path.Combine(voicevoxHomePath, "open_jtalk")) + && File.Exists(GetOnnxRuntimeLibraryPath(voicevoxHomePath)) + && Directory.EnumerateFiles(Path.Combine(voicevoxHomePath, "models"), "*.vvm").Any(); + } + catch + { + return false; + } + } + + private static bool HasDirectoryEntries(string path) + { + return Directory.Exists(path) && Directory.EnumerateFileSystemEntries(path).Any(); + } + + private static string GetVoiceVoxCoreLibraryPath(string voicevoxHomePath) + { + return Path.Combine(voicevoxHomePath, "core", "lib", + OperatingSystem.IsWindows() ? "voicevox_core.dll" + : OperatingSystem.IsLinux() ? "libvoicevox_core.so" + : OperatingSystem.IsMacOS() ? "libvoicevox_core.dylib" + : throw new PlatformNotSupportedException()); + } + + private static string GetOnnxRuntimeLibraryPath(string voicevoxHomePath) + { + return Path.Combine(voicevoxHomePath, "onnxruntime", "lib", + OperatingSystem.IsWindows() ? "voicevox_onnxruntime.dll" + : OperatingSystem.IsLinux() ? "libvoicevox_onnxruntime.so" + : OperatingSystem.IsMacOS() ? "libvoicevox_onnxruntime.dylib" + : throw new PlatformNotSupportedException("Unsupported OS")); + } + public void Load() { try { - if (!Directory.Exists(voicevoxHomePath)) + if (!IsValidInstallation(voicevoxHomePath)) { - _logger.LogError("voicevox directory not found"); + _logger.LogError("voicevox installation is missing or incomplete"); IsInstalled = false; InitializationTcs.TrySetResult(false); return; @@ -56,11 +95,7 @@ public void Load() _logger.LogInformation("Resolving native library: {Name}", name); if (name == "voicevox_core") { - var path = Path.Combine(voicevoxHomePath, "core", "lib", - OperatingSystem.IsWindows() ? "voicevox_core.dll" - : OperatingSystem.IsLinux() ? "libvoicevox_core.so" - : OperatingSystem.IsMacOS() ? "libvoicevox_core.dylib" - : throw new PlatformNotSupportedException()); + var path = GetVoiceVoxCoreLibraryPath(voicevoxHomePath); if (NativeLibrary.TryLoad(path, out var lib)) { return lib; @@ -84,11 +119,7 @@ public void Load() return; } - var onnxRuntimePath = Path.Combine(voicevoxHomePath, "onnxruntime", "lib", - OperatingSystem.IsWindows() ? "voicevox_onnxruntime.dll" - : OperatingSystem.IsLinux() ? "libvoicevox_onnxruntime.so" - : OperatingSystem.IsMacOS() ? "libvoicevox_onnxruntime.dylib" - : throw new PlatformNotSupportedException("Unsupported OS")); + var onnxRuntimePath = GetOnnxRuntimeLibraryPath(voicevoxHomePath); var loadOnnxruntimeOptions = new LoadOnnxruntimeOptions(onnxRuntimePath); result = Onnxruntime.LoadOnce(loadOnnxruntimeOptions, out var onnxruntime); Onnxruntime = onnxruntime; @@ -128,7 +159,18 @@ public void Load() continue; } - var metadatas = JsonSerializer.Deserialize(voiceModel.MetasJson); + VoiceMetadata[]? metadatas; + try + { + metadatas = JsonSerializer.Deserialize(voiceModel.MetasJson); + } + catch (JsonException ex) + { + _logger.LogError(ex, "Failed to deserialize VoiceMetadata: {Path}", path); + voiceModel.Dispose(); + continue; + } + if (metadatas == null) { _logger.LogError("Failed to deserialize VoiceMetadata: {Path}", path); @@ -219,4 +261,4 @@ public void Unload() OpenJtalk = null; IsLoaded = false; } -} \ No newline at end of file +} diff --git a/src/Beutl.Extensions.Voice/TtsLoader.cs b/src/Beutl.Extensions.Voice/TtsLoader.cs index e9046c2..a683668 100644 --- a/src/Beutl.Extensions.Voice/TtsLoader.cs +++ b/src/Beutl.Extensions.Voice/TtsLoader.cs @@ -4,6 +4,7 @@ using Beutl.Extensions.Voice.Views; using Beutl.Logging; using Beutl.Services; +using Avalonia.Threading; using Microsoft.Extensions.Logging; using Reactive.Bindings; @@ -22,17 +23,23 @@ public class TtsLoader : Extension public override void Load() { base.Load(); - StaticLoad().ContinueWith(t => + _ = StaticLoad().ContinueWith(t => { - if (VoiceVoxLoader.Value?.IsInstalled != true) + if (t.IsFaulted) { - NotificationService.ShowWarning( - title: "警告", - message:"VOICEVOXがインストールされていません。", - actionButtonText: "インストール", - onActionButtonClick: ShowInstallDialog); + _logger.LogError(t.Exception, "Failed to load TTS"); } - }); + + if (!t.IsFaulted && VoiceVoxLoader.Value?.IsInstalled != true) + { + Dispatcher.UIThread.Post(() => + NotificationService.ShowWarning( + title: "警告", + message: "VOICEVOXがインストールされていません。", + actionButtonText: "インストール", + onActionButtonClick: ShowInstallDialog)); + } + }, CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default); } private async void ShowInstallDialog() @@ -49,4 +56,4 @@ public static Task StaticLoad() VoiceVoxLoader.Value = new VoiceVoxLoader(voicevoxHomePath); return Task.Run(() => VoiceVoxLoader.Value.Load()); } -} \ No newline at end of file +} diff --git a/src/Beutl.Extensions.Voice/ViewModels/TtsTabViewModel.cs b/src/Beutl.Extensions.Voice/ViewModels/TtsTabViewModel.cs index d36ed56..a538b53 100644 --- a/src/Beutl.Extensions.Voice/ViewModels/TtsTabViewModel.cs +++ b/src/Beutl.Extensions.Voice/ViewModels/TtsTabViewModel.cs @@ -14,6 +14,7 @@ using Beutl.Editor.Services; using Beutl.Extensions.Voice.Internals; using Beutl.Serialization; +using Beutl.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Reactive.Bindings; @@ -30,6 +31,7 @@ public class TtsTabViewModel : IToolContext private IReactiveProperty _currentTime; private HistoryManager _historyManager; private TaskCompletionSource _initTcs = new(); + private int _busy; private static readonly JsonSerializerOptions s_jsonOptions = new() { @@ -91,10 +93,18 @@ public TtsTabViewModel(TtsTabExtension extension, IEditorContext editorContext) public void OnLoaded() { var loader = TtsLoader.VoiceVoxLoader.Value; - if (loader == null) return; + if (loader == null) + { + _initTcs.TrySetResult(); + return; + } IsVoiceVoxInstalled.Value = loader.IsInstalled; - if (!loader.IsLoaded) return; + if (!loader.IsLoaded) + { + _initTcs.TrySetResult(); + return; + } IsEnabled.Value = true; var a = loader.VoiceSets @@ -102,72 +112,136 @@ public void OnLoaded() var b = a.SelectMany(x => x.Metadata.Styles.Select(y => (x.Metadata, Style: y))); - Voice.Value = b.GroupBy(x => x.Metadata.Name, x => x.Style, - (x, y) => new VoiceMetadata { Name = x, Styles = y.ToArray() }) + Voice.Value = b.GroupBy(x => x.Metadata.Name, x => x, + (x, y) => + { + var items = y.ToArray(); + var metadata = items[0].Metadata; + return new VoiceMetadata + { + Name = x, + Version = metadata.Version, + SpeakerUuid = metadata.SpeakerUuid, + Styles = items.Select(z => z.Style).ToArray() + }; + }) .ToArray(); - _initTcs.SetResult(); + _initTcs.TrySetResult(); } public Task CreateQuery() { return Task.Run(() => { + if (Interlocked.Exchange(ref _busy, 1) == 1) return; + try { IsGenerating.Value = true; - var loader = TtsLoader.VoiceVoxLoader.Value; - var synthesizer = loader?.Synthesizer; - var voice = SelectedVoice.Value; - var style = SelectedStyle.Value ?? voice?.Styles.FirstOrDefault(); - if (loader == null || synthesizer == null || style == null || string.IsNullOrWhiteSpace(Text.Value)) - { - _logger.LogError("Synthesizer/style/text is not ready"); - return; - } + var query = CreateQueryCore(); + if (query == null) return; - if (!loader.EnsureVoiceModelLoaded(style.Id)) - { - _logger.LogError("Failed to load voice model for style {StyleId}", style.Id); - return; - } - - var result = synthesizer.CreateAudioQuery(Text.Value, style.Id, out var audioQueryJson); - if (result != ResultCode.RESULT_OK || audioQueryJson == null) - { - _logger.LogError("Failed to create AudioQuery: {Result}", result.ToMessage()); - return; - } - - var query = JsonSerializer.Deserialize(audioQueryJson, s_jsonOptions); - if (query == null) - { - _logger.LogError("Failed to deserialize AudioQuery"); - return; - } - - Dispatcher.UIThread.Post(() => - { - AudioQuery.Value = query; - HasAudioQuery.Value = true; - }); - - _logger.LogInformation("AudioQuery created with {Count} accent phrases", - query.AccentPhrases.Count); + UpdateAudioQuery(query); } catch (Exception ex) { _logger.LogError(ex, "Failed to create AudioQuery"); + ShowError("AudioQueryの作成に失敗しました。", ex.Message); } finally { IsGenerating.Value = false; + Interlocked.Exchange(ref _busy, 0); } }); } - private string BuildAudioQueryJson() + private AudioQueryModel? CreateQueryCore() + { + try + { + var loader = TtsLoader.VoiceVoxLoader.Value; + var synthesizer = loader?.Synthesizer; + var voice = SelectedVoice.Value; + var style = SelectedStyle.Value ?? voice?.Styles.FirstOrDefault(); + if (loader == null || synthesizer == null) + { + _logger.LogError("Synthesizer is not initialized"); + ShowWarning("VOICEVOXが初期化されていません。"); + return null; + } + + if (style == null) + { + _logger.LogError("Style is not selected"); + ShowWarning("話者またはスタイルを選択してください。"); + return null; + } + + if (string.IsNullOrWhiteSpace(Text.Value)) + { + _logger.LogError("Text is empty"); + ShowWarning("テキストを入力してください。"); + return null; + } + + if (!loader.EnsureVoiceModelLoaded(style.Id)) + { + _logger.LogError("Failed to load voice model for style {StyleId}", style.Id); + ShowError("音声モデルのロードに失敗しました。", $"Style ID: {style.Id}"); + return null; + } + + var result = synthesizer.CreateAudioQuery(Text.Value, style.Id, out var audioQueryJson); + if (result != ResultCode.RESULT_OK || audioQueryJson == null) + { + var message = result.ToMessage(); + _logger.LogError("Failed to create AudioQuery: {Result}", message); + ShowError("AudioQueryの作成に失敗しました。", message); + return null; + } + + var query = JsonSerializer.Deserialize(audioQueryJson, s_jsonOptions); + if (query == null) + { + _logger.LogError("Failed to deserialize AudioQuery"); + ShowError("AudioQueryの読み込みに失敗しました。", "VOICEVOXの応答を解析できませんでした。"); + return null; + } + + _logger.LogInformation("AudioQuery created with {Count} accent phrases", + query.AccentPhrases.Count); + return query; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create AudioQuery"); + ShowError("AudioQueryの作成に失敗しました。", ex.Message); + return null; + } + } + + private void UpdateAudioQuery(AudioQueryModel query) + { + Dispatcher.UIThread.Post(() => + { + AudioQuery.Value = query; + HasAudioQuery.Value = true; + }); + } + + private static void ShowWarning(string message) + { + Dispatcher.UIThread.Post(() => NotificationService.ShowWarning("警告", message)); + } + + private static void ShowError(string title, string message) + { + Dispatcher.UIThread.Post(() => NotificationService.ShowError(title, message)); + } + + private string BuildAudioQueryJson(AudioQueryModel query) { - var query = AudioQuery.Value!; query.SpeedScale = SpeedScale.Value; query.PitchScale = PitchScale.Value; query.IntonationScale = IntonationScale.Value; @@ -181,18 +255,22 @@ public Task Generate() { return Task.Run(async () => { + if (Interlocked.Exchange(ref _busy, 1) == 1) return; + try { IsGenerating.Value = true; // AudioQueryがない場合は先に作成 - if (AudioQuery.Value == null) + var query = AudioQuery.Value; + if (query == null) { - await CreateQuery(); - if (AudioQuery.Value == null) return; + query = CreateQueryCore(); + if (query == null) return; + UpdateAudioQuery(query); } - var outputWave = SynthesisFromQuery(); + var outputWave = SynthesisFromQuery(query); if (outputWave == null) { return; @@ -221,7 +299,7 @@ public Task Generate() Start = _currentTime.Value, ZIndex = 1, Uri = RandomFileNameGenerator.GenerateUri(_scene.Uri!, "belm"), - Name = Text.Value.ReplaceLineEndings().Replace("\n", " "), + Name = Text.Value.ReplaceLineEndings(" "), AccentColor = ColorGenerator.GenerateColor(null, typeof(TtsController).FullName!) }; var obj1 = new SourceSound(); @@ -244,10 +322,12 @@ await Dispatcher.UIThread.InvokeAsync(() => catch (Exception ex) { _logger.LogError(ex, "Failed to generate TTS"); + ShowError("音声生成に失敗しました。", ex.Message); } finally { IsGenerating.Value = false; + Interlocked.Exchange(ref _busy, 0); } }); } @@ -256,18 +336,22 @@ public Task Play() { return Task.Run(async () => { + if (Interlocked.Exchange(ref _busy, 1) == 1) return; + try { IsGenerating.Value = true; // AudioQueryがない場合は先に作成 - if (AudioQuery.Value == null) + var query = AudioQuery.Value; + if (query == null) { - await CreateQuery(); - if (AudioQuery.Value == null) return; + query = CreateQueryCore(); + if (query == null) return; + UpdateAudioQuery(query); } - var outputWave = SynthesisFromQuery(); + var outputWave = SynthesisFromQuery(query); if (outputWave == null) { return; @@ -285,15 +369,17 @@ public Task Play() catch (Exception ex) { _logger.LogError(ex, "Failed to play TTS"); + ShowError("音声再生に失敗しました。", ex.Message); } finally { IsGenerating.Value = false; + Interlocked.Exchange(ref _busy, 0); } }); } - private byte[]? SynthesisFromQuery() + private byte[]? SynthesisFromQuery(AudioQueryModel query) { try { @@ -304,23 +390,27 @@ public Task Play() if (loader == null || synthesizer == null || style == null) { _logger.LogError("Synthesizer or style is not initialized"); + ShowWarning("VOICEVOX、話者、またはスタイルが初期化されていません。"); return null; } if (!loader.EnsureVoiceModelLoaded(style.Id)) { _logger.LogError("Failed to load voice model for style {StyleId}", style.Id); + ShowError("音声モデルのロードに失敗しました。", $"Style ID: {style.Id}"); return null; } - var audioQueryJson = BuildAudioQueryJson(); + var audioQueryJson = BuildAudioQueryJson(query); var result = synthesizer.Synthesis( audioQueryJson, style.Id, SynthesisOptions.Default(), out var outputWavSize, out var outputWav); if (result != ResultCode.RESULT_OK) { - _logger.LogError("Failed to synthesize: {Result}", result.ToMessage()); + var message = result.ToMessage(); + _logger.LogError("Failed to synthesize: {Result}", message); + ShowError("音声合成に失敗しました。", message); return null; } @@ -329,6 +419,7 @@ public Task Play() catch (Exception ex) { _logger.LogError(ex, "Failed to synthesize from AudioQuery"); + ShowError("音声合成に失敗しました。", ex.Message); return null; } } @@ -368,4 +459,4 @@ public async void ReadFromJson(JsonObject json) { return null; } -} \ No newline at end of file +} From c6ec17a7fa6d895ca51aec82a494225b38de495c Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Tue, 23 Jun 2026 15:12:34 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?safer=20install=20swap,=20dict=20validation,=20guaranteed=20ini?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to automated review feedback (CodeRabbit / Codex) on the PR: - VoiceVoxInstaller: make the staged-directory activation rollback-safe. Rename the existing install to a backup before moving the staged build into place, restore it if the move fails, and only delete the backup after a successful swap — so a failed update can no longer leave the user without a working installation. - VoiceVoxLoader: validate the open_jtalk dictionary by required artifacts (sys.dic, char.bin, matrix.bin, unk.dic) instead of only checking that the directory is non-empty, so a half-extracted dictionary is not accepted as a valid install. - TtsTabViewModel.OnLoaded: wrap the body in try/catch/finally and complete _initTcs from the finally so ReadFromJson can never hang awaiting it, even if voice-metadata materialization throws. Build: 0 warnings, 0 errors (net10.0). Claude-Session: https://claude.ai/code/session_01F5pRvtNeKb5nagNh83hWRz --- .../Services/VoiceVoxInstaller.cs | 43 +++++++++++- .../Services/VoiceVoxLoader.cs | 10 ++- .../ViewModels/TtsTabViewModel.cs | 67 +++++++++++-------- 3 files changed, 85 insertions(+), 35 deletions(-) diff --git a/src/Beutl.Extensions.Voice/Services/VoiceVoxInstaller.cs b/src/Beutl.Extensions.Voice/Services/VoiceVoxInstaller.cs index d882b93..f99e28b 100644 --- a/src/Beutl.Extensions.Voice/Services/VoiceVoxInstaller.cs +++ b/src/Beutl.Extensions.Voice/Services/VoiceVoxInstaller.cs @@ -32,6 +32,7 @@ public Task Install(CancellationToken ct) { string? voicevoxHomePath = null; string? tempVoicevoxHomePath = null; + string? backupVoicevoxHomePath = null; try { var home = BeutlEnvironment.GetHomeDirectoryPath(); @@ -51,13 +52,40 @@ public Task Install(CancellationToken ct) 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); } - MoveDirectoryCrossDevice(tempVoicevoxHomePath, voicevoxHomePath); - tempVoicevoxHomePath = null; + try + { + MoveDirectoryCrossDevice(tempVoicevoxHomePath, voicevoxHomePath); + tempVoicevoxHomePath = null; + DeleteDirectoryIfExists(backupVoicevoxHomePath); + backupVoicevoxHomePath = null; + } + catch + { + if (Directory.Exists(backupVoicevoxHomePath)) + { + if (Directory.Exists(voicevoxHomePath) + && !VoiceVoxLoader.IsValidInstallation(voicevoxHomePath)) + { + DeleteDirectoryIfExists(voicevoxHomePath); + } + + if (!Directory.Exists(voicevoxHomePath)) + { + MoveDirectoryCrossDevice(backupVoicevoxHomePath, voicevoxHomePath); + backupVoicevoxHomePath = null; + } + } + + throw; + } Status.Value = "ロード中 (8/8)"; IsIndeterminate.Value = true; @@ -79,6 +107,15 @@ public Task Install(CancellationToken ct) 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"); } diff --git a/src/Beutl.Extensions.Voice/Services/VoiceVoxLoader.cs b/src/Beutl.Extensions.Voice/Services/VoiceVoxLoader.cs index 1f898f9..2cd0570 100644 --- a/src/Beutl.Extensions.Voice/Services/VoiceVoxLoader.cs +++ b/src/Beutl.Extensions.Voice/Services/VoiceVoxLoader.cs @@ -39,7 +39,7 @@ internal static bool IsValidInstallation(string voicevoxHomePath) { return Directory.Exists(voicevoxHomePath) && File.Exists(GetVoiceVoxCoreLibraryPath(voicevoxHomePath)) - && HasDirectoryEntries(Path.Combine(voicevoxHomePath, "open_jtalk")) + && HasOpenJtalkDictionary(voicevoxHomePath) && File.Exists(GetOnnxRuntimeLibraryPath(voicevoxHomePath)) && Directory.EnumerateFiles(Path.Combine(voicevoxHomePath, "models"), "*.vvm").Any(); } @@ -49,9 +49,13 @@ internal static bool IsValidInstallation(string voicevoxHomePath) } } - private static bool HasDirectoryEntries(string path) + private static bool HasOpenJtalkDictionary(string voicevoxHomePath) { - return Directory.Exists(path) && Directory.EnumerateFileSystemEntries(path).Any(); + var openJtalkPath = Path.Combine(voicevoxHomePath, "open_jtalk"); + return File.Exists(Path.Combine(openJtalkPath, "sys.dic")) + && File.Exists(Path.Combine(openJtalkPath, "char.bin")) + && File.Exists(Path.Combine(openJtalkPath, "matrix.bin")) + && File.Exists(Path.Combine(openJtalkPath, "unk.dic")); } private static string GetVoiceVoxCoreLibraryPath(string voicevoxHomePath) diff --git a/src/Beutl.Extensions.Voice/ViewModels/TtsTabViewModel.cs b/src/Beutl.Extensions.Voice/ViewModels/TtsTabViewModel.cs index a538b53..482f46b 100644 --- a/src/Beutl.Extensions.Voice/ViewModels/TtsTabViewModel.cs +++ b/src/Beutl.Extensions.Voice/ViewModels/TtsTabViewModel.cs @@ -92,41 +92,50 @@ public TtsTabViewModel(TtsTabExtension extension, IEditorContext editorContext) public void OnLoaded() { - var loader = TtsLoader.VoiceVoxLoader.Value; - if (loader == null) + try { - _initTcs.TrySetResult(); - return; - } + var loader = TtsLoader.VoiceVoxLoader.Value; + if (loader == null) + { + return; + } - IsVoiceVoxInstalled.Value = loader.IsInstalled; - if (!loader.IsLoaded) - { - _initTcs.TrySetResult(); - return; - } + IsVoiceVoxInstalled.Value = loader.IsInstalled; + if (!loader.IsLoaded) + { + return; + } - IsEnabled.Value = true; - var a = loader.VoiceSets - .SelectMany(x => x.Metadata.Select(y => new VoiceFlattenSet(x.Model, y))); + IsEnabled.Value = true; + var a = loader.VoiceSets + .SelectMany(x => x.Metadata.Select(y => new VoiceFlattenSet(x.Model, y))); - var b = a.SelectMany(x => x.Metadata.Styles.Select(y => (x.Metadata, Style: y))); + var b = a.SelectMany(x => x.Metadata.Styles.Select(y => (x.Metadata, Style: y))); - Voice.Value = b.GroupBy(x => x.Metadata.Name, x => x, - (x, y) => - { - var items = y.ToArray(); - var metadata = items[0].Metadata; - return new VoiceMetadata + Voice.Value = b.GroupBy(x => x.Metadata.Name, x => x, + (x, y) => { - Name = x, - Version = metadata.Version, - SpeakerUuid = metadata.SpeakerUuid, - Styles = items.Select(z => z.Style).ToArray() - }; - }) - .ToArray(); - _initTcs.TrySetResult(); + var items = y.ToArray(); + var metadata = items[0].Metadata; + return new VoiceMetadata + { + Name = x, + Version = metadata.Version, + SpeakerUuid = metadata.SpeakerUuid, + Styles = items.Select(z => z.Style).ToArray() + }; + }) + .ToArray(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to initialize VOICEVOX"); + ShowError("VOICEVOXの初期化に失敗しました。", ex.Message); + } + finally + { + _initTcs.TrySetResult(); + } } public Task CreateQuery()