diff --git a/CMakeLists.txt b/CMakeLists.txt index 70c0eb6..d188e46 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,6 +15,11 @@ set(CMAKE_CXX_EXTENSIONS OFF) option(MUTTERKEY_ENABLE_ASAN "Enable AddressSanitizer for repo-owned code and vendored whisper.cpp" OFF) option(MUTTERKEY_ENABLE_UBSAN "Enable UndefinedBehaviorSanitizer for repo-owned code and vendored whisper.cpp" OFF) +option(MUTTERKEY_ENABLE_WHISPER_CUDA "Enable whisper.cpp CUDA backend support (NVIDIA)" OFF) +option(MUTTERKEY_ENABLE_WHISPER_VULKAN "Enable whisper.cpp Vulkan backend support" OFF) +option(MUTTERKEY_ENABLE_WHISPER_BLAS "Enable whisper.cpp BLAS CPU acceleration" OFF) +set(MUTTERKEY_WHISPER_BLAS_VENDOR "Generic" CACHE STRING "BLAS vendor passed to whisper.cpp when BLAS acceleration is enabled") +set_property(CACHE MUTTERKEY_WHISPER_BLAS_VENDOR PROPERTY STRINGS "Generic;OpenBLAS;FLAME;ATLAS;FlexiBLAS;Intel;NVHPC;Apple") find_package(Qt6 REQUIRED COMPONENTS Core Gui Multimedia) find_package(KF6GlobalAccel CONFIG REQUIRED) @@ -29,6 +34,8 @@ set(MUTTERKEY_APP_SOURCES src/audio/recording.h src/clipboardwriter.cpp src/clipboardwriter.h + src/commanddispatch.cpp + src/commanddispatch.h src/config.cpp src/config.h src/hotkeymanager.cpp @@ -146,6 +153,10 @@ set(WHISPER_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) set(WHISPER_BUILD_SERVER OFF CACHE BOOL "" FORCE) set(WHISPER_SANITIZE_ADDRESS ${MUTTERKEY_ENABLE_ASAN} CACHE BOOL "" FORCE) set(WHISPER_SANITIZE_UNDEFINED ${MUTTERKEY_ENABLE_UBSAN} CACHE BOOL "" FORCE) +set(GGML_CUDA ${MUTTERKEY_ENABLE_WHISPER_CUDA} CACHE BOOL "" FORCE) +set(GGML_VULKAN ${MUTTERKEY_ENABLE_WHISPER_VULKAN} CACHE BOOL "" FORCE) +set(GGML_BLAS ${MUTTERKEY_ENABLE_WHISPER_BLAS} CACHE BOOL "" FORCE) +set(GGML_BLAS_VENDOR ${MUTTERKEY_WHISPER_BLAS_VENDOR} CACHE STRING "" FORCE) add_subdirectory(third_party/whisper.cpp EXCLUDE_FROM_ALL) # Mutterkey ships the vendored shared libraries, but it does not install their @@ -161,6 +172,15 @@ install(TARGETS whisper ggml ggml-base if(TARGET ggml-cpu) install(TARGETS ggml-cpu LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}) endif() +if(TARGET ggml-cuda) + install(TARGETS ggml-cuda LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}) +endif() +if(TARGET ggml-vulkan) + install(TARGETS ggml-vulkan LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}) +endif() +if(TARGET ggml-blas) + install(TARGETS ggml-blas LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}) +endif() install(FILES contrib/org.mutterkey.mutterkey.desktop DESTINATION ${CMAKE_INSTALL_DATADIR}/applications) install(FILES LICENSE THIRD_PARTY_NOTICES.md DESTINATION ${MUTTERKEY_LICENSE_INSTALL_DIR}) install(FILES third_party/whisper.cpp/LICENSE DESTINATION ${MUTTERKEY_LICENSE_INSTALL_DIR}/third_party/whisper.cpp) diff --git a/README.md b/README.md index 7754d0a..7e97176 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,37 @@ This installs: - `~/.local/lib/libwhisper.so*` and the required `ggml` libraries - `~/.local/share/applications/org.mutterkey.mutterkey.desktop` +Optional acceleration flags: + +```bash +cmake -S . -B "$BUILD_DIR" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX="$HOME/.local" \ + -DMUTTERKEY_ENABLE_WHISPER_CUDA=ON +``` + +```bash +cmake -S . -B "$BUILD_DIR" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX="$HOME/.local" \ + -DMUTTERKEY_ENABLE_WHISPER_VULKAN=ON +``` + +```bash +cmake -S . -B "$BUILD_DIR" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX="$HOME/.local" \ + -DMUTTERKEY_ENABLE_WHISPER_BLAS=ON \ + -DMUTTERKEY_WHISPER_BLAS_VENDOR=OpenBLAS +``` + +Notes: + +- `MUTTERKEY_ENABLE_WHISPER_CUDA=ON` is for NVIDIA GPUs and requires a working CUDA toolchain +- `MUTTERKEY_ENABLE_WHISPER_VULKAN=ON` is for Vulkan-capable GPUs and requires Vulkan development headers and loader libraries +- `MUTTERKEY_ENABLE_WHISPER_BLAS=ON` improves CPU inference speed rather than enabling GPU execution +- these options are forwarded to the vendored `whisper.cpp` / `ggml` build and install any resulting backend libraries alongside Mutterkey + ### 2. Put a Whisper model on disk Example location: @@ -121,11 +152,24 @@ Example location: ### 3. Create the config file ```bash -mkdir -p ~/.config/mutterkey -cp config.example.json ~/.config/mutterkey/config.json +mutterkey config init --model-path ~/.local/share/mutterkey/models/ggml-base.en.bin ``` -Edit `~/.config/mutterkey/config.json` and set at least: +`mutterkey config init` writes the Linux config file to: + +```text +~/.config/mutterkey/config.json +``` + +When run from a terminal, Mutterkey can also create this file automatically on +first launch if it does not exist yet. The interactive bootstrap asks for: + +- `transcriber.model_path` +- `shortcut.sequence` + +You can update saved values later with `mutterkey config set `. + +Set at least: - `shortcut.sequence` - `transcriber.model_path` @@ -159,8 +203,11 @@ See [config.example.json](config.example.json) for the full config. Config notes: - `transcriber.threads: 0` means auto-detect based on the local machine +- `transcriber.language` accepts a Whisper language code such as `en` or `fi`, or `auto` for language detection - invalid numeric values fall back to safe defaults and log a warning +- invalid `transcriber.language` values fall back to the default and log a warning - empty `shortcut.sequence` or `transcriber.model_path` values fall back to defaults and log a warning +- runtime flags such as `--model-path`, `--shortcut`, `--language`, `--translate`, `--threads`, and `--warmup-on-start` override the saved config for the current process only ### 4. Sanity-check the installed binary @@ -185,7 +232,8 @@ The default service file assumes: - config file at `%h/.config/mutterkey/config.json` If your paths differ, edit [contrib/mutterkey.service](contrib/mutterkey.service) -before enabling it. +before enabling it. If the config file does not exist, the service will fail +fast and instruct you to run `mutterkey config init` from a terminal first. ### 6. Use the hotkey @@ -231,6 +279,14 @@ installed setup looks like: %h/.local/bin/mutterkey daemon --config %h/.config/mutterkey/config.json ``` +Useful config commands: + +```bash +~/.local/bin/mutterkey config init --model-path ~/.local/share/mutterkey/models/ggml-base.en.bin +~/.local/bin/mutterkey config set shortcut.sequence Meta+F8 +~/.local/bin/mutterkey config set transcriber.language fi +``` + The desktop entry [contrib/org.mutterkey.mutterkey.desktop](contrib/org.mutterkey.mutterkey.desktop) is intentionally hidden from normal app menus because the project currently diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index 40f8b96..19e24f3 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -33,6 +33,27 @@ BUILD_DIR="$(mktemp -d /tmp/mutterkey-build-XXXXXX)" cmake -S . -B "$BUILD_DIR" -DCMAKE_BUILD_TYPE=Debug ``` +- If the release is intended to ship an accelerated Whisper backend, configure + the build with the relevant Mutterkey options: + +```bash +cmake -S . -B "$BUILD_DIR" -DCMAKE_BUILD_TYPE=Debug -DMUTTERKEY_ENABLE_WHISPER_CUDA=ON +``` + +```bash +cmake -S . -B "$BUILD_DIR" -DCMAKE_BUILD_TYPE=Debug -DMUTTERKEY_ENABLE_WHISPER_VULKAN=ON +``` + +```bash +cmake -S . -B "$BUILD_DIR" -DCMAKE_BUILD_TYPE=Debug -DMUTTERKEY_ENABLE_WHISPER_BLAS=ON -DMUTTERKEY_WHISPER_BLAS_VENDOR=OpenBLAS +``` + +- Acceleration option notes: + - `MUTTERKEY_ENABLE_WHISPER_CUDA=ON`: NVIDIA GPU build through vendored `ggml` + - `MUTTERKEY_ENABLE_WHISPER_VULKAN=ON`: Vulkan GPU build through vendored `ggml` + - `MUTTERKEY_ENABLE_WHISPER_BLAS=ON`: faster CPU inference, not GPU execution + - choose the backend intentionally for the release artifact and record that choice in release notes or packaging docs when relevant + - Build: ```bash @@ -104,6 +125,11 @@ cmake --install "$BUILD_DIR" --prefix "$INSTALL_DIR" - required `libwhisper` / `ggml` shared libraries - the desktop file under `share/applications` - license files under `share/licenses/mutterkey` +- If acceleration was enabled for the release, also confirm the installed tree + contains the expected backend library: + - `libggml-cuda.so*` for `MUTTERKEY_ENABLE_WHISPER_CUDA=ON` + - `libggml-vulkan.so*` for `MUTTERKEY_ENABLE_WHISPER_VULKAN=ON` + - `libggml-blas.so*` for `MUTTERKEY_ENABLE_WHISPER_BLAS=ON` - Do not expect vendored upstream public headers to be installed; Mutterkey's install rules ship the runtime libraries but intentionally clear vendored `PUBLIC_HEADER` metadata to avoid upstream header-install warnings. @@ -118,6 +144,12 @@ cmake --install "$BUILD_DIR" --prefix "$INSTALL_DIR" recommended installed-binary setup. - Confirm [contrib/org.mutterkey.mutterkey.desktop](contrib/org.mutterkey.mutterkey.desktop) still reflects the intended desktop behavior, including `NoDisplay=true`. +- If the release is intended to use accelerated Whisper inference, verify the + runtime logs on a representative machine show the expected backend instead of + CPU-only fallback. For example: + - CUDA/Vulkan releases should not log only `registered backend CPU` + - CPU-accelerated BLAS releases may still be CPU-only, but should be tested + against a representative Whisper model and expected performance target ## Vendored whisper.cpp Updates diff --git a/config.example.json b/config.example.json index 076fbe9..39199a8 100644 --- a/config.example.json +++ b/config.example.json @@ -13,7 +13,7 @@ "device_id": "" }, "transcriber": { - "model_path": "/absolute/path/to/ggml-base.en.bin", + "model_path": "/path/to/ggml-base.en.bin", "language": "en", "translate": false, "threads": 0, diff --git a/src/commanddispatch.cpp b/src/commanddispatch.cpp new file mode 100644 index 0000000..164deb1 --- /dev/null +++ b/src/commanddispatch.cpp @@ -0,0 +1,114 @@ +#include "commanddispatch.h" + +#include "config.h" + +#include +#include + +namespace { + +bool optionConsumesValue(const QString &argument) +{ + static const QStringList kOptionsWithValues{ + QStringLiteral("--config"), + QStringLiteral("--log-level"), + QStringLiteral("--model-path"), + QStringLiteral("--shortcut"), + QStringLiteral("--language"), + QStringLiteral("--threads"), + }; + + return kOptionsWithValues.contains(argument); +} + +bool isHelpArgument(const QString &argument) +{ + return argument == QStringLiteral("--help") || argument == QStringLiteral("-h") || argument == QStringLiteral("--help-all"); +} + +} // namespace + +QStringList rawArguments(std::span arguments) +{ + QStringList parsedArguments; + parsedArguments.reserve(static_cast(arguments.size())); + for (char *argument : arguments) { + parsedArguments.append(QString::fromLocal8Bit(argument)); + } + return parsedArguments; +} + +int commandIndexFromArguments(const QStringList &arguments) +{ + for (int index = 1; index < arguments.size(); ++index) { + const QString &argument = arguments.at(index); + if (argument == QStringLiteral("--")) { + return index + 1 < arguments.size() ? index + 1 : -1; + } + + if (optionConsumesValue(argument)) { + ++index; + continue; + } + + if (argument.startsWith(QLatin1String("--")) && argument.contains(QLatin1Char('='))) { + continue; + } + + if (argument.startsWith(QLatin1Char('-'))) { + continue; + } + + return index; + } + + return -1; +} + +bool shouldShowConfigHelp(const QStringList &arguments, int commandIndex) +{ + if (commandIndex < 0 || commandIndex >= arguments.size() || arguments.at(commandIndex) != QStringLiteral("config")) { + return false; + } + + if (commandIndex == arguments.size() - 1) { + return true; + } + + for (int index = commandIndex + 1; index < arguments.size(); ++index) { + if (isHelpArgument(arguments.at(index))) { + return true; + } + } + + return false; +} + +QString configHelpText() +{ + QString helpText; + QTextStream output(&helpText); + output << "Usage: mutterkey [options] config [args]" << Qt::endl; + output << Qt::endl; + output << "Configuration subcommands:" << Qt::endl; + output << " init Create the config file, prompting on a terminal when needed" << Qt::endl; + output << " set Persist one config value into the config file" << Qt::endl; + output << Qt::endl; + output << "Config options:" << Qt::endl; + output << " --config Path to the JSON config file" << Qt::endl; + output << " --model-path Set transcriber.model_path during `config init`" << Qt::endl; + output << " --shortcut Set shortcut.sequence during `config init`" << Qt::endl; + output << " --language Set transcriber.language during `config init`" << Qt::endl; + output << " --threads Set transcriber.threads during `config init`" << Qt::endl; + output << " --translate Set transcriber.translate=true during `config init`" << Qt::endl; + output << " --no-translate Set transcriber.translate=false during `config init`" << Qt::endl; + output << " --warmup-on-start Set transcriber.warmup_on_start=true during `config init`" << Qt::endl; + output << " --no-warmup-on-start Set transcriber.warmup_on_start=false during `config init`" << Qt::endl; + output << " --log-level Set log_level during `config init`" << Qt::endl; + output << Qt::endl; + output << "Supported keys for `config set`:" << Qt::endl; + for (const QString &key : supportedConfigKeys()) { + output << " " << key << Qt::endl; + } + return helpText; +} diff --git a/src/commanddispatch.h b/src/commanddispatch.h new file mode 100644 index 0000000..9906f39 --- /dev/null +++ b/src/commanddispatch.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include + +/** + * @brief Converts raw argv data into Qt strings. + * @param arguments Raw argv span. + * @return Raw command-line arguments as a QStringList. + */ +QStringList rawArguments(std::span arguments); + +/** + * @brief Finds the first positional command after known global options. + * @param arguments Raw command-line arguments. + * @return Index of the command token, or `-1` when no command is present. + */ +int commandIndexFromArguments(const QStringList &arguments); + +/** + * @brief Returns whether the config command should print dedicated help. + * @param arguments Raw command-line arguments. + * @param commandIndex Index returned by commandIndexFromArguments(). + * @return `true` for bare `config` and `config --help` style invocations. + */ +bool shouldShowConfigHelp(const QStringList &arguments, int commandIndex); + +/** + * @brief Returns the dedicated help text for config subcommands. + * @return Human-readable help text. + */ +QString configHelpText(); diff --git a/src/config.cpp b/src/config.cpp index 76aef5d..55465ee 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -1,12 +1,19 @@ #include "config.h" +#include #include #include +#include #include #include #include +#include #include +extern "C" { +#include +} + Q_LOGGING_CATEGORY(configLog, "mutterkey.config") namespace { @@ -62,6 +69,66 @@ QString sanitizedNonEmptyString(const QString &value) return trimmedValue; } +QString normalizedLanguageValue(const QString &value) +{ + return value.trimmed().toLower(); +} + +bool resolveWhisperLanguage(const QString &value, QString *resolvedLanguage) +{ + const QString normalizedValue = normalizedLanguageValue(value); + if (normalizedValue.isEmpty()) { + return false; + } + + if (normalizedValue == QStringLiteral("auto")) { + if (resolvedLanguage != nullptr) { + *resolvedLanguage = normalizedValue; + } + return true; + } + + for (int languageId = 0; languageId <= whisper_lang_max_id(); ++languageId) { + const char *languageCode = whisper_lang_str(languageId); + const char *languageName = whisper_lang_str_full(languageId); + if (languageCode == nullptr) { + continue; + } + + if (normalizedValue == QString::fromUtf8(languageCode) + || (languageName != nullptr && normalizedValue == QString::fromUtf8(languageName))) { + if (resolvedLanguage != nullptr) { + *resolvedLanguage = QString::fromUtf8(languageCode); + } + return true; + } + } + + return false; +} + +bool parseBoolValue(const QString &value, bool *parsedValue) +{ + if (parsedValue == nullptr) { + return false; + } + + const QString normalizedValue = value.trimmed().toLower(); + if (normalizedValue == QStringLiteral("true") || normalizedValue == QStringLiteral("1") + || normalizedValue == QStringLiteral("yes") || normalizedValue == QStringLiteral("on")) { + *parsedValue = true; + return true; + } + + if (normalizedValue == QStringLiteral("false") || normalizedValue == QStringLiteral("0") + || normalizedValue == QStringLiteral("no") || normalizedValue == QStringLiteral("off")) { + *parsedValue = false; + return true; + } + + return false; +} + int validatedAudioSampleRate(const QString &path, int sampleRate) { if (sampleRate > 0) { @@ -132,6 +199,177 @@ bool isSupportedLogLevel(const QString &logLevel) return kAllowedLevels.contains(logLevel); } +bool setShortcutSequence(AppConfig *config, const QString &value, QString *errorMessage) +{ + const QString trimmedValue = sanitizedNonEmptyString(value); + if (trimmedValue.isEmpty()) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("shortcut.sequence may not be empty"); + } + return false; + } + + config->shortcut.sequence = trimmedValue; + return true; +} + +bool setAudioSampleRate(AppConfig *config, const QString &value, QString *errorMessage) +{ + bool ok = false; + const int sampleRate = value.trimmed().toInt(&ok); + if (!ok || sampleRate <= 0) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("audio.sample_rate must be greater than 0"); + } + return false; + } + + config->audio.sampleRate = sampleRate; + return true; +} + +bool setAudioChannels(AppConfig *config, const QString &value, QString *errorMessage) +{ + bool ok = false; + const int channels = value.trimmed().toInt(&ok); + if (!ok || channels <= 0 || channels > kMaxAudioChannels) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("audio.channels must be between 1 and 8"); + } + return false; + } + + config->audio.channels = channels; + return true; +} + +bool setAudioMinimumSeconds(AppConfig *config, const QString &value, QString *errorMessage) +{ + bool ok = false; + const double minimumSeconds = value.trimmed().toDouble(&ok); + if (!ok || minimumSeconds < 0.0) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("audio.minimum_seconds must be 0 or greater"); + } + return false; + } + + config->audio.minimumSeconds = minimumSeconds; + return true; +} + +bool setAudioDeviceId(AppConfig *config, const QString &value, QString *) +{ + config->audio.deviceId = value.trimmed(); + return true; +} + +bool setModelPath(AppConfig *config, const QString &value, QString *errorMessage) +{ + const QString trimmedValue = sanitizedNonEmptyString(value); + if (trimmedValue.isEmpty()) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("transcriber.model_path may not be empty"); + } + return false; + } + + config->transcriber.modelPath = trimmedValue; + return true; +} + +bool setLanguage(AppConfig *config, const QString &value, QString *errorMessage) +{ + QString resolvedLanguage; + if (!resolveWhisperLanguage(value, &resolvedLanguage)) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("transcriber.language must be \"auto\" or a supported Whisper language code"); + } + return false; + } + + config->transcriber.language = resolvedLanguage; + return true; +} + +bool setTranslate(AppConfig *config, const QString &value, QString *errorMessage) +{ + bool translate = false; + if (!parseBoolValue(value, &translate)) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("transcriber.translate must be a boolean value"); + } + return false; + } + + config->transcriber.translate = translate; + return true; +} + +bool setThreads(AppConfig *config, const QString &value, QString *errorMessage) +{ + bool ok = false; + const int threads = value.trimmed().toInt(&ok); + if (!ok || threads < 0) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("transcriber.threads must be 0 or greater"); + } + return false; + } + + config->transcriber.threads = threads; + return true; +} + +bool setWarmupOnStart(AppConfig *config, const QString &value, QString *errorMessage) +{ + bool warmupOnStart = false; + if (!parseBoolValue(value, &warmupOnStart)) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("transcriber.warmup_on_start must be a boolean value"); + } + return false; + } + + config->transcriber.warmupOnStart = warmupOnStart; + return true; +} + +bool setLogLevel(AppConfig *config, const QString &value, QString *errorMessage) +{ + const QString logLevel = normalizedLogLevel(value); + if (!isSupportedLogLevel(logLevel)) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("log_level must be one of DEBUG, INFO, WARNING, ERROR"); + } + return false; + } + + config->logLevel = logLevel; + return true; +} + +using ConfigSetter = bool (*)(AppConfig *config, const QString &value, QString *errorMessage); + +struct ConfigKeyHandler { + QLatin1StringView key; + ConfigSetter setter; +}; + +constexpr std::array kConfigKeyHandlers{ + ConfigKeyHandler{.key = QLatin1StringView("shortcut.sequence"), .setter = &setShortcutSequence}, + ConfigKeyHandler{.key = QLatin1StringView("audio.sample_rate"), .setter = &setAudioSampleRate}, + ConfigKeyHandler{.key = QLatin1StringView("audio.channels"), .setter = &setAudioChannels}, + ConfigKeyHandler{.key = QLatin1StringView("audio.minimum_seconds"), .setter = &setAudioMinimumSeconds}, + ConfigKeyHandler{.key = QLatin1StringView("audio.device_id"), .setter = &setAudioDeviceId}, + ConfigKeyHandler{.key = QLatin1StringView("transcriber.model_path"), .setter = &setModelPath}, + ConfigKeyHandler{.key = QLatin1StringView("transcriber.language"), .setter = &setLanguage}, + ConfigKeyHandler{.key = QLatin1StringView("transcriber.translate"), .setter = &setTranslate}, + ConfigKeyHandler{.key = QLatin1StringView("transcriber.threads"), .setter = &setThreads}, + ConfigKeyHandler{.key = QLatin1StringView("transcriber.warmup_on_start"), .setter = &setWarmupOnStart}, + ConfigKeyHandler{.key = QLatin1StringView("log_level"), .setter = &setLogLevel}, +}; + } // namespace QString defaultConfigPath() @@ -145,10 +383,16 @@ QString defaultModelPath() return QDir(defaultModelDirectory()).filePath(QStringLiteral("ggml-base.en.bin")); } -AppConfig loadConfig(const QString &path, QString *errorMessage) +AppConfig defaultAppConfig() { AppConfig config; config.transcriber.modelPath = defaultModelPath(); + return config; +} + +AppConfig loadConfig(const QString &path, QString *errorMessage) +{ + AppConfig config = defaultAppConfig(); QFile file(path); if (!file.exists()) { return config; @@ -204,6 +448,16 @@ AppConfig loadConfig(const QString &path, QString *errorMessage) config.transcriber.modelPath = modelPath; } config.transcriber.language = readString(transcriber, QStringLiteral("language"), config.transcriber.language); + QString resolvedLanguage; + if (resolveWhisperLanguage(config.transcriber.language, &resolvedLanguage)) { + config.transcriber.language = resolvedLanguage; + } else { + warnAboutInvalidValue(path, + QStringLiteral("transcriber.language"), + QStringLiteral("unsupported Whisper language"), + defaultAppConfig().transcriber.language); + config.transcriber.language = defaultAppConfig().transcriber.language; + } config.transcriber.translate = readBool(transcriber, QStringLiteral("translate"), config.transcriber.translate); config.transcriber.threads = validatedThreads(path, readInt(transcriber, QStringLiteral("threads"), config.transcriber.threads)); config.transcriber.warmupOnStart = @@ -222,3 +476,101 @@ AppConfig loadConfig(const QString &path, QString *errorMessage) qCInfo(configLog) << "Loaded config from" << path; return config; } + +QByteArray serializeConfig(const AppConfig &config) +{ + QJsonObject shortcut; + shortcut.insert(QStringLiteral("component_unique"), config.shortcut.componentUnique); + shortcut.insert(QStringLiteral("component_friendly"), config.shortcut.componentFriendly); + shortcut.insert(QStringLiteral("action_unique"), config.shortcut.actionUnique); + shortcut.insert(QStringLiteral("action_friendly"), config.shortcut.actionFriendly); + shortcut.insert(QStringLiteral("sequence"), config.shortcut.sequence); + + QJsonObject audio; + audio.insert(QStringLiteral("sample_rate"), config.audio.sampleRate); + audio.insert(QStringLiteral("channels"), config.audio.channels); + audio.insert(QStringLiteral("minimum_seconds"), config.audio.minimumSeconds); + audio.insert(QStringLiteral("device_id"), config.audio.deviceId); + + QJsonObject transcriber; + transcriber.insert(QStringLiteral("model_path"), config.transcriber.modelPath); + transcriber.insert(QStringLiteral("language"), config.transcriber.language); + transcriber.insert(QStringLiteral("translate"), config.transcriber.translate); + transcriber.insert(QStringLiteral("threads"), config.transcriber.threads); + transcriber.insert(QStringLiteral("warmup_on_start"), config.transcriber.warmupOnStart); + + QJsonObject root; + root.insert(QStringLiteral("shortcut"), shortcut); + root.insert(QStringLiteral("audio"), audio); + root.insert(QStringLiteral("transcriber"), transcriber); + root.insert(QStringLiteral("log_level"), config.logLevel); + + return QJsonDocument(root).toJson(QJsonDocument::Indented); +} + +bool saveConfig(const QString &path, const AppConfig &config, QString *errorMessage) +{ + const QFileInfo configFileInfo(path); + QDir directory; + if (!directory.mkpath(configFileInfo.absolutePath())) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("Could not create config directory: %1").arg(configFileInfo.absolutePath()); + } + return false; + } + + QSaveFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("Could not open config file for writing: %1").arg(path); + } + return false; + } + + if (file.write(serializeConfig(config)) < 0) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("Could not write config file: %1").arg(path); + } + return false; + } + + if (!file.commit()) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("Could not commit config file: %1").arg(path); + } + return false; + } + + return true; +} + +QStringList supportedConfigKeys() +{ + QStringList keys; + keys.reserve(std::size(kConfigKeyHandlers)); + for (const ConfigKeyHandler &handler : kConfigKeyHandlers) { + keys.append(handler.key.toString()); + } + return keys; +} + +bool applyConfigValue(AppConfig *config, QStringView key, const QString &value, QString *errorMessage) +{ + if (config == nullptr) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("Internal error: missing config target"); + } + return false; + } + + for (const ConfigKeyHandler &handler : kConfigKeyHandlers) { + if (handler.key == key) { + return handler.setter(config, value, errorMessage); + } + } + + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("Unsupported config key: %1").arg(key); + } + return false; +} diff --git a/src/config.h b/src/config.h index 399d4a8..08ce5ee 100644 --- a/src/config.h +++ b/src/config.h @@ -1,6 +1,8 @@ #pragma once +#include #include +#include /** * @file @@ -83,6 +85,12 @@ struct AppConfig { QString logLevel = QStringLiteral("INFO"); }; +/** + * @brief Returns the built-in runtime defaults. + * @return Application config initialized with repo-defined defaults. + */ +AppConfig defaultAppConfig(); + /** * @brief Loads a config file and applies repo-defined fallback defaults. * @@ -94,3 +102,35 @@ struct AppConfig { * @return Resolved application config snapshot. */ AppConfig loadConfig(const QString &path, QString *errorMessage = nullptr); + +/** + * @brief Serializes a config snapshot to indented JSON. + * @param config Config snapshot to serialize. + * @return UTF-8 JSON payload. + */ +QByteArray serializeConfig(const AppConfig &config); + +/** + * @brief Saves a config snapshot to disk, creating parent directories as needed. + * @param path Destination config path. + * @param config Config snapshot to save. + * @param errorMessage Optional output for write failures. + * @return `true` on success. + */ +bool saveConfig(const QString &path, const AppConfig &config, QString *errorMessage = nullptr); + +/** + * @brief Returns the supported dotted keys for config mutation. + * @return Supported config keys in user-facing display order. + */ +QStringList supportedConfigKeys(); + +/** + * @brief Applies a single dotted-key config value with validation. + * @param config Config snapshot to mutate. + * @param key Canonical dotted config key. + * @param value Text value to parse and validate. + * @param errorMessage Optional output for validation failures. + * @return `true` when the key is supported and the value is valid. + */ +bool applyConfigValue(AppConfig *config, QStringView key, const QString &value, QString *errorMessage = nullptr); diff --git a/src/hotkeymanager.cpp b/src/hotkeymanager.cpp index c0d9860..1438046 100644 --- a/src/hotkeymanager.cpp +++ b/src/hotkeymanager.cpp @@ -52,11 +52,11 @@ QKeySequence parseKeySequence(const QString &sequenceText) if (trimmedSequence.size() == 1) { const QChar character = trimmedSequence.front(); if (!character.isNull() && !character.isSpace()) { - return QKeySequence(character.unicode()); + return QKeySequence(character.unicode()); // NOLINT(modernize-return-braced-init-list) } } - return QKeySequence(sequenceText); + return QKeySequence(sequenceText); // NOLINT(modernize-return-braced-init-list) } QString describeConflicts(const QKeySequence &requestedSequence) diff --git a/src/main.cpp b/src/main.cpp index 1f38401..2bb81ee 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,21 +1,58 @@ #include "audio/audiorecorder.h" #include "clipboardwriter.h" +#include "commanddispatch.h" #include "config.h" #include "service.h" #include "transcription/whispercpptranscriber.h" #include #include +#include #include #include #include #include #include +#if defined(Q_OS_WIN) +#include +#else +#include +#endif + Q_LOGGING_CATEGORY(appLog, "mutterkey.app") namespace { +struct ConfigOverride { + ConfigOverride(QString keyValue, QString valueValue) + : key(std::move(keyValue)) + , value(std::move(valueValue)) + { + } + + QString key; + QString value; +}; + +void writeConfigHelp() +{ + QTextStream(stdout) << configHelpText(); +} + +void configureCommandLineParser(QCommandLineParser *parser) +{ + parser->setApplicationDescription(QStringLiteral("Push-to-talk local speech transcription for KDE Plasma")); + parser->addHelpOption(); +} + +int exitWithError(const QString &message) +{ + QTextStream(stderr) << message << Qt::endl; + qCCritical(appLog).noquote() << message; + return 1; +} + bool parsePositiveSeconds(const QString &durationText, double fallbackSeconds, QStringView label, @@ -48,6 +85,355 @@ bool parsePositiveSeconds(const QString &durationText, return true; } +bool isInteractiveTerminal() +{ +#if defined(Q_OS_WIN) + return _isatty(_fileno(stdin)) != 0 && _isatty(_fileno(stdout)) != 0; +#else + return isatty(STDIN_FILENO) != 0 && isatty(STDOUT_FILENO) != 0; +#endif +} + +bool promptForConfigValue(QStringView label, + const QString &defaultValue, + bool requireNonEmpty, + QString &valueOut, + QString *errorMessage) +{ + QTextStream input(stdin); + QTextStream output(stdout); + while (true) { + output << label; + if (!defaultValue.isEmpty()) { + output << " [" << defaultValue << "]"; + } + output << ": " << Qt::flush; + + const QString line = input.readLine(); + if (line.isNull()) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("Input closed during interactive setup"); + } + return false; + } + + const QString trimmedLine = line.trimmed(); + const QString resolvedValue = trimmedLine.isEmpty() ? defaultValue.trimmed() : trimmedLine; + if (!requireNonEmpty || !resolvedValue.isEmpty()) { + valueOut = resolvedValue; + return true; + } + + output << "A value is required." << Qt::endl; + } +} + +bool applyOverrides(AppConfig *config, const QList &overrides, QString *errorMessage) +{ + for (const ConfigOverride &overrideValue : overrides) { + if (!applyConfigValue(config, overrideValue.key, overrideValue.value, errorMessage)) { + if (errorMessage != nullptr && !errorMessage->isEmpty()) { + *errorMessage = QStringLiteral("Invalid value for %1: %2").arg(overrideValue.key, *errorMessage); + } + return false; + } + } + + return true; +} + +bool collectConfigOverrides(const QCommandLineParser &parser, + const QCommandLineOption &logLevelOption, + const QCommandLineOption &modelPathOption, + const QCommandLineOption &shortcutOption, + const QCommandLineOption &languageOption, + const QCommandLineOption &threadsOption, + const QCommandLineOption &translateOption, + const QCommandLineOption &noTranslateOption, + const QCommandLineOption &warmupOption, + const QCommandLineOption &noWarmupOption, + QList *overridesOut, + QString *errorMessage) +{ + if (overridesOut == nullptr) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("Internal error: missing overrides output target"); + } + return false; + } + + overridesOut->clear(); + if (parser.isSet(logLevelOption)) { + overridesOut->append(ConfigOverride{QStringLiteral("log_level"), parser.value(logLevelOption)}); + } + if (parser.isSet(modelPathOption)) { + overridesOut->append(ConfigOverride{QStringLiteral("transcriber.model_path"), parser.value(modelPathOption)}); + } + if (parser.isSet(shortcutOption)) { + overridesOut->append(ConfigOverride{QStringLiteral("shortcut.sequence"), parser.value(shortcutOption)}); + } + if (parser.isSet(languageOption)) { + overridesOut->append(ConfigOverride{QStringLiteral("transcriber.language"), parser.value(languageOption)}); + } + if (parser.isSet(threadsOption)) { + overridesOut->append(ConfigOverride{QStringLiteral("transcriber.threads"), parser.value(threadsOption)}); + } + + if (parser.isSet(translateOption) && parser.isSet(noTranslateOption)) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("Use only one of --translate or --no-translate"); + } + return false; + } + if (parser.isSet(translateOption)) { + overridesOut->append(ConfigOverride{QStringLiteral("transcriber.translate"), QStringLiteral("true")}); + } + if (parser.isSet(noTranslateOption)) { + overridesOut->append(ConfigOverride{QStringLiteral("transcriber.translate"), QStringLiteral("false")}); + } + + if (parser.isSet(warmupOption) && parser.isSet(noWarmupOption)) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("Use only one of --warmup-on-start or --no-warmup-on-start"); + } + return false; + } + if (parser.isSet(warmupOption)) { + overridesOut->append(ConfigOverride{QStringLiteral("transcriber.warmup_on_start"), QStringLiteral("true")}); + } + if (parser.isSet(noWarmupOption)) { + overridesOut->append(ConfigOverride{QStringLiteral("transcriber.warmup_on_start"), QStringLiteral("false")}); + } + + return true; +} + +bool loadConfigForMutation(const QString &configPath, AppConfig *configOut, QString *errorMessage) +{ + if (configOut == nullptr) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("Internal error: missing config output target"); + } + return false; + } + + if (!QFileInfo::exists(configPath)) { + *configOut = defaultAppConfig(); + return true; + } + + QString loadError; + const AppConfig config = loadConfig(configPath, &loadError); + if (!loadError.isEmpty()) { + if (errorMessage != nullptr) { + *errorMessage = loadError; + } + return false; + } + + *configOut = config; + return true; +} + +bool bootstrapConfig(const QString &configPath, + AppConfig *config, + bool promptForModelPath, + bool promptForShortcut, + QString *errorMessage) +{ + if (config == nullptr) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("Internal error: missing bootstrap config"); + } + return false; + } + + QTextStream output(stdout); + output << "Creating Mutterkey config at " << configPath << Qt::endl; + + if (promptForModelPath) { + QString modelPath; + if (!promptForConfigValue(QStringLiteral("Whisper model path"), + config->transcriber.modelPath, + true, + modelPath, + errorMessage)) { + return false; + } + if (!applyConfigValue(config, QStringLiteral("transcriber.model_path"), modelPath, errorMessage)) { + return false; + } + } + + if (promptForShortcut) { + QString shortcutSequence; + if (!promptForConfigValue(QStringLiteral("Push-to-talk shortcut"), + config->shortcut.sequence, + true, + shortcutSequence, + errorMessage)) { + return false; + } + if (!applyConfigValue(config, QStringLiteral("shortcut.sequence"), shortcutSequence, errorMessage)) { + return false; + } + } + + if (!saveConfig(configPath, *config, errorMessage)) { + return false; + } + + output << "Saved config to " << configPath << Qt::endl; + return true; +} + +int runConfigInit(const QString &configPath, + const QList &overrides, + bool interactive, + bool hasModelPathOverride) +{ + if (QFileInfo::exists(configPath)) { + return exitWithError(QStringLiteral("Config file already exists: %1").arg(configPath)); + } + + AppConfig config = defaultAppConfig(); + QString errorMessage; + if (!applyOverrides(&config, overrides, &errorMessage)) { + return exitWithError(errorMessage); + } + + if (interactive) { + if (!bootstrapConfig(configPath, + &config, + !hasModelPathOverride, + true, + &errorMessage)) { + return exitWithError(errorMessage); + } + } else { + if (!hasModelPathOverride) { + return exitWithError(QStringLiteral( + "Missing config file and no terminal is available. Re-run `mutterkey config init --model-path /path/to/model.bin` from a shell.")); + } + if (!saveConfig(configPath, config, &errorMessage)) { + return exitWithError(errorMessage); + } + QTextStream(stdout) << "Saved config to " << configPath << Qt::endl; + } + + return 0; +} + +int runConfigSet(const QString &configPath, const QStringList &positionals) +{ + if (positionals.size() < 4) { + return exitWithError(QStringLiteral("Usage: mutterkey config set ")); + } + + AppConfig config; + QString errorMessage; + if (!loadConfigForMutation(configPath, &config, &errorMessage)) { + return exitWithError(errorMessage); + } + + const QString key = positionals.at(2).trimmed(); + const QString &value = positionals.at(3); + if (!applyConfigValue(&config, key, value, &errorMessage)) { + return exitWithError(errorMessage); + } + + if (!saveConfig(configPath, config, &errorMessage)) { + return exitWithError(errorMessage); + } + + QTextStream(stdout) << "Updated " << key << " in " << configPath << Qt::endl; + return 0; +} + +int runConfigCommand(const QStringList &arguments) +{ + QCommandLineParser parser; + configureCommandLineParser(&parser); + + QCommandLineOption configOption(QStringList{QStringLiteral("config")}, + QStringLiteral("Path to the JSON config file"), + QStringLiteral("path"), + defaultConfigPath()); + QCommandLineOption logLevelOption(QStringList{QStringLiteral("log-level")}, + QStringLiteral("Override the configured log level"), + QStringLiteral("level")); + QCommandLineOption modelPathOption(QStringList{QStringLiteral("model-path")}, + QStringLiteral("Override the configured Whisper model path"), + QStringLiteral("path")); + QCommandLineOption shortcutOption(QStringList{QStringLiteral("shortcut")}, + QStringLiteral("Override the configured push-to-talk shortcut"), + QStringLiteral("sequence")); + QCommandLineOption languageOption(QStringList{QStringLiteral("language")}, + QStringLiteral("Override the configured transcription language code or use auto-detect"), + QStringLiteral("code|auto")); + QCommandLineOption threadsOption(QStringList{QStringLiteral("threads")}, + QStringLiteral("Override the configured transcription thread count"), + QStringLiteral("count")); + QCommandLineOption translateOption(QStringList{QStringLiteral("translate")}, + QStringLiteral("Translate speech to English")); + QCommandLineOption noTranslateOption(QStringList{QStringLiteral("no-translate")}, + QStringLiteral("Disable translation to English")); + QCommandLineOption warmupOption(QStringList{QStringLiteral("warmup-on-start")}, + QStringLiteral("Warm up the transcriber during startup")); + QCommandLineOption noWarmupOption(QStringList{QStringLiteral("no-warmup-on-start")}, + QStringLiteral("Disable transcriber warmup during startup")); + parser.addOption(configOption); + parser.addOption(logLevelOption); + parser.addOption(modelPathOption); + parser.addOption(shortcutOption); + parser.addOption(languageOption); + parser.addOption(threadsOption); + parser.addOption(translateOption); + parser.addOption(noTranslateOption); + parser.addOption(warmupOption); + parser.addOption(noWarmupOption); + parser.addPositionalArgument(QStringLiteral("command"), QStringLiteral("daemon, once, diagnose, or config")); + parser.addPositionalArgument(QStringLiteral("extra"), QStringLiteral("Command-specific arguments")); + parser.process(arguments); + + QList overrides; + QString overrideError; + if (!collectConfigOverrides(parser, + logLevelOption, + modelPathOption, + shortcutOption, + languageOption, + threadsOption, + translateOption, + noTranslateOption, + warmupOption, + noWarmupOption, + &overrides, + &overrideError)) { + return exitWithError(overrideError); + } + + const QString configPath = parser.value(configOption); + const bool interactive = isInteractiveTerminal(); + const bool hasModelPathOverride = parser.isSet(modelPathOption); + const QStringList positional = parser.positionalArguments(); + const QString subcommand = positional.value(1); + + if (subcommand == QStringLiteral("init")) { + return runConfigInit(configPath, overrides, interactive, hasModelPathOverride); + } + + if (!overrides.isEmpty()) { + return exitWithError(QStringLiteral("Runtime override flags are not supported with `config set`.")); + } + + if (subcommand == QStringLiteral("set")) { + return runConfigSet(configPath, positional); + } + + return exitWithError(QStringLiteral("Unknown config subcommand: %1").arg(subcommand)); +} + // // logging // @@ -171,14 +557,23 @@ int runDiagnose(QGuiApplication &app, const AppConfig &config, double seconds, b int main(int argc, char *argv[]) { + const QStringList arguments = rawArguments(std::span(argv, argc)); + const int commandIndex = commandIndexFromArguments(arguments); + if (shouldShowConfigHelp(arguments, commandIndex)) { + writeConfigHelp(); + return 0; + } + if (commandIndex >= 0 && commandIndex < arguments.size() && arguments.at(commandIndex) == QStringLiteral("config")) { + return runConfigCommand(arguments); + } + QGuiApplication app(argc, argv); QGuiApplication::setDesktopFileName(QStringLiteral("org.mutterkey.mutterkey")); app.setApplicationName(QStringLiteral("mutterkey")); app.setApplicationDisplayName(QStringLiteral("Mutterkey")); QCommandLineParser parser; - parser.setApplicationDescription(QStringLiteral("Push-to-talk local speech transcription for KDE Plasma")); - parser.addHelpOption(); + configureCommandLineParser(&parser); QCommandLineOption configOption(QStringList{QStringLiteral("config")}, QStringLiteral("Path to the JSON config file"), @@ -187,30 +582,92 @@ int main(int argc, char *argv[]) QCommandLineOption logLevelOption(QStringList{QStringLiteral("log-level")}, QStringLiteral("Override the configured log level"), QStringLiteral("level")); + QCommandLineOption modelPathOption(QStringList{QStringLiteral("model-path")}, + QStringLiteral("Override the configured Whisper model path"), + QStringLiteral("path")); + QCommandLineOption shortcutOption(QStringList{QStringLiteral("shortcut")}, + QStringLiteral("Override the configured push-to-talk shortcut"), + QStringLiteral("sequence")); + QCommandLineOption languageOption(QStringList{QStringLiteral("language")}, + QStringLiteral("Override the configured transcription language code or use auto-detect"), + QStringLiteral("code|auto")); + QCommandLineOption threadsOption(QStringList{QStringLiteral("threads")}, + QStringLiteral("Override the configured transcription thread count"), + QStringLiteral("count")); + QCommandLineOption translateOption(QStringList{QStringLiteral("translate")}, + QStringLiteral("Translate speech to English")); + QCommandLineOption noTranslateOption(QStringList{QStringLiteral("no-translate")}, + QStringLiteral("Disable translation to English")); + QCommandLineOption warmupOption(QStringList{QStringLiteral("warmup-on-start")}, + QStringLiteral("Warm up the transcriber during startup")); + QCommandLineOption noWarmupOption(QStringList{QStringLiteral("no-warmup-on-start")}, + QStringLiteral("Disable transcriber warmup during startup")); parser.addOption(configOption); parser.addOption(logLevelOption); - parser.addPositionalArgument(QStringLiteral("command"), QStringLiteral("daemon, once, or diagnose")); + parser.addOption(modelPathOption); + parser.addOption(shortcutOption); + parser.addOption(languageOption); + parser.addOption(threadsOption); + parser.addOption(translateOption); + parser.addOption(noTranslateOption); + parser.addOption(warmupOption); + parser.addOption(noWarmupOption); + parser.addPositionalArgument(QStringLiteral("command"), QStringLiteral("daemon, once, diagnose, or config")); parser.addPositionalArgument(QStringLiteral("extra"), QStringLiteral("Command-specific arguments")); parser.process(app); - // Config parsing is intentionally non-fatal so the built-in defaults still let the - // user reach --help, diagnose, or other recovery paths. + QList overrides; + QString overrideError; + if (!collectConfigOverrides(parser, + logLevelOption, + modelPathOption, + shortcutOption, + languageOption, + threadsOption, + translateOption, + noTranslateOption, + warmupOption, + noWarmupOption, + &overrides, + &overrideError)) { + return exitWithError(overrideError); + } + + const QString configPath = parser.value(configOption); + const bool interactive = isInteractiveTerminal(); + const bool hasModelPathOverride = parser.isSet(modelPathOption); + + const QStringList positional = parser.positionalArguments(); + const QString command = positional.isEmpty() ? QStringLiteral("daemon") : positional.first(); + + if (!QFileInfo::exists(configPath)) { + if (!interactive) { + return exitWithError( + QStringLiteral("Config file not found: %1\nRun `mutterkey config init` from a terminal to create it.").arg(configPath)); + } + + AppConfig initialConfig = defaultAppConfig(); + QString bootstrapError; + if (!applyOverrides(&initialConfig, overrides, &bootstrapError)) { + return exitWithError(bootstrapError); + } + if (!bootstrapConfig(configPath, &initialConfig, !hasModelPathOverride, true, &bootstrapError)) { + return exitWithError(bootstrapError); + } + } + + // Config parsing is intentionally non-fatal so recovery paths remain reachable + // for malformed or partially invalid files. QString configError; - AppConfig config = loadConfig(parser.value(configOption), &configError); + AppConfig config = loadConfig(configPath, &configError); if (!configError.isEmpty()) { qWarning().noquote() << configError; } - - if (parser.isSet(logLevelOption)) { - config.logLevel = parser.value(logLevelOption).toUpper(); + if (!applyOverrides(&config, overrides, &overrideError)) { + return exitWithError(overrideError); } configureLogging(config.logLevel); - // The daemon path is the default product mode; the other commands are focused - // validation and one-shot helpers around the same service/transcriber wiring. - const QStringList positional = parser.positionalArguments(); - const QString command = positional.isEmpty() ? QStringLiteral("daemon") : positional.first(); - if (command == QStringLiteral("once")) { double seconds = 4.0; QString durationError; diff --git a/src/transcription/whispercpptranscriber.cpp b/src/transcription/whispercpptranscriber.cpp index 6e402a5..38b5b7c 100644 --- a/src/transcription/whispercpptranscriber.cpp +++ b/src/transcription/whispercpptranscriber.cpp @@ -3,15 +3,64 @@ #include #include #include +#include #include #include extern "C" { +#include #include } Q_LOGGING_CATEGORY(whisperCppLog, "mutterkey.transcriber.whispercpp") +namespace { + +QString backendDeviceTypeName(enum ggml_backend_dev_type type) +{ + switch (type) { + case GGML_BACKEND_DEVICE_TYPE_CPU: + return QStringLiteral("CPU"); + case GGML_BACKEND_DEVICE_TYPE_GPU: + return QStringLiteral("GPU"); + case GGML_BACKEND_DEVICE_TYPE_IGPU: + return QStringLiteral("IGPU"); + case GGML_BACKEND_DEVICE_TYPE_ACCEL: + return QStringLiteral("ACCEL"); + } + + return QStringLiteral("UNKNOWN"); +} + +QString describeRegisteredBackends() +{ + QStringList backendNames; + backendNames.reserve(static_cast(ggml_backend_reg_count())); + for (size_t index = 0; index < ggml_backend_reg_count(); ++index) { + if (ggml_backend_reg_t reg = ggml_backend_reg_get(index)) { + backendNames.append(QString::fromUtf8(ggml_backend_reg_name(reg))); + } + } + + QStringList deviceDescriptions; + deviceDescriptions.reserve(static_cast(ggml_backend_dev_count())); + for (size_t index = 0; index < ggml_backend_dev_count(); ++index) { + if (ggml_backend_dev_t device = ggml_backend_dev_get(index)) { + const QString deviceName = QString::fromUtf8(ggml_backend_dev_name(device)); + const QString deviceDescription = QString::fromUtf8(ggml_backend_dev_description(device)); + deviceDescriptions.append(QStringLiteral("%1[%2]: %3") + .arg(deviceName, + backendDeviceTypeName(ggml_backend_dev_type(device)), + deviceDescription)); + } + } + + return QStringLiteral("registered backends=%1; devices=%2") + .arg(backendNames.join(QStringLiteral(", ")), deviceDescriptions.join(QStringLiteral(" | "))); +} + +} // namespace + WhisperCppTranscriber::WhisperCppTranscriber(TranscriberConfig config) : m_config(std::move(config)) , m_context(nullptr, &WhisperCppTranscriber::freeContext) @@ -122,6 +171,7 @@ bool WhisperCppTranscriber::ensureInitialized(QString *errorMessage) } whisper_context_params contextParams = whisper_context_default_params(); + qCInfo(whisperCppLog).noquote() << "ggml runtime:" << describeRegisteredBackends(); m_context.reset(whisper_init_from_file_with_params(modelPath.toUtf8().constData(), contextParams)); if (m_context == nullptr) { if (errorMessage != nullptr) { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 18a7a47..b036562 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -10,15 +10,24 @@ mutterkey_add_qt_test(configtest ../src/config.cpp ) +mutterkey_add_qt_test(commanddispatchtest + commanddispatchtest.cpp + ../src/commanddispatch.cpp + ../src/config.cpp +) + mutterkey_add_qt_test(recordingnormalizertest recordingnormalizertest.cpp ../src/audio/recordingnormalizer.cpp ) +target_link_libraries(configtest PRIVATE whisper) +target_link_libraries(commanddispatchtest PRIVATE whisper) + if(TARGET clang-tidy) - add_dependencies(clang-tidy configtest_autogen recordingnormalizertest_autogen) + add_dependencies(clang-tidy configtest_autogen commanddispatchtest_autogen recordingnormalizertest_autogen) endif() if(TARGET clazy) - add_dependencies(clazy configtest_autogen recordingnormalizertest_autogen) + add_dependencies(clazy configtest_autogen commanddispatchtest_autogen recordingnormalizertest_autogen) endif() diff --git a/tests/commanddispatchtest.cpp b/tests/commanddispatchtest.cpp new file mode 100644 index 0000000..337a93e --- /dev/null +++ b/tests/commanddispatchtest.cpp @@ -0,0 +1,92 @@ +#include "commanddispatch.h" +#include "config.h" + +#include + +class CommandDispatchTest final : public QObject +{ + Q_OBJECT + +private slots: + void commandIndexSkipsGlobalOptionValues(); + void bareConfigShowsDedicatedHelp(); + void configHelpFlagShowsDedicatedHelp(); + void nonConfigCommandsDoNotShowConfigHelp(); + void configHelpTextMentionsSubcommands(); + void configHelpTextListsAllSupportedConfigKeys(); +}; + +void CommandDispatchTest::commandIndexSkipsGlobalOptionValues() +{ + const QStringList arguments{ + QStringLiteral("mutterkey"), + QStringLiteral("--config"), + QStringLiteral("/tmp/config.json"), + QStringLiteral("--model-path=/tmp/model.bin"), + QStringLiteral("config"), + QStringLiteral("set"), + }; + + QCOMPARE(commandIndexFromArguments(arguments), 4); +} + +void CommandDispatchTest::bareConfigShowsDedicatedHelp() +{ + const QStringList arguments{ + QStringLiteral("mutterkey"), + QStringLiteral("config"), + }; + + const int commandIndex = commandIndexFromArguments(arguments); + QVERIFY(shouldShowConfigHelp(arguments, commandIndex)); +} + +void CommandDispatchTest::configHelpFlagShowsDedicatedHelp() +{ + const QStringList arguments{ + QStringLiteral("mutterkey"), + QStringLiteral("--config"), + QStringLiteral("/tmp/config.json"), + QStringLiteral("config"), + QStringLiteral("--help"), + }; + + const int commandIndex = commandIndexFromArguments(arguments); + QVERIFY(shouldShowConfigHelp(arguments, commandIndex)); +} + +void CommandDispatchTest::nonConfigCommandsDoNotShowConfigHelp() +{ + const QStringList arguments{ + QStringLiteral("mutterkey"), + QStringLiteral("diagnose"), + QStringLiteral("10"), + }; + + const int commandIndex = commandIndexFromArguments(arguments); + QVERIFY(!shouldShowConfigHelp(arguments, commandIndex)); +} + +void CommandDispatchTest::configHelpTextMentionsSubcommands() +{ + const QString helpText = configHelpText(); + + QVERIFY(helpText.contains(QStringLiteral("config "))); + QVERIFY(helpText.contains(QStringLiteral("init"))); + QVERIFY(helpText.contains(QStringLiteral("set "))); + QVERIFY(helpText.contains(QStringLiteral("--language "))); + QVERIFY(helpText.contains(QStringLiteral("transcriber.model_path"))); +} + +void CommandDispatchTest::configHelpTextListsAllSupportedConfigKeys() +{ + const QString helpText = configHelpText(); + + for (const QString &key : supportedConfigKeys()) { + QVERIFY2(helpText.contains(key), qPrintable(QStringLiteral("Missing help entry for %1").arg(key))); + } +} + +QTEST_APPLESS_MAIN(CommandDispatchTest) + +#include "commanddispatchtest.moc" diff --git a/tests/configtest.cpp b/tests/configtest.cpp index 172512f..fd4b364 100644 --- a/tests/configtest.cpp +++ b/tests/configtest.cpp @@ -9,14 +9,36 @@ class ConfigTest final : public QObject Q_OBJECT private slots: + void defaultAppConfigMatchesDocumentedDefaults(); void loadConfigUsesDefaultsWhenFileIsMissing(); void loadConfigAppliesJsonOverrides(); void loadConfigRejectsInvalidValues(); void loadConfigReportsMalformedJson(); void loadConfigIgnoresWrongJsonTypes(); void loadConfigTrimsImportantStringFields(); + void saveConfigRoundTripsResolvedValues(); + void saveConfigCreatesParentDirectory(); + void applyConfigValueUpdatesSupportedFields(); + void applyConfigValueRejectsInvalidInputs(); + void applyConfigValueRejectsUnknownKeys(); }; +void ConfigTest::defaultAppConfigMatchesDocumentedDefaults() +{ + const AppConfig config = defaultAppConfig(); + + QCOMPARE(config.shortcut.sequence, QStringLiteral("F8")); + QCOMPARE(config.audio.sampleRate, 16000); + QCOMPARE(config.audio.channels, 1); + QCOMPARE(config.audio.minimumSeconds, 0.25); + QCOMPARE(config.transcriber.modelPath, defaultModelPath()); + QCOMPARE(config.transcriber.language, QStringLiteral("en")); + QCOMPARE(config.transcriber.translate, false); + QCOMPARE(config.transcriber.threads, 0); + QCOMPARE(config.transcriber.warmupOnStart, false); + QCOMPARE(config.logLevel, QStringLiteral("INFO")); +} + void ConfigTest::loadConfigUsesDefaultsWhenFileIsMissing() { QTemporaryDir tempDir; @@ -111,6 +133,7 @@ void ConfigTest::loadConfigRejectsInvalidValues() }, "transcriber": { "model_path": " ", + "language": "pirate", "threads": -4 }, "log_level": "verbose" @@ -126,6 +149,7 @@ void ConfigTest::loadConfigRejectsInvalidValues() QCOMPARE(config.audio.channels, 1); QCOMPARE(config.audio.minimumSeconds, 0.25); QCOMPARE(config.transcriber.modelPath, defaultModelPath()); + QCOMPARE(config.transcriber.language, QStringLiteral("en")); QCOMPARE(config.transcriber.threads, 0); QCOMPARE(config.logLevel, QStringLiteral("INFO")); } @@ -211,7 +235,8 @@ void ConfigTest::loadConfigTrimsImportantStringFields() "sequence": " Meta+F8 " }, "transcriber": { - "model_path": " /tmp/test-model.bin " + "model_path": " /tmp/test-model.bin ", + "language": " AUTO " }, "log_level": " warning " })json"); @@ -223,9 +248,132 @@ void ConfigTest::loadConfigTrimsImportantStringFields() QVERIFY(errorMessage.isEmpty()); QCOMPARE(config.shortcut.sequence, QStringLiteral("Meta+F8")); QCOMPARE(config.transcriber.modelPath, QStringLiteral("/tmp/test-model.bin")); + QCOMPARE(config.transcriber.language, QStringLiteral("auto")); + QCOMPARE(config.logLevel, QStringLiteral("WARNING")); +} + +void ConfigTest::saveConfigRoundTripsResolvedValues() +{ + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + + AppConfig config = defaultAppConfig(); + config.shortcut.sequence = QStringLiteral("Meta+F8"); + config.audio.sampleRate = 48000; + config.audio.channels = 2; + config.audio.minimumSeconds = 0.5; + config.audio.deviceId = QStringLiteral("usb-mic"); + config.transcriber.modelPath = QStringLiteral("/tmp/test-model.bin"); + config.transcriber.language = QStringLiteral("fi"); + config.transcriber.translate = true; + config.transcriber.threads = 6; + config.transcriber.warmupOnStart = true; + config.logLevel = QStringLiteral("DEBUG"); + + const QString configPath = tempDir.filePath(QStringLiteral("config.json")); + QString errorMessage; + QVERIFY(saveConfig(configPath, config, &errorMessage)); + QVERIFY(errorMessage.isEmpty()); + + const AppConfig loadedConfig = loadConfig(configPath, &errorMessage); + QVERIFY(errorMessage.isEmpty()); + QCOMPARE(loadedConfig.shortcut.sequence, config.shortcut.sequence); + QCOMPARE(loadedConfig.audio.sampleRate, config.audio.sampleRate); + QCOMPARE(loadedConfig.audio.channels, config.audio.channels); + QCOMPARE(loadedConfig.audio.minimumSeconds, config.audio.minimumSeconds); + QCOMPARE(loadedConfig.audio.deviceId, config.audio.deviceId); + QCOMPARE(loadedConfig.transcriber.modelPath, config.transcriber.modelPath); + QCOMPARE(loadedConfig.transcriber.language, config.transcriber.language); + QCOMPARE(loadedConfig.transcriber.translate, config.transcriber.translate); + QCOMPARE(loadedConfig.transcriber.threads, config.transcriber.threads); + QCOMPARE(loadedConfig.transcriber.warmupOnStart, config.transcriber.warmupOnStart); + QCOMPARE(loadedConfig.logLevel, config.logLevel); +} + +void ConfigTest::saveConfigCreatesParentDirectory() +{ + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + + const QString configPath = tempDir.filePath(QStringLiteral("nested/mutterkey/config.json")); + QString errorMessage; + QVERIFY(saveConfig(configPath, defaultAppConfig(), &errorMessage)); + QVERIFY(errorMessage.isEmpty()); + QVERIFY(QFile::exists(configPath)); +} + +void ConfigTest::applyConfigValueUpdatesSupportedFields() +{ + AppConfig config = defaultAppConfig(); + QString errorMessage; + + QVERIFY(applyConfigValue(&config, QStringLiteral("shortcut.sequence"), QStringLiteral("Meta+F8"), &errorMessage)); + QVERIFY(applyConfigValue(&config, QStringLiteral("audio.sample_rate"), QStringLiteral("48000"), &errorMessage)); + QVERIFY(applyConfigValue(&config, QStringLiteral("audio.channels"), QStringLiteral("2"), &errorMessage)); + QVERIFY(applyConfigValue(&config, QStringLiteral("audio.minimum_seconds"), QStringLiteral("0.75"), &errorMessage)); + QVERIFY(applyConfigValue(&config, QStringLiteral("audio.device_id"), QStringLiteral(" test-mic "), &errorMessage)); + QVERIFY(applyConfigValue(&config, QStringLiteral("transcriber.model_path"), QStringLiteral(" /tmp/model.bin "), &errorMessage)); + QVERIFY(applyConfigValue(&config, QStringLiteral("transcriber.language"), QStringLiteral(" fi "), &errorMessage)); + QVERIFY(applyConfigValue(&config, QStringLiteral("transcriber.language"), QStringLiteral(" auto "), &errorMessage)); + QVERIFY(applyConfigValue(&config, QStringLiteral("transcriber.translate"), QStringLiteral("yes"), &errorMessage)); + QVERIFY(applyConfigValue(&config, QStringLiteral("transcriber.threads"), QStringLiteral("3"), &errorMessage)); + QVERIFY(applyConfigValue(&config, QStringLiteral("transcriber.warmup_on_start"), QStringLiteral("on"), &errorMessage)); + QVERIFY(applyConfigValue(&config, QStringLiteral("log_level"), QStringLiteral(" warning "), &errorMessage)); + QVERIFY(errorMessage.isEmpty()); + + QCOMPARE(config.shortcut.sequence, QStringLiteral("Meta+F8")); + QCOMPARE(config.audio.sampleRate, 48000); + QCOMPARE(config.audio.channels, 2); + QCOMPARE(config.audio.minimumSeconds, 0.75); + QCOMPARE(config.audio.deviceId, QStringLiteral("test-mic")); + QCOMPARE(config.transcriber.modelPath, QStringLiteral("/tmp/model.bin")); + QCOMPARE(config.transcriber.language, QStringLiteral("auto")); + QCOMPARE(config.transcriber.translate, true); + QCOMPARE(config.transcriber.threads, 3); + QCOMPARE(config.transcriber.warmupOnStart, true); QCOMPARE(config.logLevel, QStringLiteral("WARNING")); } +void ConfigTest::applyConfigValueRejectsInvalidInputs() +{ + const AppConfig originalConfig = defaultAppConfig(); + AppConfig config = originalConfig; + QString errorMessage; + + QVERIFY(!applyConfigValue(&config, QStringLiteral("transcriber.model_path"), QStringLiteral(" "), &errorMessage)); + QVERIFY(errorMessage.contains(QStringLiteral("may not be empty"))); + QCOMPARE(config.transcriber.modelPath, originalConfig.transcriber.modelPath); + + errorMessage.clear(); + QVERIFY(!applyConfigValue(&config, QStringLiteral("transcriber.threads"), QStringLiteral("-1"), &errorMessage)); + QVERIFY(errorMessage.contains(QStringLiteral("0 or greater"))); + QCOMPARE(config.transcriber.threads, originalConfig.transcriber.threads); + + errorMessage.clear(); + QVERIFY(!applyConfigValue(&config, QStringLiteral("transcriber.translate"), QStringLiteral("maybe"), &errorMessage)); + QVERIFY(errorMessage.contains(QStringLiteral("boolean"))); + QCOMPARE(config.transcriber.translate, originalConfig.transcriber.translate); + + errorMessage.clear(); + QVERIFY(!applyConfigValue(&config, QStringLiteral("transcriber.language"), QStringLiteral("pirate"), &errorMessage)); + QVERIFY(errorMessage.contains(QStringLiteral("supported Whisper language"))); + QCOMPARE(config.transcriber.language, originalConfig.transcriber.language); + + errorMessage.clear(); + QVERIFY(!applyConfigValue(&config, QStringLiteral("log_level"), QStringLiteral("verbose"), &errorMessage)); + QVERIFY(errorMessage.contains(QStringLiteral("DEBUG"))); + QCOMPARE(config.logLevel, originalConfig.logLevel); +} + +void ConfigTest::applyConfigValueRejectsUnknownKeys() +{ + AppConfig config = defaultAppConfig(); + QString errorMessage; + + QVERIFY(!applyConfigValue(&config, QStringLiteral("shortcut.unknown"), QStringLiteral("F9"), &errorMessage)); + QVERIFY(errorMessage.contains(QStringLiteral("Unsupported config key"))); +} + QTEST_APPLESS_MAIN(ConfigTest) #include "configtest.moc"