diff --git a/docs/issue-14024-models-folder.md b/docs/issue-14024-models-folder.md new file mode 100644 index 00000000000..64ddd7a31ef --- /dev/null +++ b/docs/issue-14024-models-folder.md @@ -0,0 +1,122 @@ +# Issue #14024 - configurable AI models folder + +## Status + +Ported onto the current Subtitle Edit tree for audit. The original implementation is extended here so CrispASR's own auto-download cache follows the selected models root as well. + +The original request is [Subtitle Edit issue #14024](https://github.com/SubtitleEdit/subtitleedit/issues/14024): macOS users with a small internal disk need the large downloaded AI models to live on an external SSD, without moving the whole Subtitle Edit application-data directory. + +## Problem + +Before this change, Subtitle Edit had several independent model locations: + +| Model family | Previous location | Why this was a problem | +| --- | --- | --- | +| OpenAI Whisper Python | `~/.cache/whisper` | The cache was outside Subtitle Edit's settings and had no in-app location setting. | +| Hugging Face / CTranslate2 | `~/.cache/huggingface/hub` | Large model snapshots accumulated on the internal disk. | +| Subtitle Edit Whisper engines | under the app data folder | These were tied to the normal application-data location. | +| CrispASR and its TTS backends | `/CrispASR/models` | The executable and model files were coupled to one root. | +| llama.cpp | `/llama.cpp/models` | Moving the whole data folder was the only practical workaround. | +| Paddle OCR, CrispEmbed, and Tesseract | OCR-specific subfolders | OCR model downloads also consumed internal storage. | +| audio.cpp and several C++ TTS engines | engine-specific `models` folders | Each engine had to be moved independently. | + +The workaround described in the issue was to symlink the complete `Subtitle Edit` application-data directory. That also moves settings, logs, dictionaries, themes, and downloaded tools, which is broader than necessary. + +## Design + +The setting is an optional model root, not a replacement for the application-data root. + +- An empty value preserves every previous path. +- A selected path is normalized to an absolute path. +- Executables and normal Subtitle Edit state remain in the normal application-data folder. +- Model subfolders retain stable family-specific names below the selected root. +- The setting is persisted in `Settings.json` as `General.ModelsFolder`. +- Existing files are not copied or deleted automatically. This avoids destructive migration and makes changing the setting reversible. Users can copy the existing model folders to the corresponding subfolders on the new disk before using the engines. +- Invalid hand-edited paths fall back to the historical locations instead of preventing startup. + +## User-facing behavior + +Options > General now includes **AI models folder** with a folder picker. Leave it empty to use the old locations. Select a directory such as `/Volumes/AI-Models/Subtitle Edit` to put future model downloads there. + +The selected root is used after settings are saved. Python-based engines receive cache environment variables at process launch: + +- OpenAI Whisper receives `XDG_CACHE_HOME=/SpeechToText`, which makes its normal `whisper` cache resolve under the selected root. +- CTranslate2 and WhisperX receive `HF_HOME=/SpeechToText/HuggingFace`, so their Hugging Face model caches resolve under the selected root. +- CrispASR receives `CRISPASR_MODELS_DIR` and `CRISPASR_CACHE_DIR` pointing at `/CrispASR/models`, so native `--auto-download` assets do not escape back to `~/.cache/crispasr`. + +## Implementation details + +### Configuration and persistence + +- `src/ui/Logic/Config/SeGeneral.cs` adds `ModelsFolder`, defaulting to an empty string. +- `src/ui/Logic/Config/Se.cs` adds normalized `ModelsFolder`, `HasCustomModelsFolder`, and model path helpers. +- `src/libse/Common/Configuration.cs` adds the shared `ModelsDirectory` bridge and `ResolveModelsFolder`, so `libuilogic` can use the setting without depending on the UI assembly. +- `Se.UpdateLibSeSettings()` synchronizes the selected root into the shared configuration bridge. +- `Se.SaveSettings()` refreshes the llama.cpp model override after a settings change. + +### Settings UI and localization + +- `src/ui/Features/Options/Settings/SettingsPage.cs` adds the folder textbox and browse button. +- `src/ui/Features/Options/Settings/SettingsViewModel.cs` loads, browses, trims, and saves the value. +- `src/ui/Logic/Config/Language/Options/LanguageSettings.cs` and `src/ui/Assets/Languages/English.json` add the **AI models folder** label. The language property has an English initializer so older translation files remain usable. + +### Model families covered + +- OpenAI Whisper, CTranslate2, and WhisperX external model caches. +- Whisper.cpp and Const-me model folders, including the C++/cuBLAS/Vulkan engine wrappers. +- Purfview Faster Whisper XXL's `_models` folder. +- All current CrispASR speech-to-text backends and CrispASR-backed TTS model folders. +- Qwen3 ASR C++ model files. +- Qwen3 TTS C++, Kokoro TTS C++, and OmniVoice TTS C++ model folders. +- All current audio.cpp TTS model families: IndexTTS 2.5, FireRedTTS3, Fish Audio S2 Pro, and Higgs Audio v3. +- Piper voice-model `.onnx` / config files, while the Piper runtime and voice catalog remain in application data. +- llama.cpp model files, while its server executable remains in the normal data folder. +- PaddleOCR models, CrispEmbed models, and Tesseract `tessdata`. + +The path substitutions are intentionally centralized in `Se` so a future engine can opt into the same root without duplicating settings parsing or migration logic. + +## Backward compatibility and migration + +No setting means no behavior change. The legacy paths are still returned exactly when `General.ModelsFolder` is empty, and the tests pin this behavior. + +Changing the setting does not move existing data. This is deliberate: + +1. Subtitle Edit cannot safely assume that every folder is writable, mounted, or large enough for a copy. +2. A move could be interrupted and leave a partial model. +3. The user may want to keep models on both disks temporarily. + +For a manual migration, close Subtitle Edit, copy the old model subfolder to the matching path below the new root, select the root in Options, save, and verify the engine's model list before deleting the old copy. + +## Verification + +The focused test command was: + +```text +AVALONIA_TELEMETRY_OPTOUT=1 dotnet test tests/UI/UITests.csproj --no-restore --filter FullyQualifiedName~DataFolderLocationTests +``` + +The focused regression suite verifies: + +- empty settings preserve historical model locations, including Piper; +- relative hand-edited roots fail closed to the historical layout; +- a custom absolute root leaves normal application data unchanged; +- all current audio.cpp TTS model families resolve below the custom root; +- Piper model files resolve below the custom root while its runtime stays in application data; +- WhisperX routes Hugging Face downloads through the custom root and preserves caller environment in legacy mode; +- CrispASR native auto-downloads use the custom root; +- legacy CrispASR cache reuse follows `CRISPASR_CACHE_DIR` → `CRISPASR_MODELS_DIR` → the historical cache; +- legacy mode does not overwrite caller-provided CrispASR environment. + +Full build/test results are recorded by CI for the audit branch. No production model is downloaded by these path tests. + +## Not included in this slice + +- Automatic copy/move of existing model files. +- Third-party caches that are hard-coded inside external tools and are not controlled by an explicit model-directory argument. CrispASR is no longer in this exception: its documented `CRISPASR_MODELS_DIR` / `CRISPASR_CACHE_DIR` overrides are applied when a custom root is selected. +- A separate per-engine UI. The request is satisfied with one root, while preserving each engine's existing subfolder layout. + +## Follow-up options + +1. Add an explicit “Move existing models” wizard with free-space checks and resumable copy. +2. Add a “Show model folder” action beside the setting. +3. Audit newly added third-party engines for undocumented internal caches and add environment/argument overrides where upstream supports them. \ No newline at end of file diff --git a/src/libse/Common/Configuration.cs b/src/libse/Common/Configuration.cs index f9f1ebf8d0f..bb241311433 100644 --- a/src/libse/Common/Configuration.cs +++ b/src/libse/Common/Configuration.cs @@ -7,10 +7,28 @@ public class Configuration { public static string BaseDirectory = string.Empty; public static string DataDirectory = string.Empty; + /// Optional root for downloaded AI models, set by the UI configuration layer. + public static string ModelsDirectory = string.Empty; private static readonly Configuration Instance = new Configuration(); private Settings.Settings _settings = new Settings.Settings(); public static Settings.Settings Settings => Instance._settings; public static string DictionariesDirectory => Path.Combine(DataDirectory, "Dictionaries") + Path.DirectorySeparatorChar; + + public static string ResolveModelsFolder(string legacyFolder, params string[] relativePath) + { + if (string.IsNullOrWhiteSpace(ModelsDirectory)) + { + return legacyFolder; + } + + var folder = ModelsDirectory; + foreach (var part in relativePath) + { + folder = Path.Combine(folder, part); + } + + return folder; + } public static readonly string DefaultLinuxFontName = "DejaVu Serif"; private Configuration() diff --git a/src/libuilogic/AudioToText/WhisperCTranslate2Model.cs b/src/libuilogic/AudioToText/WhisperCTranslate2Model.cs index de763bd2205..f6328ffb30b 100644 --- a/src/libuilogic/AudioToText/WhisperCTranslate2Model.cs +++ b/src/libuilogic/AudioToText/WhisperCTranslate2Model.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using Nikse.SubtitleEdit.Core.Common; namespace Nikse.SubtitleEdit.UiLogic.AudioToText { @@ -19,22 +20,12 @@ public override string ToString() private readonly string[] _fileNames = { "model.bin", "config.json", "vocabulary.txt", "tokenizer.json" }; - public string ModelFolder => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cache", "huggingface", "hub"); + public string ModelFolder => Configuration.ResolveModelsFolder( + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cache", "huggingface", "hub"), + "SpeechToText", "HuggingFace", "hub"); public void CreateModelFolder() { - var cacheFolder = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cache"); - if (!Directory.Exists(cacheFolder)) - { - Directory.CreateDirectory(cacheFolder); - } - - cacheFolder = Path.Combine(cacheFolder, "hub"); - if (!Directory.Exists(cacheFolder)) - { - Directory.CreateDirectory(cacheFolder); - } - if (!Directory.Exists(ModelFolder)) { Directory.CreateDirectory(ModelFolder); diff --git a/src/libuilogic/AudioToText/WhisperConstMeModel.cs b/src/libuilogic/AudioToText/WhisperConstMeModel.cs index 42e88b07c06..501a13b8bd3 100644 --- a/src/libuilogic/AudioToText/WhisperConstMeModel.cs +++ b/src/libuilogic/AudioToText/WhisperConstMeModel.cs @@ -15,7 +15,9 @@ public string ModelFolder return Configuration.Settings.Tools.WhisperCppModelLocation; } - return Path.Combine(Configuration.DataDirectory, "SpeechToText", "Const-Me", "Models"); + return Configuration.ResolveModelsFolder( + Path.Combine(Configuration.DataDirectory, "SpeechToText", "Const-Me", "Models"), + "SpeechToText", "Const-Me", "Models"); } } diff --git a/src/libuilogic/AudioToText/WhisperCppModel.cs b/src/libuilogic/AudioToText/WhisperCppModel.cs index b91a636c43d..8099098b234 100644 --- a/src/libuilogic/AudioToText/WhisperCppModel.cs +++ b/src/libuilogic/AudioToText/WhisperCppModel.cs @@ -18,7 +18,9 @@ public string ModelFolder return Configuration.Settings.Tools.WhisperCppModelLocation; } - return Path.Combine(Configuration.DataDirectory, "SpeechToText", "Cpp", "Models"); + return Configuration.ResolveModelsFolder( + Path.Combine(Configuration.DataDirectory, "SpeechToText", "Cpp", "Models"), + "SpeechToText", "Cpp", "Models"); } } diff --git a/src/libuilogic/AudioToText/WhisperModel.cs b/src/libuilogic/AudioToText/WhisperModel.cs index f41742cbdd7..3a5a4473439 100644 --- a/src/libuilogic/AudioToText/WhisperModel.cs +++ b/src/libuilogic/AudioToText/WhisperModel.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using Nikse.SubtitleEdit.Core.Common; namespace Nikse.SubtitleEdit.UiLogic.AudioToText { @@ -19,16 +20,12 @@ public override string ToString() return $"{(AlreadyDownloaded ? "* " : string.Empty)}{Name} ({Size})"; } - public string ModelFolder => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cache", "whisper"); + public string ModelFolder => Configuration.ResolveModelsFolder( + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cache", "whisper"), + "SpeechToText", "whisper"); public void CreateModelFolder() { - var cacheFolder = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cache"); - if (!Directory.Exists(cacheFolder)) - { - Directory.CreateDirectory(cacheFolder); - } - if (!Directory.Exists(ModelFolder)) { Directory.CreateDirectory(ModelFolder); diff --git a/src/libuilogic/AudioToText/WhisperPurfviewFasterWhisperModel.cs b/src/libuilogic/AudioToText/WhisperPurfviewFasterWhisperModel.cs index 04c40082598..af0580abb3a 100644 --- a/src/libuilogic/AudioToText/WhisperPurfviewFasterWhisperModel.cs +++ b/src/libuilogic/AudioToText/WhisperPurfviewFasterWhisperModel.cs @@ -22,7 +22,9 @@ public override string ToString() private readonly string[] _fileNames = { "model.bin", "config.json", "vocabulary.txt", "vocabulary.json", "tokenizer.json", "preprocessor_config.json" }; - public string ModelFolder => Path.Combine(Configuration.DataDirectory, "SpeechToText", "Purfview-Faster-Whisper-XXL", "_models"); + public string ModelFolder => Configuration.ResolveModelsFolder( + Path.Combine(Configuration.DataDirectory, "SpeechToText", "Purfview-Faster-Whisper-XXL", "_models"), + "SpeechToText", "Purfview-Faster-Whisper-XXL", "_models"); public void CreateModelFolder() { diff --git a/src/libuilogic/LlamaCpp/LlamaCppServerManager.cs b/src/libuilogic/LlamaCpp/LlamaCppServerManager.cs index 32ffcba8474..c5a05c76594 100644 --- a/src/libuilogic/LlamaCpp/LlamaCppServerManager.cs +++ b/src/libuilogic/LlamaCpp/LlamaCppServerManager.cs @@ -369,6 +369,13 @@ public static string ResolveOcrPrompt(LlamaCppModel? model, string? userPrompt) /// public static string? FolderOverride { get; set; } + /// + /// Optional model-only folder. When unset, models remain under + /// for backwards compatibility, while the executable can continue to live in the normal + /// Subtitle Edit data folder. + /// + public static string? ModelsFolderOverride { get; set; } + /// /// Optional info-level log sink. The UI wires this to the tools log /// (Se.WriteToolsLog); seconv wires it to --verbose console output. @@ -424,7 +431,9 @@ public static string GetAndCreateFolder() public static string GetAndCreateModelsFolder() { - var folder = Path.Combine(GetAndCreateFolder(), "models"); + var folder = string.IsNullOrEmpty(ModelsFolderOverride) + ? Path.Combine(GetAndCreateFolder(), "models") + : ModelsFolderOverride; if (!Directory.Exists(folder)) { Directory.CreateDirectory(folder); diff --git a/src/ui/Assets/Languages/English.json b/src/ui/Assets/Languages/English.json index 3a8b48a3dae..6f45b9506bf 100644 --- a/src/ui/Assets/Languages/English.json +++ b/src/ui/Assets/Languages/English.json @@ -3138,6 +3138,7 @@ "defaultSaveLocationVideoFileFolder": "Video file folder", "defaultSaveLocationSubtitleFileFolder": "Subtitle file folder", "defaultSaveLocationCustomFolder": "Custom folder", + "modelsFolder": "AI models folder", "saveAsAppendLanguageCode": "\"Save as\" append language code", "gridGoToSubtitleAndSetVideoPosition": "Go to subtitle and set video position", "gridGoToNextLine": "Go to next line", diff --git a/src/ui/DependencyInjectionExtensions.cs b/src/ui/DependencyInjectionExtensions.cs index bbb7a06bb9f..adef344d416 100644 --- a/src/ui/DependencyInjectionExtensions.cs +++ b/src/ui/DependencyInjectionExtensions.cs @@ -242,6 +242,7 @@ public static void AddSubtitleEditServices(this IServiceCollection collection) // LlamaCppServerManager lives in libuilogic (shared with seconv) and cannot see the // UI's Se config - point it at the UI's llama.cpp folder and tools log explicitly. LlamaCppServerManager.FolderOverride = Logic.Config.Se.LlamaCppFolder; + LlamaCppServerManager.ModelsFolderOverride = Logic.Config.Se.LlamaCppModelsFolder; LlamaCppServerManager.LogAction = Logic.Config.Se.WriteToolsLog; // Misc services diff --git a/src/ui/Features/Ocr/Engines/CrispEmbedEngine.cs b/src/ui/Features/Ocr/Engines/CrispEmbedEngine.cs index 3623d1c993e..9d6dbd3ae50 100644 --- a/src/ui/Features/Ocr/Engines/CrispEmbedEngine.cs +++ b/src/ui/Features/Ocr/Engines/CrispEmbedEngine.cs @@ -69,7 +69,7 @@ public static string GetAndCreateFolder() public static string GetAndCreateModelFolder() { - var folder = Path.Combine(GetAndCreateFolder(), "models"); + var folder = Se.CrispEmbedModelsFolder; if (!Directory.Exists(folder)) { Directory.CreateDirectory(folder); diff --git a/src/ui/Features/Options/Settings/SettingsPage.cs b/src/ui/Features/Options/Settings/SettingsPage.cs index bccca7be561..73cb2982891 100644 --- a/src/ui/Features/Options/Settings/SettingsPage.cs +++ b/src/ui/Features/Options/Settings/SettingsPage.cs @@ -517,6 +517,17 @@ private List CreateSections() } }), + new SettingsItem(Se.Language.Options.Settings.ModelsFolder, () => new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 5, + Children = + { + UiUtil.MakeTextBox(250, _vm, nameof(_vm.ModelsFolder)), + UiUtil.MakeButtonBrowse(_vm.BrowseModelsFolderCommand, accessibleName: Se.Language.Options.Settings.ModelsFolder), + } + }), + MakeSeparator(), MakeCheckboxSetting(Se.Language.Options.Settings.AutoSave, nameof(_vm.AutoSave)), MakeCheckboxSetting(Se.Language.Options.Settings.AutoBackupOn, nameof(_vm.AutoBackupOn)), diff --git a/src/ui/Features/Options/Settings/SettingsViewModel.cs b/src/ui/Features/Options/Settings/SettingsViewModel.cs index ab73b32fff6..b9e30025a1e 100644 --- a/src/ui/Features/Options/Settings/SettingsViewModel.cs +++ b/src/ui/Features/Options/Settings/SettingsViewModel.cs @@ -184,6 +184,7 @@ partial void OnMaxLinesChanged(int? value) [ObservableProperty] private ObservableCollection _defaultSaveLocationTypes; [ObservableProperty] private string _selectedDefaultSaveLocationType; [ObservableProperty] private string _defaultSaveLocationCustomFolder = string.Empty; + [ObservableProperty] private string _modelsFolder = string.Empty; [ObservableProperty] private bool _isDefaultSaveLocationCustomFolderEnabled; [ObservableProperty] private string _selectedSaveAsBehaviorType; @@ -807,6 +808,7 @@ private void LoadSettings() SelectedSaveAsAppendLanguageCode = MapFromSelectedSaveAsAppendLanguageCode(Se.Settings.General.SaveAsAppendLanguageCode); SelectedDefaultSaveLocationType = MapFromDefaultSaveLocation(Se.Settings.General.DefaultSaveLocation); DefaultSaveLocationCustomFolder = Se.Settings.General.DefaultSaveLocationCustomFolder ?? string.Empty; + ModelsFolder = Se.Settings.General.ModelsFolder ?? string.Empty; AutoConvertToUtf8 = general.AutoConvertToUtf8; ForceCrLfOnSave = general.ForceCrLfOnSave; ShowFormatLimitWarning = general.ShowFormatLimitWarning; @@ -1359,6 +1361,21 @@ private async Task BrowseDefaultSaveLocationFolder() } } + [RelayCommand] + private async Task BrowseModelsFolder() + { + if (Window == null) + { + return; + } + + var folder = await _folderHelper.PickFolderAsync(Window, Se.Language.Options.Settings.ModelsFolder); + if (!string.IsNullOrEmpty(folder)) + { + ModelsFolder = folder; + } + } + private static string MapFromSelectedSaveAsAppendLanguageCode(string languageAppendType) { if (languageAppendType == nameof(SaveAsLanguageAppendType.TwoLetterLanguageCode)) @@ -1697,6 +1714,7 @@ private void SaveSettings() general.SaveAsAppendLanguageCode = MapToSaveAsAppendLanguageCode(SelectedSaveAsAppendLanguageCode); general.DefaultSaveLocation = MapToDefaultSaveLocation(SelectedDefaultSaveLocationType); general.DefaultSaveLocationCustomFolder = DefaultSaveLocationCustomFolder; + general.ModelsFolder = ModelsFolder?.Trim() ?? string.Empty; general.AutoConvertToUtf8 = AutoConvertToUtf8; general.ForceCrLfOnSave = ForceCrLfOnSave; general.ShowFormatLimitWarning = ShowFormatLimitWarning; diff --git a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrArk.cs b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrArk.cs index 83b9024f74d..5008c3f2ed1 100644 --- a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrArk.cs +++ b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrArk.cs @@ -103,7 +103,7 @@ public override string GetAndCreateWhisperFolder() public override string GetAndCreateWhisperModelFolder(WhisperModel? whisperModel) { var folder = GetAndCreateWhisperFolder(); - var modelsFolder = Path.Combine(folder, "models"); + var modelsFolder = Se.CrispAsrModelsFolder; if (!Directory.Exists(modelsFolder)) { Directory.CreateDirectory(modelsFolder); diff --git a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrCanary.cs b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrCanary.cs index 1a8562c1be9..b455b07ebb5 100644 --- a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrCanary.cs +++ b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrCanary.cs @@ -117,7 +117,7 @@ public override string GetAndCreateWhisperFolder() public override string GetAndCreateWhisperModelFolder(WhisperModel? whisperModel) { var folder = GetAndCreateWhisperFolder(); - var modelsFolder = Path.Combine(folder, "models"); + var modelsFolder = Se.CrispAsrModelsFolder; if (!Directory.Exists(modelsFolder)) { Directory.CreateDirectory(modelsFolder); diff --git a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrCohere.cs b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrCohere.cs index 0ced7b6f7e9..2d532a4cca7 100644 --- a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrCohere.cs +++ b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrCohere.cs @@ -194,7 +194,7 @@ public override string GetAndCreateWhisperFolder() public override string GetAndCreateWhisperModelFolder(WhisperModel? whisperModel) { var folder = GetAndCreateWhisperFolder(); - var modelsFolder = Path.Combine(folder, "models"); + var modelsFolder = Se.CrispAsrModelsFolder; if (!Directory.Exists(modelsFolder)) { Directory.CreateDirectory(modelsFolder); diff --git a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrEngineBase.cs b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrEngineBase.cs index cd02dd6cc40..e0f8ed854c8 100644 --- a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrEngineBase.cs +++ b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrEngineBase.cs @@ -3,6 +3,7 @@ using Nikse.SubtitleEdit.Logic.Config; using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Threading.Tasks; @@ -10,6 +11,44 @@ namespace Nikse.SubtitleEdit.Features.Video.SpeechToText.Engines; public abstract class CrispAsrEngineBase : ICrispAsrEngine { + public static string AutoDownloadModelsFolder + { + get + { + if (Se.HasCustomModelsFolder) + { + return Se.CrispAsrModelsFolder; + } + + // Match CrispASR's documented cache precedence in legacy mode so SE can reuse + // models downloaded by a caller-level/global CrispASR configuration too. + var cacheDirectory = Environment.GetEnvironmentVariable("CRISPASR_CACHE_DIR"); + if (!string.IsNullOrWhiteSpace(cacheDirectory)) + { + return cacheDirectory; + } + + var modelsDirectory = Environment.GetEnvironmentVariable("CRISPASR_MODELS_DIR"); + return !string.IsNullOrWhiteSpace(modelsDirectory) + ? modelsDirectory + : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cache", "crispasr"); + } + } + + public static void ConfigureModelEnvironment(ProcessStartInfo startInfo) + { + ArgumentNullException.ThrowIfNull(startInfo); + if (!Se.HasCustomModelsFolder) + { + return; + } + + var folder = Se.CrispAsrModelsFolder; + Directory.CreateDirectory(folder); + startInfo.EnvironmentVariables["CRISPASR_MODELS_DIR"] = folder; + startInfo.EnvironmentVariables["CRISPASR_CACHE_DIR"] = folder; + } + public abstract string Name { get; } public abstract string Choice { get; } public abstract string Url { get; } diff --git a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrFireRed.cs b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrFireRed.cs index 83430a81acc..bdbf4d1b62a 100644 --- a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrFireRed.cs +++ b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrFireRed.cs @@ -138,7 +138,7 @@ public override string GetAndCreateWhisperFolder() public override string GetAndCreateWhisperModelFolder(WhisperModel? whisperModel) { var folder = GetAndCreateWhisperFolder(); - var modelsFolder = Path.Combine(folder, "models"); + var modelsFolder = Se.CrispAsrModelsFolder; if (!Directory.Exists(modelsFolder)) { Directory.CreateDirectory(modelsFolder); diff --git a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrFunAsrMltNano.cs b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrFunAsrMltNano.cs index 1e4e09ab862..5bbde13db34 100644 --- a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrFunAsrMltNano.cs +++ b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrFunAsrMltNano.cs @@ -112,7 +112,7 @@ public override string GetAndCreateWhisperFolder() public override string GetAndCreateWhisperModelFolder(WhisperModel? whisperModel) { var folder = GetAndCreateWhisperFolder(); - var modelsFolder = Path.Combine(folder, "models"); + var modelsFolder = Se.CrispAsrModelsFolder; if (!Directory.Exists(modelsFolder)) { Directory.CreateDirectory(modelsFolder); diff --git a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrFunAsrNano.cs b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrFunAsrNano.cs index 950f4d7d01c..4e03daf8103 100644 --- a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrFunAsrNano.cs +++ b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrFunAsrNano.cs @@ -87,7 +87,7 @@ public override string GetAndCreateWhisperFolder() public override string GetAndCreateWhisperModelFolder(WhisperModel? whisperModel) { var folder = GetAndCreateWhisperFolder(); - var modelsFolder = Path.Combine(folder, "models"); + var modelsFolder = Se.CrispAsrModelsFolder; if (!Directory.Exists(modelsFolder)) { Directory.CreateDirectory(modelsFolder); diff --git a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrGigaAm.cs b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrGigaAm.cs index e3a009108ac..c11f5105292 100644 --- a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrGigaAm.cs +++ b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrGigaAm.cs @@ -186,7 +186,7 @@ public override string GetAndCreateWhisperFolder() public override string GetAndCreateWhisperModelFolder(WhisperModel? whisperModel) { var folder = GetAndCreateWhisperFolder(); - var modelsFolder = Path.Combine(folder, "models"); + var modelsFolder = Se.CrispAsrModelsFolder; if (!Directory.Exists(modelsFolder)) { Directory.CreateDirectory(modelsFolder); diff --git a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrGlm.cs b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrGlm.cs index b69848ea90a..0a479d82342 100644 --- a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrGlm.cs +++ b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrGlm.cs @@ -100,7 +100,7 @@ public override string GetAndCreateWhisperFolder() public override string GetAndCreateWhisperModelFolder(WhisperModel? whisperModel) { var folder = GetAndCreateWhisperFolder(); - var modelsFolder = Path.Combine(folder, "models"); + var modelsFolder = Se.CrispAsrModelsFolder; if (!Directory.Exists(modelsFolder)) { Directory.CreateDirectory(modelsFolder); diff --git a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrGranite.cs b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrGranite.cs index 88ac16ad4df..2f93cc1a60f 100644 --- a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrGranite.cs +++ b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrGranite.cs @@ -133,7 +133,7 @@ public override string GetAndCreateWhisperFolder() public override string GetAndCreateWhisperModelFolder(WhisperModel? whisperModel) { var folder = GetAndCreateWhisperFolder(); - var modelsFolder = Path.Combine(folder, "models"); + var modelsFolder = Se.CrispAsrModelsFolder; if (!Directory.Exists(modelsFolder)) { Directory.CreateDirectory(modelsFolder); diff --git a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrKyutai.cs b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrKyutai.cs index 4b52f6b72dd..5618ddbc7ec 100644 --- a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrKyutai.cs +++ b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrKyutai.cs @@ -119,7 +119,7 @@ public override string GetAndCreateWhisperFolder() public override string GetAndCreateWhisperModelFolder(WhisperModel? whisperModel) { var folder = GetAndCreateWhisperFolder(); - var modelsFolder = Path.Combine(folder, "models"); + var modelsFolder = Se.CrispAsrModelsFolder; if (!Directory.Exists(modelsFolder)) { Directory.CreateDirectory(modelsFolder); diff --git a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrMadlad.cs b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrMadlad.cs index bf5d764062a..aced46a3042 100644 --- a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrMadlad.cs +++ b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrMadlad.cs @@ -68,7 +68,7 @@ public override string GetAndCreateWhisperFolder() public override string GetAndCreateWhisperModelFolder(WhisperModel? whisperModel) { var folder = GetAndCreateWhisperFolder(); - var modelsFolder = Path.Combine(folder, "models"); + var modelsFolder = Se.CrispAsrModelsFolder; if (!Directory.Exists(modelsFolder)) { Directory.CreateDirectory(modelsFolder); diff --git a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrMega.cs b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrMega.cs index 3a46c2002c0..c492f67a083 100644 --- a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrMega.cs +++ b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrMega.cs @@ -82,7 +82,7 @@ public override string GetAndCreateWhisperFolder() public override string GetAndCreateWhisperModelFolder(WhisperModel? whisperModel) { var folder = GetAndCreateWhisperFolder(); - var modelsFolder = Path.Combine(folder, "models"); + var modelsFolder = Se.CrispAsrModelsFolder; if (!Directory.Exists(modelsFolder)) { Directory.CreateDirectory(modelsFolder); diff --git a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrMossDiarize.cs b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrMossDiarize.cs index 918bfc1d5e2..1bff3ceb073 100644 --- a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrMossDiarize.cs +++ b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrMossDiarize.cs @@ -78,7 +78,7 @@ public override string GetAndCreateWhisperFolder() public override string GetAndCreateWhisperModelFolder(WhisperModel? whisperModel) { var folder = GetAndCreateWhisperFolder(); - var modelsFolder = Path.Combine(folder, "models"); + var modelsFolder = Se.CrispAsrModelsFolder; if (!Directory.Exists(modelsFolder)) { Directory.CreateDirectory(modelsFolder); diff --git a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrOmni.cs b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrOmni.cs index d0738fb428e..cd038a3ee34 100644 --- a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrOmni.cs +++ b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrOmni.cs @@ -257,7 +257,7 @@ public override string GetAndCreateWhisperFolder() public override string GetAndCreateWhisperModelFolder(WhisperModel? whisperModel) { var folder = GetAndCreateWhisperFolder(); - var modelsFolder = Path.Combine(folder, "models"); + var modelsFolder = Se.CrispAsrModelsFolder; if (!Directory.Exists(modelsFolder)) { Directory.CreateDirectory(modelsFolder); diff --git a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrParakeet.cs b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrParakeet.cs index f6407e6303b..cd321e805c5 100644 --- a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrParakeet.cs +++ b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrParakeet.cs @@ -293,7 +293,7 @@ public override string GetAndCreateWhisperFolder() public override string GetAndCreateWhisperModelFolder(WhisperModel? whisperModel) { var folder = GetAndCreateWhisperFolder(); - var modelsFolder = Path.Combine(folder, "models"); + var modelsFolder = Se.CrispAsrModelsFolder; if (!Directory.Exists(modelsFolder)) { Directory.CreateDirectory(modelsFolder); diff --git a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrQwen3.cs b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrQwen3.cs index bdc5877d23c..c12aea6a45a 100644 --- a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrQwen3.cs +++ b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrQwen3.cs @@ -170,7 +170,7 @@ public override string GetAndCreateWhisperFolder() public override string GetAndCreateWhisperModelFolder(WhisperModel? whisperModel) { var folder = GetAndCreateWhisperFolder(); - var modelsFolder = Path.Combine(folder, "models"); + var modelsFolder = Se.CrispAsrModelsFolder; if (!Directory.Exists(modelsFolder)) { Directory.CreateDirectory(modelsFolder); diff --git a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrSenseVoice.cs b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrSenseVoice.cs index 2166358178d..054fc858fd4 100644 --- a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrSenseVoice.cs +++ b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrSenseVoice.cs @@ -89,7 +89,7 @@ public override string GetAndCreateWhisperFolder() public override string GetAndCreateWhisperModelFolder(WhisperModel? whisperModel) { var folder = GetAndCreateWhisperFolder(); - var modelsFolder = Path.Combine(folder, "models"); + var modelsFolder = Se.CrispAsrModelsFolder; if (!Directory.Exists(modelsFolder)) { Directory.CreateDirectory(modelsFolder); diff --git a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrVoxtral.cs b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrVoxtral.cs index 1926b1e1146..1af9f329b2b 100644 --- a/src/ui/Features/Video/SpeechToText/Engines/CrispAsrVoxtral.cs +++ b/src/ui/Features/Video/SpeechToText/Engines/CrispAsrVoxtral.cs @@ -91,7 +91,7 @@ public override string GetAndCreateWhisperFolder() public override string GetAndCreateWhisperModelFolder(WhisperModel? whisperModel) { var folder = GetAndCreateWhisperFolder(); - var modelsFolder = Path.Combine(folder, "models"); + var modelsFolder = Se.CrispAsrModelsFolder; if (!Directory.Exists(modelsFolder)) { Directory.CreateDirectory(modelsFolder); diff --git a/src/ui/Features/Video/SpeechToText/Engines/Qwen3AsrCppEngine.cs b/src/ui/Features/Video/SpeechToText/Engines/Qwen3AsrCppEngine.cs index ec86b207a8d..239939dc15f 100644 --- a/src/ui/Features/Video/SpeechToText/Engines/Qwen3AsrCppEngine.cs +++ b/src/ui/Features/Video/SpeechToText/Engines/Qwen3AsrCppEngine.cs @@ -138,8 +138,7 @@ public string GetAndCreateWhisperFolder() public string GetAndCreateWhisperModelFolder(WhisperModel? whisperModel) { - var baseFolder = GetAndCreateWhisperFolder(); - var modelsFolder = Path.Combine(baseFolder, "models"); + var modelsFolder = Se.Qwen3AsrModelsFolder; if (!Directory.Exists(modelsFolder)) { Directory.CreateDirectory(modelsFolder); diff --git a/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineConstMe.cs b/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineConstMe.cs index d82092f4092..0b92b40cbb6 100644 --- a/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineConstMe.cs +++ b/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineConstMe.cs @@ -62,7 +62,7 @@ public string GetAndCreateWhisperModelFolder(WhisperModel? whisperModel) { var baseFolder = GetAndCreateWhisperFolder(); - var folder = Path.Combine(baseFolder, "Models"); + var folder = new WhisperConstMeModel().ModelFolder; if (!Directory.Exists(folder)) { Directory.CreateDirectory(folder); @@ -80,7 +80,7 @@ public string GetExecutable() public bool IsModelInstalled(WhisperModel model) { var baseFolder = GetAndCreateWhisperFolder(); - var folder = Path.Combine(baseFolder, "Models"); + var folder = new WhisperConstMeModel().ModelFolder; if (!Directory.Exists(folder)) { return false; diff --git a/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineCpp.cs b/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineCpp.cs index 50e8c3ea303..2f59cf6599c 100644 --- a/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineCpp.cs +++ b/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineCpp.cs @@ -67,7 +67,7 @@ public string GetAndCreateWhisperModelFolder(WhisperModel? whisperModel) { var baseFolder = GetAndCreateWhisperFolder(); - var folder = Path.Combine(baseFolder, "Models"); + var folder = new WhisperCppModel().ModelFolder; if (!Directory.Exists(folder)) { Directory.CreateDirectory(folder); @@ -100,7 +100,7 @@ public string GetExecutable() public bool IsModelInstalled(WhisperModel model) { var baseFolder = GetAndCreateWhisperFolder(); - var folder = Path.Combine(baseFolder, "Models"); + var folder = new WhisperCppModel().ModelFolder; if (!Directory.Exists(folder)) { return false; diff --git a/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineCppCuBlas.cs b/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineCppCuBlas.cs index 72b8342b9e7..4b65ec6094f 100644 --- a/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineCppCuBlas.cs +++ b/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineCppCuBlas.cs @@ -72,7 +72,7 @@ public string GetAndCreateWhisperModelFolder(WhisperModel? whisperModel) Directory.CreateDirectory(folder); } - var modelsFolder = Path.Combine(folder, "Models"); + var modelsFolder = new WhisperCppModel().ModelFolder; if (!Directory.Exists(modelsFolder)) { Directory.CreateDirectory(modelsFolder); diff --git a/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineCppVulkan.cs b/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineCppVulkan.cs index 28b58d0f147..c4f5c16a812 100644 --- a/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineCppVulkan.cs +++ b/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineCppVulkan.cs @@ -76,7 +76,7 @@ public string GetAndCreateWhisperModelFolder(WhisperModel? whisperModel) Directory.CreateDirectory(folder); } - var modelsFolder = Path.Combine(folder, "Models"); + var modelsFolder = new WhisperCppModel().ModelFolder; if (!Directory.Exists(modelsFolder)) { Directory.CreateDirectory(modelsFolder); diff --git a/src/ui/Features/Video/SpeechToText/Engines/WhisperEnginePurfviewFasterWhisperXxl.cs b/src/ui/Features/Video/SpeechToText/Engines/WhisperEnginePurfviewFasterWhisperXxl.cs index fbe9d8c7717..8125650c695 100644 --- a/src/ui/Features/Video/SpeechToText/Engines/WhisperEnginePurfviewFasterWhisperXxl.cs +++ b/src/ui/Features/Video/SpeechToText/Engines/WhisperEnginePurfviewFasterWhisperXxl.cs @@ -70,7 +70,7 @@ public string GetAndCreateWhisperModelFolder(WhisperModel? whisperModel) { var baseFolder = GetAndCreateWhisperFolder(); - var folder = Path.Combine(baseFolder, "_models"); + var folder = Se.ResolveModelsFolder(Path.Combine(baseFolder, "_models"), "SpeechToText", "Purfview-Faster-Whisper-XXL", "_models"); if (!Directory.Exists(folder)) { Directory.CreateDirectory(folder); @@ -97,7 +97,9 @@ public string GetExecutable() public bool IsModelInstalled(WhisperModel model) { var baseFolder = GetAndCreateWhisperFolder(); - var folder = Path.Combine(baseFolder, "_models", GetModelFolderName(model)); + var folder = Path.Combine( + Se.ResolveModelsFolder(Path.Combine(baseFolder, "_models"), "SpeechToText", "Purfview-Faster-Whisper-XXL", "_models"), + GetModelFolderName(model)); if (!Directory.Exists(folder)) { return false; diff --git a/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineWhisperX.cs b/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineWhisperX.cs index 4cf98830ee1..54bdd2984db 100644 --- a/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineWhisperX.cs +++ b/src/ui/Features/Video/SpeechToText/Engines/WhisperEngineWhisperX.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Threading.Tasks; @@ -46,6 +47,18 @@ public bool IsEngineInstalled() return File.Exists(GetExecutable()); } + public static void ConfigureModelEnvironment(ProcessStartInfo startInfo) + { + ArgumentNullException.ThrowIfNull(startInfo); + if (!Se.HasCustomModelsFolder) + { + return; + } + + startInfo.EnvironmentVariables["HF_HOME"] = + Path.Combine(Se.ModelsFolder, "SpeechToText", "HuggingFace"); + } + public override string ToString() { return Name; diff --git a/src/ui/Features/Video/SpeechToText/SpeechToTextAdvancedViewModel.cs b/src/ui/Features/Video/SpeechToText/SpeechToTextAdvancedViewModel.cs index e136a432f39..58ffb663d0e 100644 --- a/src/ui/Features/Video/SpeechToText/SpeechToTextAdvancedViewModel.cs +++ b/src/ui/Features/Video/SpeechToText/SpeechToTextAdvancedViewModel.cs @@ -4,6 +4,7 @@ using CommunityToolkit.Mvvm.Input; using Nikse.SubtitleEdit.Features.Video.SpeechToText.Engines; using Nikse.SubtitleEdit.Logic.Config; +using Nikse.SubtitleEdit.UiLogic.AudioToText; using Nikse.SubtitleEdit.Logic; using System; using System.Collections.Generic; @@ -303,7 +304,7 @@ private void StandardCrispAsr() // backends - so hard-coding "Cpp" meant the lookup returned null, EnableVadCpp took its // bail-out branch, and the toggle popped back out with no message. GetVadCrispAsrFile // below already resolves its engine's folder. - var searchPaths = new List(); + var searchPaths = new List { new WhisperCppModel().ModelFolder }; if (SelectedEngine is WhisperCppEngine whisperCppEngine) { var engineFolder = whisperCppEngine.GetAndCreateWhisperFolder(); diff --git a/src/ui/Features/Video/SpeechToText/SpeechToTextViewModel.cs b/src/ui/Features/Video/SpeechToText/SpeechToTextViewModel.cs index 52c0b6cbc20..2f64df5610c 100644 --- a/src/ui/Features/Video/SpeechToText/SpeechToTextViewModel.cs +++ b/src/ui/Features/Video/SpeechToText/SpeechToTextViewModel.cs @@ -4119,6 +4119,8 @@ private Process GetWhisperProcess( startInfo.EnvironmentVariables["PYTHONUTF8"] = "1"; startInfo.EnvironmentVariables["PYTHONUNBUFFERED"] = "1"; + WhisperEngineWhisperX.ConfigureModelEnvironment(startInfo); + // pyannote warns that torchcodec is missing on every run, but WhisperX never // uses pyannote's decoder (it feeds ffmpeg-decoded audio in memory), so the // warning is pure noise in the log. Only UserWarning is silenced; real errors @@ -4212,7 +4214,7 @@ private Process GetWhisperProcess( Se.WriteToolsLog($"{exe} {crispParams}"); - return StartEngineProcess(exe, crispParams, dataReceivedHandler); + return StartEngineProcess(exe, crispParams, dataReceivedHandler, CrispAsrEngineBase.ConfigureModelEnvironment); } var settings = Se.Settings.Tools.AudioToText; @@ -4319,6 +4321,18 @@ or WhisperChoice.CppVulkan or WhisperChoice.CppCuBlasLib or WhisperChoice.ConstM process.StartInfo.EnvironmentVariables["GGML_VULKAN_DEVICE"] = cppVulkanDevice; } + // Python Whisper and huggingface_hub derive their default caches from these variables. + // Keep their expected subfolder names so model-name arguments continue to work while + // the actual weights live under the user-selected Subtitle Edit model root. + if (Se.HasCustomModelsFolder && settings.WhisperChoice == WhisperChoice.OpenAi) + { + process.StartInfo.EnvironmentVariables["XDG_CACHE_HOME"] = Path.Combine(Se.ModelsFolder, "SpeechToText"); + } + else if (Se.HasCustomModelsFolder && settings.WhisperChoice == WhisperChoice.CTranslate2) + { + process.StartInfo.EnvironmentVariables["HF_HOME"] = Path.Combine(Se.ModelsFolder, "SpeechToText", "HuggingFace"); + } + var whisperFolder = engine.GetAndCreateWhisperFolder(); if (!string.IsNullOrEmpty(whisperFolder)) { diff --git a/src/ui/Features/Video/TextToSpeech/DownloadTts/DownloadTtsViewModel.cs b/src/ui/Features/Video/TextToSpeech/DownloadTts/DownloadTtsViewModel.cs index 886123384b8..183d505375d 100644 --- a/src/ui/Features/Video/TextToSpeech/DownloadTts/DownloadTtsViewModel.cs +++ b/src/ui/Features/Video/TextToSpeech/DownloadTts/DownloadTtsViewModel.cs @@ -2451,7 +2451,7 @@ public void StartDownloadPiperVoice(PiperVoice piperVoice) { TitleText = string.Format(Se.Language.General.DownloadingX, $"voice: {piperVoice.Voice}"); - var folder = Piper.GetSetPiperFolder(); + var folder = Piper.GetSetModelsFolder(); _modelFileName = Path.Combine(folder, piperVoice.ModelShort); _configFileName = Path.Combine(folder, piperVoice.ConfigShort); diff --git a/src/ui/Features/Video/TextToSpeech/Engines/ChatterboxTtsCpp.cs b/src/ui/Features/Video/TextToSpeech/Engines/ChatterboxTtsCpp.cs index f88ec1dcc33..b655e64b8d9 100644 --- a/src/ui/Features/Video/TextToSpeech/Engines/ChatterboxTtsCpp.cs +++ b/src/ui/Features/Video/TextToSpeech/Engines/ChatterboxTtsCpp.cs @@ -205,7 +205,7 @@ public static string GetSetModelsFolder() // TextToSpeech/Chatterbox/. The voices folder and synth output WAVs still // live under TextToSpeech/Chatterbox/ since those are TTS-engine state, not // models. - var modelsFolder = Path.Combine(Se.CrispAsrFolder, "models"); + var modelsFolder = Se.CrispAsrModelsFolder; if (!Directory.Exists(modelsFolder)) { Directory.CreateDirectory(modelsFolder); diff --git a/src/ui/Features/Video/TextToSpeech/Engines/Confucius4TtsCrispAsr.cs b/src/ui/Features/Video/TextToSpeech/Engines/Confucius4TtsCrispAsr.cs index 9590d1c2dd2..58a767ee7bb 100644 --- a/src/ui/Features/Video/TextToSpeech/Engines/Confucius4TtsCrispAsr.cs +++ b/src/ui/Features/Video/TextToSpeech/Engines/Confucius4TtsCrispAsr.cs @@ -335,8 +335,7 @@ public static bool AreModelsInstalled(string? modelKey = null) => /// Path crispasr's --auto-download writes GGUFs to, so SE's downloader can adopt files a /// previous CLI run already fetched instead of re-pulling gigabytes. /// - public static string GetCrispAsrCacheFolder() => - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cache", "crispasr"); + public static string GetCrispAsrCacheFolder() => CrispAsrEngineBase.AutoDownloadModelsFolder; public static bool TrySeedModelFromCrispAsrCache(string fileName, string destinationPath) { @@ -639,7 +638,8 @@ private static async Task EnsureServerRunningAsync(string modelKey, string voice psi.ArgumentList.Add(odeSteps.ToString(CultureInfo.InvariantCulture)); CrispAsrTtsProvenance.AddServerMarkingArgs(psi.ArgumentList, exe); - var process = Process.Start(psi) + CrispAsrEngineBase.ConfigureModelEnvironment(psi); + var process = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start crispasr (confucius4-tts)"); var launchCommand = FormatLaunchCommand(exe, psi.ArgumentList); diff --git a/src/ui/Features/Video/TextToSpeech/Engines/CosyVoice3CrispAsr.cs b/src/ui/Features/Video/TextToSpeech/Engines/CosyVoice3CrispAsr.cs index 29929cd5652..17463e0ae89 100644 --- a/src/ui/Features/Video/TextToSpeech/Engines/CosyVoice3CrispAsr.cs +++ b/src/ui/Features/Video/TextToSpeech/Engines/CosyVoice3CrispAsr.cs @@ -263,7 +263,7 @@ public static string GetSetFolder() public static string GetSetModelsFolder() { - var modelsFolder = Path.Combine(Se.CrispAsrFolder, "models"); + var modelsFolder = Se.CrispAsrModelsFolder; if (!Directory.Exists(modelsFolder)) { Directory.CreateDirectory(modelsFolder); @@ -369,8 +369,7 @@ private static void SeedVoicesFromQwen3TtsCppIfEmpty(string voicesFolder) public static string GetLlmPath(string? modelKey = null) => Path.Combine(GetSetModelsFolder(), GetLlmFileName(modelKey)); - public static string GetCrispAsrCacheFolder() => - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cache", "crispasr"); + public static string GetCrispAsrCacheFolder() => CrispAsrEngineBase.AutoDownloadModelsFolder; public static bool TrySeedModelFromCrispAsrCache(string fileName, string destinationPath) { @@ -881,7 +880,8 @@ bool MatchesCurrent() => CrispAsrTtsProvenance.AddServerMarkingArgs(psi.ArgumentList, exe); - var process = Process.Start(psi) + CrispAsrEngineBase.ConfigureModelEnvironment(psi); + var process = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start crispasr (cosyvoice3-tts)"); var launchCommand = FormatLaunchCommand(exe, psi.ArgumentList); diff --git a/src/ui/Features/Video/TextToSpeech/Engines/DotsTtsCrispAsr.cs b/src/ui/Features/Video/TextToSpeech/Engines/DotsTtsCrispAsr.cs index c3d298614ef..33931739540 100644 --- a/src/ui/Features/Video/TextToSpeech/Engines/DotsTtsCrispAsr.cs +++ b/src/ui/Features/Video/TextToSpeech/Engines/DotsTtsCrispAsr.cs @@ -328,8 +328,7 @@ public static bool AreModelsInstalled(string? modelKey = null) => /// Path crispasr's --auto-download writes GGUFs to, so SE's downloader can adopt files a /// previous CLI run already fetched instead of re-pulling gigabytes. /// - public static string GetCrispAsrCacheFolder() => - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cache", "crispasr"); + public static string GetCrispAsrCacheFolder() => CrispAsrEngineBase.AutoDownloadModelsFolder; public static bool TrySeedModelFromCrispAsrCache(string fileName, string destinationPath) { @@ -617,7 +616,8 @@ private static async Task EnsureServerRunningAsync(string modelKey, string voice // dialog is the value actually used. psi.Environment[OdeStepsEnvironmentVariable] = odeSteps.ToString(CultureInfo.InvariantCulture); - var process = Process.Start(psi) + CrispAsrEngineBase.ConfigureModelEnvironment(psi); + var process = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start crispasr (dots-tts)"); var launchCommand = FormatLaunchCommand(exe, psi.ArgumentList); diff --git a/src/ui/Features/Video/TextToSpeech/Engines/F5TtsCrispAsr.cs b/src/ui/Features/Video/TextToSpeech/Engines/F5TtsCrispAsr.cs index ce08b43b711..9a2ad56dead 100644 --- a/src/ui/Features/Video/TextToSpeech/Engines/F5TtsCrispAsr.cs +++ b/src/ui/Features/Video/TextToSpeech/Engines/F5TtsCrispAsr.cs @@ -175,7 +175,7 @@ public static string GetSetFolder() public static string GetSetModelsFolder() { - var modelsFolder = Path.Combine(Se.CrispAsrFolder, "models"); + var modelsFolder = Se.CrispAsrModelsFolder; if (!Directory.Exists(modelsFolder)) { Directory.CreateDirectory(modelsFolder); @@ -282,8 +282,7 @@ private static void SeedVoicesFromQwen3TtsCppIfEmpty(string voicesFolder) public static string GetTalkerPath(string? modelKey = null) => Path.Combine(GetSetModelsFolder(), GetTalkerFileName(modelKey)); - public static string GetCrispAsrCacheFolder() => - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cache", "crispasr"); + public static string GetCrispAsrCacheFolder() => CrispAsrEngineBase.AutoDownloadModelsFolder; public static bool TrySeedModelFromCrispAsrCache(string fileName, string destinationPath) { @@ -597,7 +596,8 @@ bool MatchesCurrent() => } } - var process = Process.Start(psi) + CrispAsrEngineBase.ConfigureModelEnvironment(psi); + var process = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start crispasr (f5-tts)"); var launchCommand = FormatLaunchCommand(exe, psi.ArgumentList); diff --git a/src/ui/Features/Video/TextToSpeech/Engines/FireRedTts3AudioCpp.cs b/src/ui/Features/Video/TextToSpeech/Engines/FireRedTts3AudioCpp.cs index 17e8d037e3b..01846832a36 100644 --- a/src/ui/Features/Video/TextToSpeech/Engines/FireRedTts3AudioCpp.cs +++ b/src/ui/Features/Video/TextToSpeech/Engines/FireRedTts3AudioCpp.cs @@ -150,7 +150,7 @@ public static string GetSetFolder() /// public static string GetSetModelsFolder() { - var folder = Path.Combine(AudioCppRuntime.GetSetEngineFolder(), "models", "FireRedTTS3-Base-GGUF"); + var folder = Path.Combine(Se.AudioCppModelsFolder, "FireRedTTS3-Base-GGUF"); if (!Directory.Exists(folder)) { Directory.CreateDirectory(folder); diff --git a/src/ui/Features/Video/TextToSpeech/Engines/FishTtsAudioCpp.cs b/src/ui/Features/Video/TextToSpeech/Engines/FishTtsAudioCpp.cs index b97a9e06821..e804a756f7a 100644 --- a/src/ui/Features/Video/TextToSpeech/Engines/FishTtsAudioCpp.cs +++ b/src/ui/Features/Video/TextToSpeech/Engines/FishTtsAudioCpp.cs @@ -180,7 +180,7 @@ public static string GetSetFolder() /// public static string GetSetModelsFolder() { - var folder = Path.Combine(AudioCppRuntime.GetSetEngineFolder(), "models", "Fish-Audio-S2-Pro-GGUF"); + var folder = Path.Combine(Se.AudioCppModelsFolder, "Fish-Audio-S2-Pro-GGUF"); if (!Directory.Exists(folder)) { Directory.CreateDirectory(folder); diff --git a/src/ui/Features/Video/TextToSpeech/Engines/HiggsTtsAudioCpp.cs b/src/ui/Features/Video/TextToSpeech/Engines/HiggsTtsAudioCpp.cs index 738f361d080..38c089fced5 100644 --- a/src/ui/Features/Video/TextToSpeech/Engines/HiggsTtsAudioCpp.cs +++ b/src/ui/Features/Video/TextToSpeech/Engines/HiggsTtsAudioCpp.cs @@ -176,7 +176,7 @@ public static string GetSetFolder() /// public static string GetSetModelsFolder() { - var folder = Path.Combine(AudioCppRuntime.GetSetEngineFolder(), "models", "Higgs-Audio-v3-TTS-4B-GGUF"); + var folder = Path.Combine(Se.AudioCppModelsFolder, "Higgs-Audio-v3-TTS-4B-GGUF"); if (!Directory.Exists(folder)) { Directory.CreateDirectory(folder); diff --git a/src/ui/Features/Video/TextToSpeech/Engines/IndexTts25AudioCpp.cs b/src/ui/Features/Video/TextToSpeech/Engines/IndexTts25AudioCpp.cs index e3a1ba65bdd..0842640c72d 100644 --- a/src/ui/Features/Video/TextToSpeech/Engines/IndexTts25AudioCpp.cs +++ b/src/ui/Features/Video/TextToSpeech/Engines/IndexTts25AudioCpp.cs @@ -158,7 +158,7 @@ public static string GetSetFolder() /// public static string GetSetModelsFolder() { - var folder = Path.Combine(GetSetEngineFolder(), "models", "IndexTTS2.5-GGUF"); + var folder = Path.Combine(Se.AudioCppModelsFolder, "IndexTTS2.5-GGUF"); if (!Directory.Exists(folder)) { Directory.CreateDirectory(folder); diff --git a/src/ui/Features/Video/TextToSpeech/Engines/IndexTtsCrispAsr.cs b/src/ui/Features/Video/TextToSpeech/Engines/IndexTtsCrispAsr.cs index 93e294e2b03..377940cf0da 100644 --- a/src/ui/Features/Video/TextToSpeech/Engines/IndexTtsCrispAsr.cs +++ b/src/ui/Features/Video/TextToSpeech/Engines/IndexTtsCrispAsr.cs @@ -190,7 +190,7 @@ public static string GetSetModelsFolder() { // Like the other CrispASR-backed engines, the GGUFs live alongside CrispASR's // speech-to-text models in CrispASR/models/ rather than under TextToSpeech/. - var modelsFolder = Path.Combine(Se.CrispAsrFolder, "models"); + var modelsFolder = Se.CrispAsrModelsFolder; if (!Directory.Exists(modelsFolder)) { Directory.CreateDirectory(modelsFolder); @@ -269,8 +269,7 @@ public static bool AreModelsInstalled(string? modelKey = null) => /// Path crispasr's --auto-download writes GGUFs to. Mirrors the Qwen3 (CrispASR) helper so /// the SE-side downloader can adopt already-cached files instead of re-pulling ~870 MB. /// - public static string GetCrispAsrCacheFolder() => - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cache", "crispasr"); + public static string GetCrispAsrCacheFolder() => CrispAsrEngineBase.AutoDownloadModelsFolder; /// /// Best-effort copy of from @@ -578,7 +577,8 @@ private static async Task EnsureServerRunningAsync(string modelKey, string voice psi.ArgumentList.Add(voicePath); CrispAsrTtsProvenance.AddServerMarkingArgs(psi.ArgumentList, exe); - var process = Process.Start(psi) + CrispAsrEngineBase.ConfigureModelEnvironment(psi); + var process = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start crispasr (indextts)"); var launchCommand = FormatLaunchCommand(exe, psi.ArgumentList); diff --git a/src/ui/Features/Video/TextToSpeech/Engines/KokoroTtsCpp.cs b/src/ui/Features/Video/TextToSpeech/Engines/KokoroTtsCpp.cs index 385ff89222d..b2ad979f06d 100644 --- a/src/ui/Features/Video/TextToSpeech/Engines/KokoroTtsCpp.cs +++ b/src/ui/Features/Video/TextToSpeech/Engines/KokoroTtsCpp.cs @@ -135,7 +135,7 @@ public static string GetSetFolder() public static string GetSetModelsFolder() { - var modelsFolder = Path.Combine(GetSetFolder(), "models"); + var modelsFolder = Se.KokoroTtsCppModelsFolder; if (!Directory.Exists(modelsFolder)) { Directory.CreateDirectory(modelsFolder); diff --git a/src/ui/Features/Video/TextToSpeech/Engines/MossTtsCrispAsr.cs b/src/ui/Features/Video/TextToSpeech/Engines/MossTtsCrispAsr.cs index da6e5fba11a..b63fdb548e6 100644 --- a/src/ui/Features/Video/TextToSpeech/Engines/MossTtsCrispAsr.cs +++ b/src/ui/Features/Video/TextToSpeech/Engines/MossTtsCrispAsr.cs @@ -233,7 +233,7 @@ public static string GetSetFolder() public static string GetSetModelsFolder() { - var modelsFolder = Path.Combine(Se.CrispAsrFolder, "models"); + var modelsFolder = Se.CrispAsrModelsFolder; if (!Directory.Exists(modelsFolder)) { Directory.CreateDirectory(modelsFolder); @@ -344,8 +344,7 @@ public static string GetModelPath(string? modelKey = null) => public static string GetCodecPath() => Path.Combine(GetSetModelsFolder(), CodecFileName); - public static string GetCrispAsrCacheFolder() => - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cache", "crispasr"); + public static string GetCrispAsrCacheFolder() => CrispAsrEngineBase.AutoDownloadModelsFolder; public static bool TrySeedModelFromCrispAsrCache(string fileName, string destinationPath) { @@ -776,7 +775,8 @@ bool MatchesCurrent() => } } - var process = Process.Start(psi) + CrispAsrEngineBase.ConfigureModelEnvironment(psi); + var process = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start crispasr (moss-tts)"); var launchCommand = FormatLaunchCommand(exe, psi.ArgumentList); diff --git a/src/ui/Features/Video/TextToSpeech/Engines/OmniVoiceCrispAsr.cs b/src/ui/Features/Video/TextToSpeech/Engines/OmniVoiceCrispAsr.cs index 0d94657100e..4ae7c29e956 100644 --- a/src/ui/Features/Video/TextToSpeech/Engines/OmniVoiceCrispAsr.cs +++ b/src/ui/Features/Video/TextToSpeech/Engines/OmniVoiceCrispAsr.cs @@ -231,7 +231,7 @@ public static string GetSetFolder() public static string GetSetModelsFolder() { - var modelsFolder = Path.Combine(Se.CrispAsrFolder, "models"); + var modelsFolder = Se.CrispAsrModelsFolder; if (!Directory.Exists(modelsFolder)) { Directory.CreateDirectory(modelsFolder); @@ -310,8 +310,7 @@ public static string GetModelPath(string? modelKey = null) => public static string GetTokenizerPath() => Path.Combine(GetSetModelsFolder(), TokenizerFileName); - public static string GetCrispAsrCacheFolder() => - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cache", "crispasr"); + public static string GetCrispAsrCacheFolder() => CrispAsrEngineBase.AutoDownloadModelsFolder; public static bool TrySeedModelFromCrispAsrCache(string fileName, string destinationPath) { @@ -642,7 +641,8 @@ bool MatchesCurrent() => } } - var process = Process.Start(psi) + CrispAsrEngineBase.ConfigureModelEnvironment(psi); + var process = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start crispasr (omnivoice)"); var launchCommand = FormatLaunchCommand(exe, psi.ArgumentList); diff --git a/src/ui/Features/Video/TextToSpeech/Engines/OmniVoiceTtsCpp.cs b/src/ui/Features/Video/TextToSpeech/Engines/OmniVoiceTtsCpp.cs index 1f5a2a86379..1a71dd50d7b 100644 --- a/src/ui/Features/Video/TextToSpeech/Engines/OmniVoiceTtsCpp.cs +++ b/src/ui/Features/Video/TextToSpeech/Engines/OmniVoiceTtsCpp.cs @@ -167,7 +167,7 @@ public static string GetSetFolder() public static string GetSetModelsFolder() { - var modelsFolder = Path.Combine(GetSetFolder(), "models"); + var modelsFolder = Se.OmniVoiceTtsCppModelsFolder; if (!Directory.Exists(modelsFolder)) { Directory.CreateDirectory(modelsFolder); diff --git a/src/ui/Features/Video/TextToSpeech/Engines/Piper.cs b/src/ui/Features/Video/TextToSpeech/Engines/Piper.cs index b9ac46703e9..b73faf82e9a 100644 --- a/src/ui/Features/Video/TextToSpeech/Engines/Piper.cs +++ b/src/ui/Features/Video/TextToSpeech/Engines/Piper.cs @@ -84,13 +84,13 @@ public bool IsVoiceInstalled(Voice voice) return false; } - var modelFileName = Path.Combine(GetSetPiperFolder(), piperVoice.ModelShort); + var modelFileName = Path.Combine(GetSetModelsFolder(), piperVoice.ModelShort); if (!File.Exists(modelFileName)) { return false; } - var configFileName = Path.Combine(GetSetPiperFolder(), piperVoice.ConfigShort); + var configFileName = Path.Combine(GetSetModelsFolder(), piperVoice.ConfigShort); if (!File.Exists(configFileName)) { return false; @@ -131,6 +131,17 @@ public static string GetSetPiperFolder() return piperFolder; } + public static string GetSetModelsFolder() + { + var folder = Se.PiperModelsFolder; + if (!Directory.Exists(folder)) + { + Directory.CreateDirectory(folder); + } + + return folder; + } + public Task GetLanguages(Voice voice, string? model) { return Task.FromResult(Array.Empty()); @@ -185,9 +196,9 @@ private static Voice[] Map(string voiceFileName) /// /// Lists user-added voice models (a .onnx + .onnx.json pair dropped in the - /// Piper folder, e.g. via "Import voice") that are not part of the official voice list. - /// Model/Config hold bare file names - Speak runs piper with the Piper folder as working - /// directory, so bare names resolve the same way as downloaded voices. + /// Piper model folder, e.g. via "Import voice") that are not part of the official voice list. + /// Model/Config keep bare names for voice identity; execution resolves them against the + /// configured model folder so the Piper runtime itself can stay in the application-data folder. /// private static void AddCustomVoices(List result) { @@ -197,7 +208,7 @@ private static void AddCustomVoices(List result) .Select(v => v.ModelShort) .ToHashSet(StringComparer.OrdinalIgnoreCase); - foreach (var modelFileName in Directory.GetFiles(GetSetPiperFolder(), "*.onnx")) + foreach (var modelFileName in Directory.GetFiles(GetSetModelsFolder(), "*.onnx")) { var modelNameOnly = Path.GetFileName(modelFileName); if (knownModelNames.Contains(modelNameOnly)) @@ -334,7 +345,7 @@ private Process StartPiperProcess(PiperVoice voice, string inputText, string out FileName = GetPiperExecutableFileName(), // -f is quoted: the output file now lives in the caller's run folder (an absolute // path that can contain spaces), not a bare GUID name in the piper folder. - Arguments = $"-m \"{voice.ModelShort}\" -c \"{voice.ConfigShort}\" -f \"{outputFileName}\"", + Arguments = $"-m \"{Path.Combine(GetSetModelsFolder(), voice.ModelShort)}\" -c \"{Path.Combine(GetSetModelsFolder(), voice.ConfigShort)}\" -f \"{outputFileName}\"", UseShellExecute = false, CreateNoWindow = true, RedirectStandardInput = true, @@ -371,7 +382,7 @@ public Task GetModels() /// /// Imports a custom Piper voice: a .onnx model with its .onnx.json config as - /// a sibling file. Both are copied into the Piper folder and the voice shows up in the + /// a sibling file. Both are copied into the Piper model folder and the voice shows up in the /// voice list (via ) as "Custom - name". /// public bool ImportVoice(string fileName) @@ -389,9 +400,9 @@ public bool ImportVoice(string fileName) try { - var piperFolder = GetSetPiperFolder(); - var targetModel = Path.Combine(piperFolder, Path.GetFileName(fileName)); - var targetConfig = Path.Combine(piperFolder, Path.GetFileName(configFileName)); + var modelsFolder = GetSetModelsFolder(); + var targetModel = Path.Combine(modelsFolder, Path.GetFileName(fileName)); + var targetConfig = Path.Combine(modelsFolder, Path.GetFileName(configFileName)); if (!string.Equals(Path.GetFullPath(fileName), Path.GetFullPath(targetModel), StringComparison.OrdinalIgnoreCase)) { diff --git a/src/ui/Features/Video/TextToSpeech/Engines/PocketTtsCrispAsr.cs b/src/ui/Features/Video/TextToSpeech/Engines/PocketTtsCrispAsr.cs index 13dd9412195..261bc17cf91 100644 --- a/src/ui/Features/Video/TextToSpeech/Engines/PocketTtsCrispAsr.cs +++ b/src/ui/Features/Video/TextToSpeech/Engines/PocketTtsCrispAsr.cs @@ -295,8 +295,7 @@ public static bool AreModelsInstalled(string? modelKey = null) => /// Path crispasr's --auto-download writes GGUFs to. Mirrors the IndexTTS (CrispASR) helper /// so the SE-side downloader can adopt already-cached files instead of re-pulling. /// - public static string GetCrispAsrCacheFolder() => - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cache", "crispasr"); + public static string GetCrispAsrCacheFolder() => CrispAsrEngineBase.AutoDownloadModelsFolder; /// /// Best-effort copy of from @@ -696,7 +695,8 @@ private static async Task EnsureServerRunningAsync(string modelKey, string voice psi.ArgumentList.Add(voicePath); CrispAsrTtsProvenance.AddServerMarkingArgs(psi.ArgumentList, exe); - var process = Process.Start(psi) + CrispAsrEngineBase.ConfigureModelEnvironment(psi); + var process = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start crispasr (pocket-tts)"); var launchCommand = FormatLaunchCommand(exe, psi.ArgumentList); diff --git a/src/ui/Features/Video/TextToSpeech/Engines/Qwen3TtsCpp.cs b/src/ui/Features/Video/TextToSpeech/Engines/Qwen3TtsCpp.cs index 5d5250e4178..a2a5d172545 100644 --- a/src/ui/Features/Video/TextToSpeech/Engines/Qwen3TtsCpp.cs +++ b/src/ui/Features/Video/TextToSpeech/Engines/Qwen3TtsCpp.cs @@ -149,7 +149,7 @@ public static string GetSetFolder() public static string GetSetModelsFolder() { - var modelsFolder = Path.Combine(GetSetFolder(), "models"); + var modelsFolder = Se.Qwen3TtsCppModelsFolder; if (!Directory.Exists(modelsFolder)) { Directory.CreateDirectory(modelsFolder); diff --git a/src/ui/Features/Video/TextToSpeech/Engines/Qwen3TtsCrispAsr.cs b/src/ui/Features/Video/TextToSpeech/Engines/Qwen3TtsCrispAsr.cs index e08adf09414..0c132253655 100644 --- a/src/ui/Features/Video/TextToSpeech/Engines/Qwen3TtsCrispAsr.cs +++ b/src/ui/Features/Video/TextToSpeech/Engines/Qwen3TtsCrispAsr.cs @@ -253,7 +253,7 @@ public static string GetSetModelsFolder() // than under TextToSpeech/Qwen3TtsCrispAsr/. The voices folder and synth // output WAVs still live under TextToSpeech/Qwen3TtsCrispAsr/ since those // are TTS-engine state, not models. Mirrors ChatterboxTtsCpp's layout. - var modelsFolder = Path.Combine(Se.CrispAsrFolder, "models"); + var modelsFolder = Se.CrispAsrModelsFolder; if (!Directory.Exists(modelsFolder)) { Directory.CreateDirectory(modelsFolder); @@ -837,8 +837,7 @@ public static bool AreModelsInstalled(string? modelKey = null) => /// Exposed so the SE downloader can copy already-cached files instead of re-pulling 2 GB, /// and so settings UI can reference the same location. /// - public static string GetCrispAsrCacheFolder() => - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cache", "crispasr"); + public static string GetCrispAsrCacheFolder() => CrispAsrEngineBase.AutoDownloadModelsFolder; /// /// Best-effort copy of from @@ -1210,7 +1209,8 @@ private static async Task EnsureServerRunningAsync(string modelKey, Cancellation psi.ArgumentList.Add(GetSetVoicesFolder()); CrispAsrTtsProvenance.AddServerMarkingArgs(psi.ArgumentList, exe); - var process = Process.Start(psi) + CrispAsrEngineBase.ConfigureModelEnvironment(psi); + var process = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start crispasr (qwen3-tts)"); var launchCommand = FormatLaunchCommand(exe, psi.ArgumentList); diff --git a/src/ui/Features/Video/TextToSpeech/Engines/VibeVoiceCrispAsr.cs b/src/ui/Features/Video/TextToSpeech/Engines/VibeVoiceCrispAsr.cs index db856605e79..135e86574e1 100644 --- a/src/ui/Features/Video/TextToSpeech/Engines/VibeVoiceCrispAsr.cs +++ b/src/ui/Features/Video/TextToSpeech/Engines/VibeVoiceCrispAsr.cs @@ -183,7 +183,7 @@ public static string GetSetModelsFolder() // VibeVoice is driven by the CrispASR binary, so its GGUF lives alongside the // CrispASR speech-to-text models in CrispASR/models/ rather than under // TextToSpeech/VibeVoiceCrispAsr/. Matches the Qwen3 (CrispASR) layout. - var modelsFolder = Path.Combine(Se.CrispAsrFolder, "models"); + var modelsFolder = Se.CrispAsrModelsFolder; if (!Directory.Exists(modelsFolder)) { Directory.CreateDirectory(modelsFolder); @@ -257,8 +257,7 @@ public static string GetTalkerPath(string? modelKey = null) => /// Path crispasr's --auto-download writes GGUFs to. Mirrors the Qwen3 (CrispASR) helper so /// the SE-side downloader can adopt already-cached files instead of re-pulling 2.8 GB. /// - public static string GetCrispAsrCacheFolder() => - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cache", "crispasr"); + public static string GetCrispAsrCacheFolder() => CrispAsrEngineBase.AutoDownloadModelsFolder; /// /// Best-effort copy of from @@ -595,7 +594,8 @@ private static async Task EnsureServerRunningAsync(string modelKey, Cancellation psi.ArgumentList.Add(GetSetVoicesFolder()); CrispAsrTtsProvenance.AddServerMarkingArgs(psi.ArgumentList, exe); - var process = Process.Start(psi) + CrispAsrEngineBase.ConfigureModelEnvironment(psi); + var process = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start crispasr (vibevoice)"); var launchCommand = FormatLaunchCommand(exe, psi.ArgumentList); diff --git a/src/ui/Features/Video/TextToSpeech/Engines/VoxCPM2CrispAsr.cs b/src/ui/Features/Video/TextToSpeech/Engines/VoxCPM2CrispAsr.cs index 58a37739b68..cb7b13a20ab 100644 --- a/src/ui/Features/Video/TextToSpeech/Engines/VoxCPM2CrispAsr.cs +++ b/src/ui/Features/Video/TextToSpeech/Engines/VoxCPM2CrispAsr.cs @@ -210,7 +210,7 @@ public static string GetSetFolder() public static string GetSetModelsFolder() { - var modelsFolder = Path.Combine(Se.CrispAsrFolder, "models"); + var modelsFolder = Se.CrispAsrModelsFolder; if (!Directory.Exists(modelsFolder)) { Directory.CreateDirectory(modelsFolder); @@ -317,8 +317,7 @@ private static void SeedVoicesFromQwen3TtsCppIfEmpty(string voicesFolder) public static string GetModelPath(string? modelKey = null) => Path.Combine(GetSetModelsFolder(), GetModelFileName(modelKey)); - public static string GetCrispAsrCacheFolder() => - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cache", "crispasr"); + public static string GetCrispAsrCacheFolder() => CrispAsrEngineBase.AutoDownloadModelsFolder; public static bool TrySeedModelFromCrispAsrCache(string fileName, string destinationPath) { @@ -718,7 +717,8 @@ bool MatchesCurrent() => } } - var process = Process.Start(psi) + CrispAsrEngineBase.ConfigureModelEnvironment(psi); + var process = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start crispasr (voxcpm2-tts)"); var launchCommand = FormatLaunchCommand(exe, psi.ArgumentList); diff --git a/src/ui/Features/Video/TextToSpeech/Engines/ZonosTtsCrispAsr.cs b/src/ui/Features/Video/TextToSpeech/Engines/ZonosTtsCrispAsr.cs index 3bc9d246c07..f8750124537 100644 --- a/src/ui/Features/Video/TextToSpeech/Engines/ZonosTtsCrispAsr.cs +++ b/src/ui/Features/Video/TextToSpeech/Engines/ZonosTtsCrispAsr.cs @@ -165,7 +165,7 @@ public static string GetSetModelsFolder() { // Like the other CrispASR-backed engines, the GGUFs live alongside CrispASR's // speech-to-text models in CrispASR/models/ rather than under TextToSpeech/. - var modelsFolder = Path.Combine(Se.CrispAsrFolder, "models"); + var modelsFolder = Se.CrispAsrModelsFolder; if (!Directory.Exists(modelsFolder)) { Directory.CreateDirectory(modelsFolder); @@ -244,8 +244,7 @@ public static bool AreModelsInstalled() => /// Path crispasr's --auto-download writes GGUFs to. Mirrors the IndexTTS (CrispASR) helper so /// the SE-side downloader can adopt already-cached files instead of re-pulling the models. /// - public static string GetCrispAsrCacheFolder() => - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cache", "crispasr"); + public static string GetCrispAsrCacheFolder() => CrispAsrEngineBase.AutoDownloadModelsFolder; /// /// Best-effort copy of from @@ -560,7 +559,8 @@ private static async Task EnsureServerRunningAsync(string voicePath, string lang } CrispAsrTtsProvenance.AddServerMarkingArgs(psi.ArgumentList, exe); - var process = Process.Start(psi) + CrispAsrEngineBase.ConfigureModelEnvironment(psi); + var process = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start crispasr (zonos-tts)"); var launchCommand = FormatLaunchCommand(exe, psi.ArgumentList); diff --git a/src/ui/Features/Video/TextToSpeech/TtsVoiceInstaller.cs b/src/ui/Features/Video/TextToSpeech/TtsVoiceInstaller.cs index 022c54a6e23..8d8e3936b33 100644 --- a/src/ui/Features/Video/TextToSpeech/TtsVoiceInstaller.cs +++ b/src/ui/Features/Video/TextToSpeech/TtsVoiceInstaller.cs @@ -40,8 +40,11 @@ public static async Task EnsureVoiceInstalled(ITtsEngine engine, Voice voi window, vm => vm.StartDownloadPiperVoice(piperVoice)); if (!dlResult.OkPressed) { - SafeDelete(Path.Combine(Piper.GetSetPiperFolder(), piperVoice.ModelShort)); - SafeDelete(Path.Combine(Piper.GetSetPiperFolder(), piperVoice.ConfigShort)); + foreach (var fileName in GetPiperVoiceModelFiles(piperVoice)) + { + SafeDelete(fileName); + } + return false; } } @@ -49,6 +52,16 @@ public static async Task EnsureVoiceInstalled(ITtsEngine engine, Voice voi return true; } + internal static string[] GetPiperVoiceModelFiles(PiperVoice piperVoice) + { + var modelsFolder = Piper.GetSetModelsFolder(); + return + [ + Path.Combine(modelsFolder, piperVoice.ModelShort), + Path.Combine(modelsFolder, piperVoice.ConfigShort), + ]; + } + /// /// Ensures the CrispASR runtime that Chatterbox TTS runs on is installed and new enough. /// When is true the download runs even if CrispASR is diff --git a/src/ui/Logic/Config/Language/Options/LanguageSettings.cs b/src/ui/Logic/Config/Language/Options/LanguageSettings.cs index f7ee0a864f9..92b191d9f5e 100644 --- a/src/ui/Logic/Config/Language/Options/LanguageSettings.cs +++ b/src/ui/Logic/Config/Language/Options/LanguageSettings.cs @@ -262,6 +262,7 @@ public class LanguageSettings public string DefaultSaveLocationVideoFileFolder { get; set; } public string DefaultSaveLocationSubtitleFileFolder { get; set; } public string DefaultSaveLocationCustomFolder { get; set; } + public string ModelsFolder { get; set; } = "AI models folder"; public string SaveAsAppendLanguageCode { get; set; } public string GridGoToSubtitleAndSetVideoPosition { get; set; } public string GridGoToNextLine { get; set; } @@ -590,6 +591,7 @@ public LanguageSettings() DefaultSaveLocationVideoFileFolder = "Video file folder"; DefaultSaveLocationSubtitleFileFolder = "Subtitle file folder"; DefaultSaveLocationCustomFolder = "Custom folder"; + ModelsFolder = "AI models folder"; SaveAsAppendLanguageCode = "\"Save as\" append language code"; GridGoToSubtitleAndSetVideoPosition = "Go to subtitle and set video position"; GridGoToNextLine = "Go to next line"; diff --git a/src/ui/Logic/Config/Se.cs b/src/ui/Logic/Config/Se.cs index b633ee217bf..2180b97d334 100644 --- a/src/ui/Logic/Config/Se.cs +++ b/src/ui/Logic/Config/Se.cs @@ -3,6 +3,7 @@ using Nikse.SubtitleEdit.Core.SubtitleFormats; using Nikse.SubtitleEdit.Features.Main; using Nikse.SubtitleEdit.Logic.Config.Language; +using Nikse.SubtitleEdit.UiLogic.LlamaCpp; using Nikse.SubtitleEdit.UiLogic.Ocr; using System; using System.Collections.Generic; @@ -202,6 +203,70 @@ public static string DictionariesFolder // level like CrispASR and llama.cpp rather than under a single feature's folder — the // binaries are shared the moment a second audio.cpp-backed engine is added. public static string AudioCppFolder => Path.Combine(DataFolder, "audio.cpp"); + + /// + /// Root for downloaded AI models. Empty preserves the + /// existing per-user layout. The configured path is normalized so a relative value from an + /// older hand-edited Settings.json cannot make model paths relative to the process folder. + /// + public static string ModelsFolder + { + get + { + var configured = Settings?.General?.ModelsFolder; + if (string.IsNullOrWhiteSpace(configured)) + { + return DataFolder; + } + + try + { + if (!Path.IsPathFullyQualified(configured)) + { + // The UI normally stores an absolute path from the folder picker. Reject a + // hand-edited relative value rather than making model storage depend on the + // process working directory. + return DataFolder; + } + + return Path.TrimEndingDirectorySeparator(Path.GetFullPath(configured)); + } + catch + { + // A malformed hand-edited value must not prevent Subtitle Edit from starting. + // The settings page always writes a path returned by the folder picker. + return DataFolder; + } + } + } + + public static bool HasCustomModelsFolder => !string.Equals( + ModelsFolder, + Path.TrimEndingDirectorySeparator(DataFolder), + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + + /// + /// Resolves a model directory while retaining the exact historical path when no custom root + /// is configured. This lets every engine opt in without migrating or hiding existing models. + /// + public static string ResolveModelsFolder(string legacyFolder, params string[] relativePath) + { + if (!HasCustomModelsFolder) + { + return legacyFolder; + } + + return Path.Combine(new[] { ModelsFolder }.Concat(relativePath).ToArray()); + } + + public static string CrispAsrModelsFolder => Path.Combine(ModelsFolder, "CrispASR", "models"); + public static string LlamaCppModelsFolder => Path.Combine(ModelsFolder, "llama.cpp", "models"); + public static string AudioCppModelsFolder => Path.Combine(ModelsFolder, "audio.cpp", "models"); + public static string Qwen3AsrModelsFolder => Path.Combine(ModelsFolder, "Qwen3ASR", "models"); + public static string Qwen3TtsCppModelsFolder => ResolveModelsFolder(Path.Combine(TextToSpeechFolder, "Qwen3TtsCpp", "models"), "TextToSpeech", "Qwen3TtsCpp", "models"); + public static string KokoroTtsCppModelsFolder => ResolveModelsFolder(Path.Combine(TextToSpeechFolder, "KokoroTtsCpp", "models"), "TextToSpeech", "KokoroTtsCpp", "models"); + public static string OmniVoiceTtsCppModelsFolder => ResolveModelsFolder(Path.Combine(TextToSpeechFolder, "OmniVoice", "models"), "TextToSpeech", "OmniVoice", "models"); + public static string PiperModelsFolder => ResolveModelsFolder(Path.Combine(TextToSpeechFolder, "Piper"), "TextToSpeech", "Piper", "models"); public static string WaveformsFolder => Path.Combine(DataFolder, "Waveforms"); public static string SpectrogramsFolder => Path.Combine(DataFolder, "Spectrograms"); public static string ShotChangesFolder => Path.Combine(DataFolder, "ShotChanges"); @@ -224,7 +289,7 @@ public static string DictionariesFolder // devanagari PP-OCRv5 or el/ta/te/th/ka models at all). A new folder means engine and // models always come from the same release. public static string PaddleOcrFolder => Path.Combine(OcrFolder, "PaddleOCR3-7"); - public static string PaddleOcrModelsFolder => Path.Combine(PaddleOcrFolder, "models"); + public static string PaddleOcrModelsFolder => ResolveModelsFolder(Path.Combine(PaddleOcrFolder, "models"), "OCR", "PaddleOCR3-7", "models"); /// Install folders of superseded PaddleOCR versions, deleted after a new install succeeds. public static IReadOnlyList PaddleOcrLegacyFolders => new[] @@ -234,6 +299,7 @@ public static string DictionariesFolder }; public static string GoogleLensOcrFolder => Path.Combine(OcrFolder, "Google-Lens"); public static string CrispEmbedFolder => Path.Combine(OcrFolder, "CrispEmbed"); + public static string CrispEmbedModelsFolder => ResolveModelsFolder(Path.Combine(CrispEmbedFolder, "models"), "OCR", "CrispEmbed", "models"); public static string VlcFolder => Path.Combine(DataFolder, "VLC"); public static string SevenZipFolder => Path.Combine(DataFolder, "7Zip"); private const string TesseractFolderName = "Tesseract"; @@ -308,7 +374,9 @@ private static string ResolveTesseractFolder() } private static readonly Lazy _tesseractModelFolder = new(ResolveTesseractModelFolder); - public static string TesseractModelFolder => _tesseractModelFolder.Value; + public static string TesseractModelFolder => HasCustomModelsFolder + ? Path.Combine(ModelsFolder, "OCR", "Tesseract", "tessdata") + : _tesseractModelFolder.Value; private static string ResolveTesseractModelFolder() { @@ -558,6 +626,7 @@ public static void SaveSettings(string settingsFileName) } UpdateLibSeSettings(); + LlamaCppServerManager.ModelsFolderOverride = LlamaCppModelsFolder; } public static void LoadSettings() @@ -970,6 +1039,7 @@ private static string GetSeInfo() internal static void UpdateLibSeSettings() { + Configuration.ModelsDirectory = HasCustomModelsFolder ? ModelsFolder : string.Empty; Configuration.Settings.General.FFmpegLocation = Settings.General.FfmpegPath; Configuration.Settings.General.UseTimeFormatHHMMSSFF = Settings.General.UseFrameMode; if (Settings.General.CurrentFrameRate > 0) diff --git a/src/ui/Logic/Config/SeGeneral.cs b/src/ui/Logic/Config/SeGeneral.cs index c4c525e15dc..d60fc26d420 100644 --- a/src/ui/Logic/Config/SeGeneral.cs +++ b/src/ui/Logic/Config/SeGeneral.cs @@ -74,6 +74,11 @@ public bool UseFrameMode public string SaveAsAppendLanguageCode { get; set; } public string DefaultSaveLocation { get; set; } public string DefaultSaveLocationCustomFolder { get; set; } + /// + /// Optional root for downloaded AI models. An empty value keeps the historical per-user + /// locations, which makes the setting backwards compatible for existing installations. + /// + public string ModelsFolder { get; set; } public bool AutoConvertToUtf8 { get; set; } public bool AutoGuessAnsiEncoding { get; set; } public int MaxNumberOfLinesPlusAbort { get; set; } @@ -267,6 +272,7 @@ public SeGeneral() SaveAsBehavior = nameof(SaveAsBehaviourType.UseVideoFileNameThenSubtitleFileName); DefaultSaveLocation = nameof(DefaultSaveLocationType.SourceFileFolder); DefaultSaveLocationCustomFolder = string.Empty; + ModelsFolder = string.Empty; SaveAsAppendLanguageCode = nameof(SaveAsLanguageAppendType.None); AutoConvertToUtf8 = false; AutoGuessAnsiEncoding = true; diff --git a/tests/UI/Logic/Config/DataFolderLocationTests.cs b/tests/UI/Logic/Config/DataFolderLocationTests.cs index 60d6ee56fb2..f1e44bdd2d1 100644 --- a/tests/UI/Logic/Config/DataFolderLocationTests.cs +++ b/tests/UI/Logic/Config/DataFolderLocationTests.cs @@ -1,4 +1,10 @@ using Nikse.SubtitleEdit.Logic.Config; +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Features.Video.SpeechToText.Engines; +using Nikse.SubtitleEdit.Features.Video.TextToSpeech; +using Nikse.SubtitleEdit.Features.Video.TextToSpeech.Engines; +using Nikse.SubtitleEdit.Features.Video.TextToSpeech.Voices; +using Nikse.SubtitleEdit.UiLogic.AudioToText; namespace UITests.Logic.Config; @@ -99,4 +105,312 @@ public void RuntimeFolders_AreAllUnderTheDataFolder() Assert.StartsWith(Se.DataFolder, folder, StringComparison.Ordinal); } } + + [Fact] + public void EmptyModelsFolder_PreservesLegacyLocations() + { + var original = Se.Settings.General.ModelsFolder; + var originalModelsDirectory = Configuration.ModelsDirectory; + try + { + Se.Settings.General.ModelsFolder = string.Empty; + Configuration.ModelsDirectory = string.Empty; + + Assert.Equal(Se.DataFolder, Se.ModelsFolder); + Assert.False(Se.HasCustomModelsFolder); + Assert.Equal( + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cache", "whisper"), + new WhisperModel().ModelFolder); + Assert.Equal(Path.Combine(Se.TextToSpeechFolder, "Piper"), Se.PiperModelsFolder); + } + finally + { + Se.Settings.General.ModelsFolder = original; + Configuration.ModelsDirectory = originalModelsDirectory; + } + } + + [Fact] + public void DataFolderWithTrailingSeparator_RemainsLegacyLayout() + { + var original = Se.Settings.General.ModelsFolder; + try + { + Se.Settings.General.ModelsFolder = Se.DataFolder + Path.DirectorySeparatorChar; + + Assert.Equal(Path.TrimEndingDirectorySeparator(Se.DataFolder), Se.ModelsFolder); + Assert.False(Se.HasCustomModelsFolder); + } + finally + { + Se.Settings.General.ModelsFolder = original; + } + } + + [Fact] + public void WindowsDataFolderComparison_IsCaseInsensitive() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + var original = Se.Settings.General.ModelsFolder; + try + { + Se.Settings.General.ModelsFolder = Se.DataFolder.ToUpperInvariant(); + + Assert.False(Se.HasCustomModelsFolder); + } + finally + { + Se.Settings.General.ModelsFolder = original; + } + } + + [Fact] + public void RelativeModelsFolder_FallsBackToDataFolder() + { + var original = Se.Settings.General.ModelsFolder; + try + { + Se.Settings.General.ModelsFolder = Path.Combine("relative", "models"); + + Assert.Equal(Se.DataFolder, Se.ModelsFolder); + Assert.False(Se.HasCustomModelsFolder); + } + finally + { + Se.Settings.General.ModelsFolder = original; + } + } + + [Fact] + public void CustomModelsFolder_UsesTheSelectedRootWithoutChangingTheAppDataFolder() + { + var original = Se.Settings.General.ModelsFolder; + var originalModelsDirectory = Configuration.ModelsDirectory; + var selected = Path.Combine(Path.GetTempPath(), "subtitle-edit-models-" + Guid.NewGuid().ToString("N")); + try + { + Se.Settings.General.ModelsFolder = selected; + Configuration.ModelsDirectory = selected; + + Assert.Equal(Path.GetFullPath(selected), Se.ModelsFolder); + Assert.True(Se.HasCustomModelsFolder); + Assert.Equal(Se.DataFolder, Path.GetDirectoryName(Se.GetErrorLogFilePath())); + Assert.Equal(Path.Combine(selected, "CrispASR", "models"), Se.CrispAsrModelsFolder); + Assert.Equal( + Path.Combine(selected, "SpeechToText", "whisper"), + new WhisperModel().ModelFolder); + Assert.Equal( + Path.Combine(selected, "TextToSpeech", "Piper", "models"), + Se.PiperModelsFolder); + } + finally + { + Se.Settings.General.ModelsFolder = original; + Configuration.ModelsDirectory = originalModelsDirectory; + } + } + + [Fact] + public void CustomModelsFolder_CanceledPiperVoiceCleanupTargetsModelRoot() + { + var original = Se.Settings.General.ModelsFolder; + var originalModelsDirectory = Configuration.ModelsDirectory; + var selected = Path.Combine(Path.GetTempPath(), "subtitle-edit-piper-cleanup-" + Guid.NewGuid().ToString("N")); + try + { + Se.Settings.General.ModelsFolder = selected; + Configuration.ModelsDirectory = selected; + var voice = new PiperVoice( + "Test voice", + "English", + "medium", + "en/test-medium.onnx", + "en/test-medium.onnx.json"); + + var files = TtsVoiceInstaller.GetPiperVoiceModelFiles(voice); + + Assert.Equal( + [ + Path.Combine(selected, "TextToSpeech", "Piper", "models", "test-medium.onnx"), + Path.Combine(selected, "TextToSpeech", "Piper", "models", "test-medium.onnx.json"), + ], + files); + Assert.All(files, file => Assert.DoesNotContain(Piper.GetSetPiperFolder(), file, StringComparison.Ordinal)); + } + finally + { + Se.Settings.General.ModelsFolder = original; + Configuration.ModelsDirectory = originalModelsDirectory; + if (Directory.Exists(selected)) + { + Directory.Delete(selected, recursive: true); + } + } + } + + [Fact] + public void CustomModelsFolder_RoutesAllCurrentAudioCppTtsModelsUnderSelectedRoot() + { + var original = Se.Settings.General.ModelsFolder; + var originalModelsDirectory = Configuration.ModelsDirectory; + var selected = Path.Combine(Path.GetTempPath(), "subtitle-edit-audiocpp-models-" + Guid.NewGuid().ToString("N")); + try + { + Se.Settings.General.ModelsFolder = selected; + Configuration.ModelsDirectory = selected; + + Assert.Equal( + Path.Combine(selected, "audio.cpp", "models", "IndexTTS2.5-GGUF"), + IndexTts25AudioCpp.GetSetModelsFolder()); + Assert.Equal( + Path.Combine(selected, "audio.cpp", "models", "FireRedTTS3-Base-GGUF"), + FireRedTts3AudioCpp.GetSetModelsFolder()); + Assert.Equal( + Path.Combine(selected, "audio.cpp", "models", "Fish-Audio-S2-Pro-GGUF"), + FishTtsAudioCpp.GetSetModelsFolder()); + Assert.Equal( + Path.Combine(selected, "audio.cpp", "models", "Higgs-Audio-v3-TTS-4B-GGUF"), + HiggsTtsAudioCpp.GetSetModelsFolder()); + } + finally + { + Se.Settings.General.ModelsFolder = original; + Configuration.ModelsDirectory = originalModelsDirectory; + if (Directory.Exists(selected)) + { + Directory.Delete(selected, recursive: true); + } + } + } + + [Fact] + public void CustomModelsFolder_ConfiguresWhisperXHuggingFaceCacheUnderSelectedRoot() + { + var original = Se.Settings.General.ModelsFolder; + var selected = Path.Combine(Path.GetTempPath(), "subtitle-edit-whisperx-models-" + Guid.NewGuid().ToString("N")); + try + { + Se.Settings.General.ModelsFolder = selected; + var startInfo = new System.Diagnostics.ProcessStartInfo(); + + WhisperEngineWhisperX.ConfigureModelEnvironment(startInfo); + + Assert.Equal( + Path.Combine(selected, "SpeechToText", "HuggingFace"), + startInfo.EnvironmentVariables["HF_HOME"]); + } + finally + { + Se.Settings.General.ModelsFolder = original; + } + } + + [Fact] + public void LegacyModelsLayout_DoesNotOverrideCallerWhisperXHuggingFaceCache() + { + var original = Se.Settings.General.ModelsFolder; + try + { + Se.Settings.General.ModelsFolder = string.Empty; + var startInfo = new System.Diagnostics.ProcessStartInfo(); + startInfo.EnvironmentVariables["HF_HOME"] = "keep-hf-home"; + + WhisperEngineWhisperX.ConfigureModelEnvironment(startInfo); + + Assert.Equal("keep-hf-home", startInfo.EnvironmentVariables["HF_HOME"]); + } + finally + { + Se.Settings.General.ModelsFolder = original; + } + } + + [Fact] + public void CustomModelsFolder_ConfiguresCrispAsrAutoDownloadsUnderSelectedRoot() + { + var original = Se.Settings.General.ModelsFolder; + var originalModelsDirectory = Configuration.ModelsDirectory; + var selected = Path.Combine(Path.GetTempPath(), "subtitle-edit-crispasr-models-" + Guid.NewGuid().ToString("N")); + try + { + Se.Settings.General.ModelsFolder = selected; + Configuration.ModelsDirectory = selected; + var startInfo = new System.Diagnostics.ProcessStartInfo(); + + CrispAsrEngineBase.ConfigureModelEnvironment(startInfo); + + var expected = Path.Combine(selected, "CrispASR", "models"); + Assert.Equal(expected, CrispAsrEngineBase.AutoDownloadModelsFolder); + Assert.Equal(expected, startInfo.EnvironmentVariables["CRISPASR_MODELS_DIR"]); + Assert.Equal(expected, startInfo.EnvironmentVariables["CRISPASR_CACHE_DIR"]); + Assert.True(Directory.Exists(expected)); + } + finally + { + Se.Settings.General.ModelsFolder = original; + Configuration.ModelsDirectory = originalModelsDirectory; + if (Directory.Exists(selected)) + { + Directory.Delete(selected, recursive: true); + } + } + } + + [Fact] + public void LegacyModelsLayout_ReusesCrispAsrEnvironmentPrecedence() + { + var originalModelsFolder = Se.Settings.General.ModelsFolder; + var originalModelsDirectory = Configuration.ModelsDirectory; + var originalCacheEnvironment = Environment.GetEnvironmentVariable("CRISPASR_CACHE_DIR"); + var originalModelsEnvironment = Environment.GetEnvironmentVariable("CRISPASR_MODELS_DIR"); + try + { + Se.Settings.General.ModelsFolder = string.Empty; + Configuration.ModelsDirectory = string.Empty; + Environment.SetEnvironmentVariable("CRISPASR_MODELS_DIR", "models-root"); + Environment.SetEnvironmentVariable("CRISPASR_CACHE_DIR", "cache-root"); + + Assert.Equal("cache-root", CrispAsrEngineBase.AutoDownloadModelsFolder); + + Environment.SetEnvironmentVariable("CRISPASR_CACHE_DIR", null); + + Assert.Equal("models-root", CrispAsrEngineBase.AutoDownloadModelsFolder); + } + finally + { + Environment.SetEnvironmentVariable("CRISPASR_CACHE_DIR", originalCacheEnvironment); + Environment.SetEnvironmentVariable("CRISPASR_MODELS_DIR", originalModelsEnvironment); + Se.Settings.General.ModelsFolder = originalModelsFolder; + Configuration.ModelsDirectory = originalModelsDirectory; + } + } + + [Fact] + public void LegacyModelsLayout_DoesNotOverrideCallerCrispAsrEnvironment() + { + var original = Se.Settings.General.ModelsFolder; + var originalModelsDirectory = Configuration.ModelsDirectory; + try + { + Se.Settings.General.ModelsFolder = string.Empty; + Configuration.ModelsDirectory = string.Empty; + var startInfo = new System.Diagnostics.ProcessStartInfo(); + startInfo.EnvironmentVariables["CRISPASR_MODELS_DIR"] = "keep-models"; + startInfo.EnvironmentVariables["CRISPASR_CACHE_DIR"] = "keep-cache"; + + CrispAsrEngineBase.ConfigureModelEnvironment(startInfo); + + Assert.Equal("keep-models", startInfo.EnvironmentVariables["CRISPASR_MODELS_DIR"]); + Assert.Equal("keep-cache", startInfo.EnvironmentVariables["CRISPASR_CACHE_DIR"]); + } + finally + { + Se.Settings.General.ModelsFolder = original; + Configuration.ModelsDirectory = originalModelsDirectory; + } + } }