Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions docs/issue-14024-models-folder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Issue #14024 - configurable AI models folder

## Status

Implemented locally on 2026-08-23. The change is uncommitted and has not been pushed or opened as a pull request.

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 | `<data>/CrispASR/models` | The executable and model files were coupled to one root. |
| llama.cpp | `<data>/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=<selected root>/SpeechToText`, which makes its normal `whisper` cache resolve under the selected root.
- CTranslate2 receives `HF_HOME=<selected root>/SpeechToText/HuggingFace`, which makes its normal `hub` cache resolve under the selected root.

## 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 and CTranslate2 cache folders in `src/libuilogic/AudioToText`.
- 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.
- llama.cpp model files, while its server executable remains in the normal data folder.
- PaddleOCR models, CrispEmbed models, and Tesseract `tessdata`.
- IndexTTS 2.5 audio.cpp model files.

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
```

Result: **6 passed, 0 failed** on `net10.0`.

The test coverage verifies:

- Empty setting preserves the historical data and Whisper cache paths.
- A custom absolute path is normalized and used for CrispASR and Whisper model paths.
- The application-data folder and error-log path remain unchanged when the model root changes.

Restore/build emitted existing `NU1900` vulnerability-feed access warnings from the sandboxed NuGet cache. They did not prevent compilation or test execution. No production model was downloaded during verification.

## 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. For example, CrispASR's optional auto-download behavior may still use its own cache when a backend downloads additional assets internally; Subtitle Edit-managed model downloads use the selected CrispASR model folder.
- 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.
18 changes: 18 additions & 0 deletions src/libse/Common/Configuration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,28 @@ public class Configuration
{
public static string BaseDirectory = string.Empty;
public static string DataDirectory = string.Empty;
/// <summary>Optional root for downloaded AI models, set by the UI configuration layer.</summary>
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()
Expand Down
17 changes: 4 additions & 13 deletions src/libuilogic/AudioToText/WhisperCTranslate2Model.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using Nikse.SubtitleEdit.Core.Common;

namespace Nikse.SubtitleEdit.UiLogic.AudioToText
{
Expand All @@ -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);
Expand Down
4 changes: 3 additions & 1 deletion src/libuilogic/AudioToText/WhisperConstMeModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}

Expand Down
4 changes: 3 additions & 1 deletion src/libuilogic/AudioToText/WhisperCppModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}

Expand Down
11 changes: 4 additions & 7 deletions src/libuilogic/AudioToText/WhisperModel.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.IO;
using Nikse.SubtitleEdit.Core.Common;

namespace Nikse.SubtitleEdit.UiLogic.AudioToText
{
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
11 changes: 10 additions & 1 deletion src/libuilogic/LlamaCpp/LlamaCppServerManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,13 @@ public static class LlamaCppServerManager
/// </summary>
public static string? FolderOverride { get; set; }

/// <summary>
/// Optional model-only folder. When unset, models remain under <see cref="FolderOverride"/>
/// for backwards compatibility, while the executable can continue to live in the normal
/// Subtitle Edit data folder.
/// </summary>
public static string? ModelsFolderOverride { get; set; }

/// <summary>
/// Optional info-level log sink. The UI wires this to the tools log
/// (<c>Se.WriteToolsLog</c>); seconv wires it to --verbose console output.
Expand Down Expand Up @@ -391,7 +398,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);
Expand Down
3 changes: 2 additions & 1 deletion src/ui/Assets/Languages/English.json
Original file line number Diff line number Diff line change
Expand Up @@ -2990,6 +2990,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",
Expand Down Expand Up @@ -3800,4 +3801,4 @@
"detail": "Detail",
"tip": "Double-click a line (or press Enter) to go to it. Which checks run, and their limits, are set in Settings > General."
}
}
}
3 changes: 2 additions & 1 deletion src/ui/DependencyInjectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,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
Expand Down Expand Up @@ -616,4 +617,4 @@ private static void AddHttpClientWithProxy<TClient, TImplementation>(this IServi
})
.ConfigurePrimaryHttpMessageHandler(() => HttpClientFactoryWithProxy.CreateHandler());
}
}
}
2 changes: 1 addition & 1 deletion src/ui/Features/Ocr/Engines/CrispEmbedEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
13 changes: 12 additions & 1 deletion src/ui/Features/Options/Settings/SettingsPage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,17 @@ private List<SettingsSection> 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)),
Expand Down Expand Up @@ -1372,4 +1383,4 @@ protected override void OnLoaded(RoutedEventArgs e)
_searchBox.Focus(); // hack to make OnKeyDown work
});
}
}
}
18 changes: 18 additions & 0 deletions src/ui/Features/Options/Settings/SettingsViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ partial void OnMaxLinesChanged(int? value)
[ObservableProperty] private ObservableCollection<string> _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;

Expand Down Expand Up @@ -796,6 +797,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;
Expand Down Expand Up @@ -1358,6 +1360,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))
Expand Down Expand Up @@ -1684,6 +1701,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;
Expand Down
2 changes: 1 addition & 1 deletion src/ui/Features/Video/SpeechToText/Engines/CrispAsrArk.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,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);
Expand Down
Loading