diff --git a/AGENTS.md b/AGENTS.md index 6ab251d..a4fc376 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,8 @@ Current architecture: - Global shortcut handling goes through `KGlobalAccel` - Audio capture uses Qt Multimedia - Transcription is in-process through vendored `whisper.cpp` +- Native Mutterkey model packages are now the canonical model artifact; raw + whisper.cpp-compatible `.bin` files remain only as a migration/import path - The public runtime seam is streaming-first through app-owned chunks, events, and compatibility helpers - Static backend support lives in `BackendCapabilities`, while runtime/device/model inspection lives in `RuntimeDiagnostics` - Clipboard writes prefer `KSystemClipboard` with `QClipboard` fallback @@ -35,6 +37,11 @@ This repository is intentionally kept minimal: - `src/clipboardwriter.*`: clipboard integration, preferring KDE system clipboard support - `src/audio/recordingnormalizer.*`: conversion to runtime-ready mono `float32` at `16 kHz` - `src/transcription/audiochunker.*`: deterministic chunking of normalized audio for the streaming runtime path +- `src/transcription/modelpackage.*`: product-owned manifest and validated package value types +- `src/transcription/modelvalidator.*`: package integrity, compatibility, and bounds validation +- `src/transcription/modelcatalog.*`: model artifact inspection and resolution +- `src/transcription/rawwhisperprobe.*`: lightweight raw whisper.cpp header inspection used for migration compatibility +- `src/transcription/rawwhisperimporter.*`: import path from raw Whisper `.bin` files into native Mutterkey packages - `src/transcription/transcriptassembler.*`: final transcript assembly from streaming transcript events - `src/transcription/transcriptioncompat.*`: compatibility wrapper that routes one-shot recordings through the streaming runtime seam - `src/transcription/whispercpptranscriber.*`: in-process Whisper integration and whisper-specific engine construction @@ -112,7 +119,7 @@ QT_QPA_PLATFORM=offscreen "$BUILD_DIR/mutterkey" diagnose 1 Notes: -- `once` mode requires microphone access and a valid Whisper model path +- `once` mode requires microphone access and a valid model artifact path - Real transcription verification needs a configured model in `~/.config/mutterkey/config.json` or a custom config path - A small `Qt Test` + `CTest` suite exists for config loading, audio normalization, streaming-runtime helpers, and transcription-worker orchestration, including malformed JSON, wrong-type config inputs, recording-normalizer edge cases, and fake streaming backend behavior - Repo-owned test cases are expected to carry `WHAT/HOW/WHY` comments near the start of each real test body; `scripts/check-test-commentary.sh` and `scripts/check-release-hygiene.sh` enforce that convention @@ -130,6 +137,9 @@ Notes: - Use `cmake --build "$BUILD_DIR" --target docs` when touching repo-owned public headers, Doxygen config, the Doxygen main page, or CI/docs wiring - If install rules or licensing files change, confirm the temporary install contains the expected files under `share/licenses/mutterkey` - If you add or change public methods in repo-owned headers, expect `cmake --build "$BUILD_DIR" --target docs` to fail until the new API is documented; treat that as part of the normal implementation loop, not follow-up polish +- Newly added repo-owned public structs and free functions in public headers also + need Doxygen comments immediately; the `docs` target treats undocumented new + API surface as a real failure, not optional cleanup ## Tooling Best Practices @@ -166,11 +176,17 @@ Notes: - Avoid introducing optional backends, plugin systems, or cross-platform abstractions unless the task requires them - Keep the audio path explicit: recorder output may not already match Whisper input requirements, so preserve normalization behavior - Prefer product-owned naming such as runtime audio, chunks, events, diagnostics, and compatibility wrappers over backend-shaped naming when touching app-owned code +- Prefer product-owned model terminology too: package, manifest, catalog, metadata, + compatibility marker, and model artifact path are the primary nouns now; + reserve backend-shaped wording for the whisper adapter or raw-file migration path - Prefer narrow shared value types across subsystems; for example, consumers that only need captured audio should include `src/audio/recording.h`, not the full recorder class - Keep JSON and other transport details at subsystem boundaries; prefer typed C++ snapshots/results once data crosses into app-owned control, tray, or service code - Prefer dependency injection for tray-shell and control-surface code from the first implementation so headless Qt tests stay simple - When preparing the transcription path for future runtime work, prefer app-owned engine/session seams and injected sessions over leaking concrete backend types into CLI, service, or worker orchestration. Keep immutable capability reporting on the engine side, keep runtime inspection data in `RuntimeDiagnostics`, and keep the session side focused on mutable decode state, warmup, chunk ingestion, finish, and cancellation - Prefer product-owned runtime interfaces, model/session separation, and deterministic backend selection before adding new inference backends or widening cross-platform support +- Keep model validation, metadata extraction, and compatibility checks app-owned. + `whisper.cpp` should not be the first component that tells Mutterkey whether a + model artifact is obviously malformed, incompatible, or oversized - Keep compatibility shims explicit in naming. If a one-shot daemon/CLI path is implemented on top of the streaming runtime seam, name it as a compatibility wrapper rather than making the old one-shot shape look like the primary contract - Keep backend-specific validation out of `src/config.*` when practical. Product config parsing should normalize and preserve user input, while backend support checks should live in the app-owned runtime layer near `src/transcription/*` - Preserve the current product direction: embedded `whisper.cpp`, KDE-first, CLI/service-first @@ -199,6 +215,9 @@ Apply the C++ Core Guidelines selectively and pragmatically. For this repo, the - `scripts/update-whisper.sh` requires a clean Git work tree before it will fetch or run subtree operations - Treat `third_party/whisper.cpp` as subtree-managed vendor content and update it through the helper script rather than manual directory replacement - Prefer changing app-side integration code before patching vendored dependency code +- Prefer resolving model-package, metadata, and import work entirely in app-owned + code. Raw whisper.cpp `.bin` support is now a compatibility/import concern, not + the canonical product contract - Prefer keeping fake runtime tests and app-owned helpers free of vendored whisper linkage unless the test is specifically about the whisper adapter or engine factory - Prefer fixing vendored target metadata from the top-level CMake when the issue is Mutterkey packaging or warning noise, instead of patching upstream vendored files directly - If you must modify vendored code, document why in the final response and record the deviation in `third_party/whisper.cpp.UPSTREAM.md` @@ -209,6 +228,9 @@ Apply the C++ Core Guidelines selectively and pragmatically. For this repo, the - Repo-owned source is MIT-licensed in `LICENSE` - Third-party licensing and provenance notes live in `THIRD_PARTY_NOTICES.md` - `whisper.cpp` model files are not bundled; do not add model binaries to the repository +- Native Mutterkey model packages also must not be committed to the repository; + if a release needs to ship one, include it only in the release artifact or as a + separate release asset outside Git - Do not introduce machine-specific home-directory paths, absolute local Markdown links, or generated build artifacts into tracked files - If a task changes install layout or shipped assets, keep the CMake install rules and license installs aligned with the new behavior - The installed shared-library payload is runtime-focused; do not start installing vendored upstream public headers unless the package contract intentionally changes @@ -232,15 +254,24 @@ Default config path: Typical model location: ```text -~/.local/share/mutterkey/models/ggml-base.en.bin +~/.local/share/mutterkey/models/ ``` +Current `transcriber.model_path` semantics: + +- package directory is the canonical target +- `model.json` manifest path is also accepted +- raw whisper.cpp-compatible `.bin` files are accepted only as a migration + compatibility path + ## Agent Workflow - Read `README.md` first, especially `Overview`, `Quick Start`, `Run As Service`, and `Development`, then read the touched source files before editing - Prefer targeted changes over speculative cleanup - If a change grows daemon, tray, or control-plane behavior, prefer extracting or extending repo-owned libraries under `src/app/`, `src/control/`, or other focused modules instead of piling more orchestration into `src/main.cpp` - Update `README.md` and `config.example.json` when behavior or setup changes +- Update `RELEASE_CHECKLIST.md` too when release-facing model packaging, shipped + assets, or release-bundle guidance changes - Update `contrib/mutterkey.service` and `contrib/org.mutterkey.mutterkey.desktop` when service/desktop behavior changes - Update `LICENSE`, `THIRD_PARTY_NOTICES.md`, CMake install rules, and `third_party/whisper.cpp.UPSTREAM.md` when packaging, licensing, or vendored dependency behavior changes - Keep `README.md`, `AGENTS.md`, and any relevant local skills aligned with the current `scripts/update-whisper.sh` workflow when the vendor-update process changes @@ -262,7 +293,9 @@ Typical model location: - Prefer the `lint` target for a full pre-handoff analyzer pass, and use the individual analyzer targets when iterating on one class of warnings - Run `bash scripts/run-valgrind.sh "$BUILD_DIR"` before handoff when the task is specifically about memory, ownership, lifetime, shutdown, or release hardening - Run `bash scripts/check-release-hygiene.sh` before handoff when the task touches publication-facing files or repository metadata -- Remember that the release-hygiene script now also enforces test commentary coverage, so changes to test structure or helper scripts may need both test updates and commentary updates +- Remember that the release-hygiene script now also enforces test commentary + coverage and rejects tracked `.bin` / `.gguf` artifacts, so release-facing or + helper-script changes may need both commentary updates and binary-artifact policy checks - If `QT_QPA_PLATFORM=offscreen "$BUILD_DIR/mutterkey" diagnose 1` fails in a headless environment after model loading or during KDE/session-dependent startup, note that limitation explicitly rather than assuming the runtime seam or docs-only change regressed behavior - A headless `diagnose 1` failure after whisper model loading still does not necessarily indicate a streaming-runtime regression; separate runtime-contract changes from KDE/session or headless-environment limits - Do not leave generated artifacts in the repository tree at the end of the task diff --git a/CMakeLists.txt b/CMakeLists.txt index 8e898e1..e2eacbb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -47,6 +47,16 @@ set(MUTTERKEY_CORE_SOURCES src/transcription/transcriptionengine.h src/transcription/audiochunker.cpp src/transcription/audiochunker.h + src/transcription/modelcatalog.cpp + src/transcription/modelcatalog.h + src/transcription/modelpackage.cpp + src/transcription/modelpackage.h + src/transcription/modelvalidator.cpp + src/transcription/modelvalidator.h + src/transcription/rawwhisperimporter.cpp + src/transcription/rawwhisperimporter.h + src/transcription/rawwhisperprobe.cpp + src/transcription/rawwhisperprobe.h src/transcription/transcriptassembler.cpp src/transcription/transcriptassembler.h src/transcription/transcriptioncompat.cpp diff --git a/README.md b/README.md index 22e0aaa..8c57c67 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ Build requirements: Runtime requirements: -1. a local Whisper model file +1. a local Mutterkey model package, or a raw Whisper `.bin` file for migration compatibility 2. a config file at `~/.config/mutterkey/config.json` or a custom `--config` path Optional developer tooling: @@ -81,9 +81,9 @@ Optional developer tooling: - `valgrind` - `libc6-dbg` on Debian-family systems so Valgrind Memcheck can start cleanly -The repository vendors `whisper.cpp`, but it does not bundle Whisper model -files. Any model file you download separately may be subject to its own license -or usage terms. +The repository vendors `whisper.cpp`, but it does not bundle speech model +artifacts. Any model file you download separately may be subject to its own +license or usage terms. If CMake fails before compilation starts, the most common cause is missing Qt 6 development packages for `Core`, `Gui`, `Multimedia`, or KDE Frameworks @@ -165,9 +165,30 @@ Notes: - `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 +### 2. Put a model on disk -Example location: +Preferred Phase 4 path: + +1. place a raw Whisper `.bin` file somewhere temporary +2. import it into a native Mutterkey package: + +```bash +~/.local/bin/mutterkey model import /path/to/ggml-base.en.bin +``` + +This creates a package directory under: + +```text +~/.local/share/mutterkey/models// +``` + +You can inspect a package or a legacy raw file with: + +```bash +~/.local/bin/mutterkey model inspect /path/to/ggml-base.en.bin +``` + +Legacy compatibility path: ```text ~/.local/share/mutterkey/models/ggml-base.en.bin @@ -176,7 +197,7 @@ Example location: ### 3. Create the config file ```bash -mutterkey config init --model-path ~/.local/share/mutterkey/models/ggml-base.en.bin +mutterkey config init --model-path ~/.local/share/mutterkey/models/ ``` `mutterkey config init` writes the Linux config file to: @@ -213,7 +234,7 @@ Minimal example: "sequence": "F8" }, "transcriber": { - "model_path": "/absolute/path/to/ggml-base.en.bin", + "model_path": "/absolute/path/to/mutterkey-model-package", "language": "en", "translate": false, "threads": 0, @@ -228,6 +249,7 @@ 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 +- `transcriber.model_path` may point to a native Mutterkey package directory, a `model.json` manifest, or a legacy raw Whisper `.bin` file - 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 @@ -306,7 +328,8 @@ installed setup looks like: Useful config commands: ```bash -~/.local/bin/mutterkey config init --model-path ~/.local/share/mutterkey/models/ggml-base.en.bin +~/.local/bin/mutterkey config init --model-path ~/.local/share/mutterkey/models/ +~/.local/bin/mutterkey model inspect ~/.local/share/mutterkey/models/ ~/.local/bin/mutterkey config set shortcut.sequence Meta+F8 ~/.local/bin/mutterkey config set transcriber.language fi ``` @@ -329,10 +352,9 @@ journalctl --user -u mutterkey.service -f Common failures: -`Embedded Whisper model not found: ...` +`Model artifact not found: ...` -- the embedded backend is active -- the configured model path does not exist +- the configured package path, manifest path, or raw compatibility artifact does not exist - fix `transcriber.model_path` `Recorder returned no audio` @@ -375,6 +397,11 @@ Repository layout: - `src/transcription/audiochunker.*`: fixed-size normalized streaming chunk generation - `src/transcription/transcriptassembler.*`: final transcript assembly from streaming events - `src/transcription/transcriptioncompat.*`: compatibility wrapper from one-shot recordings to the streaming runtime path +- `src/transcription/modelpackage.*`: product-owned manifest and validated package value types +- `src/transcription/modelvalidator.*`: package integrity and compatibility validation +- `src/transcription/modelcatalog.*`: model artifact inspection and resolution +- `src/transcription/rawwhisperprobe.*`: lightweight raw Whisper header inspection +- `src/transcription/rawwhisperimporter.*`: migration path from raw Whisper files to native packages - `src/transcription/whispercpptranscriber.*`: embedded Whisper integration behind the app-owned runtime seam - `src/transcription/transcriptionworker.*`: worker object on a dedicated `QThread` - `src/transcription/transcriptiontypes.h`: runtime diagnostics, normalized-audio, chunk, event, and error value types diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index 7c6d20e..0664d52 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -21,8 +21,10 @@ bash scripts/check-release-hygiene.sh - Review [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) for accuracy. - Review [third_party/whisper.cpp.UPSTREAM.md](third_party/whisper.cpp.UPSTREAM.md) and make sure the recorded upstream version/ref is current. -- Confirm no Whisper model binaries or other large third-party artifacts are - tracked in the repository. +- Confirm no speech model binaries, native model packages, or other large + third-party artifacts are tracked in the repository source tree. +- If the release is intended to ship a model, treat that as a release-bundle or + release-asset decision, not a Git-tracked source-tree decision. ## Build And Test @@ -150,11 +152,58 @@ cmake --install "$BUILD_DIR" --prefix "$INSTALL_DIR" install rules ship the runtime libraries but intentionally clear vendored `PUBLIC_HEADER` metadata to avoid upstream header-install warnings. +## Model Packaging For Releases + +- Decide explicitly whether the release ships: + - no model at all + - a separate downloadable model package + - a release bundle that includes a model package alongside the binaries +- Keep model artifacts out of Git history even when the release ships one. + The repository source tree should stay free of raw Whisper `.bin` files and + native Mutterkey model packages. +- If you need a model for the release, start from a raw whisper.cpp-compatible + `ggml` `.bin` file and import it into a native Mutterkey package: + +```bash +MODEL_SRC="/path/to/ggml-base.en.bin" +MODEL_OUT="$(mktemp -d /tmp/mutterkey-release-model-XXXXXX)/base-en" +"$BUILD_DIR/mutterkey" model import "$MODEL_SRC" --output "$MODEL_OUT" +``` + +- Inspect the resulting package before shipping it: + +```bash +"$BUILD_DIR/mutterkey" model inspect "$MODEL_OUT" +``` + +- Confirm the package contains at least: + - `model.json` + - `assets/model.bin` +- Review the inspected metadata and make sure the release notes record: + - model family / size + - language profile + - source provenance + - any separate model license or usage terms +- If the release bundle is meant to include a model, add the package directory + to the release artifact outside the Git source tree. Preferred locations are: + - a separate downloadable release asset such as `mutterkey-model-base-en.tar.zst` + - a bundled runtime tree under `share/mutterkey/models//` +- If you include a model in an installable release bundle, validate the final + staged tree after copying the package in: + - the package directory is intact + - `mutterkey model inspect ` succeeds + - release notes and packaging docs tell users where `transcriber.model_path` + should point +- Do not commit the raw `.bin` source file, the generated native package, or + any unpacked release-bundle copy back into the repository. + ## Documentation And User Flow - Review [README.md](README.md) for consistency with current behavior. - Review `docs/mainpage.md` and `docs/Doxyfile.in` if the release touched repo-owned API docs or docs/CI wiring. +- Confirm the docs describe native Mutterkey model packages as the canonical + artifact and raw Whisper `.bin` files as migration compatibility only. - Confirm the documented recommended path is still the `systemd --user` service. - Confirm [contrib/mutterkey.service](contrib/mutterkey.service) matches the recommended installed-binary setup. diff --git a/config.example.json b/config.example.json index 39199a8..52c476e 100644 --- a/config.example.json +++ b/config.example.json @@ -13,7 +13,7 @@ "device_id": "" }, "transcriber": { - "model_path": "/path/to/ggml-base.en.bin", + "model_path": "/path/to/mutterkey-model-package", "language": "en", "translate": false, "threads": 0, diff --git a/docs/mainpage.md b/docs/mainpage.md index df79f23..d0455b5 100644 --- a/docs/mainpage.md +++ b/docs/mainpage.md @@ -21,12 +21,17 @@ Current runtime shape: - `TranscriptionEngine` is the immutable runtime/provider boundary - `TranscriptionSession` is the mutable per-session decode boundary +- native Mutterkey model packages are now the canonical model artifact - internal audio flow is streaming-first through normalized chunks and transcript events - `BackendCapabilities` reports static backend support used for orchestration - `RuntimeDiagnostics` reports runtime/device/model inspection data separately from static capabilities - `RuntimeError` and `RuntimeErrorCode` provide typed runtime failures +- `ModelCatalog`, `ModelPackage`, and `ModelValidator` own model inspection, + compatibility checks, and integrity validation before backend load +- raw Whisper `.bin` files are handled only through an explicit compatibility + path and import flow - `TranscriptionWorker` hosts transcription on a dedicated `QThread` and creates live sessions lazily on that worker thread - the shipped daemon and `once` flows still use a compatibility wrapper that @@ -42,6 +47,10 @@ Core API surface covered here: samples at `16 kHz`. - `AudioChunker` splits normalized audio into deterministic stream chunks. - `TranscriptAssembler` builds final transcript text from streaming events. +- `ModelCatalog` resolves package directories, `model.json`, and legacy raw + artifacts into validated product-owned model metadata. +- `RawWhisperImporter` converts raw whisper.cpp-compatible `ggml` `.bin` files + into native Mutterkey packages. - `TranscriptionEngine` and `TranscriptionSession` define the app-owned runtime seam. - `WhisperCppTranscriber` performs in-process transcription through vendored diff --git a/scripts/check-release-hygiene.sh b/scripts/check-release-hygiene.sh index 6e474a2..8d3fa1c 100644 --- a/scripts/check-release-hygiene.sh +++ b/scripts/check-release-hygiene.sh @@ -52,6 +52,26 @@ collect_repo_files() { -type f -print } +collect_tracked_binary_artifacts() { + if command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + git ls-files \ + | grep -E '(^|/).+\.(bin|gguf)$' \ + | grep -Ev '^third_party/whisper\.cpp/' || true + return + fi + + find . \ + -path './.git' -prune -o \ + -path './third_party/whisper.cpp' -prune -o \ + -path './build' -prune -o \ + -name 'build-*' -prune -o \ + -name 'cmake-build-*' -prune -o \ + -name 'CMakeFiles' -prune -o \ + -name '*_autogen' -prune -o \ + \( -type f -name '*.bin' -o -type f -name '*.gguf' \) -print \ + | sed 's#^\./##' || true +} + mapfile -t repo_files < <(collect_repo_files) home_path_matches="" if ((${#repo_files[@]} > 0)); then @@ -90,6 +110,9 @@ generated_artifact_matches="$( )" print_violation "repository must not contain generated build artifacts" "$generated_artifact_matches" +tracked_binary_artifact_matches="$(collect_tracked_binary_artifacts)" +print_violation "repository must not track model or binary artifacts such as .bin or .gguf files" "$tracked_binary_artifact_matches" + commentary_check_output="$(bash "$repo_root/scripts/check-test-commentary.sh" "$repo_root" 2>&1 || true)" print_violation "test sources must keep WHAT/HOW/WHY commentary blocks" "$commentary_check_output" diff --git a/src/app/applicationcommands.cpp b/src/app/applicationcommands.cpp index 8f7d883..9d71cc0 100644 --- a/src/app/applicationcommands.cpp +++ b/src/app/applicationcommands.cpp @@ -5,6 +5,9 @@ #include "audio/recording.h" #include "clipboardwriter.h" #include "control/daemoncontrolserver.h" +#include "transcription/modelcatalog.h" +#include "transcription/modelpackage.h" +#include "transcription/rawwhisperimporter.h" #include "service.h" #include "transcription/transcriptioncompat.h" #include "transcription/transcriptionengine.h" @@ -14,6 +17,8 @@ #include #include #include +#include +#include #include #include #include @@ -156,3 +161,102 @@ int runDiagnose(QGuiApplication &app, const AppConfig &config, double seconds, b return QGuiApplication::exec(); } + +namespace { + +QJsonObject metadataToJson(const ModelMetadata &metadata) +{ + return QJsonObject{ + {QStringLiteral("package_id"), metadata.packageId}, + {QStringLiteral("display_name"), metadata.displayName}, + {QStringLiteral("package_version"), metadata.packageVersion}, + {QStringLiteral("runtime_family"), metadata.runtimeFamily}, + {QStringLiteral("source_format"), metadata.sourceFormat}, + {QStringLiteral("model_format"), metadata.modelFormat}, + {QStringLiteral("architecture"), metadata.architecture}, + {QStringLiteral("language_profile"), metadata.languageProfile}, + {QStringLiteral("quantization"), metadata.quantization}, + {QStringLiteral("tokenizer"), metadata.tokenizer}, + {QStringLiteral("legacy_compatibility"), metadata.legacyCompatibility}, + {QStringLiteral("vocabulary_size"), metadata.vocabularySize}, + {QStringLiteral("audio_context"), metadata.audioContext}, + {QStringLiteral("audio_state"), metadata.audioState}, + {QStringLiteral("audio_head_count"), metadata.audioHeadCount}, + {QStringLiteral("audio_layer_count"), metadata.audioLayerCount}, + {QStringLiteral("text_context"), metadata.textContext}, + {QStringLiteral("text_state"), metadata.textState}, + {QStringLiteral("text_head_count"), metadata.textHeadCount}, + {QStringLiteral("text_layer_count"), metadata.textLayerCount}, + {QStringLiteral("mel_count"), metadata.melCount}, + {QStringLiteral("format_type"), metadata.formatType}, + }; +} + +QJsonArray compatibilityToJson(const std::vector &markers) +{ + QJsonArray array; + for (const ModelCompatibilityMarker &marker : markers) { + array.append(QJsonObject{ + {QStringLiteral("engine"), marker.engine}, + {QStringLiteral("model_format"), marker.modelFormat}, + }); + } + return array; +} + +QJsonArray assetsToJson(const std::vector &assets) +{ + QJsonArray array; + for (const ModelAssetMetadata &asset : assets) { + array.append(QJsonObject{ + {QStringLiteral("role"), asset.role}, + {QStringLiteral("path"), asset.relativePath}, + {QStringLiteral("sha256"), asset.sha256}, + {QStringLiteral("size_bytes"), asset.sizeBytes}, + }); + } + return array; +} + +} // namespace + +int runModelImport(const QString &sourcePath, const QString &outputPath, const QString &packageIdOverride) +{ + RuntimeError runtimeError; + const std::optional package = + RawWhisperImporter::importFile(sourcePath, + RawWhisperImportRequest{ + .outputPath = outputPath, + .packageIdOverride = packageIdOverride, + }, + &runtimeError); + if (!package.has_value()) { + qCCritical(appLog) << "Model import failed:" << runtimeError.message; + return 1; + } + + QTextStream(stdout) << package->packageRootPath << Qt::endl; + return 0; +} + +int runModelInspect(const QString &path) +{ + RuntimeError runtimeError; + const std::optional package = ModelCatalog::inspectPath(path, {}, {}, &runtimeError); + if (!package.has_value()) { + qCCritical(appLog) << "Model inspect failed:" << runtimeError.message; + return 1; + } + + QJsonObject object; + object.insert(QStringLiteral("package_root"), package->packageRootPath); + object.insert(QStringLiteral("manifest_path"), package->manifestPath); + object.insert(QStringLiteral("source_path"), package->sourcePath); + object.insert(QStringLiteral("weights_path"), package->weightsPath); + object.insert(QStringLiteral("description"), package->description()); + object.insert(QStringLiteral("metadata"), metadataToJson(package->metadata())); + object.insert(QStringLiteral("compatible_engines"), compatibilityToJson(package->manifest.compatibleEngines)); + object.insert(QStringLiteral("assets"), assetsToJson(package->manifest.assets)); + QTextStream(stdout) << QJsonDocument(object).toJson(QJsonDocument::Indented); + return 0; +} diff --git a/src/app/applicationcommands.h b/src/app/applicationcommands.h index d96bb31..a3a5529 100644 --- a/src/app/applicationcommands.h +++ b/src/app/applicationcommands.h @@ -46,3 +46,19 @@ int runOnce(QGuiApplication &app, const AppConfig &config, double seconds); * @return Process exit code. */ int runDiagnose(QGuiApplication &app, const AppConfig &config, double seconds, bool invokeShortcut); + +/** + * @brief Imports a legacy raw Whisper model into a native Mutterkey package. + * @param sourcePath Source raw model file. + * @param outputPath Optional package destination path. + * @param packageIdOverride Optional package id override. + * @return Process exit code. + */ +int runModelImport(const QString &sourcePath, const QString &outputPath, const QString &packageIdOverride); + +/** + * @brief Inspects a model artifact and prints metadata as JSON. + * @param path Package directory, manifest path, or raw compatibility artifact. + * @return Process exit code. + */ +int runModelInspect(const QString &path); diff --git a/src/commanddispatch.cpp b/src/commanddispatch.cpp index 0a0f66d..c1a80fa 100644 --- a/src/commanddispatch.cpp +++ b/src/commanddispatch.cpp @@ -84,6 +84,25 @@ bool shouldShowConfigHelp(const QStringList &arguments, int commandIndex) return false; } +bool shouldShowModelHelp(const QStringList &arguments, int commandIndex) +{ + if (commandIndex < 0 || commandIndex >= arguments.size() || arguments.at(commandIndex) != QStringLiteral("model")) { + 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; @@ -96,7 +115,7 @@ QString configHelpText() 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 << " --model-path Set transcriber.model_path to a package path, manifest, or raw compatibility artifact" << 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; @@ -112,3 +131,16 @@ QString configHelpText() } return helpText; } + +QString modelHelpText() +{ + QString helpText; + QTextStream output(&helpText); + output << "Usage: mutterkey model [args]" << Qt::endl; + output << Qt::endl; + output << "Model subcommands:" << Qt::endl; + output << " import [--output ] [--id ]" << Qt::endl; + output << " Import a raw whisper.cpp ggml model into a native Mutterkey package" << Qt::endl; + output << " inspect Inspect a model package directory, model.json, or raw compatibility artifact" << Qt::endl; + return helpText; +} diff --git a/src/commanddispatch.h b/src/commanddispatch.h index 9906f39..91e7b4b 100644 --- a/src/commanddispatch.h +++ b/src/commanddispatch.h @@ -30,3 +30,17 @@ bool shouldShowConfigHelp(const QStringList &arguments, int commandIndex); * @return Human-readable help text. */ QString configHelpText(); + +/** + * @brief Returns whether the model command should print dedicated help. + * @param arguments Raw command-line arguments. + * @param commandIndex Index returned by commandIndexFromArguments(). + * @return `true` for bare `model` and `model --help` style invocations. + */ +bool shouldShowModelHelp(const QStringList &arguments, int commandIndex); + +/** + * @brief Returns the dedicated help text for model subcommands. + * @return Human-readable help text. + */ +QString modelHelpText(); diff --git a/src/config.h b/src/config.h index a9d8898..a279d6f 100644 --- a/src/config.h +++ b/src/config.h @@ -21,8 +21,8 @@ QString defaultConfigPath(); /** - * @brief Returns the default Whisper model path used for fallback config values. - * @return Default path to the expected local model file. + * @brief Returns the default model artifact path used for fallback config values. + * @return Default path to the expected local model artifact. */ QString defaultModelPath(); @@ -57,10 +57,10 @@ struct AudioConfig { }; /** - * @brief Whisper backend configuration. + * @brief Transcription runtime configuration. */ struct TranscriberConfig { - /// Filesystem path to the Whisper model file. + /// Filesystem path to a model package, manifest, or raw compatibility artifact. QString modelPath; /// Language code passed to whisper.cpp. QString language = QStringLiteral("en"); diff --git a/src/main.cpp b/src/main.cpp index 1a83c40..d711b6f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -33,6 +33,11 @@ void writeConfigHelp() QTextStream(stdout) << configHelpText(); } +void writeModelHelp() +{ + QTextStream(stdout) << modelHelpText(); +} + void configureCommandLineParser(QCommandLineParser *parser) { parser->setApplicationDescription(QStringLiteral("Push-to-talk local speech transcription for KDE Plasma")); @@ -245,7 +250,7 @@ bool bootstrapConfig(const QString &configPath, if (promptForModelPath) { QString modelPath; - if (!promptForConfigValue(QStringLiteral("Whisper model path"), + if (!promptForConfigValue(QStringLiteral("Model artifact path"), config->transcriber.modelPath, true, modelPath, @@ -305,7 +310,7 @@ int runConfigInit(const QString &configPath, } 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.")); + "Missing config file and no terminal is available. Re-run `mutterkey config init --model-path /path/to/model` from a shell.")); } if (!saveConfig(configPath, config, &errorMessage)) { return exitWithError(errorMessage); @@ -355,7 +360,7 @@ int runConfigCommand(const QStringList &arguments) QStringLiteral("Override the configured log level"), QStringLiteral("level")); const QCommandLineOption modelPathOption(QStringList{QStringLiteral("model-path")}, - QStringLiteral("Override the configured Whisper model path"), + QStringLiteral("Override the configured model artifact path"), QStringLiteral("path")); const QCommandLineOption shortcutOption(QStringList{QStringLiteral("shortcut")}, QStringLiteral("Override the configured push-to-talk shortcut"), @@ -384,7 +389,7 @@ int runConfigCommand(const QStringList &arguments) parser.addOption(noTranslateOption); parser.addOption(warmupOption); parser.addOption(noWarmupOption); - parser.addPositionalArgument(QStringLiteral("command"), QStringLiteral("daemon, once, diagnose, or config")); + parser.addPositionalArgument(QStringLiteral("command"), QStringLiteral("daemon, once, diagnose, config, or model")); parser.addPositionalArgument(QStringLiteral("extra"), QStringLiteral("Command-specific arguments")); parser.process(arguments); @@ -426,6 +431,43 @@ int runConfigCommand(const QStringList &arguments) return exitWithError(QStringLiteral("Unknown config subcommand: %1").arg(subcommand)); } +int runModelCommand(const QStringList &arguments) +{ + QCommandLineParser parser; + configureCommandLineParser(&parser); + + const QCommandLineOption outputOption(QStringList{QStringLiteral("output")}, + QStringLiteral("Output package directory"), + QStringLiteral("path")); + const QCommandLineOption idOption(QStringList{QStringLiteral("id")}, + QStringLiteral("Override the generated package id"), + QStringLiteral("package-id")); + parser.addOption(outputOption); + parser.addOption(idOption); + parser.addPositionalArgument(QStringLiteral("command"), QStringLiteral("model")); + parser.addPositionalArgument(QStringLiteral("subcommand"), QStringLiteral("import or inspect")); + parser.addPositionalArgument(QStringLiteral("path"), QStringLiteral("Model path or source artifact")); + parser.process(arguments); + + const QStringList positional = parser.positionalArguments(); + const QString subcommand = positional.value(1); + if (subcommand == QStringLiteral("import")) { + if (positional.size() < 3) { + return exitWithError(QStringLiteral("Usage: mutterkey model import [--output ] [--id ]")); + } + return runModelImport(positional.at(2), parser.value(outputOption), parser.value(idOption)); + } + + if (subcommand == QStringLiteral("inspect")) { + if (positional.size() < 3) { + return exitWithError(QStringLiteral("Usage: mutterkey model inspect ")); + } + return runModelInspect(positional.at(2)); + } + + return exitWithError(QStringLiteral("Unknown model subcommand: %1").arg(subcommand)); +} + } // namespace int main(int argc, char *argv[]) @@ -436,9 +478,16 @@ int main(int argc, char *argv[]) writeConfigHelp(); return 0; } + if (shouldShowModelHelp(arguments, commandIndex)) { + writeModelHelp(); + return 0; + } if (commandIndex >= 0 && commandIndex < arguments.size() && arguments.at(commandIndex) == QStringLiteral("config")) { return runConfigCommand(arguments); } + if (commandIndex >= 0 && commandIndex < arguments.size() && arguments.at(commandIndex) == QStringLiteral("model")) { + return runModelCommand(arguments); + } QGuiApplication app(argc, argv); QGuiApplication::setDesktopFileName(QStringLiteral("org.mutterkey.mutterkey")); @@ -456,7 +505,7 @@ int main(int argc, char *argv[]) QStringLiteral("Override the configured log level"), QStringLiteral("level")); const QCommandLineOption modelPathOption(QStringList{QStringLiteral("model-path")}, - QStringLiteral("Override the configured Whisper model path"), + QStringLiteral("Override the configured model artifact path"), QStringLiteral("path")); const QCommandLineOption shortcutOption(QStringList{QStringLiteral("shortcut")}, QStringLiteral("Override the configured push-to-talk shortcut"), @@ -485,7 +534,7 @@ int main(int argc, char *argv[]) parser.addOption(noTranslateOption); parser.addOption(warmupOption); parser.addOption(noWarmupOption); - parser.addPositionalArgument(QStringLiteral("command"), QStringLiteral("daemon, once, diagnose, or config")); + parser.addPositionalArgument(QStringLiteral("command"), QStringLiteral("daemon, once, diagnose, config, or model")); parser.addPositionalArgument(QStringLiteral("extra"), QStringLiteral("Command-specific arguments")); parser.process(app); diff --git a/src/transcription/modelcatalog.cpp b/src/transcription/modelcatalog.cpp new file mode 100644 index 0000000..25e2a04 --- /dev/null +++ b/src/transcription/modelcatalog.cpp @@ -0,0 +1,81 @@ +#include "transcription/modelcatalog.h" + +#include "transcription/modelvalidator.h" +#include "transcription/rawwhisperprobe.h" + +#include + +namespace { + +RuntimeError makeRuntimeError(RuntimeErrorCode code, QString message, QString detail = {}) +{ + return RuntimeError{.code = code, .message = std::move(message), .detail = std::move(detail)}; +} + +ModelPackageManifest legacyManifestForPath(const QString &sourcePath, const ModelMetadata &metadata) +{ + ModelPackageManifest manifest; + manifest.format = QStringLiteral("mutterkey.model-package"); + manifest.schemaVersion = 1; + manifest.metadata = metadata; + manifest.compatibleEngines.push_back(ModelCompatibilityMarker{ + .engine = QStringLiteral("whisper.cpp"), + .modelFormat = QStringLiteral("ggml"), + }); + manifest.assets.push_back(ModelAssetMetadata{ + .role = QStringLiteral("weights"), + .relativePath = QFileInfo(sourcePath).fileName(), + .sha256 = {}, + .sizeBytes = QFileInfo(sourcePath).size(), + }); + manifest.sourceArtifact = sourcePath; + return manifest; +} + +} // namespace + +std::optional ModelCatalog::inspectPath(const QString &path, + QStringView requiredEngine, + QStringView requiredModelFormat, + RuntimeError *error) +{ + const QFileInfo info(path); + if (!info.exists()) { + if (error != nullptr) { + *error = makeRuntimeError(RuntimeErrorCode::ModelNotFound, + QStringLiteral("Model artifact not found: %1").arg(path), + info.absoluteFilePath()); + } + return std::nullopt; + } + + if (info.isDir() || info.fileName() == QStringLiteral("model.json")) { + return ModelValidator::validatePackagePath(path, requiredEngine, requiredModelFormat, error); + } + + RuntimeError probeError; + const std::optional metadata = RawWhisperProbe::inspectFile(path, &probeError); + if (!metadata.has_value()) { + if (error != nullptr) { + *error = probeError; + } + return std::nullopt; + } + + if ((!requiredEngine.isEmpty() && requiredEngine != QStringLiteral("whisper.cpp")) + || (!requiredModelFormat.isEmpty() && requiredModelFormat != QStringLiteral("ggml"))) { + if (error != nullptr) { + *error = makeRuntimeError(RuntimeErrorCode::IncompatibleModelPackage, + QStringLiteral("Raw Whisper compatibility artifact does not match the active runtime")); + } + return std::nullopt; + } + + return ValidatedModelPackage{ + .packageRootPath = QFileInfo(path).absolutePath(), + .manifestPath = {}, + .sourcePath = QFileInfo(path).absoluteFilePath(), + .weightsPath = QFileInfo(path).absoluteFilePath(), + .manifest = legacyManifestForPath(QFileInfo(path).absoluteFilePath(), *metadata), + }; +} diff --git a/src/transcription/modelcatalog.h b/src/transcription/modelcatalog.h new file mode 100644 index 0000000..b30f76a --- /dev/null +++ b/src/transcription/modelcatalog.h @@ -0,0 +1,32 @@ +#pragma once + +#include "transcription/modelpackage.h" +#include "transcription/transcriptiontypes.h" + +#include + +/** + * @file + * @brief Product-owned discovery and inspection helpers for model artifacts. + */ + +/** + * @brief Product-owned entrypoint for inspecting and resolving model artifacts. + */ +class ModelCatalog final +{ +public: + /** + * @brief Inspects and resolves a model artifact path into a validated package value. + * @param path Package root, manifest path, or raw compatibility artifact. + * @param requiredEngine Optional engine compatibility filter. + * @param requiredModelFormat Optional model-format compatibility filter. + * @param error Optional destination for structured failures. + * @return Validated package description on success. + */ + [[nodiscard]] static std::optional + inspectPath(const QString &path, + QStringView requiredEngine = {}, + QStringView requiredModelFormat = {}, + RuntimeError *error = nullptr); +}; diff --git a/src/transcription/modelpackage.cpp b/src/transcription/modelpackage.cpp new file mode 100644 index 0000000..ff835e6 --- /dev/null +++ b/src/transcription/modelpackage.cpp @@ -0,0 +1,201 @@ +#include "transcription/modelpackage.h" + +#include +#include +#include + +namespace { + +QString readString(const QJsonObject &object, QStringView key) +{ + const QJsonValue value = object.value(key); + return value.isString() ? value.toString().trimmed() : QString{}; +} + +int readInt(const QJsonObject &object, QStringView key) +{ + const QJsonValue value = object.value(key); + return value.isDouble() ? value.toInt() : 0; +} + +void setIfNotEmpty(QJsonObject *object, QStringView key, const QString &value) +{ + if (object != nullptr && !value.isEmpty()) { + object->insert(key.toString(), value); + } +} + +QJsonObject metadataToJson(const ModelMetadata &metadata) +{ + QJsonObject object; + setIfNotEmpty(&object, QStringLiteral("package_id"), metadata.packageId); + setIfNotEmpty(&object, QStringLiteral("display_name"), metadata.displayName); + setIfNotEmpty(&object, QStringLiteral("package_version"), metadata.packageVersion); + setIfNotEmpty(&object, QStringLiteral("runtime_family"), metadata.runtimeFamily); + setIfNotEmpty(&object, QStringLiteral("source_format"), metadata.sourceFormat); + setIfNotEmpty(&object, QStringLiteral("model_format"), metadata.modelFormat); + setIfNotEmpty(&object, QStringLiteral("architecture"), metadata.architecture); + setIfNotEmpty(&object, QStringLiteral("language_profile"), metadata.languageProfile); + setIfNotEmpty(&object, QStringLiteral("quantization"), metadata.quantization); + setIfNotEmpty(&object, QStringLiteral("tokenizer"), metadata.tokenizer); + object.insert(QStringLiteral("legacy_compatibility"), metadata.legacyCompatibility); + object.insert(QStringLiteral("vocabulary_size"), metadata.vocabularySize); + object.insert(QStringLiteral("audio_context"), metadata.audioContext); + object.insert(QStringLiteral("audio_state"), metadata.audioState); + object.insert(QStringLiteral("audio_head_count"), metadata.audioHeadCount); + object.insert(QStringLiteral("audio_layer_count"), metadata.audioLayerCount); + object.insert(QStringLiteral("text_context"), metadata.textContext); + object.insert(QStringLiteral("text_state"), metadata.textState); + object.insert(QStringLiteral("text_head_count"), metadata.textHeadCount); + object.insert(QStringLiteral("text_layer_count"), metadata.textLayerCount); + object.insert(QStringLiteral("mel_count"), metadata.melCount); + object.insert(QStringLiteral("format_type"), metadata.formatType); + return object; +} + +ModelMetadata metadataFromJson(const QJsonObject &object) +{ + return ModelMetadata{ + .packageId = readString(object, QStringLiteral("package_id")), + .displayName = readString(object, QStringLiteral("display_name")), + .packageVersion = readString(object, QStringLiteral("package_version")), + .runtimeFamily = readString(object, QStringLiteral("runtime_family")), + .sourceFormat = readString(object, QStringLiteral("source_format")), + .modelFormat = readString(object, QStringLiteral("model_format")), + .architecture = readString(object, QStringLiteral("architecture")), + .languageProfile = readString(object, QStringLiteral("language_profile")), + .quantization = readString(object, QStringLiteral("quantization")), + .tokenizer = readString(object, QStringLiteral("tokenizer")), + .legacyCompatibility = object.value(QStringLiteral("legacy_compatibility")).toBool(false), + .vocabularySize = readInt(object, QStringLiteral("vocabulary_size")), + .audioContext = readInt(object, QStringLiteral("audio_context")), + .audioState = readInt(object, QStringLiteral("audio_state")), + .audioHeadCount = readInt(object, QStringLiteral("audio_head_count")), + .audioLayerCount = readInt(object, QStringLiteral("audio_layer_count")), + .textContext = readInt(object, QStringLiteral("text_context")), + .textState = readInt(object, QStringLiteral("text_state")), + .textHeadCount = readInt(object, QStringLiteral("text_head_count")), + .textLayerCount = readInt(object, QStringLiteral("text_layer_count")), + .melCount = readInt(object, QStringLiteral("mel_count")), + .formatType = readInt(object, QStringLiteral("format_type")), + }; +} + +} // namespace + +QString ValidatedModelPackage::description() const +{ + const QString packageLabel = manifest.metadata.displayName.isEmpty() ? manifest.metadata.packageId : manifest.metadata.displayName; + QString sourceLabel = sourcePath.isEmpty() ? weightsPath : sourcePath; + if (packageLabel.isEmpty()) { + return sourceLabel; + } + return QStringLiteral("%1 (%2)").arg(packageLabel, sourceLabel); +} + +QString defaultModelPackageDirectory() +{ + const QString dataRoot = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); + return QDir(dataRoot).filePath(QStringLiteral("models")); +} + +QString sanitizePackageId(const QString &value) +{ + QString sanitized; + sanitized.reserve(value.size()); + for (const QChar character : value.trimmed().toLower()) { + if (character.isLetterOrNumber()) { + sanitized.append(character); + continue; + } + if (character == QLatin1Char('-') || character == QLatin1Char('_')) { + sanitized.append(QLatin1Char('-')); + continue; + } + if (character.isSpace() || character == QLatin1Char('.')) { + sanitized.append(QLatin1Char('-')); + } + } + + while (sanitized.contains(QStringLiteral("--"))) { + sanitized.replace(QStringLiteral("--"), QStringLiteral("-")); + } + sanitized = sanitized.trimmed(); + while (sanitized.startsWith(QLatin1Char('-'))) { + sanitized.remove(0, 1); + } + while (sanitized.endsWith(QLatin1Char('-'))) { + sanitized.chop(1); + } + return sanitized; +} + +QJsonObject modelPackageManifestToJson(const ModelPackageManifest &manifest) +{ + QJsonObject root; + root.insert(QStringLiteral("format"), manifest.format); + root.insert(QStringLiteral("schema_version"), manifest.schemaVersion); + root.insert(QStringLiteral("metadata"), metadataToJson(manifest.metadata)); + + QJsonArray compatibleEngines; + for (const ModelCompatibilityMarker &marker : manifest.compatibleEngines) { + compatibleEngines.append(QJsonObject{ + {QStringLiteral("engine"), marker.engine}, + {QStringLiteral("model_format"), marker.modelFormat}, + }); + } + root.insert(QStringLiteral("compatible_engines"), compatibleEngines); + + QJsonArray assets; + for (const ModelAssetMetadata &asset : manifest.assets) { + assets.append(QJsonObject{ + {QStringLiteral("role"), asset.role}, + {QStringLiteral("path"), asset.relativePath}, + {QStringLiteral("sha256"), asset.sha256}, + {QStringLiteral("size_bytes"), static_cast(asset.sizeBytes)}, + }); + } + root.insert(QStringLiteral("assets"), assets); + setIfNotEmpty(&root, QStringLiteral("source_artifact"), manifest.sourceArtifact); + return root; +} + +std::optional modelPackageManifestFromJson(const QJsonObject &root, QString *errorMessage) +{ + ModelPackageManifest manifest; + manifest.format = readString(root, QStringLiteral("format")); + manifest.schemaVersion = readInt(root, QStringLiteral("schema_version")); + manifest.metadata = metadataFromJson(root.value(QStringLiteral("metadata")).toObject()); + manifest.sourceArtifact = readString(root, QStringLiteral("source_artifact")); + + const QJsonArray compatibleArray = root.value(QStringLiteral("compatible_engines")).toArray(); + manifest.compatibleEngines.reserve(static_cast(compatibleArray.size())); + for (const auto &value : compatibleArray) { + const QJsonObject object = value.toObject(); + manifest.compatibleEngines.push_back(ModelCompatibilityMarker{ + .engine = readString(object, QStringLiteral("engine")), + .modelFormat = readString(object, QStringLiteral("model_format")), + }); + } + + const QJsonArray assetArray = root.value(QStringLiteral("assets")).toArray(); + manifest.assets.reserve(static_cast(assetArray.size())); + for (const auto &value : assetArray) { + const QJsonObject object = value.toObject(); + manifest.assets.push_back(ModelAssetMetadata{ + .role = readString(object, QStringLiteral("role")), + .relativePath = readString(object, QStringLiteral("path")), + .sha256 = readString(object, QStringLiteral("sha256")).toLower(), + .sizeBytes = object.value(QStringLiteral("size_bytes")).toInteger(-1), + }); + } + + if (manifest.format.isEmpty()) { + if (errorMessage != nullptr) { + *errorMessage = QStringLiteral("Manifest is missing format"); + } + return std::nullopt; + } + + return manifest; +} diff --git a/src/transcription/modelpackage.h b/src/transcription/modelpackage.h new file mode 100644 index 0000000..4e42ac8 --- /dev/null +++ b/src/transcription/modelpackage.h @@ -0,0 +1,117 @@ +#pragma once + +#include "transcription/transcriptiontypes.h" + +#include + +#include +#include + +/** + * @file + * @brief Product-owned model package manifest and validated package value types. + */ + +/** + * @brief One engine/model-format compatibility marker recorded in a package manifest. + */ +struct ModelCompatibilityMarker { + /// Stable engine identifier accepted by this package. + QString engine; + /// Engine-specific model format marker such as `ggml`. + QString modelFormat; +}; + +/** + * @brief One packaged asset entry recorded in a package manifest. + */ +struct ModelAssetMetadata { + /// Logical asset role such as `weights`. + QString role; + /// Relative path from the package root to the asset file. + QString relativePath; + /// Lowercase SHA-256 digest for the asset contents. + QString sha256; + /// Declared size of the asset in bytes. + qint64 sizeBytes = 0; +}; + +/** + * @brief Product-owned manifest data parsed from `model.json`. + */ +struct ModelPackageManifest { + /// Stable package format identifier. + QString format; + /// Schema version for this manifest. + int schemaVersion = 0; + /// Product-owned immutable metadata about the packaged model. + ModelMetadata metadata; + /// Compatible engines and model formats for this package. + std::vector compatibleEngines; + /// Packaged assets referenced by this manifest. + std::vector assets; + /// Optional source artifact description used for diagnostics. + QString sourceArtifact; +}; + +/** + * @brief Fully validated model package resolved from disk. + */ +struct ValidatedModelPackage { + /// Resolved package root directory. + QString packageRootPath; + /// Resolved `model.json` path when the artifact is a native package. + QString manifestPath; + /// Original user-provided source path resolved to an absolute path. + QString sourcePath; + /// Resolved weights asset path used by the backend adapter. + QString weightsPath; + /// Parsed product-owned manifest data. + ModelPackageManifest manifest; + + /** + * @brief Returns the immutable model metadata carried by this package. + * @return Product-owned metadata for the resolved artifact. + */ + [[nodiscard]] const ModelMetadata &metadata() const { return manifest.metadata; } + + /** + * @brief Reports whether this package came from the legacy raw-file compatibility path. + * @return `true` for raw Whisper compatibility artifacts. + */ + [[nodiscard]] bool isLegacyCompatibility() const { return manifest.metadata.legacyCompatibility; } + + /** + * @brief Returns a human-readable description for diagnostics and logs. + * @return Display label derived from package metadata and source path. + */ + [[nodiscard]] QString description() const; +}; + +/** + * @brief Returns the default root directory for native model packages. + * @return Default package directory under the app data root. + */ +[[nodiscard]] QString defaultModelPackageDirectory(); + +/** + * @brief Normalizes a human-provided package id into a stable filesystem-safe form. + * @param value Raw package id or display string. + * @return Lowercase sanitized package id. + */ +[[nodiscard]] QString sanitizePackageId(const QString &value); + +/** + * @brief Serializes a product-owned package manifest to JSON. + * @param manifest Manifest value to serialize. + * @return JSON object suitable for writing to `model.json`. + */ +[[nodiscard]] QJsonObject modelPackageManifestToJson(const ModelPackageManifest &manifest); + +/** + * @brief Parses a product-owned package manifest from JSON. + * @param root JSON object read from `model.json`. + * @param errorMessage Optional destination for parse failures. + * @return Parsed manifest on success. + */ +[[nodiscard]] std::optional modelPackageManifestFromJson(const QJsonObject &root, QString *errorMessage = nullptr); diff --git a/src/transcription/modelvalidator.cpp b/src/transcription/modelvalidator.cpp new file mode 100644 index 0000000..6aca76c --- /dev/null +++ b/src/transcription/modelvalidator.cpp @@ -0,0 +1,275 @@ +#include "transcription/modelvalidator.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +RuntimeError makeRuntimeError(RuntimeErrorCode code, QString message, QString detail = {}) +{ + return RuntimeError{.code = code, .message = std::move(message), .detail = std::move(detail)}; +} + +RuntimeError invalidPackage(QString message, QString detail = {}) +{ + return makeRuntimeError(RuntimeErrorCode::InvalidModelPackage, std::move(message), std::move(detail)); +} + +RuntimeError incompatiblePackage(QString message, QString detail = {}) +{ + return makeRuntimeError(RuntimeErrorCode::IncompatibleModelPackage, std::move(message), std::move(detail)); +} + +RuntimeError integrityFailure(QString message, QString detail = {}) +{ + return makeRuntimeError(RuntimeErrorCode::ModelIntegrityFailed, std::move(message), std::move(detail)); +} + +RuntimeError modelTooLarge(QString message, QString detail = {}) +{ + return makeRuntimeError(RuntimeErrorCode::ModelTooLarge, std::move(message), std::move(detail)); +} + +bool computeSha256(const QString &path, QString *digest, RuntimeError *error) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + if (error != nullptr) { + *error = integrityFailure(QStringLiteral("Failed to open model asset for hashing"), + QFileInfo(path).absoluteFilePath()); + } + return false; + } + + QCryptographicHash hash(QCryptographicHash::Sha256); + if (!hash.addData(&file)) { + if (error != nullptr) { + *error = integrityFailure(QStringLiteral("Failed to hash model asset"), QFileInfo(path).absoluteFilePath()); + } + return false; + } + + if (digest != nullptr) { + *digest = QString::fromLatin1(hash.result().toHex()); + } + return true; +} + +bool isRelativeSafePath(const QString &path) +{ + if (path.isEmpty() || QDir::isAbsolutePath(path) || path.contains(QStringLiteral(".."))) { + return false; + } + + const QString cleaned = QDir::cleanPath(path); + return !cleaned.startsWith(QStringLiteral("../")) && cleaned != QStringLiteral(".."); +} + +std::optional weightsAsset(const ModelPackageManifest &manifest) +{ + for (const ModelAssetMetadata &asset : manifest.assets) { + if (asset.role == QStringLiteral("weights")) { + return asset; + } + } + return std::nullopt; +} + +bool hasRequiredCompatibility(const ModelPackageManifest &manifest, QStringView engine, QStringView modelFormat) +{ + if (engine.isEmpty() && modelFormat.isEmpty()) { + return true; + } + + return std::ranges::any_of(manifest.compatibleEngines, [engine, modelFormat](const ModelCompatibilityMarker &marker) { + const bool engineMatches = engine.isEmpty() || marker.engine == engine; + const bool formatMatches = modelFormat.isEmpty() || marker.modelFormat == modelFormat; + return engineMatches && formatMatches; + }); +} + +} // namespace + +ModelValidationLimits ModelValidator::defaultLimits() +{ + return ModelValidationLimits{}; +} + +std::optional ModelValidator::validatePackagePath(const QString &path, + QStringView requiredEngine, + QStringView requiredModelFormat, + RuntimeError *error, + const ModelValidationLimits &limits) +{ + const QFileInfo inputInfo(path); + if (!inputInfo.exists()) { + if (error != nullptr) { + *error = makeRuntimeError(RuntimeErrorCode::ModelNotFound, + QStringLiteral("Model package not found: %1").arg(path), + inputInfo.absoluteFilePath()); + } + return std::nullopt; + } + + QString manifestPath; + QString packageRootPath; + if (inputInfo.isDir()) { + packageRootPath = inputInfo.absoluteFilePath(); + manifestPath = QDir(packageRootPath).filePath(QStringLiteral("model.json")); + } else { + manifestPath = inputInfo.absoluteFilePath(); + packageRootPath = QFileInfo(manifestPath).absolutePath(); + } + + const QFileInfo manifestInfo(manifestPath); + if (!manifestInfo.exists() || !manifestInfo.isFile()) { + if (error != nullptr) { + *error = invalidPackage(QStringLiteral("Model package manifest not found: %1").arg(manifestPath)); + } + return std::nullopt; + } + if (manifestInfo.isSymLink()) { + if (error != nullptr) { + *error = integrityFailure(QStringLiteral("Model package manifest must not be a symlink"), manifestPath); + } + return std::nullopt; + } + if (manifestInfo.size() > limits.maxManifestBytes) { + if (error != nullptr) { + *error = modelTooLarge(QStringLiteral("Model package manifest is too large"), manifestPath); + } + return std::nullopt; + } + + QFile manifestFile(manifestPath); + if (!manifestFile.open(QIODevice::ReadOnly | QIODevice::Text)) { + if (error != nullptr) { + *error = invalidPackage(QStringLiteral("Failed to open model package manifest"), manifestPath); + } + return std::nullopt; + } + + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(manifestFile.readAll(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) { + if (error != nullptr) { + *error = invalidPackage(QStringLiteral("Invalid model package manifest"), + parseError.errorString()); + } + return std::nullopt; + } + + QString manifestError; + const std::optional manifest = modelPackageManifestFromJson(document.object(), &manifestError); + if (!manifest.has_value()) { + if (error != nullptr) { + *error = invalidPackage(QStringLiteral("Invalid model package manifest"), manifestError); + } + return std::nullopt; + } + + if (manifest->format != QStringLiteral("mutterkey.model-package")) { + if (error != nullptr) { + *error = invalidPackage(QStringLiteral("Unsupported model package format"), manifest->format); + } + return std::nullopt; + } + if (manifest->schemaVersion != 1) { + if (error != nullptr) { + *error = makeRuntimeError(RuntimeErrorCode::UnsupportedModelPackageVersion, + QStringLiteral("Unsupported model package schema version: %1").arg(manifest->schemaVersion)); + } + return std::nullopt; + } + if (manifest->assets.empty() || manifest->assets.size() > static_cast(limits.maxAssetCount)) { + if (error != nullptr) { + *error = invalidPackage(QStringLiteral("Model package asset count is invalid")); + } + return std::nullopt; + } + if (manifest->metadata.packageId.isEmpty() || manifest->metadata.runtimeFamily != QStringLiteral("asr")) { + if (error != nullptr) { + *error = invalidPackage(QStringLiteral("Model package metadata is incomplete")); + } + return std::nullopt; + } + if (!hasRequiredCompatibility(*manifest, requiredEngine, requiredModelFormat)) { + if (error != nullptr) { + *error = incompatiblePackage(QStringLiteral("Model package is not compatible with the active runtime"), + QStringLiteral("%1 / %2").arg(requiredEngine, requiredModelFormat)); + } + return std::nullopt; + } + + const std::optional asset = weightsAsset(*manifest); + if (!asset.has_value()) { + if (error != nullptr) { + *error = invalidPackage(QStringLiteral("Model package is missing a weights asset")); + } + return std::nullopt; + } + if (!isRelativeSafePath(asset->relativePath)) { + if (error != nullptr) { + *error = integrityFailure(QStringLiteral("Model asset path is not safe"), asset->relativePath); + } + return std::nullopt; + } + if (asset->sizeBytes < 0 || asset->sizeBytes > limits.maxWeightsBytes) { + if (error != nullptr) { + *error = modelTooLarge(QStringLiteral("Model weights asset exceeds supported size limits")); + } + return std::nullopt; + } + + const QString weightsPath = QDir(packageRootPath).filePath(QDir::cleanPath(asset->relativePath)); + const QFileInfo weightsInfo(weightsPath); + if (!weightsInfo.exists() || !weightsInfo.isFile()) { + if (error != nullptr) { + *error = integrityFailure(QStringLiteral("Model weights asset is missing"), weightsPath); + } + return std::nullopt; + } + if (weightsInfo.isSymLink()) { + if (error != nullptr) { + *error = integrityFailure(QStringLiteral("Model weights asset must not be a symlink"), weightsPath); + } + return std::nullopt; + } + if (weightsInfo.size() != asset->sizeBytes) { + if (error != nullptr) { + *error = integrityFailure(QStringLiteral("Model weights asset size does not match manifest"), weightsPath); + } + return std::nullopt; + } + + const qint64 packageBytes = manifestInfo.size() + weightsInfo.size(); + if (packageBytes > limits.maxPackageBytes) { + if (error != nullptr) { + *error = modelTooLarge(QStringLiteral("Model package exceeds supported size limits"), packageRootPath); + } + return std::nullopt; + } + + QString hashDigest; + if (!computeSha256(weightsPath, &hashDigest, error)) { + return std::nullopt; + } + if (!asset->sha256.isEmpty() && hashDigest != asset->sha256.toLower()) { + if (error != nullptr) { + *error = integrityFailure(QStringLiteral("Model weights hash does not match manifest"), weightsPath); + } + return std::nullopt; + } + + return ValidatedModelPackage{ + .packageRootPath = packageRootPath, + .manifestPath = manifestPath, + .sourcePath = QFileInfo(path).absoluteFilePath(), + .weightsPath = weightsPath, + .manifest = *manifest, + }; +} diff --git a/src/transcription/modelvalidator.h b/src/transcription/modelvalidator.h new file mode 100644 index 0000000..c4b6daa --- /dev/null +++ b/src/transcription/modelvalidator.h @@ -0,0 +1,54 @@ +#pragma once + +#include "transcription/modelpackage.h" +#include "transcription/transcriptiontypes.h" + +#include + +/** + * @file + * @brief Validation helpers for product-owned model packages. + */ + +/** + * @brief Hard bounds applied while validating model packages. + */ +struct ModelValidationLimits { + /// Maximum accepted `model.json` size in bytes. + qint64 maxManifestBytes = 64LL * 1024LL; + /// Maximum number of asset entries allowed in one manifest. + qint64 maxAssetCount = 16; + /// Maximum total package size in bytes. + qint64 maxPackageBytes = 8LL * 1024 * 1024 * 1024; + /// Maximum weights asset size in bytes. + qint64 maxWeightsBytes = 8LL * 1024 * 1024 * 1024; +}; + +/** + * @brief Validates native Mutterkey model packages before runtime loading. + */ +class ModelValidator final +{ +public: + /** + * @brief Returns the default validation limits for native packages. + * @return Default hard validation bounds. + */ + [[nodiscard]] static ModelValidationLimits defaultLimits(); + + /** + * @brief Validates a native model package on disk. + * @param path Package root directory or manifest path. + * @param requiredEngine Optional engine compatibility filter. + * @param requiredModelFormat Optional model-format compatibility filter. + * @param error Optional destination for structured validation failures. + * @param limits Validation bounds to enforce. + * @return Fully validated package on success. + */ + [[nodiscard]] static std::optional + validatePackagePath(const QString &path, + QStringView requiredEngine = {}, + QStringView requiredModelFormat = {}, + RuntimeError *error = nullptr, + const ModelValidationLimits &limits = defaultLimits()); +}; diff --git a/src/transcription/rawwhisperimporter.cpp b/src/transcription/rawwhisperimporter.cpp new file mode 100644 index 0000000..e819ed1 --- /dev/null +++ b/src/transcription/rawwhisperimporter.cpp @@ -0,0 +1,167 @@ +#include "transcription/rawwhisperimporter.h" + +#include "transcription/modelvalidator.h" +#include "transcription/rawwhisperprobe.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +RuntimeError makeRuntimeError(RuntimeErrorCode code, QString message, QString detail = {}) +{ + return RuntimeError{.code = code, .message = std::move(message), .detail = std::move(detail)}; +} + +bool writeFileHash(const QString &path, QString *digest, RuntimeError *error) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + if (error != nullptr) { + *error = makeRuntimeError(RuntimeErrorCode::ModelNotFound, + QStringLiteral("Failed to open raw Whisper model for import"), + QFileInfo(path).absoluteFilePath()); + } + return false; + } + + QCryptographicHash hash(QCryptographicHash::Sha256); + if (!hash.addData(&file)) { + if (error != nullptr) { + *error = makeRuntimeError(RuntimeErrorCode::ModelIntegrityFailed, + QStringLiteral("Failed to hash raw Whisper model during import")); + } + return false; + } + + if (digest != nullptr) { + *digest = QString::fromLatin1(hash.result().toHex()); + } + return true; +} + +QString resolvePackageDirectory(const RawWhisperImportRequest &request, const QString &packageId) +{ + const QString &outputPath = request.outputPath; + if (outputPath.isEmpty()) { + return QDir(defaultModelPackageDirectory()).filePath(packageId); + } + + const QFileInfo outputInfo(outputPath); + if (!outputInfo.exists() || outputInfo.isDir()) { + if (outputPath.endsWith(QLatin1String(".json"))) { + return QFileInfo(outputPath).absolutePath(); + } + return outputInfo.absoluteFilePath(); + } + + return QFileInfo(outputPath).absolutePath(); +} + +} // namespace + +std::optional RawWhisperImporter::importFile(const QString &sourcePath, + const RawWhisperImportRequest &request, + RuntimeError *error) +{ + RuntimeError probeError; + std::optional metadata = RawWhisperProbe::inspectFile(sourcePath, &probeError); + if (!metadata.has_value()) { + if (error != nullptr) { + *error = probeError; + } + return std::nullopt; + } + + const QString packageId = + sanitizePackageId(request.packageIdOverride.isEmpty() ? metadata->packageId : request.packageIdOverride); + if (packageId.isEmpty()) { + if (error != nullptr) { + *error = makeRuntimeError(RuntimeErrorCode::InvalidModelPackage, + QStringLiteral("Imported model package id would be empty")); + } + return std::nullopt; + } + + metadata->packageId = packageId; + metadata->legacyCompatibility = false; + + const QString packageDirectory = resolvePackageDirectory(request, packageId); + if (QFileInfo::exists(packageDirectory)) { + if (error != nullptr) { + *error = makeRuntimeError(RuntimeErrorCode::InvalidConfig, + QStringLiteral("Refusing to overwrite an existing model package"), + packageDirectory); + } + return std::nullopt; + } + + const QDir root; + if (!root.mkpath(QDir(packageDirectory).filePath(QStringLiteral("assets")))) { + if (error != nullptr) { + *error = makeRuntimeError(RuntimeErrorCode::InvalidConfig, + QStringLiteral("Failed to create model package directory"), + packageDirectory); + } + return std::nullopt; + } + + const QString weightsPath = QDir(packageDirectory).filePath(QStringLiteral("assets/model.bin")); + if (!QFile::copy(sourcePath, weightsPath)) { + if (error != nullptr) { + *error = makeRuntimeError(RuntimeErrorCode::ModelIntegrityFailed, + QStringLiteral("Failed to copy raw Whisper model into package"), + weightsPath); + } + return std::nullopt; + } + + QString weightsHash; + if (!writeFileHash(weightsPath, &weightsHash, error)) { + return std::nullopt; + } + + ModelPackageManifest manifest; + manifest.format = QStringLiteral("mutterkey.model-package"); + manifest.schemaVersion = 1; + manifest.metadata = *metadata; + manifest.compatibleEngines.push_back(ModelCompatibilityMarker{ + .engine = QStringLiteral("whisper.cpp"), + .modelFormat = QStringLiteral("ggml"), + }); + manifest.assets.push_back(ModelAssetMetadata{ + .role = QStringLiteral("weights"), + .relativePath = QStringLiteral("assets/model.bin"), + .sha256 = weightsHash, + .sizeBytes = QFileInfo(weightsPath).size(), + }); + manifest.sourceArtifact = QFileInfo(sourcePath).absoluteFilePath(); + + const QString manifestPath = QDir(packageDirectory).filePath(QStringLiteral("model.json")); + QSaveFile manifestFile(manifestPath); + if (!manifestFile.open(QIODevice::WriteOnly | QIODevice::Text)) { + if (error != nullptr) { + *error = makeRuntimeError(RuntimeErrorCode::InvalidConfig, + QStringLiteral("Failed to create model package manifest"), + manifestPath); + } + return std::nullopt; + } + + const QJsonDocument document(modelPackageManifestToJson(manifest)); + manifestFile.write(document.toJson(QJsonDocument::Indented)); + if (!manifestFile.commit()) { + if (error != nullptr) { + *error = makeRuntimeError(RuntimeErrorCode::InvalidConfig, + QStringLiteral("Failed to save model package manifest"), + manifestPath); + } + return std::nullopt; + } + + return ModelValidator::validatePackagePath(packageDirectory, QStringLiteral("whisper.cpp"), QStringLiteral("ggml"), error); +} diff --git a/src/transcription/rawwhisperimporter.h b/src/transcription/rawwhisperimporter.h new file mode 100644 index 0000000..80347aa --- /dev/null +++ b/src/transcription/rawwhisperimporter.h @@ -0,0 +1,38 @@ +#pragma once + +#include "transcription/modelpackage.h" +#include "transcription/transcriptiontypes.h" + +#include + +/** + * @file + * @brief Import helpers that convert legacy raw Whisper model files into native packages. + */ + +/** + * @brief Request parameters for importing a raw Whisper artifact into a native package. + */ +struct RawWhisperImportRequest { + /// Destination package path or parent directory. Empty uses the default models root. + QString outputPath; + /// Optional package id override. + QString packageIdOverride; +}; + +/** + * @brief Importer that converts legacy raw Whisper files into native packages. + */ +class RawWhisperImporter final +{ +public: + /** + * @brief Imports a raw whisper.cpp-compatible `.bin` file into a native package. + * @param sourcePath Source raw Whisper model path. + * @param request Output-path and package-id overrides for the import. + * @param error Optional destination for import failures. + * @return Validated native package on success. + */ + [[nodiscard]] static std::optional + importFile(const QString &sourcePath, const RawWhisperImportRequest &request = {}, RuntimeError *error = nullptr); +}; diff --git a/src/transcription/rawwhisperprobe.cpp b/src/transcription/rawwhisperprobe.cpp new file mode 100644 index 0000000..e9135fe --- /dev/null +++ b/src/transcription/rawwhisperprobe.cpp @@ -0,0 +1,136 @@ +#include "transcription/rawwhisperprobe.h" + +#include "transcription/modelpackage.h" + +#include +#include + +#include +#include +#include +#include + +namespace { + +constexpr quint32 kGgmlFileMagic = 0x67676d6cU; +constexpr std::array kQuantizationNames{ + "float32", + "float16", + "q4_0", + "q4_1", + "q5_0", + "q5_1", + "q8_0", +}; + +RuntimeError makeRuntimeError(RuntimeErrorCode code, QString message, QString detail = {}) +{ + return RuntimeError{.code = code, .message = std::move(message), .detail = std::move(detail)}; +} + +template +bool readValue(QFile *file, T *value) +{ + if (file == nullptr || value == nullptr) { + return false; + } + + const QByteArray bytes = file->read(static_cast(sizeof(T))); + if (bytes.size() != static_cast(sizeof(T))) { + return false; + } + + std::memcpy(value, bytes.constData(), sizeof(T)); + return true; +} + +QString modelFamilyFromAudioLayers(int layerCount) +{ + switch (layerCount) { + case 4: + return QStringLiteral("tiny"); + case 6: + return QStringLiteral("base"); + case 12: + return QStringLiteral("small"); + case 24: + return QStringLiteral("medium"); + case 32: + return QStringLiteral("large"); + default: + return QStringLiteral("unknown"); + } +} + +QString languageProfileFromVocabulary(int vocabularySize) +{ + return vocabularySize >= 51865 ? QStringLiteral("multilingual") : QStringLiteral("en"); +} + +QString quantizationName(int ftype) +{ + if (ftype >= 0 && std::cmp_less(ftype, kQuantizationNames.size())) { + return QString::fromLatin1(kQuantizationNames.at(static_cast(ftype))); + } + return QStringLiteral("ftype-%1").arg(ftype); +} + +} // namespace + +std::optional RawWhisperProbe::inspectFile(const QString &path, RuntimeError *error) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + if (error != nullptr) { + *error = makeRuntimeError(RuntimeErrorCode::ModelNotFound, + QStringLiteral("Raw Whisper model not found: %1").arg(path)); + } + return std::nullopt; + } + + quint32 magic = 0; + if (!readValue(&file, &magic) || magic != kGgmlFileMagic) { + if (error != nullptr) { + *error = makeRuntimeError(RuntimeErrorCode::InvalidModelPackage, + QStringLiteral("Raw Whisper model has an unsupported or invalid header"), + QFileInfo(path).absoluteFilePath()); + } + return std::nullopt; + } + + std::array values{}; + for (qint32 &value : values) { + if (!readValue(&file, &value)) { + if (error != nullptr) { + *error = makeRuntimeError(RuntimeErrorCode::InvalidModelPackage, + QStringLiteral("Raw Whisper model header is truncated"), + QFileInfo(path).absoluteFilePath()); + } + return std::nullopt; + } + } + + ModelMetadata metadata; + metadata.packageId = sanitizePackageId(QFileInfo(path).completeBaseName()); + metadata.displayName = QFileInfo(path).completeBaseName(); + metadata.runtimeFamily = QStringLiteral("asr"); + metadata.sourceFormat = QStringLiteral("whisper.cpp-ggml"); + metadata.modelFormat = QStringLiteral("ggml"); + metadata.architecture = modelFamilyFromAudioLayers(values.at(4)); + metadata.languageProfile = languageProfileFromVocabulary(values.at(0)); + metadata.quantization = quantizationName(values.at(9)); + metadata.tokenizer = QStringLiteral("embedded"); + metadata.legacyCompatibility = true; + metadata.vocabularySize = values.at(0); + metadata.audioContext = values.at(1); + metadata.audioState = values.at(2); + metadata.audioHeadCount = values.at(3); + metadata.audioLayerCount = values.at(4); + metadata.textContext = values.at(5); + metadata.textState = values.at(6); + metadata.textHeadCount = values.at(7); + metadata.textLayerCount = values.at(8); + metadata.melCount = values.at(9); + metadata.formatType = values.at(10); + return metadata; +} diff --git a/src/transcription/rawwhisperprobe.h b/src/transcription/rawwhisperprobe.h new file mode 100644 index 0000000..9368caf --- /dev/null +++ b/src/transcription/rawwhisperprobe.h @@ -0,0 +1,25 @@ +#pragma once + +#include "transcription/transcriptiontypes.h" + +#include + +/** + * @file + * @brief Lightweight app-owned parser for legacy raw whisper.cpp model files. + */ + +/** + * @brief Lightweight parser for legacy raw whisper.cpp model files. + */ +class RawWhisperProbe final +{ +public: + /** + * @brief Reads header-level metadata from a legacy raw whisper.cpp model file. + * @param path Raw `.bin` model path. + * @param error Optional destination for parse failures. + * @return Extracted product-owned model metadata on success. + */ + [[nodiscard]] static std::optional inspectFile(const QString &path, RuntimeError *error = nullptr); +}; diff --git a/src/transcription/transcriptionengine.h b/src/transcription/transcriptionengine.h index d45a150..d08cbac 100644 --- a/src/transcription/transcriptionengine.h +++ b/src/transcription/transcriptionengine.h @@ -33,6 +33,12 @@ class TranscriptionModelHandle */ [[nodiscard]] virtual QString backendName() const = 0; + /** + * @brief Returns product-owned immutable metadata for the loaded artifact. + * @return Metadata surfaced without exposing backend-specific handles. + */ + [[nodiscard]] virtual ModelMetadata metadata() const = 0; + /** * @brief Returns a human-readable description of the loaded model. * @return Diagnostic model description such as the resolved model path. diff --git a/src/transcription/transcriptiontypes.h b/src/transcription/transcriptiontypes.h index 604c55a..ae0964c 100644 --- a/src/transcription/transcriptiontypes.h +++ b/src/transcription/transcriptiontypes.h @@ -20,6 +20,11 @@ enum class RuntimeErrorCode : std::uint8_t { Cancelled, InvalidConfig, ModelNotFound, + InvalidModelPackage, + UnsupportedModelPackageVersion, + ModelIntegrityFailed, + IncompatibleModelPackage, + ModelTooLarge, ModelLoadFailed, AudioNormalizationFailed, UnsupportedLanguage, @@ -73,6 +78,56 @@ struct RuntimeDiagnostics { QString loadedModelDescription; }; +/** + * @brief Product-owned immutable metadata about a validated model artifact. + */ +struct ModelMetadata { + /// Stable product-owned package identifier. + QString packageId; + /// Human-readable package/model name. + QString displayName; + /// Optional package version string. + QString packageVersion; + /// Runtime family this artifact belongs to. + QString runtimeFamily; + /// Source format imported or packaged by Mutterkey. + QString sourceFormat; + /// Backend-facing model format marker such as `ggml`. + QString modelFormat; + /// Model family or architecture string when known. + QString architecture; + /// Language profile such as `en` or `multilingual`. + QString languageProfile; + /// Quantization metadata when known. + QString quantization; + /// Tokenizer metadata when known. + QString tokenizer; + /// Raw-path compatibility marker for migration diagnostics. + bool legacyCompatibility = false; + /// Vocabulary size when known. + int vocabularySize = 0; + /// Audio context size when known. + int audioContext = 0; + /// Audio state size when known. + int audioState = 0; + /// Audio attention head count when known. + int audioHeadCount = 0; + /// Audio layer count when known. + int audioLayerCount = 0; + /// Text context size when known. + int textContext = 0; + /// Text state size when known. + int textState = 0; + /// Text attention head count when known. + int textHeadCount = 0; + /// Text layer count when known. + int textLayerCount = 0; + /// Mel filter count when known. + int melCount = 0; + /// Backend-specific format type value when known. + int formatType = 0; +}; + /** * @brief Normalized runtime audio payload. */ @@ -164,3 +219,4 @@ struct TranscriptionResult { Q_DECLARE_METATYPE(RuntimeErrorCode) Q_DECLARE_METATYPE(RuntimeError) Q_DECLARE_METATYPE(BackendCapabilities) +Q_DECLARE_METATYPE(ModelMetadata) diff --git a/src/transcription/whispercpptranscriber.cpp b/src/transcription/whispercpptranscriber.cpp index 22aeda5..aa87d7d 100644 --- a/src/transcription/whispercpptranscriber.cpp +++ b/src/transcription/whispercpptranscriber.cpp @@ -1,5 +1,7 @@ #include "transcription/whispercpptranscriber.h" +#include "transcription/modelcatalog.h" + #include #include #include @@ -22,9 +24,9 @@ Q_LOGGING_CATEGORY(whisperCppLog, "mutterkey.transcriber.whispercpp") class WhisperCppModelHandle final : public TranscriptionModelHandle { public: - WhisperCppModelHandle(QString modelPath, + WhisperCppModelHandle(ValidatedModelPackage package, std::unique_ptr context) - : m_modelPath(std::move(modelPath)) + : m_package(std::move(package)) , m_context(std::move(context)) { } @@ -36,7 +38,7 @@ class WhisperCppModelHandle final : public TranscriptionModelHandle [[nodiscard]] const QString &modelPath() const { - return m_modelPath; + return m_package.weightsPath; } [[nodiscard]] QString backendName() const override @@ -44,13 +46,18 @@ class WhisperCppModelHandle final : public TranscriptionModelHandle return WhisperCppTranscriber::backendNameStatic(); } + [[nodiscard]] ModelMetadata metadata() const override + { + return m_package.metadata(); + } + [[nodiscard]] QString modelDescription() const override { - return m_modelPath; + return m_package.description(); } private: - QString m_modelPath; + ValidatedModelPackage m_package; std::unique_ptr m_context; }; @@ -162,17 +169,12 @@ bool shouldAbortDecode(void *userData) std::shared_ptr WhisperCppTranscriber::loadModelHandle(const TranscriberConfig &config, RuntimeError *error) { - // Resolve to an absolute path before initialization so logs and error messages match - // the exact model file whisper.cpp is attempting to load. - const QString modelPath = QFileInfo(config.modelPath).absoluteFilePath(); - if (modelPath.isEmpty() || !QFileInfo::exists(modelPath)) { - if (error != nullptr) { - *error = makeRuntimeError(RuntimeErrorCode::ModelNotFound, - QStringLiteral("Embedded Whisper model not found: %1").arg(config.modelPath), - modelPath); - } + const std::optional package = + ModelCatalog::inspectPath(config.modelPath, QStringLiteral("whisper.cpp"), QStringLiteral("ggml"), error); + if (!package.has_value()) { return nullptr; } + const QString modelPath = package->weightsPath; const whisper_context_params contextParams = whisper_context_default_params(); qCInfo(whisperCppLog).noquote() << "ggml runtime:" << describeRegisteredBackends(); @@ -188,7 +190,7 @@ WhisperCppTranscriber::loadModelHandle(const TranscriberConfig &config, RuntimeE } qCInfo(whisperCppLog) << "Loaded embedded Whisper model from" << modelPath; - return std::make_shared(modelPath, std::move(context)); + return std::make_shared(*package, std::move(context)); } std::unique_ptr diff --git a/src/transcription/whispercpptranscriber.h b/src/transcription/whispercpptranscriber.h index ad73593..d36574f 100644 --- a/src/transcription/whispercpptranscriber.h +++ b/src/transcription/whispercpptranscriber.h @@ -1,6 +1,7 @@ #pragma once #include "config.h" +#include "transcription/modelpackage.h" #include "transcription/transcriptionengine.h" #include "transcription/transcriptiontypes.h" diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6ddf9c4..05f6df8 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -32,6 +32,15 @@ mutterkey_add_qt_test(streamingtranscriptiontest ../src/transcription/transcriptioncompat.cpp ) +mutterkey_add_qt_test(modelpackagetest + modelpackagetest.cpp + ../src/transcription/modelcatalog.cpp + ../src/transcription/modelpackage.cpp + ../src/transcription/modelvalidator.cpp + ../src/transcription/rawwhisperimporter.cpp + ../src/transcription/rawwhisperprobe.cpp +) + mutterkey_add_qt_test(daemoncontrolprotocoltest daemoncontrolprotocoltest.cpp ../src/control/daemoncontrolprotocol.cpp @@ -58,7 +67,11 @@ mutterkey_add_qt_test(transcriptionworkertest mutterkey_add_qt_test(whispercpptranscribertest whispercpptranscribertest.cpp + ../src/transcription/modelcatalog.cpp ../src/transcription/transcriptionengine.cpp + ../src/transcription/modelpackage.cpp + ../src/transcription/modelvalidator.cpp + ../src/transcription/rawwhisperprobe.cpp ../src/transcription/whispercpptranscriber.cpp ) @@ -84,9 +97,9 @@ add_test(NAME testcommentarycheck ) if(TARGET clang-tidy) - add_dependencies(clang-tidy configtest_autogen commanddispatchtest_autogen recordingnormalizertest_autogen streamingtranscriptiontest_autogen daemoncontrolprotocoltest_autogen daemoncontroltypestest_autogen daemoncontrolclientservertest_autogen transcriptionworkertest_autogen whispercpptranscribertest_autogen platformlogicstest_autogen traystatuswindowtest_autogen) + add_dependencies(clang-tidy configtest_autogen commanddispatchtest_autogen recordingnormalizertest_autogen streamingtranscriptiontest_autogen modelpackagetest_autogen daemoncontrolprotocoltest_autogen daemoncontroltypestest_autogen daemoncontrolclientservertest_autogen transcriptionworkertest_autogen whispercpptranscribertest_autogen platformlogicstest_autogen traystatuswindowtest_autogen) endif() if(TARGET clazy) - add_dependencies(clazy configtest_autogen commanddispatchtest_autogen recordingnormalizertest_autogen streamingtranscriptiontest_autogen daemoncontrolprotocoltest_autogen daemoncontroltypestest_autogen daemoncontrolclientservertest_autogen transcriptionworkertest_autogen whispercpptranscribertest_autogen platformlogicstest_autogen traystatuswindowtest_autogen) + add_dependencies(clazy configtest_autogen commanddispatchtest_autogen recordingnormalizertest_autogen streamingtranscriptiontest_autogen modelpackagetest_autogen daemoncontrolprotocoltest_autogen daemoncontroltypestest_autogen daemoncontrolclientservertest_autogen transcriptionworkertest_autogen whispercpptranscribertest_autogen platformlogicstest_autogen traystatuswindowtest_autogen) endif() diff --git a/tests/commanddispatchtest.cpp b/tests/commanddispatchtest.cpp index fc0dcb0..25dd043 100644 --- a/tests/commanddispatchtest.cpp +++ b/tests/commanddispatchtest.cpp @@ -18,6 +18,8 @@ private slots: void nonConfigCommandsDoNotShowConfigHelp(); void configHelpTextMentionsSubcommands(); void configHelpTextListsAllSupportedConfigKeys(); + void bareModelShowsDedicatedHelp(); + void modelHelpTextMentionsSubcommands(); }; } // namespace @@ -160,6 +162,35 @@ void CommandDispatchTest::configHelpTextListsAllSupportedConfigKeys() } } +void CommandDispatchTest::bareModelShowsDedicatedHelp() +{ + // WHAT: Verify that the bare "model" command opens model-specific help. + // HOW: Build an argument list with only the top-level "model" command and assert that + // the helper decides to show the dedicated model help text. + // WHY: The new Phase 4 model tooling should be discoverable directly from the CLI without + // requiring users to guess the subcommand shape or read the README first. + const QStringList arguments{ + QStringLiteral("mutterkey"), + QStringLiteral("model"), + }; + + const int commandIndex = commandIndexFromArguments(arguments); + QVERIFY(shouldShowModelHelp(arguments, commandIndex)); +} + +void CommandDispatchTest::modelHelpTextMentionsSubcommands() +{ + // WHAT: Verify that the model help text mentions the import and inspect workflows. + // HOW: Render the generated model help text and assert that it contains both subcommands. + // WHY: The new native-package tooling needs clear self-documentation because it is now the + // canonical way to migrate and inspect model artifacts. + const QString helpText = modelHelpText(); + + QVERIFY(helpText.contains(QStringLiteral("model "))); + QVERIFY(helpText.contains(QStringLiteral("import "))); + QVERIFY(helpText.contains(QStringLiteral("inspect "))); +} + QTEST_APPLESS_MAIN(CommandDispatchTest) #include "commanddispatchtest.moc" diff --git a/tests/modelpackagetest.cpp b/tests/modelpackagetest.cpp new file mode 100644 index 0000000..a531215 --- /dev/null +++ b/tests/modelpackagetest.cpp @@ -0,0 +1,249 @@ +#include "transcription/modelcatalog.h" +#include "transcription/modelpackage.h" +#include "transcription/modelvalidator.h" +#include "transcription/rawwhisperimporter.h" + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace { + +class ModelPackageTest final : public QObject +{ + Q_OBJECT + +private slots: + void validatorAcceptsWellFormedPackage(); + void validatorRejectsHashMismatch(); + void catalogSupportsLegacyRawWhisperCompatibility(); + void importerCreatesNativePackageFromRawWhisperFile(); +}; + +template +bool writePaddedValue(QFile *file, const T &value) +{ + if (file == nullptr) { + return false; + } + + QByteArray bytes(static_cast(sizeof(T)), '\0'); + std::memcpy(bytes.data(), &value, sizeof(T)); + return file->write(bytes) == bytes.size(); +} + +bool writeRawWhisperFixture(const QString &path) +{ + QFile file(path); + if (!file.open(QIODevice::WriteOnly)) { + return false; + } + + const quint32 magic = 0x67676d6cU; + const std::array values{ + 51864, // n_vocab + 1500, // n_audio_ctx + 384, // n_audio_state + 6, // n_audio_head + 6, // n_audio_layer + 448, // n_text_ctx + 384, // n_text_state + 6, // n_text_head + 6, // n_text_layer + 80, // n_mels + 1, // ftype + }; + + if (!writePaddedValue(&file, magic)) { + return false; + } + for (const qint32 value : values) { + if (!writePaddedValue(&file, value)) { + return false; + } + } + return file.write("fixture-whisper-weights", 23) == 23; +} + +QString sha256ForFile(const QString &path) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + return {}; + } + + QCryptographicHash hash(QCryptographicHash::Sha256); + hash.addData(&file); + return QString::fromLatin1(hash.result().toHex()); +} + +struct PackageFixtureRequest { + QString packageRoot; + QString weightsPayload; + QString hashOverride; +}; + +bool writePackage(const PackageFixtureRequest &request) +{ + const QDir root; + if (!root.mkpath(QDir(request.packageRoot).filePath(QStringLiteral("assets")))) { + return false; + } + + const QString weightsPath = QDir(request.packageRoot).filePath(QStringLiteral("assets/model.bin")); + QFile weightsFile(weightsPath); + if (!weightsFile.open(QIODevice::WriteOnly)) { + return false; + } + weightsFile.write(request.weightsPayload.toUtf8()); + weightsFile.close(); + + ModelPackageManifest manifest; + manifest.format = QStringLiteral("mutterkey.model-package"); + manifest.schemaVersion = 1; + manifest.metadata.packageId = QStringLiteral("fixture-base-en"); + manifest.metadata.displayName = QStringLiteral("Fixture Base EN"); + manifest.metadata.runtimeFamily = QStringLiteral("asr"); + manifest.metadata.sourceFormat = QStringLiteral("whisper.cpp-ggml"); + manifest.metadata.modelFormat = QStringLiteral("ggml"); + manifest.metadata.architecture = QStringLiteral("base"); + manifest.metadata.languageProfile = QStringLiteral("en"); + manifest.metadata.quantization = QStringLiteral("float16"); + manifest.metadata.tokenizer = QStringLiteral("embedded"); + manifest.compatibleEngines.push_back(ModelCompatibilityMarker{ + .engine = QStringLiteral("whisper.cpp"), + .modelFormat = QStringLiteral("ggml"), + }); + manifest.assets.push_back(ModelAssetMetadata{ + .role = QStringLiteral("weights"), + .relativePath = QStringLiteral("assets/model.bin"), + .sha256 = request.hashOverride.isEmpty() ? sha256ForFile(weightsPath) : request.hashOverride, + .sizeBytes = QFileInfo(weightsPath).size(), + }); + + QFile manifestFile(QDir(request.packageRoot).filePath(QStringLiteral("model.json"))); + if (!manifestFile.open(QIODevice::WriteOnly | QIODevice::Text)) { + return false; + } + manifestFile.write(QJsonDocument(modelPackageManifestToJson(manifest)).toJson(QJsonDocument::Indented)); + return true; +} + +} // namespace + +void ModelPackageTest::validatorAcceptsWellFormedPackage() +{ + // WHAT: Verify that a well-formed native model package passes validation. + // HOW: Create a temporary package directory with a valid manifest, weights file, and hash, + // then validate it for the whisper.cpp / ggml runtime markers. + // WHY: The product-owned package gate only helps if valid packages can be accepted before + // inference begins while malformed ones are rejected deterministically. + const QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + const QString packageRoot = tempDir.filePath(QStringLiteral("fixture-package")); + QVERIFY(writePackage(PackageFixtureRequest{ + .packageRoot = packageRoot, + .weightsPayload = QStringLiteral("fixture weights"), + })); + + RuntimeError error; + const std::optional package = + ModelValidator::validatePackagePath(packageRoot, QStringLiteral("whisper.cpp"), QStringLiteral("ggml"), &error); + + if (!package.has_value()) { + QFAIL("Expected validated package"); + return; + } + QVERIFY(error.isOk()); + const ValidatedModelPackage &validatedPackage = *package; + QCOMPARE(validatedPackage.metadata().packageId, QStringLiteral("fixture-base-en")); + QCOMPARE(validatedPackage.weightsPath, QDir(packageRoot).filePath(QStringLiteral("assets/model.bin"))); +} + +void ModelPackageTest::validatorRejectsHashMismatch() +{ + // WHAT: Verify that the package validator rejects a manifest whose hash does not match the weights asset. + // HOW: Create a normal package but force the manifest SHA-256 to an incorrect value, then validate it. + // WHY: Integrity checks are the main reason Phase 4 exists, so a mismatched asset hash must fail + // before the runtime can hand the file to whisper.cpp. + const QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + const QString packageRoot = tempDir.filePath(QStringLiteral("broken-package")); + QVERIFY(writePackage(PackageFixtureRequest{ + .packageRoot = packageRoot, + .weightsPayload = QStringLiteral("fixture weights"), + .hashOverride = QStringLiteral("deadbeef"), + })); + + RuntimeError error; + const std::optional package = ModelValidator::validatePackagePath(packageRoot, {}, {}, &error); + + QVERIFY(!package.has_value()); + QCOMPARE(error.code, RuntimeErrorCode::ModelIntegrityFailed); +} + +void ModelPackageTest::catalogSupportsLegacyRawWhisperCompatibility() +{ + // WHAT: Verify that the catalog can inspect a legacy raw Whisper model file through the compatibility path. + // HOW: Write a minimal raw whisper.cpp ggml header fixture and inspect it through the model catalog. + // WHY: Phase 4 keeps raw Whisper files supported during migration, but that support should still flow + // through product-owned inspection rather than backend-specific loader behavior. + const QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + const QString rawPath = tempDir.filePath(QStringLiteral("ggml-base.en.bin")); + QVERIFY(writeRawWhisperFixture(rawPath)); + + RuntimeError error; + const std::optional package = ModelCatalog::inspectPath(rawPath, {}, {}, &error); + + if (!package.has_value()) { + QFAIL("Expected legacy compatibility package"); + return; + } + QVERIFY(error.isOk()); + const ValidatedModelPackage &legacyPackage = *package; + QVERIFY(legacyPackage.isLegacyCompatibility()); + QCOMPARE(legacyPackage.metadata().sourceFormat, QStringLiteral("whisper.cpp-ggml")); + QCOMPARE(legacyPackage.metadata().modelFormat, QStringLiteral("ggml")); +} + +void ModelPackageTest::importerCreatesNativePackageFromRawWhisperFile() +{ + // WHAT: Verify that importing a legacy raw Whisper file produces a validated native package. + // HOW: Create a minimal raw fixture, import it into a temporary models directory, and then + // validate the resulting package through the normal package validator. + // WHY: The native package format only becomes practical if there is a deterministic migration + // path from the raw whisper.cpp files users already have on disk. + const QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + const QString rawPath = tempDir.filePath(QStringLiteral("ggml-base.en.bin")); + QVERIFY(writeRawWhisperFixture(rawPath)); + + RuntimeError error; + const std::optional imported = + RawWhisperImporter::importFile(rawPath, + RawWhisperImportRequest{ + .outputPath = tempDir.filePath(QStringLiteral("base-en")), + }, + &error); + + if (!imported.has_value()) { + QFAIL("Expected imported native package"); + return; + } + QVERIFY(error.isOk()); + const ValidatedModelPackage &importedPackage = *imported; + QVERIFY(!importedPackage.isLegacyCompatibility()); + QVERIFY(QFileInfo::exists(importedPackage.manifestPath)); + QVERIFY(QFileInfo::exists(importedPackage.weightsPath)); +} + +QTEST_APPLESS_MAIN(ModelPackageTest) + +#include "modelpackagetest.moc" diff --git a/tests/transcriptionworkertest.cpp b/tests/transcriptionworkertest.cpp index cc94ccb..cedd52f 100644 --- a/tests/transcriptionworkertest.cpp +++ b/tests/transcriptionworkertest.cpp @@ -73,6 +73,17 @@ class FakeModelHandle final : public TranscriptionModelHandle return QStringLiteral("fake"); } + [[nodiscard]] ModelMetadata metadata() const override + { + return ModelMetadata{ + .packageId = QStringLiteral("fake-model"), + .displayName = QStringLiteral("Fake Model"), + .runtimeFamily = QStringLiteral("asr"), + .sourceFormat = QStringLiteral("fake"), + .modelFormat = QStringLiteral("fake"), + }; + } + [[nodiscard]] QString modelDescription() const override { return QStringLiteral("fake-model"); diff --git a/tests/whispercpptranscribertest.cpp b/tests/whispercpptranscribertest.cpp index 2872d70..aa59c35 100644 --- a/tests/whispercpptranscribertest.cpp +++ b/tests/whispercpptranscribertest.cpp @@ -32,7 +32,7 @@ void WhisperCppTranscriberTest::whisperEngineSurfacesMissingModelAtLoadTime() QVERIFY(model == nullptr); QCOMPARE(error.code, RuntimeErrorCode::ModelNotFound); - QVERIFY(error.message.contains(QStringLiteral("Embedded Whisper model not found"))); + QCOMPARE(error.code, RuntimeErrorCode::ModelNotFound); } void WhisperCppTranscriberTest::whisperRuntimeRejectsUnsupportedLanguage()