From 3bf0de7c7d1d7252845e4c1f9b80dab240b69c29 Mon Sep 17 00:00:00 2001 From: malidev <46960964+malithedeveloper@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:01:18 +0300 Subject: [PATCH] Release VoxLocal 1.1.2 model startup fixes --- .github/workflows/build.yml | 24 +-- CHANGELOG.md | 12 ++ CMakeLists.txt | 3 +- README.md | 23 ++- buildspec.json | 2 +- cmake/onnxruntime.cmake | 4 +- cmake/package-installer.cmake | 6 +- docs/RELEASE_NOTES_1.1.2.md | 34 ++++ include/voxlocal/runtime.hpp | 6 + include/voxlocal/speech-queue.hpp | 1 + include/voxlocal/types.hpp | 4 +- include/voxlocal/voxlocal-dock.hpp | 4 + include/voxlocal/welcome-wizard.hpp | 6 + .../meta/installscript.qs | 26 +++ .../meta/package.xml.in | 1 + src/chatterbox-engine.cpp | 151 ++++++++++++---- src/config-store.cpp | 25 +++ src/model-manager.cpp | 2 + src/plugin-main.cpp | 148 +++++++++++++-- src/runtime.cpp | 64 +++++-- src/speech-queue.cpp | 8 + src/voxlocal-dock.cpp | 58 +++++- src/welcome-wizard.cpp | 168 +++++++++++------- tests/core-tests.cpp | 42 ++++- 24 files changed, 661 insertions(+), 161 deletions(-) create mode 100644 docs/RELEASE_NOTES_1.1.2.md create mode 100644 installer/packages/dev.malithedeveloper.voxlocal/meta/installscript.qs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ecb199a..9268625 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -74,15 +74,15 @@ jobs: cmake -DVOXLOCAL_PLATFORM=linux -DVOXLOCAL_PAYLOAD_DIR=${{ github.workspace }}/package/plugin - -DVOXLOCAL_OUTPUT=${{ github.workspace }}/release/VoxLocal-Setup-1.1.1-Linux-x86_64.run + -DVOXLOCAL_OUTPUT=${{ github.workspace }}/release/VoxLocal-Setup-1.1.2-Linux-x86_64.run -DVOXLOCAL_WORK_DIR=${{ github.workspace }}/build/installer-linux -P cmake/package-installer.cmake - name: Mark setup executable - run: chmod +x release/VoxLocal-Setup-1.1.1-Linux-x86_64.run + run: chmod +x release/VoxLocal-Setup-1.1.2-Linux-x86_64.run - uses: actions/upload-artifact@v4 with: - name: VoxLocal-Setup-1.1.1-Linux-x86_64 - path: release/VoxLocal-Setup-1.1.1-Linux-x86_64.run + name: VoxLocal-Setup-1.1.2-Linux-x86_64 + path: release/VoxLocal-Setup-1.1.2-Linux-x86_64.run if-no-files-found: error windows-plugin: @@ -170,13 +170,13 @@ jobs: cmake -DVOXLOCAL_PLATFORM=windows -DVOXLOCAL_PAYLOAD_DIR=${{ github.workspace }}/package/plugin - -DVOXLOCAL_OUTPUT=${{ github.workspace }}/release/VoxLocal-Setup-1.1.1-Windows-x64.exe + -DVOXLOCAL_OUTPUT=${{ github.workspace }}/release/VoxLocal-Setup-1.1.2-Windows-x64.exe -DVOXLOCAL_WORK_DIR=${{ github.workspace }}/build/installer-windows -P cmake/package-installer.cmake - uses: actions/upload-artifact@v4 with: - name: VoxLocal-Setup-1.1.1-Windows-x64 - path: release/VoxLocal-Setup-1.1.1-Windows-x64.exe + name: VoxLocal-Setup-1.1.2-Windows-x64 + path: release/VoxLocal-Setup-1.1.2-Windows-x64.exe if-no-files-found: error macos-plugin: @@ -281,13 +281,13 @@ jobs: cmake -DVOXLOCAL_PLATFORM=macos -DVOXLOCAL_PAYLOAD_DIR=${{ github.workspace }}/package/plugin - -DVOXLOCAL_OUTPUT=${{ github.workspace }}/release/VoxLocal-Setup-1.1.1-macOS-universal.dmg + -DVOXLOCAL_OUTPUT=${{ github.workspace }}/release/VoxLocal-Setup-1.1.2-macOS-universal.dmg -DVOXLOCAL_WORK_DIR=${{ github.workspace }}/build/installer-macos -P cmake/package-installer.cmake - uses: actions/upload-artifact@v4 with: - name: VoxLocal-Setup-1.1.1-macOS-universal - path: release/VoxLocal-Setup-1.1.1-macOS-universal.dmg + name: VoxLocal-Setup-1.1.2-macOS-universal + path: release/VoxLocal-Setup-1.1.2-macOS-universal.dmg if-no-files-found: error release: @@ -301,11 +301,11 @@ jobs: - uses: actions/checkout@v4 - uses: actions/download-artifact@v4 with: - pattern: VoxLocal-Setup-1.1.1-* + pattern: VoxLocal-Setup-1.1.2-* path: dist merge-multiple: true - uses: softprops/action-gh-release@v2 with: - body_path: docs/RELEASE_NOTES_1.1.1.md + body_path: docs/RELEASE_NOTES_1.1.2.md files: dist/* fail_on_unmatched_files: true diff --git a/CHANGELOG.md b/CHANGELOG.md index b014e06..f15df3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 1.1.2 — 2026-08-14 + +- Fixed Windows model startup by applying DirectML's required sequential/no-memory-pattern session settings. +- Updated Windows to Microsoft ONNX Runtime DirectML 1.24.4 and added automatic strongest-GPU selection with full CPU fallback. +- Moved model initialization to a background worker after OBS finishes loading, preventing the OBS interface from freezing during model startup. +- Added `Ask at startup`, `Load automatically`, and `Do not load` model-memory policies to both Welcome and the VoxLocal dock; the default is `Ask at startup`. +- Kept the VoxLocal dock hidden until Welcome finishes and made Welcome non-dismissible, while adding a `Skip` button to every page so optional setup sections never block progress. +- Added a manual `Load model now` action and a visible loaded backend indicator to the dock. +- Logged model initialization and inference failures to the OBS log instead of showing them only in the dock. +- Prevented harmless reads from an already-closed TLS reply after a completed model download. +- Made the Windows setup remove VoxLocal's obsolete `%APPDATA%` plugin copy when installing to `%ProgramData%`, avoiding duplicate-version conflicts. + ## 1.1.1 — 2026-08-13 - Updated the Windows and macOS SDK builds to OBS Studio 32.2.1 and its matching 2026-07-15 dependencies. diff --git a/CMakeLists.txt b/CMakeLists.txt index f2b8b91..f883109 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.28) -project(VoxLocal VERSION 1.1.1 LANGUAGES CXX) +project(VoxLocal VERSION 1.1.2 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -87,6 +87,7 @@ if(VOXLOCAL_ENABLE_ONNX) target_compile_definitions(voxlocal-core PRIVATE VOXLOCAL_HAVE_ONNXRUNTIME=1) if(VOXLOCAL_ORT_DIRECTML) target_compile_definitions(voxlocal-core PRIVATE VOXLOCAL_ORT_DIRECTML=1) + target_link_libraries(voxlocal-core PRIVATE dxgi) endif() else() message(STATUS "ONNX Runtime not found: VoxLocal will build with model-management support but inference disabled") diff --git a/README.md b/README.md index dcdae2a..4c17323 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ VoxLocal is a native OBS Studio plugin that turns live Kick chat commands into l ## Features - Local Chatterbox Multilingual ONNX inference with Turkish and 22 other languages, including safe handling for tokenizer characters that are absent from the exported embedding table. +- Non-blocking model initialization after OBS is ready, with `Ask at startup`, `Load automatically`, and `Do not load` memory policies. +- Automatic hardware backend selection with DirectML GPU/CPU fallback on Windows, CoreML detection on macOS, and CUDA detection when a CUDA-enabled Linux runtime is supplied. - Checksum-verified model download with pause/resume support and detailed byte progress. - Zero-shot voice cloning from audio or video. FFmpeg extracts the first audio track and stores a validated 24 kHz mono WAV locally. - Multiple personas, each with its own chat command, language mode, voice sample, and viewer role policy. @@ -45,15 +47,15 @@ VoxLocal attempts DirectML on Windows, CoreML on macOS, and CUDA on Linux when t Close OBS, download the one setup matching your platform from [Releases](../../releases), and run it: -- `VoxLocal-Setup-1.1.1-Windows-x64.exe` -- `VoxLocal-Setup-1.1.1-macOS-universal.dmg` for Apple Silicon and Intel Macs -- `VoxLocal-Setup-1.1.1-Linux-x86_64.run` +- `VoxLocal-Setup-1.1.2-Windows-x64.exe` +- `VoxLocal-Setup-1.1.2-macOS-universal.dmg` for Apple Silicon and Intel Macs +- `VoxLocal-Setup-1.1.2-Linux-x86_64.run` On Linux, make the downloaded setup executable first if your browser removed its executable bit: ```bash -chmod +x VoxLocal-Setup-1.1.1-Linux-x86_64.run -./VoxLocal-Setup-1.1.1-Linux-x86_64.run +chmod +x VoxLocal-Setup-1.1.2-Linux-x86_64.run +./VoxLocal-Setup-1.1.2-Linux-x86_64.run ``` The setup detects and selects the plugin folder scanned by OBS Studio 32.2.1 automatically: @@ -69,10 +71,13 @@ Windows and macOS setups are currently unsigned. Windows SmartScreen or macOS Ga ## First run 1. Select English or Turkish. -2. Download and verify the local model. Setup cannot continue before it finishes. -3. Enter the public Kick channel slug. -4. Create a persona with a name, command, language, access policy, and audio or video sample. -5. VoxLocal adds **VoxLocal Overlay** to the current scene. +2. Download and verify the local model, or use **Skip** to do it later. +3. Choose whether the model should ask, load automatically, or remain unloaded when OBS starts. +4. Enter the public Kick channel slug or skip it. +5. Create a persona with a name, command, language, access policy, and audio or video sample, or skip it. +6. VoxLocal adds **VoxLocal Overlay** to the current scene. + +The VoxLocal dock remains hidden until Welcome finishes. Welcome cannot be accidentally dismissed, but each page has a **Skip** button and all sections can be completed later from the dock. Model weights are loaded into memory on a background worker only after OBS has finished opening. The dock also provides **Load model now** and shows the selected CPU/GPU backend. The settings dock opens automatically. Reopen it from **Tools → VoxLocal Settings** or press `Ctrl+Shift+V`. diff --git a/buildspec.json b/buildspec.json index c3c6c98..ddd965d 100644 --- a/buildspec.json +++ b/buildspec.json @@ -38,7 +38,7 @@ }, "name": "voxlocal", "displayName": "VoxLocal", - "version": "1.1.1", + "version": "1.1.2", "author": "malidev", "website": "https://github.com/malithedeveloper/voxlocal", "email": "" diff --git a/cmake/onnxruntime.cmake b/cmake/onnxruntime.cmake index 6cb6f57..777c640 100644 --- a/cmake/onnxruntime.cmake +++ b/cmake/onnxruntime.cmake @@ -2,8 +2,8 @@ include(FetchContent) function(voxlocal_fetch_onnxruntime) if(WIN32) - set(_ort_url "https://github.com/microsoft/onnxruntime/releases/download/v1.22.1/Microsoft.ML.OnnxRuntime.DirectML.1.22.1.nupkg") - set(_ort_hash "9fe1d91a697fb8a68abd29fdc2a88dea8f864ee74e758a055ce63c9e023c6908") + set(_ort_url "https://api.nuget.org/v3-flatcontainer/microsoft.ml.onnxruntime.directml/1.24.4/microsoft.ml.onnxruntime.directml.1.24.4.nupkg") + set(_ort_hash "57e9f11b73437bef7a309496135d4c1f96b1a8e9ddba60013fa27bfc1d788681") set(_ort_include_suffix "build/native/include") set(_ort_library_suffix "runtimes/win-x64/native") set(_ort_directml ON) diff --git a/cmake/package-installer.cmake b/cmake/package-installer.cmake index a5fa5dc..e6ec08a 100644 --- a/cmake/package-installer.cmake +++ b/cmake/package-installer.cmake @@ -43,7 +43,7 @@ endif() get_filename_component(VOXLOCAL_SOURCE_DIR "${CMAKE_CURRENT_LIST_DIR}/.." ABSOLUTE) file(READ "${VOXLOCAL_SOURCE_DIR}/buildspec.json" buildspec) string(JSON VOXLOCAL_VERSION GET "${buildspec}" version) -set(VOXLOCAL_RELEASE_DATE "2026-08-13") +set(VOXLOCAL_RELEASE_DATE "2026-08-14") if(VOXLOCAL_PLATFORM STREQUAL "windows") set(VOXLOCAL_DEFAULT_TARGET "@RootDir@/ProgramData/obs-studio/plugins") @@ -76,6 +76,10 @@ configure_file( "${VOXLOCAL_SOURCE_DIR}/installer/packages/dev.malithedeveloper.voxlocal/meta/package.xml.in" "${meta_dir}/package.xml" @ONLY) +configure_file( + "${VOXLOCAL_SOURCE_DIR}/installer/packages/dev.malithedeveloper.voxlocal/meta/installscript.qs" + "${meta_dir}/installscript.qs" + COPYONLY) configure_file( "${VOXLOCAL_SOURCE_DIR}/installer/config/controller.qs" "${config_dir}/controller.qs" diff --git a/docs/RELEASE_NOTES_1.1.2.md b/docs/RELEASE_NOTES_1.1.2.md new file mode 100644 index 0000000..39afb7e --- /dev/null +++ b/docs/RELEASE_NOTES_1.1.2.md @@ -0,0 +1,34 @@ +# VoxLocal 1.1.2 + +VoxLocal 1.1.2 fixes Windows TTS model startup, makes model loading non-blocking, and gives the streamer explicit control over whether the model enters memory when OBS starts. + +## Download + +Choose one setup for your operating system: + +- `VoxLocal-Setup-1.1.2-Windows-x64.exe` +- `VoxLocal-Setup-1.1.2-macOS-universal.dmg` for Apple Silicon and Intel Macs +- `VoxLocal-Setup-1.1.2-Linux-x86_64.run` + +Close OBS before running the setup. The Windows setup uses `%ProgramData%\obs-studio\plugins` and removes VoxLocal's obsolete `%APPDATA%` plugin copy. Existing settings, voices, and downloaded models remain untouched. + +## Changes + +- Applied the session options required by ONNX Runtime DirectML, fixing model initialization failures on Windows. +- Updated the Windows inference runtime from DirectML 1.22.1 to 1.24.4. +- Automatically chooses the hardware GPU with the most dedicated video memory on Windows. If the GPU or any model session cannot initialize, VoxLocal recreates every session on CPU automatically. +- Detects the available ONNX execution provider on each platform and displays the backend selected for the current session. +- Opens the OBS interface first and initializes the model on a background worker afterward, so loading model weights does not block OBS startup. +- Added three model startup behaviors to Welcome and the dock: **Ask at startup** (default), **Load automatically**, and **Do not load**. +- Added **Load model now** to the dock for manual per-session loading. +- Keeps the VoxLocal dock closed while Welcome is active. Welcome cannot be dismissed with Cancel, Escape, or the window close button, but every page has **Skip** and all setup sections are optional. +- Writes model load and inference failures into the OBS log for useful diagnostics. +- Avoids reading a closed TLS socket after a completed model download. +- Retains the 1.1.1 Turkish tokenizer bounds fix and larger Minimal overlay text. + +## Notes + +- The startup behavior controls loading the already-downloaded model into memory; it does not delete or redownload model files. +- Windows and macOS setups are unsigned and may require SmartScreen or Gatekeeper confirmation. +- The Linux setup targets native x86-64 OBS installations. Flatpak OBS uses Flatpak's own plugin mechanism. +- FFmpeg must be available locally to import voice samples. diff --git a/include/voxlocal/runtime.hpp b/include/voxlocal/runtime.hpp index 9e5af0a..1c5e4ed 100644 --- a/include/voxlocal/runtime.hpp +++ b/include/voxlocal/runtime.hpp @@ -29,6 +29,8 @@ class VoxLocalRuntime final : public QObject [[nodiscard]] KickConnector *kickConnector() { return &kick_; } [[nodiscard]] QString status() const { return status_; } [[nodiscard]] bool engineReady() const { return engine_->isReady(); } + [[nodiscard]] bool engineLoading() const { return engineLoading_; } + [[nodiscard]] QString engineBackend() const { return engine_->backendName(); } [[nodiscard]] bool isVoiceImporting(const QString &personaId = {}) const; bool applySettings(Settings settings, QString *error = nullptr); @@ -36,12 +38,14 @@ class VoxLocalRuntime final : public QObject void importVoiceAsync(const QString &sourcePath, const QString &personaId); void start(); void stop(); + void loadModel(); bool preview(const QString &personaId, const QString &text, QString *error = nullptr); signals: void settingsChanged(); void statusChanged(const QString &status); void modelProgress(qint64 received, qint64 total, const QString &fileName); + void engineLoadingChanged(bool loading); void voiceImportProgress(const QString &personaId, int percent, const QString &operation); void voiceImportFinished(const QString &personaId, const QString &voicePath); void voiceImportFailed(const QString &personaId, const QString &error); @@ -68,10 +72,12 @@ class VoxLocalRuntime final : public QObject std::shared_ptr engine_; SpeechQueue queue_; QThreadPool voiceImportPool_; + QThreadPool modelLoadPool_; QSet voiceImports_; QString activeChatRequestId_; QDateTime globalCooldownUntil_; bool chatGenerationActive_ = false; + bool engineLoading_ = false; QString status_ = QStringLiteral("idle"); }; diff --git a/include/voxlocal/speech-queue.hpp b/include/voxlocal/speech-queue.hpp index 93019b6..405e048 100644 --- a/include/voxlocal/speech-queue.hpp +++ b/include/voxlocal/speech-queue.hpp @@ -20,6 +20,7 @@ class SpeechQueue final : public QObject ~SpeechQueue() override; void setCapacity(int capacity); + bool setEngine(std::shared_ptr engine); [[nodiscard]] int size() const; bool enqueue(const QueueItem &item); diff --git a/include/voxlocal/types.hpp b/include/voxlocal/types.hpp index 7d3c647..1e0999c 100644 --- a/include/voxlocal/types.hpp +++ b/include/voxlocal/types.hpp @@ -15,13 +15,14 @@ namespace voxlocal { -inline constexpr int kConfigSchemaVersion = 3; +inline constexpr int kConfigSchemaVersion = 4; inline constexpr int kDefaultMaxTextLength = 250; inline constexpr int kDefaultGlobalCooldownSeconds = 10; inline constexpr int kDefaultQueueCapacity = 10; enum class InterfaceLanguage { English, Turkish }; enum class LanguageMode { Fixed, Automatic }; +enum class ModelStartupBehavior { Ask, AlwaysLoad, NeverLoad }; enum class UserRole : std::uint32_t { None = 0, Everyone = 1u << 0, @@ -112,6 +113,7 @@ struct Settings { InterfaceLanguage interfaceLanguage = InterfaceLanguage::English; QString defaultSpeechLanguage = QStringLiteral("en"); bool welcomeCompleted = false; + ModelStartupBehavior modelStartupBehavior = ModelStartupBehavior::Ask; bool readUrls = false; bool ttsEnabled = true; int globalCooldownSeconds = kDefaultGlobalCooldownSeconds; diff --git a/include/voxlocal/voxlocal-dock.hpp b/include/voxlocal/voxlocal-dock.hpp index 8eddade..20c58a9 100644 --- a/include/voxlocal/voxlocal-dock.hpp +++ b/include/voxlocal/voxlocal-dock.hpp @@ -42,6 +42,7 @@ class VoxLocalDock final : public QWidget void chooseColor(QPushButton *button, QString *target); void updateModelProgress(qint64 received, qint64 total, const QString &fileName); void updateModelDownloadState(bool installing); + void updateModelRuntimeState(); void updateVoiceImportProgress(const QString &personaId, int percent, const QString &operation); void finishVoiceImport(const QString &personaId, const QString &voicePath); void failVoiceImport(const QString &personaId, const QString &error); @@ -59,6 +60,9 @@ class VoxLocalDock final : public QWidget QLabel *model_ = nullptr; QProgressBar *modelProgress_ = nullptr; QPushButton *modelInstall_ = nullptr; + QPushButton *modelLoad_ = nullptr; + QLabel *modelRuntime_ = nullptr; + QComboBox *modelStartup_ = nullptr; QComboBox *interfaceLanguage_ = nullptr; QLineEdit *channel_ = nullptr; QListWidget *personas_ = nullptr; diff --git a/include/voxlocal/welcome-wizard.hpp b/include/voxlocal/welcome-wizard.hpp index 7d5807e..439408a 100644 --- a/include/voxlocal/welcome-wizard.hpp +++ b/include/voxlocal/welcome-wizard.hpp @@ -6,6 +6,7 @@ #include class QComboBox; +class QCloseEvent; class QLabel; class QLineEdit; class QListWidget; @@ -20,9 +21,12 @@ class WelcomeWizard final : public QWizard public: explicit WelcomeWizard(VoxLocalRuntime *runtime, QWidget *parent = nullptr); + void closeForShutdown(); protected: void accept() override; + void reject() override; + void closeEvent(QCloseEvent *event) override; private: struct LocalizedWidget { @@ -42,6 +46,7 @@ class WelcomeWizard final : public QWizard VoxLocalRuntime *runtime_; QVector localizedWidgets_; QComboBox *interfaceLanguage_ = nullptr; + QComboBox *modelStartup_ = nullptr; QLabel *modelInfo_ = nullptr; QLabel *modelStatus_ = nullptr; QProgressBar *modelProgress_ = nullptr; @@ -59,6 +64,7 @@ class WelcomeWizard final : public QWizard QLineEdit *voice_ = nullptr; QComboBox *language_ = nullptr; QComboBox *access_ = nullptr; + bool allowClose_ = false; }; } // namespace voxlocal diff --git a/installer/packages/dev.malithedeveloper.voxlocal/meta/installscript.qs b/installer/packages/dev.malithedeveloper.voxlocal/meta/installscript.qs new file mode 100644 index 0000000..fd99d98 --- /dev/null +++ b/installer/packages/dev.malithedeveloper.voxlocal/meta/installscript.qs @@ -0,0 +1,26 @@ +function Component() +{ +} + +Component.prototype.createOperations = function() +{ + component.createOperations(); + + if (systemInfo.productType !== "windows") + return; + + var appData = installer.environmentVariable("APPDATA"); + if (appData === "") + return; + + var stalePlugin = appData + "/obs-studio/plugins/voxlocal"; + var selectedTarget = installer.value("TargetDir").replace(/\\/g, "/").toLowerCase(); + var staleParent = (appData + "/obs-studio/plugins").replace(/\\/g, "/").toLowerCase(); + if (selectedTarget === staleParent) + return; + + var systemRoot = installer.environmentVariable("SystemRoot"); + var command = (systemRoot !== "" ? systemRoot : "C:/Windows") + "/System32/cmd.exe"; + component.addOperation("Execute", command, "/D", "/S", "/C", + "if exist \"" + stalePlugin + "\" rmdir /S /Q \"" + stalePlugin + "\""); +} diff --git a/installer/packages/dev.malithedeveloper.voxlocal/meta/package.xml.in b/installer/packages/dev.malithedeveloper.voxlocal/meta/package.xml.in index af3b266..943240b 100644 --- a/installer/packages/dev.malithedeveloper.voxlocal/meta/package.xml.in +++ b/installer/packages/dev.malithedeveloper.voxlocal/meta/package.xml.in @@ -6,6 +6,7 @@ @VOXLOCAL_RELEASE_DATE@ dev.malithedeveloper.voxlocal true + @VOXLOCAL_ADMIN_REQUIREMENT@ diff --git a/src/chatterbox-engine.cpp b/src/chatterbox-engine.cpp index cc39c44..7fd3cd2 100644 --- a/src/chatterbox-engine.cpp +++ b/src/chatterbox-engine.cpp @@ -25,6 +25,7 @@ #include #ifdef VOXLOCAL_ORT_DIRECTML #include +#include #endif #endif @@ -35,6 +36,41 @@ constexpr int kSampleRate = 24000; constexpr std::int64_t kStartSpeechToken = 6561; constexpr std::int64_t kStopSpeechToken = 6562; +#ifdef VOXLOCAL_ORT_DIRECTML +struct DmlAdapter { + int index = -1; + quint64 dedicatedVideoMemory = 0; + QString name; +}; + +DmlAdapter preferredDmlAdapter() +{ + IDXGIFactory1 *factory = nullptr; + if (FAILED(CreateDXGIFactory1(IID_PPV_ARGS(&factory)))) + return {}; + + DmlAdapter preferred; + for (UINT index = 0;; ++index) { + IDXGIAdapter1 *adapter = nullptr; + const HRESULT enumeration = factory->EnumAdapters1(index, &adapter); + if (enumeration == DXGI_ERROR_NOT_FOUND) + break; + if (FAILED(enumeration)) + break; + DXGI_ADAPTER_DESC1 description{}; + if (SUCCEEDED(adapter->GetDesc1(&description)) && !(description.Flags & DXGI_ADAPTER_FLAG_SOFTWARE) && + (preferred.index < 0 || description.DedicatedVideoMemory > preferred.dedicatedVideoMemory)) { + preferred.index = static_cast(index); + preferred.dedicatedVideoMemory = description.DedicatedVideoMemory; + preferred.name = QString::fromWCharArray(description.Description).trimmed(); + } + adapter->Release(); + } + factory->Release(); + return preferred; +} +#endif + struct ReferenceAudio { std::vector samples; int sampleRate = 0; @@ -404,7 +440,6 @@ class ChatterboxEngine::Impl bool ready = false; #ifdef VOXLOCAL_HAVE_ONNXRUNTIME Ort::Env environment{ORT_LOGGING_LEVEL_WARNING, "VoxLocal"}; - Ort::SessionOptions options; Ort::MemoryInfo memory = Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault); std::unique_ptr encoder, embed, languageModel, decoder; #endif @@ -427,54 +462,94 @@ bool ChatterboxEngine::initialize(const QString &modelPath, QString *error) "This build does not include ONNX Runtime. Install a VoxLocal release package or configure ONNXRUNTIME_ROOT."); return false; #else - try { - impl_->options.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL); - impl_->options.SetExecutionMode(ExecutionMode::ORT_SEQUENTIAL); - impl_->options.SetIntraOpNumThreads(static_cast(std::max(1u, std::thread::hardware_concurrency() / 2))); - impl_->backend = QStringLiteral("CPU"); -#ifdef VOXLOCAL_ORT_DIRECTML - try { - Ort::ThrowOnError(OrtSessionOptionsAppendExecutionProvider_DML(impl_->options, 0)); - impl_->backend = QStringLiteral("DirectML"); - } catch (const Ort::Exception &) { - impl_->backend = QStringLiteral("CPU"); - } -#elif ORT_API_VERSION >= 16 - const char *provider = -#ifdef Q_OS_WIN - "DML"; -#elif defined(Q_OS_MACOS) - "CoreML"; -#else - "CUDA"; -#endif - try { - Ort::ThrowOnError( - Ort::GetApi().SessionOptionsAppendExecutionProvider(impl_->options, provider, nullptr, nullptr, 0)); - impl_->backend = QString::fromLatin1(provider); - } catch (const Ort::Exception &) { - impl_->backend = QStringLiteral("CPU"); - } -#endif + const auto configureBase = [](Ort::SessionOptions &options) { + options.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL); + options.SetExecutionMode(ExecutionMode::ORT_SEQUENTIAL); + options.SetIntraOpNumThreads(static_cast(std::max(1u, std::thread::hardware_concurrency() / 2))); + }; + const auto createSessions = [&](Ort::SessionOptions &options) { const auto session = [&](const QString &relative) { const auto path = QDir(modelPath).filePath(relative); #ifdef Q_OS_WIN const auto native = path.toStdWString(); - return std::make_unique(impl_->environment, native.c_str(), impl_->options); + return std::make_unique(impl_->environment, native.c_str(), options); #else const auto native = path.toUtf8(); - return std::make_unique(impl_->environment, native.constData(), impl_->options); + return std::make_unique(impl_->environment, native.constData(), options); #endif }; - impl_->encoder = session(QStringLiteral("onnx/speech_encoder.onnx")); - impl_->embed = session(QStringLiteral("onnx/embed_tokens.onnx")); - impl_->languageModel = session(QStringLiteral("onnx/language_model_q4.onnx")); - impl_->decoder = session(QStringLiteral("onnx/conditional_decoder.onnx")); + auto encoder = session(QStringLiteral("onnx/speech_encoder.onnx")); + auto embed = session(QStringLiteral("onnx/embed_tokens.onnx")); + auto languageModel = session(QStringLiteral("onnx/language_model_q4.onnx")); + auto decoder = session(QStringLiteral("onnx/conditional_decoder.onnx")); + impl_->encoder = std::move(encoder); + impl_->embed = std::move(embed); + impl_->languageModel = std::move(languageModel); + impl_->decoder = std::move(decoder); + }; + + QString hardwareFailure; + try { +#ifdef VOXLOCAL_ORT_DIRECTML + const auto adapter = preferredDmlAdapter(); + if (adapter.index >= 0) { + Ort::SessionOptions options; + configureBase(options); + // DirectML rejects sessions unless memory patterns are disabled and + // execution is sequential. Keep both requirements next to EP setup. + options.DisableMemPattern(); + options.SetExecutionMode(ExecutionMode::ORT_SEQUENTIAL); + Ort::ThrowOnError(OrtSessionOptionsAppendExecutionProvider_DML(options, adapter.index)); + createSessions(options); + impl_->backend = adapter.name.isEmpty() ? QStringLiteral("DirectML GPU") + : QStringLiteral("DirectML GPU — %1").arg(adapter.name); + impl_->ready = true; + return true; + } +#elif ORT_API_VERSION >= 16 + const auto providers = Ort::GetAvailableProviders(); +#ifdef Q_OS_MACOS + const auto availableName = std::string("CoreMLExecutionProvider"); + const char *provider = "CoreML"; + const auto backend = QStringLiteral("CoreML GPU/Neural Engine"); +#else + const auto availableName = std::string("CUDAExecutionProvider"); + const char *provider = "CUDA"; + const auto backend = QStringLiteral("CUDA GPU"); +#endif + if (std::ranges::find(providers, availableName) != providers.end()) { + Ort::SessionOptions options; + configureBase(options); + Ort::ThrowOnError(Ort::GetApi().SessionOptionsAppendExecutionProvider(options, provider, nullptr, nullptr, 0)); + createSessions(options); + impl_->backend = backend; + impl_->ready = true; + return true; + } +#endif + } catch (const Ort::Exception &exception) { + hardwareFailure = QString::fromUtf8(exception.what()); + impl_->encoder.reset(); + impl_->embed.reset(); + impl_->languageModel.reset(); + impl_->decoder.reset(); + } + + try { + Ort::SessionOptions options; + configureBase(options); + createSessions(options); + impl_->backend = hardwareFailure.isEmpty() ? QStringLiteral("CPU") : QStringLiteral("CPU — GPU fallback"); impl_->ready = true; return true; } catch (const Ort::Exception &exception) { - if (error) - *error = QStringLiteral("ONNX Runtime could not load Chatterbox: %1").arg(QString::fromUtf8(exception.what())); + if (error) { + const QString cpuFailure = QString::fromUtf8(exception.what()); + *error = hardwareFailure.isEmpty() + ? QStringLiteral("ONNX Runtime could not load Chatterbox on CPU: %1").arg(cpuFailure) + : QStringLiteral("ONNX Runtime could not load Chatterbox. GPU: %1; CPU fallback: %2") + .arg(hardwareFailure, cpuFailure); + } return false; } #endif diff --git a/src/config-store.cpp b/src/config-store.cpp index d6c80a1..a892a59 100644 --- a/src/config-store.cpp +++ b/src/config-store.cpp @@ -19,6 +19,29 @@ QString qstring(const json &value, const QString &fallback = {}) return value.is_string() ? QString::fromUtf8(value.get()) : fallback; } +const char *modelStartupBehaviorToString(ModelStartupBehavior behavior) +{ + switch (behavior) { + case ModelStartupBehavior::AlwaysLoad: + return "always"; + case ModelStartupBehavior::NeverLoad: + return "never"; + case ModelStartupBehavior::Ask: + default: + return "ask"; + } +} + +ModelStartupBehavior modelStartupBehaviorFromJson(const json &value) +{ + const QString stored = qstring(value, QStringLiteral("ask")); + if (stored == QStringLiteral("always")) + return ModelStartupBehavior::AlwaysLoad; + if (stored == QStringLiteral("never")) + return ModelStartupBehavior::NeverLoad; + return ModelStartupBehavior::Ask; +} + json accessToJson(const AccessPolicy &access) { return {{"allowed", static_cast(access.allowed)}}; } AccessPolicy accessFromJson(const json &value) @@ -82,6 +105,7 @@ json toJson(const Settings &settings) {"interfaceLanguage", settings.interfaceLanguage == InterfaceLanguage::Turkish ? "tr" : "en"}, {"defaultSpeechLanguage", utf8(settings.defaultSpeechLanguage)}, {"welcomeCompleted", settings.welcomeCompleted}, + {"modelStartupBehavior", modelStartupBehaviorToString(settings.modelStartupBehavior)}, {"readUrls", settings.readUrls}, {"ttsEnabled", settings.ttsEnabled}, {"globalCooldownSeconds", settings.globalCooldownSeconds}, @@ -121,6 +145,7 @@ Settings fromJson(const json &root) : InterfaceLanguage::English; result.defaultSpeechLanguage = qstring(root.value("defaultSpeechLanguage", json()), QStringLiteral("en")).toLower(); result.welcomeCompleted = root.value("welcomeCompleted", false); + result.modelStartupBehavior = modelStartupBehaviorFromJson(root.value("modelStartupBehavior", json())); result.readUrls = root.value("readUrls", false); result.ttsEnabled = root.value("ttsEnabled", true); result.globalCooldownSeconds = diff --git a/src/model-manager.cpp b/src/model-manager.cpp index 5494ecc..cec7ac5 100644 --- a/src/model-manager.cpp +++ b/src/model-manager.cpp @@ -301,6 +301,8 @@ bool ModelManager::writeAvailableData() { if (!reply_ || !partFile_ || !prepareResponse()) return false; + if (reply_->bytesAvailable() <= 0) + return true; const QByteArray data = reply_->readAll(); if (data.isEmpty()) return true; diff --git a/src/plugin-main.cpp b/src/plugin-main.cpp index 565fc5e..26f4944 100644 --- a/src/plugin-main.cpp +++ b/src/plugin-main.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -25,14 +26,32 @@ voxlocal::VoxLocalRuntime *runtime = nullptr; QPointer dock; QPointer wizard; QPointer settingsAction; +bool frontendReady = false; +bool startupHandled = false; +bool modelDecisionHandled = false; +bool frontendShuttingDown = false; -void showVoxLocalDock() +QWidget *dockContainer() { - if (!dock) - return; QWidget *container = dock; while (container && !qobject_cast(container)) container = container->parentWidget(); + return container; +} + +void hideVoxLocalDock() +{ + if (auto *container = dockContainer()) + container->hide(); + if (dock) + dock->hide(); +} + +void showVoxLocalDock() +{ + if (!dock) + return; + QWidget *container = dockContainer(); if (container) { container->show(); container->raise(); @@ -41,6 +60,80 @@ void showVoxLocalDock() dock->raise(); } +void handleModelStartup() +{ + if (!runtime || !frontendReady || !runtime->settings().welcomeCompleted || runtime->engineReady() || + runtime->engineLoading() || !runtime->modelManager()->isInstalled() || modelDecisionHandled) + return; + modelDecisionHandled = true; + switch (runtime->settings().modelStartupBehavior) { + case voxlocal::ModelStartupBehavior::AlwaysLoad: + runtime->loadModel(); + return; + case voxlocal::ModelStartupBehavior::NeverLoad: + return; + case voxlocal::ModelStartupBehavior::Ask: + break; + } + + const bool turkish = runtime->settings().interfaceLanguage == voxlocal::InterfaceLanguage::Turkish; + auto *parent = static_cast(obs_frontend_get_main_window()); + const auto answer = QMessageBox::question( + parent, QStringLiteral("VoxLocal"), + turkish ? QStringLiteral("TTS modeli bu OBS oturumunda belleğe açılsın mı? Model arka planda açılır.") + : QStringLiteral("Load the TTS model into memory for this OBS session? It loads in the background."), + QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); + if (answer == QMessageBox::Yes) + runtime->loadModel(); +} + +void showWelcomeWizard() +{ + if (!runtime || wizard) + return; + auto *parent = static_cast(obs_frontend_get_main_window()); + hideVoxLocalDock(); + wizard = new voxlocal::WelcomeWizard(runtime, parent); + wizard->setAttribute(Qt::WA_DeleteOnClose); + QObject::connect(wizard, &QWizard::accepted, parent, [] { + if (!runtime) + return; + runtime->start(); + showVoxLocalDock(); + QTimer::singleShot(250, runtime, [] { handleModelStartup(); }); + }); + wizard->show(); + wizard->raise(); + wizard->activateWindow(); +} + +void handleFrontendReady() +{ + if (!runtime || startupHandled) + return; + frontendReady = true; + startupHandled = true; + runtime->start(); + if (!runtime->settings().welcomeCompleted) { + showWelcomeWizard(); + return; + } + showVoxLocalDock(); + QTimer::singleShot(250, runtime, [] { handleModelStartup(); }); +} + +void frontendEvent(enum obs_frontend_event event, void *) +{ + if (event == OBS_FRONTEND_EVENT_EXIT) { + frontendShuttingDown = true; + return; + } + if (event != OBS_FRONTEND_EVENT_FINISHED_LOADING) + return; + auto *parent = static_cast(obs_frontend_get_main_window()); + QTimer::singleShot(0, parent, [] { handleFrontendReady(); }); +} + } // namespace const char *obs_module_description(void) @@ -75,12 +168,23 @@ bool obs_module_load(void) voxlocal::registerVoxLocalSource(); auto *parent = static_cast(obs_frontend_get_main_window()); + QObject::connect(runtime, &voxlocal::VoxLocalRuntime::errorOccurred, parent, [](const QString &error) { + const auto message = error.toUtf8(); + blog(LOG_ERROR, "[VoxLocal] %s", message.constData()); + }); + QObject::connect(runtime, &voxlocal::VoxLocalRuntime::statusChanged, parent, [](const QString &status) { + if (status.startsWith(QStringLiteral("ready:"))) { + const auto message = status.section(QLatin1Char(':'), 1).toUtf8(); + blog(LOG_INFO, "[VoxLocal] TTS model ready on %s", message.constData()); + } + }); dock = new voxlocal::VoxLocalDock(runtime, parent); if (!obs_frontend_add_dock_by_id("voxlocal.controls", "VoxLocal", dock)) { blog(LOG_ERROR, "[VoxLocal] Could not add the VoxLocal dock"); delete dock; dock = nullptr; } + hideVoxLocalDock(); const bool turkish = runtime->settings().interfaceLanguage == voxlocal::InterfaceLanguage::Turkish; settingsAction = static_cast(obs_frontend_add_tools_menu_qaction(turkish ? "VoxLocal Ayarları" : "VoxLocal Settings")); @@ -88,7 +192,17 @@ bool obs_module_load(void) settingsAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+Shift+V"))); settingsAction->setShortcutContext(Qt::ApplicationShortcut); parent->addAction(settingsAction); - QObject::connect(settingsAction, &QAction::triggered, parent, [] { showVoxLocalDock(); }); + QObject::connect(settingsAction, &QAction::triggered, parent, [] { + if (runtime && !runtime->settings().welcomeCompleted) { + showWelcomeWizard(); + if (wizard) { + wizard->raise(); + wizard->activateWindow(); + } + return; + } + showVoxLocalDock(); + }); QObject::connect(runtime, &voxlocal::VoxLocalRuntime::settingsChanged, settingsAction, [] { if (settingsAction && runtime) settingsAction->setText(runtime->settings().interfaceLanguage == voxlocal::InterfaceLanguage::Turkish @@ -96,27 +210,21 @@ bool obs_module_load(void) : QStringLiteral("VoxLocal Settings")); }); } - QTimer::singleShot(900, parent, [] { showVoxLocalDock(); }); - runtime->start(); - if (!runtime->settings().welcomeCompleted) { - QTimer::singleShot(400, parent, [parent] { - if (!runtime || runtime->settings().welcomeCompleted) - return; - wizard = new voxlocal::WelcomeWizard(runtime, parent); - wizard->setAttribute(Qt::WA_DeleteOnClose); - wizard->show(); - wizard->raise(); - }); - } + QObject::connect(runtime->modelManager(), &voxlocal::ModelManager::ready, parent, + [] { QTimer::singleShot(0, runtime, [] { handleModelStartup(); }); }); + obs_frontend_add_event_callback(frontendEvent, nullptr); blog(LOG_INFO, "[VoxLocal] plugin %s loaded", VOXLOCAL_VERSION); return true; } void obs_module_unload(void) { + if (!frontendShuttingDown) + obs_frontend_remove_event_callback(frontendEvent, nullptr); if (wizard) - wizard->close(); - obs_frontend_remove_dock("voxlocal.controls"); + wizard->closeForShutdown(); + if (!frontendShuttingDown) + obs_frontend_remove_dock("voxlocal.controls"); if (dock) delete dock; dock = nullptr; @@ -126,5 +234,9 @@ void obs_module_unload(void) voxlocal::bindSourceRuntime(nullptr); delete runtime; runtime = nullptr; + frontendReady = false; + startupHandled = false; + modelDecisionHandled = false; + frontendShuttingDown = false; blog(LOG_INFO, "[VoxLocal] plugin unloaded"); } diff --git a/src/runtime.cpp b/src/runtime.cpp index d366695..6967f3e 100644 --- a/src/runtime.cpp +++ b/src/runtime.cpp @@ -208,12 +208,22 @@ class VoxLocalRuntime::Detector final : public LanguageDetector } }; +namespace { + +struct EngineLoadResult { + std::shared_ptr engine; + QString error; +}; + +} // namespace + VoxLocalRuntime::VoxLocalRuntime(QString settingsPath, QString modelRoot, QObject *parent) : QObject(parent), store_(std::move(settingsPath)), settings_(store_.load()), models_(std::move(modelRoot)), detector_(std::make_unique()), router_(detector_.get()), engine_(std::make_shared()), queue_(engine_) { voiceImportPool_.setMaxThreadCount(1); + modelLoadPool_.setMaxThreadCount(1); queue_.setCapacity(settings_.queueCapacity); store_.save(settings_); connect(&kick_, &KickConnector::messageReceived, this, &VoxLocalRuntime::handleMessage); @@ -226,7 +236,6 @@ VoxLocalRuntime::VoxLocalRuntime(QString settingsPath, QString modelRoot, QObjec }); connect(&models_, &ModelManager::progress, this, &VoxLocalRuntime::modelProgress); connect(&models_, &ModelManager::failed, this, &VoxLocalRuntime::errorOccurred); - connect(&models_, &ModelManager::ready, this, &VoxLocalRuntime::initializeEngine); connect(&queue_, &SpeechQueue::queueChanged, this, &VoxLocalRuntime::queueChanged); connect(&queue_, &SpeechQueue::itemStarted, this, [this](const QueueItem &item) { setStatus(QStringLiteral("Generating speech locally…")); @@ -269,6 +278,7 @@ VoxLocalRuntime::VoxLocalRuntime(QString settingsPath, QString modelRoot, QObjec VoxLocalRuntime::~VoxLocalRuntime() { + modelLoadPool_.waitForDone(); voiceImportPool_.waitForDone(); stop(); } @@ -492,13 +502,22 @@ void VoxLocalRuntime::importVoiceAsync(const QString &sourcePath, const QString void VoxLocalRuntime::start() { - if (models_.isInstalled()) - initializeEngine(models_.revisionRoot()); if (settings_.welcomeCompleted && settings_.kick.enabled && !settings_.kick.channelSlug.isEmpty()) kick_.start(settings_.kick); emit settingsChanged(); } +void VoxLocalRuntime::loadModel() +{ + if (engineLoading_ || engine_->isReady()) + return; + if (!models_.isInstalled()) { + emit errorOccurred(QStringLiteral("The TTS model is not installed and verified.")); + return; + } + initializeEngine(models_.revisionRoot()); +} + void VoxLocalRuntime::stop() { kick_.stop(); @@ -554,14 +573,39 @@ void VoxLocalRuntime::setStatus(const QString &status) void VoxLocalRuntime::initializeEngine(const QString &modelPath) { - setStatus(QStringLiteral("loading-model")); - QString error; - if (!engine_->initialize(modelPath, &error)) { - setStatus(QStringLiteral("model-error")); - emit errorOccurred(error); + if (engineLoading_) return; - } - setStatus(QStringLiteral("ready:%1").arg(engine_->backendName())); + engineLoading_ = true; + emit engineLoadingChanged(true); + setStatus(QStringLiteral("loading-model")); + auto candidate = std::make_shared(); + auto *watcher = new QFutureWatcher(this); + connect(watcher, &QFutureWatcher::finished, this, [this, watcher] { + const EngineLoadResult result = watcher->result(); + watcher->deleteLater(); + engineLoading_ = false; + emit engineLoadingChanged(false); + if (!result.error.isEmpty()) { + setStatus(QStringLiteral("model-error")); + emit errorOccurred(result.error); + return; + } + if (!queue_.setEngine(result.engine)) { + const QString error = QStringLiteral("The TTS engine could not be activated while speech was queued."); + setStatus(QStringLiteral("model-error")); + emit errorOccurred(error); + return; + } + engine_ = result.engine; + setStatus(QStringLiteral("ready:%1").arg(engine_->backendName())); + }); + watcher->setFuture(QtConcurrent::run(&modelLoadPool_, [candidate, modelPath] { + EngineLoadResult result; + result.engine = candidate; + if (!candidate->initialize(modelPath, &result.error) && result.error.isEmpty()) + result.error = QStringLiteral("The TTS model could not be initialized."); + return result; + })); } void VoxLocalRuntime::handleMessage(const ChatMessage &message) diff --git a/src/speech-queue.cpp b/src/speech-queue.cpp index 1dd4984..8741c0e 100644 --- a/src/speech-queue.cpp +++ b/src/speech-queue.cpp @@ -20,6 +20,14 @@ SpeechQueue::~SpeechQueue() void SpeechQueue::setCapacity(int capacity) { capacity_ = std::clamp(capacity, 1, 100); } +bool SpeechQueue::setEngine(std::shared_ptr engine) +{ + if (!engine || speaking_ || !queue_.isEmpty()) + return false; + engine_ = std::move(engine); + return true; +} + int SpeechQueue::size() const { return queue_.size() + (speaking_ ? 1 : 0); } bool SpeechQueue::enqueue(const QueueItem &item) diff --git a/src/voxlocal-dock.cpp b/src/voxlocal-dock.cpp index 74ec405..d87e724 100644 --- a/src/voxlocal-dock.cpp +++ b/src/voxlocal-dock.cpp @@ -54,8 +54,10 @@ VoxLocalDock::VoxLocalDock(VoxLocalRuntime *runtime, QWidget *parent) setObjectName(QStringLiteral("VoxLocalDock")); buildUi(); connect(runtime_, &VoxLocalRuntime::settingsChanged, this, &VoxLocalDock::refresh); - connect(runtime_, &VoxLocalRuntime::statusChanged, this, - [this](const QString &status) { status_->setText(localizedStatus(status)); }); + connect(runtime_, &VoxLocalRuntime::statusChanged, this, [this](const QString &status) { + status_->setText(localizedStatus(status)); + updateModelRuntimeState(); + }); connect(runtime_, &VoxLocalRuntime::errorOccurred, this, [this](const QString &error) { status_->setText(error); status_->setToolTip(error); @@ -63,6 +65,7 @@ VoxLocalDock::VoxLocalDock(VoxLocalRuntime *runtime, QWidget *parent) connect(runtime_, &VoxLocalRuntime::modelProgress, this, [this](qint64 received, qint64 total, const QString &file) { updateModelProgress(received, total, file); }); connect(runtime_->modelManager(), &ModelManager::installingChanged, this, &VoxLocalDock::updateModelDownloadState); + connect(runtime_, &VoxLocalRuntime::engineLoadingChanged, this, [this] { updateModelRuntimeState(); }); connect(runtime_->modelManager(), &ModelManager::failed, model_, &QLabel::setText); connect(runtime_->modelManager(), &ModelManager::ready, this, [this] { updateModelProgress(runtime_->modelManager()->totalBytes(), runtime_->modelManager()->totalBytes(), {}); @@ -146,6 +149,9 @@ void VoxLocalDock::retranslateUi() } languageMode_->setItemText(0, text("Fixed", "Sabit")); languageMode_->setItemText(1, text("Automatic", "Otomatik")); + modelStartup_->setItemText(0, text("Ask at startup", "Açılışta sor")); + modelStartup_->setItemText(1, text("Load automatically", "Açılsın")); + modelStartup_->setItemText(2, text("Do not load", "Açılmasın")); preset_->setItemText(preset_->findData(QStringLiteral("minimal")), text("Minimal", "Minimal")); preset_->setItemText(preset_->findData(QStringLiteral("subtitle")), text("Subtitle", "Altyazı")); const std::array, 6> animations{{{"Fade", "Solma"}, @@ -163,6 +169,7 @@ void VoxLocalDock::retranslateUi() .arg(ModelManager::formatBytes(runtime_->modelManager()->totalBytes()))); updateModelProgress(runtime_->modelManager()->downloadedBytes(), runtime_->modelManager()->totalBytes(), {}); updateModelDownloadState(runtime_->modelManager()->isInstalling()); + updateModelRuntimeState(); } void VoxLocalDock::buildUi() @@ -228,11 +235,26 @@ void VoxLocalDock::buildUi() modelProgress_ = new QProgressBar; modelProgress_->setRange(0, 10000); modelInstall_ = new QPushButton; + modelLoad_ = new QPushButton; + localize(modelLoad_, "Load model now", "Modeli şimdi aç"); + modelRuntime_ = new QLabel; + modelRuntime_->setWordWrap(true); + modelStartup_ = new QComboBox; + modelStartup_->addItem(QString{}, QVariant(QStringLiteral("ask"))); + modelStartup_->addItem(QString{}, QVariant(QStringLiteral("always"))); + modelStartup_->addItem(QString{}, QVariant(QStringLiteral("never"))); + auto *modelStartupLabel = new QLabel; + localize(modelStartupLabel, "At OBS startup", "OBS açılışında"); modelLayout->addWidget(modelInfo_); modelLayout->addWidget(model_); modelLayout->addWidget(modelProgress_); modelLayout->addWidget(modelInstall_); + modelLayout->addWidget(modelRuntime_); + modelLayout->addWidget(modelLoad_); + modelLayout->addWidget(modelStartupLabel); + modelLayout->addWidget(modelStartup_); connect(modelInstall_, &QPushButton::clicked, runtime_->modelManager(), &ModelManager::install); + connect(modelLoad_, &QPushButton::clicked, runtime_, &VoxLocalRuntime::loadModel); layout->addWidget(modelGroup); auto *personaGroup = new QGroupBox; @@ -447,6 +469,17 @@ void VoxLocalDock::refresh() ttsEnabled_->setChecked(working_.ttsEnabled); globalCooldown_->setValue(working_.globalCooldownSeconds); maxCharacters_->setValue(working_.maxTextLength); + switch (working_.modelStartupBehavior) { + case ModelStartupBehavior::AlwaysLoad: + modelStartup_->setCurrentIndex(1); + break; + case ModelStartupBehavior::NeverLoad: + modelStartup_->setCurrentIndex(2); + break; + case ModelStartupBehavior::Ask: + modelStartup_->setCurrentIndex(0); + break; + } preset_->setCurrentIndex(std::max(0, preset_->findData(working_.overlay.preset))); animation_->setCurrentIndex(std::max(0, animation_->findData(working_.overlay.entranceAnimation))); font_->setCurrentFont(QFont(working_.overlay.fontFamily)); @@ -512,6 +545,23 @@ void VoxLocalDock::updateModelDownloadState(bool installing) modelInstall_->setText(text("Resume and verify model", "İndirmeye devam et ve doğrula")); else modelInstall_->setText(text("Download and verify model", "Modeli indir ve doğrula")); + updateModelRuntimeState(); +} + +void VoxLocalDock::updateModelRuntimeState() +{ + if (runtime_->engineReady()) { + modelRuntime_->setText(text("Loaded in memory — %1", "Bellekte açık — %1").arg(runtime_->engineBackend())); + } else if (runtime_->engineLoading()) { + modelRuntime_->setText(text("Loading in the background…", "Arka planda açılıyor…")); + } else if (runtime_->modelManager()->isInstalled()) { + modelRuntime_->setText(text("Installed on disk, not loaded in memory.", "Diskte kurulu, bellekte açık değil.")); + } else { + modelRuntime_->setText( + text("Download and verify the model before loading it.", "Modeli açmadan önce indirip doğrula.")); + } + modelLoad_->setEnabled(runtime_->modelManager()->isInstalled() && !runtime_->engineReady() && + !runtime_->engineLoading()); } void VoxLocalDock::updateVoiceImportProgress(const QString &personaId, int percent, const QString &operation) @@ -657,6 +707,10 @@ bool VoxLocalDock::save() : InterfaceLanguage::English; working_.kick.channelSlug = channel_->text().trimmed().toLower(); working_.ttsEnabled = ttsEnabled_->isChecked(); + const QString startup = modelStartup_->currentData().toString(); + working_.modelStartupBehavior = startup == QStringLiteral("always") ? ModelStartupBehavior::AlwaysLoad + : startup == QStringLiteral("never") ? ModelStartupBehavior::NeverLoad + : ModelStartupBehavior::Ask; working_.globalCooldownSeconds = globalCooldown_->value(); working_.maxTextLength = maxCharacters_->value(); working_.overlay.preset = preset_->currentData().toString(); diff --git a/src/welcome-wizard.cpp b/src/welcome-wizard.cpp index 0df0d30..3da7c97 100644 --- a/src/welcome-wizard.cpp +++ b/src/welcome-wizard.cpp @@ -3,6 +3,7 @@ #include "voxlocal/config-store.hpp" #include "voxlocal/voxlocal-source.hpp" +#include #include #include #include @@ -19,33 +20,10 @@ #include #include -#include namespace voxlocal { namespace { -class RequiredModelPage final : public QWizardPage -{ -public: - explicit RequiredModelPage(ModelManager *manager) : manager_(manager) {} - [[nodiscard]] bool isComplete() const override { return manager_->isInstalled(); } - void refreshCompleteState() { emit completeChanged(); } - -private: - ModelManager *manager_; -}; - -class RequiredVoicePage final : public QWizardPage -{ -public: - explicit RequiredVoicePage(std::function complete) : complete_(std::move(complete)) {} - [[nodiscard]] bool isComplete() const override { return complete_(); } - void refreshCompleteState() { emit completeChanged(); } - -private: - std::function complete_; -}; - int progressValue(qint64 received, qint64 total) { if (total <= 0) @@ -68,9 +46,20 @@ WelcomeWizard::WelcomeWizard(VoxLocalRuntime *runtime, QWidget *parent) : QWizar voicePersonaId_ = QUuid::createUuid().toString(QUuid::WithoutBraces); setWizardStyle(QWizard::ClassicStyle); setOption(QWizard::NoBackButtonOnStartPage); + setOption(QWizard::NoCancelButton); + setOption(QWizard::HaveCustomButton1); + setWindowModality(Qt::ApplicationModal); setMinimumSize(620, 460); buildPages(); retranslateUi(); + connect(this, &QWizard::customButtonClicked, this, [this](int button) { + if (button != QWizard::CustomButton1) + return; + if (currentId() == pageIds().last()) + accept(); + else + next(); + }); } QString WelcomeWizard::text(const char *english, const char *turkish) const @@ -117,7 +106,10 @@ void WelcomeWizard::retranslateUi() setButtonText(QWizard::BackButton, text("Back", "Geri")); setButtonText(QWizard::NextButton, text("Next", "İleri")); setButtonText(QWizard::FinishButton, text("Finish", "Bitir")); - setButtonText(QWizard::CancelButton, text("Cancel", "İptal")); + setButtonText(QWizard::CustomButton1, text("Skip", "Geç")); + modelStartup_->setItemText(0, text("Ask at startup", "Açılışta sor")); + modelStartup_->setItemText(1, text("Load automatically", "Açılsın")); + modelStartup_->setItemText(2, text("Do not load", "Açılmasın")); access_->setItemText(0, text("Everyone", "Herkes")); access_->setItemText(1, text("Subscribers, moderators and broadcaster", "Aboneler, moderatörler ve yayıncı")); access_->setItemText(2, text("Moderators and broadcaster", "Moderatörler ve yayıncı")); @@ -149,6 +141,7 @@ void WelcomeWizard::buildPages() interfaceLanguage_ = new QComboBox; interfaceLanguage_->addItem(QStringLiteral("English"), QStringLiteral("en")); interfaceLanguage_->addItem(QStringLiteral("Türkçe"), QStringLiteral("tr")); + interfaceLanguage_->setCurrentIndex(runtime_->settings().interfaceLanguage == InterfaceLanguage::Turkish ? 1 : 0); auto *languageLabel = new QLabel; localize(languageLabel, "Interface language", "Arayüz dili"); languageLayout->addWidget(intro); @@ -158,7 +151,7 @@ void WelcomeWizard::buildPages() connect(interfaceLanguage_, &QComboBox::currentIndexChanged, this, &WelcomeWizard::retranslateUi); addPage(languagePage); - auto *modelPage = new RequiredModelPage(runtime_->modelManager()); + auto *modelPage = new QWizardPage; localize(modelPage, "Local voice model", "Yerel ses modeli"); auto *modelLayout = new QVBoxLayout(modelPage); modelInfo_ = new QLabel; @@ -168,18 +161,36 @@ void WelcomeWizard::buildPages() modelProgress_ = new QProgressBar; modelProgress_->setRange(0, 10000); modelInstall_ = new QPushButton; + modelStartup_ = new QComboBox; + modelStartup_->addItem(QString{}, QVariant(QStringLiteral("ask"))); + modelStartup_->addItem(QString{}, QVariant(QStringLiteral("always"))); + modelStartup_->addItem(QString{}, QVariant(QStringLiteral("never"))); + switch (runtime_->settings().modelStartupBehavior) { + case ModelStartupBehavior::AlwaysLoad: + modelStartup_->setCurrentIndex(1); + break; + case ModelStartupBehavior::NeverLoad: + modelStartup_->setCurrentIndex(2); + break; + case ModelStartupBehavior::Ask: + modelStartup_->setCurrentIndex(0); + break; + } + auto *modelStartupLabel = new QLabel; + localize(modelStartupLabel, "At OBS startup", "OBS açılışında"); modelLayout->addWidget(modelInfo_); modelLayout->addWidget(modelStatus_); modelLayout->addWidget(modelProgress_); modelLayout->addWidget(modelInstall_); + modelLayout->addWidget(modelStartupLabel); + modelLayout->addWidget(modelStartup_); modelLayout->addStretch(); connect(modelInstall_, &QPushButton::clicked, runtime_->modelManager(), &ModelManager::install); connect(runtime_, &VoxLocalRuntime::modelProgress, this, [this](qint64 value, qint64 total, const QString &file) { updateModelProgress(value, total, file); }); connect(runtime_->modelManager(), &ModelManager::installingChanged, this, &WelcomeWizard::updateModelDownloadState); - connect(runtime_->modelManager(), &ModelManager::ready, this, [this, modelPage] { + connect(runtime_->modelManager(), &ModelManager::ready, this, [this] { updateModelProgress(runtime_->modelManager()->totalBytes(), runtime_->modelManager()->totalBytes(), {}); - modelPage->refreshCompleteState(); }); connect(runtime_, &VoxLocalRuntime::errorOccurred, modelStatus_, &QLabel::setText); addPage(modelPage); @@ -193,11 +204,12 @@ void WelcomeWizard::buildPages() kickInfo->setWordWrap(true); channel_ = new QLineEdit; channel_->setPlaceholderText(QStringLiteral("channel-name")); + channel_->setText(runtime_->settings().kick.channelSlug); kickLayout->addRow(kickInfo); addRow(kickLayout, "Channel", "Kanal", channel_); addPage(kickPage); - auto *personaPage = new RequiredVoicePage([this] { return !preparedVoicePath_.isEmpty(); }); + auto *personaPage = new QWizardPage; localize(personaPage, "First persona", "İlk persona"); auto *personaLayout = new QFormLayout(personaPage); personaName_ = new QLineEdit(QStringLiteral("Voice")); @@ -220,6 +232,22 @@ void WelcomeWizard::buildPages() access_->addItem(QString{}, QVariant::fromValue(static_cast(UserRole::Subscriber | UserRole::Moderator | UserRole::Broadcaster))); access_->addItem(QString{}, QVariant::fromValue(static_cast(UserRole::Moderator | UserRole::Broadcaster))); + if (!runtime_->settings().personas.empty()) { + const auto &persona = runtime_->settings().personas.front(); + voicePersonaId_ = persona.id; + personaName_->setText(persona.name); + command_->setText(persona.command); + preparedVoicePath_ = persona.referenceAudioPath; + voice_->setText(preparedVoicePath_); + language_->setCurrentIndex(std::max(0, language_->findData(persona.language))); + const auto allowed = static_cast(persona.access.allowed); + for (int index = 0; index < access_->count(); ++index) { + if (access_->itemData(index).toUInt() == allowed) { + access_->setCurrentIndex(index); + break; + } + } + } addRow(personaLayout, "Persona name", "Persona adı", personaName_); addRow(personaLayout, "Chat command", "Sohbet komutu", command_); addRow(personaLayout, "Short clean audio or video sample", "Kısa ve temiz ses veya video örneği", voiceField); @@ -239,7 +267,7 @@ void WelcomeWizard::buildPages() voiceImportLayout->addWidget(voiceImportSteps_); voiceImportPanel_->hide(); personaLayout->addRow(voiceImportPanel_); - connect(voiceBrowse_, &QPushButton::clicked, this, [this, personaPage] { + connect(voiceBrowse_, &QPushButton::clicked, this, [this] { const auto file = QFileDialog::getOpenFileName( this, text("Voice sample", "Ses örneği"), {}, text("Audio and video (*.wav *.mp3 *.m4a *.aac *.flac *.ogg *.opus *.wma *.aiff *.mp4 *.mkv *.mov *.webm " @@ -253,7 +281,6 @@ void WelcomeWizard::buildPages() voiceImportSteps_->clear(); voiceImportPanel_->show(); voiceBrowse_->setEnabled(false); - personaPage->refreshCompleteState(); runtime_->importVoiceAsync(file, voicePersonaId_); }); connect(runtime_, &VoxLocalRuntime::voiceImportProgress, this, @@ -273,7 +300,7 @@ void WelcomeWizard::buildPages() voiceImportSteps_->scrollToBottom(); }); connect(runtime_, &VoxLocalRuntime::voiceImportFinished, this, - [this, personaPage](const QString &personaId, const QString &voicePath) { + [this](const QString &personaId, const QString &voicePath) { if (personaId != voicePersonaId_) return; preparedVoicePath_ = voicePath; @@ -287,18 +314,15 @@ void WelcomeWizard::buildPages() last->setText(QStringLiteral("✓ ") + last->text().mid(2)); } voiceBrowse_->setEnabled(true); - personaPage->refreshCompleteState(); - }); - connect(runtime_, &VoxLocalRuntime::voiceImportFailed, this, - [this, personaPage](const QString &personaId, const QString &error) { - if (personaId != voicePersonaId_) - return; - voiceImportStatus_->setText(error); - voiceImportProgress_->setFormat(text("Failed", "Başarısız")); - voiceImportSteps_->addItem(QStringLiteral("✕ ") + error); - voiceBrowse_->setEnabled(true); - personaPage->refreshCompleteState(); }); + connect(runtime_, &VoxLocalRuntime::voiceImportFailed, this, [this](const QString &personaId, const QString &error) { + if (personaId != voicePersonaId_) + return; + voiceImportStatus_->setText(error); + voiceImportProgress_->setFormat(text("Failed", "Başarısız")); + voiceImportSteps_->addItem(QStringLiteral("✕ ") + error); + voiceBrowse_->setEnabled(true); + }); addPage(personaPage); auto *done = new QWizardPage; @@ -318,34 +342,28 @@ void WelcomeWizard::buildPages() void WelcomeWizard::accept() { - if (!runtime_->modelManager()->isInstalled()) { - QMessageBox::warning(this, QStringLiteral("VoxLocal"), - text("The local model must finish downloading and verification before setup can continue.", - "Kuruluma devam etmeden önce yerel modelin indirilmesi ve doğrulanması tamamlanmalı.")); - return; - } - if (channel_->text().trimmed().isEmpty() || personaName_->text().trimmed().isEmpty() || - CommandRouter::normalizeCommand(command_->text()).isEmpty() || voice_->text().trimmed().isEmpty()) { - QMessageBox::warning(this, QStringLiteral("VoxLocal"), - text("Channel, persona, command, and media sample are required.", - "Kanal, persona, komut ve medya örneği zorunludur.")); - return; - } Settings settings = runtime_->settings(); settings.interfaceLanguage = interfaceLanguage_->currentData().toString() == QStringLiteral("tr") ? InterfaceLanguage::Turkish : InterfaceLanguage::English; + const QString startup = modelStartup_->currentData().toString(); + settings.modelStartupBehavior = startup == QStringLiteral("always") ? ModelStartupBehavior::AlwaysLoad + : startup == QStringLiteral("never") ? ModelStartupBehavior::NeverLoad + : ModelStartupBehavior::Ask; settings.kick.channelSlug = channel_->text().trimmed().toLower(); - settings.kick.enabled = true; - Persona persona; - persona.id = voicePersonaId_; - persona.name = personaName_->text().trimmed(); - persona.command = CommandRouter::normalizeCommand(command_->text()); - persona.language = language_->currentData().toString(); - persona.automaticFallbackLanguage = persona.language; - persona.access.allowed = static_cast(access_->currentData().toUInt()); - persona.referenceAudioPath = preparedVoicePath_; - settings.personas = {persona}; + settings.kick.enabled = !settings.kick.channelSlug.isEmpty(); + const QString normalizedCommand = CommandRouter::normalizeCommand(command_->text()); + if (!personaName_->text().trimmed().isEmpty() && !normalizedCommand.isEmpty() && !preparedVoicePath_.isEmpty()) { + Persona persona; + persona.id = voicePersonaId_; + persona.name = personaName_->text().trimmed(); + persona.command = normalizedCommand; + persona.language = language_->currentData().toString(); + persona.automaticFallbackLanguage = persona.language; + persona.access.allowed = static_cast(access_->currentData().toUInt()); + persona.referenceAudioPath = preparedVoicePath_; + settings.personas = {persona}; + } settings.welcomeCompleted = true; QString error; if (!runtime_->applySettings(settings, &error)) { @@ -359,6 +377,26 @@ void WelcomeWizard::accept() QWizard::accept(); } +void WelcomeWizard::reject() +{ + if (allowClose_) + QWizard::reject(); +} + +void WelcomeWizard::closeEvent(QCloseEvent *event) +{ + if (allowClose_) + QWizard::closeEvent(event); + else + event->ignore(); +} + +void WelcomeWizard::closeForShutdown() +{ + allowClose_ = true; + QWizard::reject(); +} + void WelcomeWizard::updateModelProgress(qint64 received, qint64 total, const QString &fileName) { received = std::clamp(received, qint64{0}, std::max(total, qint64{0})); diff --git a/tests/core-tests.cpp b/tests/core-tests.cpp index 04042a4..913cf7b 100644 --- a/tests/core-tests.cpp +++ b/tests/core-tests.cpp @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -20,8 +21,8 @@ #include #include -#include #include +#include #include #include #include @@ -49,9 +50,12 @@ void configRoundTrip() require(directory.isValid(), "temporary directory unavailable"); voxlocal::ConfigStore store(directory.filePath(QStringLiteral("settings.json"))); auto settings = voxlocal::ConfigStore::defaults(); + require(settings.modelStartupBehavior == voxlocal::ModelStartupBehavior::Ask, + "the default model startup behavior must ask the user"); settings.interfaceLanguage = voxlocal::InterfaceLanguage::Turkish; settings.kick.channelSlug = QStringLiteral("voxlocal"); settings.overlay.preset = QStringLiteral("subtitle"); + settings.modelStartupBehavior = voxlocal::ModelStartupBehavior::NeverLoad; voxlocal::Persona persona; persona.name = QStringLiteral("Türkçe Ses"); persona.command = QStringLiteral("ses"); @@ -71,6 +75,8 @@ void configRoundTrip() require(!loaded.ttsEnabled && loaded.globalCooldownSeconds == 17 && loaded.maxTextLength == 180, "global TTS controls were not persisted"); require(loaded.overlay.preset == QStringLiteral("subtitle"), "overlay preset was not persisted"); + require(loaded.modelStartupBehavior == voxlocal::ModelStartupBehavior::NeverLoad, + "model startup behavior was not persisted"); require(voxlocal::hasRole(loaded.personas[0].access.allowed, voxlocal::UserRole::Moderator), "role was not persisted"); } @@ -319,6 +325,39 @@ void optionalTtsSmokeTest() require(audio.sampleRate == 24000 && !audio.samples.empty(), "TTS smoke test returned no audio"); } +void optionalAsyncModelLoadTest() +{ + const QString modelPath = qEnvironmentVariable("VOXLOCAL_TEST_MODEL"); + if (modelPath.isEmpty()) + return; + + QTemporaryDir directory; + require(directory.isValid(), "temporary async model-load directory unavailable"); + const QString modelRoot = QDir(modelPath).absoluteFilePath(QStringLiteral("../..")); + voxlocal::VoxLocalRuntime runtime(directory.filePath(QStringLiteral("settings.json")), modelRoot); + QEventLoop loop; + QTimer timeout; + timeout.setSingleShot(true); + QString failure; + QObject::connect(&runtime, &voxlocal::VoxLocalRuntime::engineLoadingChanged, &loop, [&](bool loading) { + if (!loading) + loop.quit(); + }); + QObject::connect(&runtime, &voxlocal::VoxLocalRuntime::errorOccurred, &loop, [&](const QString &error) { + failure = error; + loop.quit(); + }); + QObject::connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit); + QElapsedTimer returnTimer; + returnTimer.start(); + runtime.loadModel(); + require(returnTimer.elapsed() < 250, "model loading blocked the UI thread"); + timeout.start(60000); + loop.exec(); + require(failure.isEmpty(), failure.toUtf8().constData()); + require(runtime.engineReady() && !runtime.engineLoading(), "background model loading did not finish"); +} + } // namespace int main(int argc, char **argv) @@ -333,6 +372,7 @@ int main(int argc, char **argv) tokenizerEmbeddingBounds(); partialModelAccounting(); mediaVoiceImport(); + optionalAsyncModelLoadTest(); optionalTtsSmokeTest(); std::cout << "All VoxLocal core tests passed.\n"; return 0;