diff --git a/src/ui/Features/Video/SpeechToText/MissingSharedLibrary.cs b/src/ui/Features/Video/SpeechToText/MissingSharedLibrary.cs index 2cc037b70ec..9e3dec8bfeb 100644 --- a/src/ui/Features/Video/SpeechToText/MissingSharedLibrary.cs +++ b/src/ui/Features/Video/SpeechToText/MissingSharedLibrary.cs @@ -45,6 +45,46 @@ public static class MissingSharedLibrary return name.Length == 0 ? null : name; } + /// + /// True for libraries that are part of an engine download rather than something the user + /// installs from their distro. A missing one means the engine folder is incomplete - which + /// is what happened to the whisper.cpp Linux archives in issue #13680, where whisper-cli + /// shipped without the libwhisper.so.1 and libggml.so.0 it links against. + /// + public static bool IsBundledWithEngine(string? libraryName) + { + if (string.IsNullOrEmpty(libraryName)) + { + return false; + } + + // dyld reports the full install name, e.g. "@rpath/libwhisper.1.dylib" + var name = libraryName; + var slash = name.LastIndexOfAny(new[] { '/', '\\' }); + if (slash >= 0) + { + name = name.Substring(slash + 1); + } + + foreach (var prefix in BundledLibraryPrefixes) + { + if (name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static readonly string[] BundledLibraryPrefixes = + { + "libwhisper", // whisper.cpp + "libggml", // whisper.cpp / qwen3-asr.cpp / chatllm.cpp - the ggml core and its backends + "libllama", // llama.cpp based engines + "libmtmd", // llama.cpp multimodal helper + }; + private static string? GetAfterMarker(string line, string marker) { var index = line.IndexOf(marker, StringComparison.OrdinalIgnoreCase); diff --git a/src/ui/Features/Video/SpeechToText/SpeechToTextViewModel.cs b/src/ui/Features/Video/SpeechToText/SpeechToTextViewModel.cs index dee36de04ac..19de080f3aa 100644 --- a/src/ui/Features/Video/SpeechToText/SpeechToTextViewModel.cs +++ b/src/ui/Features/Video/SpeechToText/SpeechToTextViewModel.cs @@ -946,6 +946,18 @@ private void ProcessChatLlmTranscription(SeAudioToText settings, ChatLlmCppEngin /// private static string GetMissingSharedLibraryMessage(string libraryName) { + // Libraries that ship inside the engine download itself. Telling the user to install + // these with their package manager is a dead end - no distro packages them, and the + // real cause is a bad or incomplete engine folder (issue #13680). + if (MissingSharedLibrary.IsBundledWithEngine(libraryName)) + { + return + $"The speech to text engine could not start - the shared library \"{libraryName}\" is missing.{Environment.NewLine}{Environment.NewLine}" + + "This library is part of the engine download, so the installed engine is incomplete." + + $"{Environment.NewLine}{Environment.NewLine}" + + "Re-download the engine (Download button next to the engine) and try again."; + } + var message = $"The speech to text engine could not start - the shared library \"{libraryName}\" is missing.{Environment.NewLine}{Environment.NewLine}" + "Install it with your package manager and try again."; @@ -3674,6 +3686,26 @@ private static Process StartEngineProcess(string executable, string arguments, D return p; } + /// + /// Puts the engine folder on the dynamic loader's search path, so an engine that ships its + /// own shared libraries next to the executable can find them. + /// + /// Windows resolves DLLs from the executable's own directory, so this is a Linux/macOS-only + /// concern - and there setting WorkingDirectory is not enough, because the loader does not + /// search the working directory. The libraries are supposed to carry an $ORIGIN/@loader_path + /// RPATH that makes this unnecessary, but whisper.cpp archives shipped for four releases + /// without one (issue #13680), so set it as a belt-and-braces measure. + /// + private static void AddEngineFolderToLibrarySearchPath(ProcessStartInfo startInfo, string engineFolder) + { + var variable = OperatingSystem.IsMacOS() ? "DYLD_LIBRARY_PATH" : "LD_LIBRARY_PATH"; + var existing = startInfo.EnvironmentVariables[variable]; + + startInfo.EnvironmentVariables[variable] = string.IsNullOrEmpty(existing) + ? engineFolder + : engineFolder + Path.PathSeparator + existing; + } + private Process GetWhisperProcess( ISpeechToTextEngine engine, string waveFileName, @@ -3911,6 +3943,10 @@ or WhisperChoice.CppVulkan or WhisperChoice.CppCuBlasLib or WhisperChoice.ConstM process.StartInfo.EnvironmentVariables["Path"]?.TrimEnd(';') + ";" + whisperFolder; } } + else if (!string.IsNullOrEmpty(whisperFolder)) + { + AddEngineFolderToLibrarySearchPath(process.StartInfo, whisperFolder); + } if (settings.WhisperChoice != WhisperChoice.Cpp && settings.WhisperChoice != WhisperChoice.CppCuBlas && diff --git a/src/ui/Logic/Download/DownloadHashManager.cs b/src/ui/Logic/Download/DownloadHashManager.cs index f94a35722bb..506fa60ede1 100644 --- a/src/ui/Logic/Download/DownloadHashManager.cs +++ b/src/ui/Logic/Download/DownloadHashManager.cs @@ -297,12 +297,17 @@ public static class WhisperCpp // Hashes of the unpacked main executable (whisper-cli / whisper-cli.exe) — used to detect // the installed version when no sidecar is present (e.g. installs from older SE builds). - // Linux Vulkan and Linux CUDA produce identical whisper-cli binaries (the backend lives in - // libggml-*.so), so executable-hash fallback is intentionally Windows + Mac only. + // The Linux Vulkan and CUDA builds shipped an identical whisper-cli up to v1.8.4 (the + // backend lives in libggml-*.so), and they have differed since v1.8.5. Either way the + // lookup is unambiguous: the key comes from the engine choice being asked about, and each + // backend has its own install folder, so a hash shared by both lists is simply listed + // under both. public const string WindowsBlasExecutable = "WhisperCpp.Windows.Blas.Executable"; public const string WindowsCuBlasExecutable = "WhisperCpp.Windows.CuBlas.Executable"; public const string WindowsVulkanExecutable = "WhisperCpp.Windows.Vulkan.Executable"; public const string MacOsExecutable = "WhisperCpp.MacOs.Executable"; + public const string LinuxVulkanExecutable = "WhisperCpp.Linux.Vulkan.Executable"; + public const string LinuxCudaExecutable = "WhisperCpp.Linux.Cuda.Executable"; } // For each key, hashes are ordered newest-first. Index 0 is the latest known release. @@ -1818,19 +1823,25 @@ public static class WhisperCpp "49ef4acfaef0b4989885c258f22eb1355592c5f343897508899a2b598cd683bf", // whispercpp-185 / v1.8.5 "81dbd530f21a6daf10b1c9cece61d7e56d774e3bcb3d21af4d17299caa532a4d", // whispercpp-184 / v1.8.4 }, + // whispercpp-184 through -191 shipped whisper-cli without the libwhisper.so.1 / + // libggml.so.0 it links against, so every one of those installs is broken and the + // user needs the -r2 rebuild (issue #13680). They stay listed so an existing install + // is still identified rather than reported as unknown. [WhisperCpp.LinuxVulkan] = new[] { - "7969c5a0ba912d0b0d8aaa2bdf911ca7896ef97a89e293d2596a96022c839e80", // whispercpp-191 / v1.9.1 (current download URL) - "10aed3a2b28e5ad40fee8267d554f0824943e50e75bfe7bceb7c16b6e3fe8a45", // whispercpp-186 / v1.8.6 - "c385a01228f85764cfd9b6072e078d03e44f151d71ab145e912425e5cc9a7c8e", // whispercpp-185 / v1.8.5 - "7a7d131b5fbb605fef6a8d39b6f2480480d8a26ec8a1dbec3b3740485023158d", // whispercpp-184 / v1.8.4 + "0943f97f58ca98aafa26659e7c4d8d87f474c74f966f0d9ae91491989a32cf2b", // whispercpp-191-r2 / v1.9.1 (current download URL) + "7969c5a0ba912d0b0d8aaa2bdf911ca7896ef97a89e293d2596a96022c839e80", // whispercpp-191 / v1.9.1 (missing shared libraries) + "10aed3a2b28e5ad40fee8267d554f0824943e50e75bfe7bceb7c16b6e3fe8a45", // whispercpp-186 / v1.8.6 (missing shared libraries) + "c385a01228f85764cfd9b6072e078d03e44f151d71ab145e912425e5cc9a7c8e", // whispercpp-185 / v1.8.5 (missing shared libraries) + "7a7d131b5fbb605fef6a8d39b6f2480480d8a26ec8a1dbec3b3740485023158d", // whispercpp-184 / v1.8.4 (missing shared libraries) }, [WhisperCpp.LinuxCuda] = new[] { - "19a232255838c77c9bcddf220292d96dfb62b9a8da1e66ed402961f6a41b1661", // whispercpp-191 / v1.9.1 (current download URL) - "b8922f7fc25ff4f602c887655882c4114c005177017335f39181cc0417f0cb03", // whispercpp-186 / v1.8.6 - "1aee5fddee30f8486275d3b2bd1d568e92615e7b1b063d46c635cb9f44d7d13b", // whispercpp-185 / v1.8.5 - "708ea1c502ac5082d4eb9afc86c8adb9d67d76da25e70fd81cb6fae2cfcf00ce", // whispercpp-184 / v1.8.4 + "02129bac653d00d85ef110c24c37eb56d5470d4fee432a09f55ac9fbc63ff768", // whispercpp-191-r2 / v1.9.1 (current download URL) + "19a232255838c77c9bcddf220292d96dfb62b9a8da1e66ed402961f6a41b1661", // whispercpp-191 / v1.9.1 (missing shared libraries) + "b8922f7fc25ff4f602c887655882c4114c005177017335f39181cc0417f0cb03", // whispercpp-186 / v1.8.6 (missing shared libraries) + "1aee5fddee30f8486275d3b2bd1d568e92615e7b1b063d46c635cb9f44d7d13b", // whispercpp-185 / v1.8.5 (missing shared libraries) + "708ea1c502ac5082d4eb9afc86c8adb9d67d76da25e70fd81cb6fae2cfcf00ce", // whispercpp-184 / v1.8.4 (missing shared libraries) }, // SHA-256 of whisper-cli / whisper-cli.exe extracted from each archive above. @@ -1862,6 +1873,25 @@ public static class WhisperCpp "0fd752e0384484eb3a72ce644135f20963879e80624b23f7f739eda187a23359", // whispercpp-185 / v1.8.5 "11d902af004d1e79538f8f801b4a42ac3a21094370c9063f67d885d04dccdd96", // whispercpp-184 / v1.8.4 }, + + // The v1.8.4 hash is the same in both Linux lists on purpose - that release shipped a + // single whisper-cli for both backends. See the note on LinuxVulkanExecutable. + [WhisperCpp.LinuxVulkanExecutable] = new[] + { + "da61c0c1910c103cf8dea855855f072249689ee2310f375e2b79615cbd012c05", // whispercpp-191-r2 / v1.9.1 (current download URL) + "782fac61b9bcfe8f6db22564bb5a2cda2c22550b9ef38064ec5f188bc86dfe79", // whispercpp-191 / v1.9.1 (missing shared libraries) + "5a2343777fe57327c8956d836d1515ac422d2e7f9fd33ce4e7e62cfe4cd33cbd", // whispercpp-186 / v1.8.6 (missing shared libraries) + "e9d5318d513f9dc23ae9fe35cd2783e8def5c66642079dc6a5074b9e5f0cd61c", // whispercpp-185 / v1.8.5 (missing shared libraries) + "315f46514fc09a4fefe2f7b6ae95cfac5441a03b8be04eed1b5f567d5ccc38de", // whispercpp-184 / v1.8.4 (missing shared libraries) + }, + [WhisperCpp.LinuxCudaExecutable] = new[] + { + "3a4d717745c2d8cf19ca7a954c29c0349a7f158d93a6597bb86e155f0735474a", // whispercpp-191-r2 / v1.9.1 (current download URL) + "16a838ae67e248020b9bc65b8584fcc113cf18167c9cfc0a2d98e194fa52cf95", // whispercpp-191 / v1.9.1 (missing shared libraries) + "330ff60cbabd8e77137000d05905cabca90e299c455a3282948c9edddf28bcd7", // whispercpp-186 / v1.8.6 (missing shared libraries) + "33bad46e07f0d9bb64fc03ca8c7047b212482c1f45536290f80bb0fe716c9775", // whispercpp-185 / v1.8.5 (missing shared libraries) + "315f46514fc09a4fefe2f7b6ae95cfac5441a03b8be04eed1b5f567d5ccc38de", // whispercpp-184 / v1.8.4 (missing shared libraries) + }, }; /// @@ -2432,9 +2462,10 @@ public static bool IsQwen3AsrCppVulkanInstall(string installFolder) /// /// Resolves the WhisperCpp executable-hash key for the given engine choice on the current OS. - /// Used as a fallback when no sidecar hash exists alongside the install. - /// Returns null on Linux: the same whisper-cli binary ships in both the Vulkan and CUDA archives, - /// so executable hashing cannot disambiguate. + /// Used as a fallback when no sidecar hash exists alongside the install - without it a Linux + /// install predating the sidecar reports Unknown, so the user is never told their engine is + /// outdated. That mattered for issue #13680, where every Linux build from v1.8.4 to v1.9.1 is + /// broken and the update prompt is how affected users learn to re-download. /// public static string? ResolveWhisperCppExecutableKey(string? whisperChoice) { @@ -2459,6 +2490,18 @@ public static bool IsQwen3AsrCppVulkanInstall(string installFolder) return whisperChoice == WhisperChoice.Cpp ? WhisperCpp.MacOsExecutable : null; } + if (OperatingSystem.IsLinux()) + { + // Same mapping as ResolveWhisperCppKey: the default Cpp backend on Linux is the + // SE-packaged Vulkan build. + return whisperChoice switch + { + WhisperChoice.Cpp => WhisperCpp.LinuxVulkanExecutable, + WhisperChoice.CppCuBlas => WhisperCpp.LinuxCudaExecutable, + _ => null, + }; + } + return null; } diff --git a/src/ui/Logic/Download/WhisperDownloadService.cs b/src/ui/Logic/Download/WhisperDownloadService.cs index 1921e59e456..6755ff59851 100644 --- a/src/ui/Logic/Download/WhisperDownloadService.cs +++ b/src/ui/Logic/Download/WhisperDownloadService.cs @@ -25,11 +25,17 @@ public class WhisperDownloadService : IWhisperDownloadService private const string WindowsUrl = "https://github.com/ggml-org/whisper.cpp/releases/download/v1.9.1/whisper-blas-bin-x64.zip"; private const string MacArmUrl = "https://github.com/SubtitleEdit/support-files/releases/download/whispercpp-191/whisper-mac.zip"; private const string MacX64Url = "https://github.com/SubtitleEdit/support-files/releases/download/whispercpp-191/whisper-mac.zip"; - private const string LinuxUrl = "https://github.com/SubtitleEdit/support-files/releases/download/whispercpp-191/whisper-vulkan-linux64.zip"; + // The Linux archives point at whispercpp-191-r2, not whispercpp-191: the original v1.9.1 + // Linux zips (like v1.8.4 and v1.8.6 before them) shipped whisper-cli without the + // libwhisper.so.1 / libggml.so.0 it links against, so the engine died on startup with + // "error while loading shared libraries" (issue #13680). The -r2 rebuild is the same + // whisper.cpp v1.9.1, repackaged with the libraries under their SONAMEs and $ORIGIN + // RPATHs. Windows and macOS were unaffected and still use whispercpp-191. + private const string LinuxUrl = "https://github.com/SubtitleEdit/support-files/releases/download/whispercpp-191-r2/whisper-vulkan-linux64.zip"; private const string WindowsUrlCuBlass = "https://github.com/ggml-org/whisper.cpp/releases/download/v1.9.1/whisper-cublas-12.4.0-bin-x64.zip"; private const string WindowsUrlCppVulkan = "https://github.com/SubtitleEdit/support-files/releases/download/whispercpp-191/whisper-vulkan-x64.zip"; - private const string LinuxUrlCuBlass = "https://github.com/SubtitleEdit/support-files/releases/download/whispercpp-191/whisper-cuda-linux64.zip"; + private const string LinuxUrlCuBlass = "https://github.com/SubtitleEdit/support-files/releases/download/whispercpp-191-r2/whisper-cuda-linux64.zip"; private const string DownloadUrlConstMe = "https://github.com/Const-me/Whisper/releases/download/1.12.0/cli.zip"; private const string SileroVadUrl = "https://github.com/SubtitleEdit/support-files/releases/download/whispercpp-184/ggml-silero-v6.2.0.zip"; diff --git a/tests/UI/Features/Video/SpeechToText/MissingSharedLibraryTests.cs b/tests/UI/Features/Video/SpeechToText/MissingSharedLibraryTests.cs index e077d0f884a..9c1f7baa687 100644 --- a/tests/UI/Features/Video/SpeechToText/MissingSharedLibraryTests.cs +++ b/tests/UI/Features/Video/SpeechToText/MissingSharedLibraryTests.cs @@ -32,4 +32,38 @@ public void GetName_UnrelatedOutput_ReturnsNull(string? line) { Assert.Null(MissingSharedLibrary.GetName(line)); } + + [Fact] + public void GetName_WhisperCppLoaderError_ReturnsLibraryName() + { + // The exact line from issue #13680 (EndeavourOS, whisper.cpp) + const string line = "/home/user/.config/Subtitle Edit/SpeechToText/Cpp/whisper-cli: " + + "error while loading shared libraries: libwhisper.so.1: " + + "cannot open shared object file: No such file or directory"; + + Assert.Equal("libwhisper.so.1", MissingSharedLibrary.GetName(line)); + } + + [Theory] + [InlineData("libwhisper.so.1")] + [InlineData("libggml.so.0")] + [InlineData("libggml-base.so.0")] + [InlineData("libllama.so")] + [InlineData("@rpath/libwhisper.1.dylib")] + public void IsBundledWithEngine_EngineOwnedLibrary_ReturnsTrue(string libraryName) + { + Assert.True(MissingSharedLibrary.IsBundledWithEngine(libraryName)); + } + + [Theory] + [InlineData("libopenblas.so.0")] + [InlineData("libvulkan.so.1")] + [InlineData("libcudart.so.12")] + [InlineData("libstdc++.so.6")] + [InlineData("")] + [InlineData(null)] + public void IsBundledWithEngine_SystemLibrary_ReturnsFalse(string? libraryName) + { + Assert.False(MissingSharedLibrary.IsBundledWithEngine(libraryName)); + } } diff --git a/tests/UI/Logic/Download/WhisperCppLinuxExecutableHashTests.cs b/tests/UI/Logic/Download/WhisperCppLinuxExecutableHashTests.cs new file mode 100644 index 00000000000..550de92556e --- /dev/null +++ b/tests/UI/Logic/Download/WhisperCppLinuxExecutableHashTests.cs @@ -0,0 +1,90 @@ +using System; +using Nikse.SubtitleEdit.Logic.Download; +using Nikse.SubtitleEdit.UiLogic.AudioToText; + +namespace UITests.Logic.Download; + +/// +/// Every Linux whisper.cpp build from v1.8.4 to v1.9.1 shipped whisper-cli without the +/// libwhisper.so.1 it links against, so the engine could not start at all (issue #13680). The +/// rebuilt whispercpp-191-r2 archives fix that, and these tests pin the path by which an affected +/// user finds out: an install with no .installed.sha256 sidecar is identified by hashing +/// whisper-cli, and a recognised-but-superseded hash is what turns the engine dot amber and +/// raises the update prompt. If the executable-hash fallback stops resolving on Linux, those +/// users silently keep a broken engine. +/// +public class WhisperCppLinuxExecutableHashTests +{ + // whisper-cli extracted from whisper-vulkan-linux64.zip / whisper-cuda-linux64.zip + private const string VulkanR2 = "da61c0c1910c103cf8dea855855f072249689ee2310f375e2b79615cbd012c05"; + private const string VulkanBroken191 = "782fac61b9bcfe8f6db22564bb5a2cda2c22550b9ef38064ec5f188bc86dfe79"; + private const string CudaR2 = "3a4d717745c2d8cf19ca7a954c29c0349a7f158d93a6597bb86e155f0735474a"; + private const string CudaBroken191 = "16a838ae67e248020b9bc65b8584fcc113cf18167c9cfc0a2d98e194fa52cf95"; + private const string Shared184 = "315f46514fc09a4fefe2f7b6ae95cfac5441a03b8be04eed1b5f567d5ccc38de"; + + [Theory] + [InlineData(DownloadHashManager.WhisperCpp.LinuxVulkanExecutable, VulkanR2)] + [InlineData(DownloadHashManager.WhisperCpp.LinuxCudaExecutable, CudaR2)] + public void GetStatus_RebuiltArchive_IsUpToDate(string key, string hash) + { + Assert.Equal(DownloadHashManager.UpdateStatus.UpToDate, DownloadHashManager.GetStatus(key, hash)); + } + + [Theory] + [InlineData(DownloadHashManager.WhisperCpp.LinuxVulkanExecutable, VulkanBroken191)] + [InlineData(DownloadHashManager.WhisperCpp.LinuxCudaExecutable, CudaBroken191)] + [InlineData(DownloadHashManager.WhisperCpp.LinuxVulkanExecutable, Shared184)] + [InlineData(DownloadHashManager.WhisperCpp.LinuxCudaExecutable, Shared184)] + public void GetStatus_BuildWithMissingSharedLibraries_OffersUpdate(string key, string hash) + { + Assert.Equal(DownloadHashManager.UpdateStatus.UpdateAvailable, DownloadHashManager.GetStatus(key, hash)); + } + + [Fact] + public void GetStatus_VulkanAndCudaHashesAreNotInterchangeable() + { + // v1.8.5 onwards the two backends produce different whisper-cli binaries, so a CUDA hash + // must not validate a Vulkan install (v1.8.4 is the documented exception, covered above). + Assert.Equal( + DownloadHashManager.UpdateStatus.Unknown, + DownloadHashManager.GetStatus(DownloadHashManager.WhisperCpp.LinuxVulkanExecutable, CudaR2)); + Assert.Equal( + DownloadHashManager.UpdateStatus.Unknown, + DownloadHashManager.GetStatus(DownloadHashManager.WhisperCpp.LinuxCudaExecutable, VulkanR2)); + } + + [Fact] + public void ResolveWhisperCppExecutableKey_OnLinux_ResolvesBothBackends() + { + if (!OperatingSystem.IsLinux()) + { + return; + } + + Assert.Equal( + DownloadHashManager.WhisperCpp.LinuxVulkanExecutable, + DownloadHashManager.ResolveWhisperCppExecutableKey(WhisperChoice.Cpp)); + Assert.Equal( + DownloadHashManager.WhisperCpp.LinuxCudaExecutable, + DownloadHashManager.ResolveWhisperCppExecutableKey(WhisperChoice.CppCuBlas)); + } + + [Fact] + public void ResolveWhisperCppExecutableKey_MatchesArchiveKeyBackend() + { + // The executable key and the archive key must name the same backend for a given choice, + // or the sidecar and no-sidecar paths would disagree about which install is present. + foreach (var choice in new[] { WhisperChoice.Cpp, WhisperChoice.CppCuBlas, WhisperChoice.CppVulkan }) + { + var executableKey = DownloadHashManager.ResolveWhisperCppExecutableKey(choice); + if (executableKey == null) + { + continue; + } + + var archiveKey = DownloadHashManager.ResolveWhisperCppKey(choice); + Assert.NotNull(archiveKey); + Assert.Equal(archiveKey + ".Executable", executableKey); + } + } +}