From 8918f9998903c4c333d1db88eea2c00e75e61964 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Jun 2026 03:22:50 +0000 Subject: [PATCH 1/7] Initial plan From bf4caf3db3b7e671fb9beaebfaf76db806fde1d5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Jun 2026 03:29:47 +0000 Subject: [PATCH 2/7] Add CLI model management commands --- CMakeLists.txt | 4 +- README.md | 13 ++ apps/CMakeLists.txt | 12 ++ apps/model_manager.cpp | 320 +++++++++++++++++++++++++++++++++++ apps/model_manager.h | 49 ++++++ apps/vox.cpp | 13 ++ tests/CMakeLists.txt | 11 ++ tests/model_manager_test.cpp | 77 +++++++++ 8 files changed, 496 insertions(+), 3 deletions(-) create mode 100644 apps/model_manager.cpp create mode 100644 apps/model_manager.h create mode 100644 tests/model_manager_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index d3cc801..61430e7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -123,9 +123,7 @@ add_subdirectory(asr) add_subdirectory(translate) add_subdirectory(tts) add_subdirectory(pipeline) -if(VOX_BUILD_APPS) - add_subdirectory(apps) -endif() +add_subdirectory(apps) if(VOX_BUILD_TESTS) enable_testing() diff --git a/README.md b/README.md index 289aa0a..8db64cf 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,19 @@ cmake --build build --target vox -j ## Model +The `vox` CLI can list, download, verify, repair, and remove known local models: + +```sh +./build/bin/vox model list +./build/bin/vox model download qwen3-asr-1.7b +./build/bin/vox model verify qwen3-asr-1.7b +./build/bin/vox model repair qwen3-asr-1.7b +``` + +Model verification checks that expected files exist, are non-empty, and do not +have leftover partial downloads. Checksums are reported when metadata is +available; the current bundled manifests rely on file presence and size. + ### Whisper ASR Download or place a local Whisper GGML model under `models/`. For multilingual recognition, use a non-`.en` model. diff --git a/apps/CMakeLists.txt b/apps/CMakeLists.txt index 58bd912..820c017 100644 --- a/apps/CMakeLists.txt +++ b/apps/CMakeLists.txt @@ -1,3 +1,13 @@ +add_library(vox_model_manager STATIC + model_manager.cpp +) + +target_include_directories(vox_model_manager + PUBLIC + "${CMAKE_CURRENT_SOURCE_DIR}" +) + +if(VOX_BUILD_APPS) add_executable(vox vox.cpp microphone_audio_source.cpp @@ -5,6 +15,7 @@ add_executable(vox target_link_libraries(vox PRIVATE + vox_model_manager vox_translation_pipeline vox_sdl_audio ) @@ -18,3 +29,4 @@ target_compile_definitions(vox PRIVATE VOX_PROJECT_ROOT="${CMAKE_SOURCE_DIR}" ) +endif() diff --git a/apps/model_manager.cpp b/apps/model_manager.cpp new file mode 100644 index 0000000..f53d07d --- /dev/null +++ b/apps/model_manager.cpp @@ -0,0 +1,320 @@ +#include "model_manager.h" + +#include +#include +#include +#include +#include +#include + +namespace vox::app::model { +namespace { + +std::filesystem::path model_path(const std::filesystem::path & project_root, const std::string & relative_path) { + return project_root / std::filesystem::path(relative_path); +} + +std::string format_size(uintmax_t bytes) { + const char * units[] = {"B", "KiB", "MiB", "GiB"}; + double value = static_cast(bytes); + size_t unit = 0; + while (value >= 1024.0 && unit + 1 < std::size(units)) { + value /= 1024.0; + ++unit; + } + char buffer[64]; + if (unit == 0) { + std::snprintf(buffer, sizeof(buffer), "%llu %s", static_cast(bytes), units[unit]); + } else { + std::snprintf(buffer, sizeof(buffer), "%.1f %s", value, units[unit]); + } + return buffer; +} + +const char * status_name(const ManagedModelStatus & status) { + if (status.complete) { + return "installed"; + } + if (status.installed || status.has_partial_download) { + return "incomplete"; + } + return "missing"; +} + +void print_model_usage(std::ostream & out) { + out << "usage: vox model [model-name]\n" + << "\n" + << "commands:\n" + << " list [--installed] list supported models and local status\n" + << " download download a supported model\n" + << " verify verify local model files exist and are non-empty\n" + << " repair remove incomplete files, then download missing files\n" + << " remove remove local files for a model\n" + << "\n" + << "Run 'vox model list' to see model names.\n"; +} + +int run_download_command(const ManagedModel & model, const std::filesystem::path & project_root, std::ostream & err) { + if (model.download_command.empty()) { + err << "No download command is available for " << model.name << ".\n"; + return 1; + } + + std::error_code ec; + const std::filesystem::path previous = std::filesystem::current_path(ec); + if (ec) { + err << "Could not read current directory: " << ec.message() << "\n"; + return 1; + } + std::filesystem::current_path(project_root, ec); + if (ec) { + err << "Could not enter project directory " << project_root << ": " << ec.message() << "\n"; + return 1; + } + const int result = std::system(model.download_command.c_str()); + std::filesystem::current_path(previous, ec); + if (result != 0) { + err << "Download command failed for " << model.name << ".\n"; + return 1; + } + return 0; +} + +void print_model_details(const ManagedModel & model, const ManagedModelStatus & status, std::ostream & out) { + out << model.name << " (" << status_name(status) << ")\n" + << " description: " << model.description << "\n" + << " source: " << model.source << "\n" + << " version: " << model.version << "\n" + << " checksum: " << (model.checksum.empty() ? "unavailable" : model.checksum) << "\n"; + for (size_t i = 0; i < model.files.size(); ++i) { + const ManagedModelFile & file = model.files[i]; + const ManagedModelFileStatus & file_status = status.files[i]; + out << " file: " << file_status.path.string() + << " [" << (file_status.complete ? "ok" : (file_status.exists ? "empty" : "missing")) << "]"; + if (file_status.exists) { + out << " size=" << format_size(file_status.size); + } else if (!file.size_hint.empty()) { + out << " expected=" << file.size_hint; + } + out << "\n"; + } + if (status.has_partial_download) { + out << " partial download detected; run 'vox model repair " << model.name << "'.\n"; + } +} + +int require_model_name(const std::vector & args, std::ostream & err) { + if (args.size() < 2) { + err << "Missing model name.\n"; + return 1; + } + if (args.size() > 2) { + err << "Too many arguments for model command.\n"; + return 1; + } + return 0; +} + +} // namespace + +const std::vector & supported_models() { + static const std::vector models = { + { + "qwen3-asr-1.7b", + "Default Qwen3-ASR GGUF model and multimodal projector", + "ggml-org/Qwen3-ASR-1.7B-GGUF", + "1.7B Q8_0", + "", + { + {"models/asr/qwen3-asr-1.7b/Qwen3-ASR-1.7B-Q8_0.gguf", "about 2.17 GB"}, + {"models/asr/qwen3-asr-1.7b/mmproj-Qwen3-ASR-1.7B-Q8_0.gguf", "about 356 MB"}, + }, + "scripts/download-qwen3-asr-gguf.sh", + }, + { + "qwen3-asr-0.6b", + "Smaller Qwen3-ASR GGUF model and multimodal projector", + "ggml-org/Qwen3-ASR-0.6B-GGUF", + "0.6B Q8_0", + "", + { + {"models/asr/qwen3-asr-0.6b/Qwen3-ASR-0.6B-Q8_0.gguf", ""}, + {"models/asr/qwen3-asr-0.6b/mmproj-Qwen3-ASR-0.6B-Q8_0.gguf", ""}, + }, + "scripts/download-qwen3-asr-gguf.sh 0.6B Q8_0 models/asr/qwen3-asr-0.6b", + }, + { + "whisper-base", + "Whisper.cpp base GGML ASR model", + "ggerganov/whisper.cpp", + "base", + "", + {{"models/ggml-base.bin", ""}}, + "./external/whisper.cpp/models/download-ggml-model.sh base models", + }, + { + "hymt-translate", + "Tencent HY-MT1.5 translation GGUF model", + "tencent/HY-MT1.5-1.8B-GGUF", + "1.8B Q4_K_M", + "", + {{"models/translate/HY-MT1.5-1.8B-Q4_K_M.gguf", "about 1.13 GB"}}, + "scripts/download-hymt-gguf.sh", + }, + { + "cosyvoice3-tts", + "Minimum baked-voice CosyVoice3 TTS GGUF set", + "cstr/cosyvoice3-0.5b-2512-GGUF", + "q4_k/q8_0/f16", + "", + { + {"models/tts/cosyvoice3/cosyvoice3-llm-q4_k.gguf", ""}, + {"models/tts/cosyvoice3/cosyvoice3-flow-q8_0.gguf", ""}, + {"models/tts/cosyvoice3/cosyvoice3-hift-f16.gguf", ""}, + {"models/tts/cosyvoice3/cosyvoice3-voices.gguf", ""}, + }, + "scripts/download-cosyvoice3-tts-gguf.sh", + }, + }; + return models; +} + +const ManagedModel * find_model(const std::string & name) { + for (const ManagedModel & model : supported_models()) { + if (model.name == name) { + return &model; + } + } + return nullptr; +} + +ManagedModelStatus inspect_model(const ManagedModel & model, const std::filesystem::path & project_root) { + ManagedModelStatus status; + status.model = &model; + status.complete = true; + for (const ManagedModelFile & file : model.files) { + ManagedModelFileStatus file_status; + file_status.path = model_path(project_root, file.relative_path); + + std::error_code ec; + file_status.exists = std::filesystem::is_regular_file(file_status.path, ec); + if (file_status.exists) { + file_status.size = std::filesystem::file_size(file_status.path, ec); + if (ec) { + file_status.size = 0; + } + file_status.complete = file_status.size > 0; + status.installed = true; + } + status.complete = status.complete && file_status.complete; + + const std::filesystem::path partial = file_status.path.string() + ".part"; + if (std::filesystem::exists(partial, ec)) { + status.has_partial_download = true; + } + status.files.push_back(file_status); + } + status.complete = status.complete && !status.has_partial_download; + return status; +} + +int run_model_command( + const std::vector & args, + const std::filesystem::path & project_root, + std::ostream & out, + std::ostream & err) { + if (args.empty() || args[0] == "-h" || args[0] == "--help" || args[0] == "help") { + print_model_usage(out); + return 0; + } + + const std::string command = args[0]; + if (command == "list") { + bool installed_only = false; + if (args.size() > 2 || (args.size() == 2 && args[1] != "--installed")) { + err << "usage: vox model list [--installed]\n"; + return 1; + } + installed_only = args.size() == 2; + for (const ManagedModel & model : supported_models()) { + const ManagedModelStatus status = inspect_model(model, project_root); + if (installed_only && !status.complete) { + continue; + } + out << model.name << "\t" << status_name(status) << "\t" << model.version << "\t"; + if (!model.files.empty()) { + out << model_path(project_root, model.files.front().relative_path).string(); + } + out << "\n"; + } + return 0; + } + + if (command != "download" && command != "verify" && command != "repair" && command != "remove") { + err << "Unknown model command: " << command << "\n"; + print_model_usage(err); + return 1; + } + if (require_model_name(args, err) != 0) { + return 1; + } + + const ManagedModel * model = find_model(args[1]); + if (!model) { + err << "Unknown model: " << args[1] << "\n" + << "Run 'vox model list' to see supported models.\n"; + return 1; + } + + if (command == "verify") { + const ManagedModelStatus status = inspect_model(*model, project_root); + print_model_details(*model, status, out); + if (!status.complete) { + err << "Model is missing or incomplete. Run 'vox model repair " << model->name << "'.\n"; + return 1; + } + return 0; + } + + if (command == "remove") { + std::error_code ec; + for (const ManagedModelFile & file : model->files) { + const std::filesystem::path path = model_path(project_root, file.relative_path); + const bool removed = std::filesystem::remove(path, ec); + if (ec) { + err << "Could not remove " << path << ": " << ec.message() << "\n"; + return 1; + } + std::filesystem::remove(path.string() + ".part", ec); + out << (removed ? "Removed: " : "Not installed: ") << path.string() << "\n"; + } + return 0; + } + + if (command == "repair") { + std::error_code ec; + const ManagedModelStatus status = inspect_model(*model, project_root); + for (size_t i = 0; i < model->files.size(); ++i) { + const std::filesystem::path path = status.files[i].path; + if (status.files[i].exists && !status.files[i].complete) { + std::filesystem::remove(path, ec); + if (ec) { + err << "Could not remove incomplete file " << path << ": " << ec.message() << "\n"; + return 1; + } + out << "Removed incomplete file: " << path.string() << "\n"; + } + std::filesystem::remove(path.string() + ".part", ec); + } + } + + const ManagedModelStatus before = inspect_model(*model, project_root); + if (before.complete) { + out << "Model already installed: " << model->name << "\n"; + return 0; + } + out << "Downloading " << model->name << " using: " << model->download_command << "\n"; + return run_download_command(*model, project_root, err); +} + +} // namespace vox::app::model diff --git a/apps/model_manager.h b/apps/model_manager.h new file mode 100644 index 0000000..fea4c3b --- /dev/null +++ b/apps/model_manager.h @@ -0,0 +1,49 @@ +#pragma once + +#include +#include +#include +#include + +namespace vox::app::model { + +struct ManagedModelFile { + std::string relative_path; + std::string size_hint; +}; + +struct ManagedModel { + std::string name; + std::string description; + std::string source; + std::string version; + std::string checksum; + std::vector files; + std::string download_command; +}; + +struct ManagedModelFileStatus { + std::filesystem::path path; + bool exists = false; + bool complete = false; + uintmax_t size = 0; +}; + +struct ManagedModelStatus { + const ManagedModel * model = nullptr; + std::vector files; + bool has_partial_download = false; + bool installed = false; + bool complete = false; +}; + +const std::vector & supported_models(); +const ManagedModel * find_model(const std::string & name); +ManagedModelStatus inspect_model(const ManagedModel & model, const std::filesystem::path & project_root); +int run_model_command( + const std::vector & args, + const std::filesystem::path & project_root, + std::ostream & out, + std::ostream & err); + +} // namespace vox::app::model diff --git a/apps/vox.cpp b/apps/vox.cpp index 6c128b1..f8ca903 100644 --- a/apps/vox.cpp +++ b/apps/vox.cpp @@ -2,6 +2,7 @@ #include "async_text_to_speech.h" #include "async_audio_player.h" #include "microphone_audio_source.h" +#include "model_manager.h" #include "streaming_qwen_asr.h" #include "streaming_whisper.h" @@ -453,6 +454,10 @@ CliOptions parse_cli(int argc, char ** argv) { void print_usage(const char * program) { std::cout << "usage: " << program << " [options] [asr_model] [language] [translation_model] [target_language]\n" + << " " << program << " model [model-name]\n" + << "\n" + << "commands:\n" + << " model list, download, verify, repair, or remove local models\n" << "\n" << "options:\n" << " --asr-engine NAME\n" @@ -574,6 +579,14 @@ int main(int argc, char ** argv) { std::signal(SIGTERM, stop); try { + if (argc >= 2 && std::string(argv[1]) == "model") { + std::vector args; + for (int i = 2; i < argc; ++i) { + args.push_back(argv[i]); + } + return vox::app::model::run_model_command(args, VOX_PROJECT_ROOT, std::cout, std::cerr); + } + const CliOptions cli = parse_cli(argc, argv); if (cli.show_help) { print_usage(argv[0]); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index ef9f1a9..6fcfb55 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -27,6 +27,17 @@ target_link_libraries(vox_qwen_asr_config_test add_test(NAME vox_qwen_asr_config_test COMMAND vox_qwen_asr_config_test) +add_executable(vox_model_manager_test + model_manager_test.cpp +) + +target_link_libraries(vox_model_manager_test + PRIVATE + vox_model_manager +) + +add_test(NAME vox_model_manager_test COMMAND vox_model_manager_test) + add_executable(vox_qwen_asr_smoke_test qwen_asr_smoke_test.cpp ) diff --git a/tests/model_manager_test.cpp b/tests/model_manager_test.cpp new file mode 100644 index 0000000..2b0a728 --- /dev/null +++ b/tests/model_manager_test.cpp @@ -0,0 +1,77 @@ +#include "model_manager.h" + +#include +#include +#include +#include + +namespace { + +bool expect(bool condition, const std::string & message) { + if (!condition) { + std::cerr << message << "\n"; + return false; + } + return true; +} + +} // namespace + +int main() { + namespace fs = std::filesystem; + + bool ok = true; + const fs::path root = fs::temp_directory_path() / "vox_model_manager_test"; + std::error_code ec; + fs::remove_all(root, ec); + fs::create_directories(root / "models", ec); + + const vox::app::model::ManagedModel * model = vox::app::model::find_model("whisper-base"); + ok = expect(model != nullptr, "whisper-base should be supported") && ok; + ok = expect(vox::app::model::find_model("missing-model") == nullptr, "unknown model should not resolve") && ok; + if (!model) { + return 1; + } + + vox::app::model::ManagedModelStatus status = vox::app::model::inspect_model(*model, root); + ok = expect(!status.installed && !status.complete, "missing model should not be complete") && ok; + + const fs::path model_path = root / "models/ggml-base.bin"; + { + std::ofstream empty(model_path, std::ios::binary); + } + status = vox::app::model::inspect_model(*model, root); + ok = expect(status.installed && !status.complete, "empty model file should be incomplete") && ok; + + { + std::ofstream file(model_path, std::ios::binary); + file << "not a real model, but enough to test file completeness"; + } + status = vox::app::model::inspect_model(*model, root); + ok = expect(status.installed && status.complete, "non-empty model file should be complete") && ok; + + std::ostringstream out; + std::ostringstream err; + int result = vox::app::model::run_model_command({"verify", "whisper-base"}, root, out, err); + ok = expect(result == 0, "verify should succeed for a complete local model") && ok; + ok = expect(out.str().find("checksum: unavailable") != std::string::npos, "verify should show checksum status") && ok; + + out.str(""); + out.clear(); + err.str(""); + err.clear(); + result = vox::app::model::run_model_command({"list", "--installed"}, root, out, err); + ok = expect(result == 0, "list --installed should succeed") && ok; + ok = expect(out.str().find("whisper-base") != std::string::npos, "installed list should include complete model") && ok; + + out.str(""); + out.clear(); + err.str(""); + err.clear(); + result = vox::app::model::run_model_command({"remove", "whisper-base"}, root, out, err); + ok = expect(result == 0, "remove should succeed") && ok; + ok = expect(!fs::exists(model_path), "remove should delete model file") && ok; + + fs::remove_all(root, ec); + return ok ? 0 : 1; +} From 675078ec1fdea738d85ca650ef948d5899bdb4f0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Jun 2026 03:36:18 +0000 Subject: [PATCH 3/7] Point missing model errors to CLI --- apps/vox.cpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/vox.cpp b/apps/vox.cpp index f8ca903..7257405 100644 --- a/apps/vox.cpp +++ b/apps/vox.cpp @@ -638,9 +638,9 @@ int main(int argc, char ** argv) { if (!file_exists(common_config.model_path)) { std::cerr << "Missing ASR model: " << common_config.model_path << "\n"; if (asr_engine == AsrEngine::Whisper) { - std::cerr << "Download: ./external/whisper.cpp/models/download-ggml-model.sh base models\n"; + std::cerr << "Download: vox model download whisper-base\n"; } else { - std::cerr << "Download: scripts/download-qwen3-asr-gguf.sh\n"; + std::cerr << "Download: vox model download qwen3-asr-1.7b\n"; } return 1; } @@ -651,7 +651,7 @@ int main(int argc, char ** argv) { !cli.asr_mmproj_path.empty() ? cli.asr_mmproj_path : default_qwen_mmproj); if (!file_exists(qwen_mmproj_path)) { std::cerr << "Missing Qwen3-ASR mmproj: " << qwen_mmproj_path << "\n" - << "Download: scripts/download-qwen3-asr-gguf.sh\n"; + << "Download: vox model download qwen3-asr-1.7b\n"; return 1; } } @@ -682,7 +682,7 @@ int main(int argc, char ** argv) { cli.positional.size() > 2 ? resolve_model_path(cli.positional[2]) : std::string(); if (!translation_model_path.empty() && !file_exists(translation_model_path)) { std::cerr << "Missing translation model: " << translation_model_path << "\n" - << "Download: scripts/download-hymt-gguf.sh\n"; + << "Download: vox model download hymt-translate\n"; return 1; } @@ -707,7 +707,7 @@ int main(int argc, char ** argv) { tts_model_path = resolve_model_path(cli.tts_model_path); if (!file_exists(tts_model_path)) { std::cerr << "Missing TTS model: " << tts_model_path << "\n" - << "Download: " << tts_download_script(tts_engine) << "\n"; + << "Download: vox model download cosyvoice3-tts\n"; return 1; } From 8d39d86cec3c7219b0d01feb71bf81f9f16ffdb2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Jun 2026 07:40:31 +0000 Subject: [PATCH 4/7] Address model manager review feedback --- CMakeLists.txt | 4 +++- apps/model_manager.cpp | 12 ++++++++++-- apps/model_manager.h | 1 + tests/model_manager_test.cpp | 33 +++++++++++++++++++++++++++++---- 4 files changed, 43 insertions(+), 7 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 61430e7..2e2b752 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -123,7 +123,9 @@ add_subdirectory(asr) add_subdirectory(translate) add_subdirectory(tts) add_subdirectory(pipeline) -add_subdirectory(apps) +if(VOX_BUILD_APPS OR VOX_BUILD_TESTS) + add_subdirectory(apps) +endif() if(VOX_BUILD_TESTS) enable_testing() diff --git a/apps/model_manager.cpp b/apps/model_manager.cpp index f53d07d..caaad0f 100644 --- a/apps/model_manager.cpp +++ b/apps/model_manager.cpp @@ -72,7 +72,15 @@ int run_download_command(const ManagedModel & model, const std::filesystem::path return 1; } const int result = std::system(model.download_command.c_str()); - std::filesystem::current_path(previous, ec); + std::error_code restore_ec; + std::filesystem::current_path(previous, restore_ec); + if (restore_ec) { + err << "Could not restore working directory " << previous << ": " << restore_ec.message() << "\n"; + if (result != 0) { + err << "Download command failed for " << model.name << ".\n"; + } + return 1; + } if (result != 0) { err << "Download command failed for " << model.name << ".\n"; return 1; @@ -238,7 +246,7 @@ int run_model_command( installed_only = args.size() == 2; for (const ManagedModel & model : supported_models()) { const ManagedModelStatus status = inspect_model(model, project_root); - if (installed_only && !status.complete) { + if (installed_only && !status.installed && !status.has_partial_download) { continue; } out << model.name << "\t" << status_name(status) << "\t" << model.version << "\t"; diff --git a/apps/model_manager.h b/apps/model_manager.h index fea4c3b..0641f0c 100644 --- a/apps/model_manager.h +++ b/apps/model_manager.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include diff --git a/tests/model_manager_test.cpp b/tests/model_manager_test.cpp index 2b0a728..547013e 100644 --- a/tests/model_manager_test.cpp +++ b/tests/model_manager_test.cpp @@ -1,9 +1,11 @@ #include "model_manager.h" +#include #include #include #include #include +#include namespace { @@ -21,7 +23,8 @@ int main() { namespace fs = std::filesystem; bool ok = true; - const fs::path root = fs::temp_directory_path() / "vox_model_manager_test"; + const auto now = std::chrono::steady_clock::now().time_since_epoch().count(); + const fs::path root = fs::temp_directory_path() / ("vox_model_manager_test_" + std::to_string(now)); std::error_code ec; fs::remove_all(root, ec); fs::create_directories(root / "models", ec); @@ -43,6 +46,12 @@ int main() { status = vox::app::model::inspect_model(*model, root); ok = expect(status.installed && !status.complete, "empty model file should be incomplete") && ok; + std::ostringstream out; + std::ostringstream err; + int result = vox::app::model::run_model_command({"list", "--installed"}, root, out, err); + ok = expect(result == 0, "list --installed should succeed for incomplete models") && ok; + ok = expect(out.str().find("whisper-base") != std::string::npos, "installed list should include incomplete model") && ok; + { std::ofstream file(model_path, std::ios::binary); file << "not a real model, but enough to test file completeness"; @@ -50,9 +59,11 @@ int main() { status = vox::app::model::inspect_model(*model, root); ok = expect(status.installed && status.complete, "non-empty model file should be complete") && ok; - std::ostringstream out; - std::ostringstream err; - int result = vox::app::model::run_model_command({"verify", "whisper-base"}, root, out, err); + out.str(""); + out.clear(); + err.str(""); + err.clear(); + result = vox::app::model::run_model_command({"verify", "whisper-base"}, root, out, err); ok = expect(result == 0, "verify should succeed for a complete local model") && ok; ok = expect(out.str().find("checksum: unavailable") != std::string::npos, "verify should show checksum status") && ok; @@ -72,6 +83,20 @@ int main() { ok = expect(result == 0, "remove should succeed") && ok; ok = expect(!fs::exists(model_path), "remove should delete model file") && ok; + { + std::ofstream partial(model_path.string() + ".part", std::ios::binary); + partial << "partial"; + } + status = vox::app::model::inspect_model(*model, root); + ok = expect(!status.installed && status.has_partial_download && !status.complete, "partial download should be incomplete") && ok; + out.str(""); + out.clear(); + err.str(""); + err.clear(); + result = vox::app::model::run_model_command({"list", "--installed"}, root, out, err); + ok = expect(result == 0, "list --installed should succeed for partial downloads") && ok; + ok = expect(out.str().find("whisper-base") != std::string::npos, "installed list should include partial download") && ok; + fs::remove_all(root, ec); return ok ? 0 : 1; } From 6c32b255ec9891f19771ad9c55bbf8e1b5060a8a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Jun 2026 08:32:58 +0000 Subject: [PATCH 5/7] Re-inspect model after download and drop unused include --- apps/model_manager.cpp | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/model_manager.cpp b/apps/model_manager.cpp index caaad0f..7e60a84 100644 --- a/apps/model_manager.cpp +++ b/apps/model_manager.cpp @@ -2,7 +2,6 @@ #include #include -#include #include #include #include @@ -322,7 +321,18 @@ int run_model_command( return 0; } out << "Downloading " << model->name << " using: " << model->download_command << "\n"; - return run_download_command(*model, project_root, err); + const int download_result = run_download_command(*model, project_root, err); + if (download_result != 0) { + return download_result; + } + const ManagedModelStatus after = inspect_model(*model, project_root); + if (!after.complete) { + err << "Model is still incomplete after download: " << model->name + << " (" << status_name(after) << "). Run 'vox model repair " << model->name << "'.\n"; + return 1; + } + out << "Model installed: " << model->name << "\n"; + return 0; } } // namespace vox::app::model From 11f3bf6ce9607ac0a57e6e06c92388b6a586f3cd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:48:38 +0000 Subject: [PATCH 6/7] Add repair command test coverage in model_manager_test --- tests/model_manager_test.cpp | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/model_manager_test.cpp b/tests/model_manager_test.cpp index 547013e..a636663 100644 --- a/tests/model_manager_test.cpp +++ b/tests/model_manager_test.cpp @@ -97,6 +97,22 @@ int main() { ok = expect(result == 0, "list --installed should succeed for partial downloads") && ok; ok = expect(out.str().find("whisper-base") != std::string::npos, "installed list should include partial download") && ok; + { + std::ofstream file(model_path, std::ios::binary); + file << "not a real model, but enough to test file completeness"; + } + const fs::path partial_path = model_path.string() + ".part"; + ok = expect(fs::exists(partial_path), "stale .part file should exist before repair") && ok; + out.str(""); + out.clear(); + err.str(""); + err.clear(); + result = vox::app::model::run_model_command({"repair", "whisper-base"}, root, out, err); + ok = expect(result == 0, "repair should succeed when model is already complete") && ok; + ok = expect(!fs::exists(partial_path), "repair should remove stale .part file") && ok; + ok = expect(fs::exists(model_path), "repair should keep the complete model file") && ok; + ok = expect(out.str().find("Model already installed") != std::string::npos, "repair should report model already installed") && ok; + fs::remove_all(root, ec); return ok ? 0 : 1; } From 8afcc2928b19efe49e2f0e0539a73a9045c125a8 Mon Sep 17 00:00:00 2001 From: MuoDoo Date: Fri, 3 Jul 2026 23:00:30 +0800 Subject: [PATCH 7/7] Extend model manager for new TTS models --- README.md | 14 +++-- apps/model_manager.cpp | 114 ++++++++++++++++++++++++++++++++++- apps/model_manager.h | 1 + apps/vox.cpp | 12 ++-- tests/model_manager_test.cpp | 41 ++++++++++++- 5 files changed, 167 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 8db64cf..90cc041 100644 --- a/README.md +++ b/README.md @@ -62,13 +62,17 @@ The `vox` CLI can list, download, verify, repair, and remove known local models: ```sh ./build/bin/vox model list ./build/bin/vox model download qwen3-asr-1.7b +./build/bin/vox model download kokoro-tts +./build/bin/vox model download qwen3-tts ./build/bin/vox model verify qwen3-asr-1.7b ./build/bin/vox model repair qwen3-asr-1.7b ``` Model verification checks that expected files exist, are non-empty, and do not have leftover partial downloads. Checksums are reported when metadata is -available; the current bundled manifests rely on file presence and size. +available; the current bundled manifests rely on file presence and size. Common +aliases such as `kokoro`, `cosyvoice`, and `qwen3-tts` resolve to their +canonical model entries. ### Whisper ASR @@ -142,7 +146,7 @@ CosyVoice3 remains the default TTS engine. Download the minimum baked-voice CosyVoice3 GGUF set: ```sh -scripts/download-cosyvoice3-tts-gguf.sh +./build/bin/vox model download cosyvoice3-tts ``` That creates: @@ -159,7 +163,7 @@ Pass the LLM GGUF with `--tts-model`. The runtime auto-discovers sibling flow, H Kokoro-82M is available with `--tts-engine kokoro`: ```sh -scripts/download-kokoro-tts-gguf.sh +./build/bin/vox model download kokoro-tts ``` On Windows PowerShell: @@ -180,7 +184,7 @@ Pass the Kokoro model with `--tts-model`. The runtime auto-discovers `kokoro-voi Qwen3-TTS 0.6B is available with `--tts-engine qwen3-tts`. The recommended quick-test path is CustomVoice Q8_0 because it has built-in speakers and does not need a reference WAV: ```sh -scripts/download-qwen3-tts-gguf.sh +./build/bin/vox model download qwen3-tts ``` On Windows PowerShell: @@ -196,7 +200,7 @@ models/tts/qwen3-tts-0.6b-customvoice/qwen3-tts-12hz-0.6b-customvoice-q8_0.gguf models/tts/qwen3-tts-0.6b-customvoice/qwen3-tts-tokenizer-12hz.gguf ``` -Pass the talker GGUF with `--tts-model`. The runtime auto-discovers `qwen3-tts-tokenizer-12hz.gguf` in the same directory, or use `--tts-codec-model PATH`. CustomVoice speakers include `aiden`, `dylan`, `eric`, `ono_anna`, `ryan`, `serena`, `sohee`, `uncle_fu`, and `vivian`; use `dylan` or `eric` for Chinese output tests. The Base variant can also be downloaded with `scripts/download-qwen3-tts-gguf.sh models/tts/qwen3-tts-0.6b-base base q8_0`; it requires `--tts-voice-model` pointing to a baked voice GGUF or a reference WAV plus `--tts-ref-text`. +Pass the talker GGUF with `--tts-model`. The runtime auto-discovers `qwen3-tts-tokenizer-12hz.gguf` in the same directory, or use `--tts-codec-model PATH`. CustomVoice speakers include `aiden`, `dylan`, `eric`, `ono_anna`, `ryan`, `serena`, `sohee`, `uncle_fu`, and `vivian`; use `dylan` or `eric` for Chinese output tests. The Base variant can also be downloaded with `./build/bin/vox model download qwen3-tts-0.6b-base`; it requires `--tts-voice-model` pointing to a baked voice GGUF or a reference WAV plus `--tts-ref-text`. ## Run diff --git a/apps/model_manager.cpp b/apps/model_manager.cpp index 7e60a84..84d2bba 100644 --- a/apps/model_manager.cpp +++ b/apps/model_manager.cpp @@ -1,5 +1,6 @@ #include "model_manager.h" +#include #include #include #include @@ -93,6 +94,13 @@ void print_model_details(const ManagedModel & model, const ManagedModelStatus & << " source: " << model.source << "\n" << " version: " << model.version << "\n" << " checksum: " << (model.checksum.empty() ? "unavailable" : model.checksum) << "\n"; + if (!model.aliases.empty()) { + out << " aliases:"; + for (const std::string & alias : model.aliases) { + out << " " << alias; + } + out << "\n"; + } for (size_t i = 0; i < model.files.size(); ++i) { const ManagedModelFile & file = model.files[i]; const ManagedModelFileStatus & file_status = status.files[i]; @@ -122,6 +130,42 @@ int require_model_name(const std::vector & args, std::ostream & err return 0; } +bool name_matches_model(const ManagedModel & model, const std::string & name) { + if (model.name == name) { + return true; + } + return std::find(model.aliases.begin(), model.aliases.end(), name) != model.aliases.end(); +} + +bool contains_token(const std::string & haystack, const std::string & needle) { + return !needle.empty() && haystack.find(needle) != std::string::npos; +} + +void append_unique(std::vector & values, const std::string & value) { + if (std::find(values.begin(), values.end(), value) == values.end()) { + values.push_back(value); + } +} + +std::vector suggest_models(const std::string & name) { + std::vector suggestions; + for (const ManagedModel & model : supported_models()) { + if (contains_token(model.name, name) || contains_token(name, model.name)) { + append_unique(suggestions, model.name); + } + for (const std::string & alias : model.aliases) { + if (contains_token(alias, name) || contains_token(name, alias)) { + append_unique(suggestions, model.name); + break; + } + } + if (suggestions.size() >= 5) { + break; + } + } + return suggestions; +} + } // namespace const std::vector & supported_models() { @@ -137,6 +181,7 @@ const std::vector & supported_models() { {"models/asr/qwen3-asr-1.7b/mmproj-Qwen3-ASR-1.7B-Q8_0.gguf", "about 356 MB"}, }, "scripts/download-qwen3-asr-gguf.sh", + {"qwen3-asr", "qwen3-asr-default", "default-asr"}, }, { "qwen3-asr-0.6b", @@ -149,6 +194,7 @@ const std::vector & supported_models() { {"models/asr/qwen3-asr-0.6b/mmproj-Qwen3-ASR-0.6B-Q8_0.gguf", ""}, }, "scripts/download-qwen3-asr-gguf.sh 0.6B Q8_0 models/asr/qwen3-asr-0.6b", + {"qwen3-asr-small", "small-asr"}, }, { "whisper-base", @@ -158,6 +204,7 @@ const std::vector & supported_models() { "", {{"models/ggml-base.bin", ""}}, "./external/whisper.cpp/models/download-ggml-model.sh base models", + {"whisper", "whisper.cpp"}, }, { "hymt-translate", @@ -167,6 +214,7 @@ const std::vector & supported_models() { "", {{"models/translate/HY-MT1.5-1.8B-Q4_K_M.gguf", "about 1.13 GB"}}, "scripts/download-hymt-gguf.sh", + {"hymt", "hy-mt", "translate"}, }, { "cosyvoice3-tts", @@ -181,6 +229,58 @@ const std::vector & supported_models() { {"models/tts/cosyvoice3/cosyvoice3-voices.gguf", ""}, }, "scripts/download-cosyvoice3-tts-gguf.sh", + {"cosyvoice3", "cosyvoice", "cosy"}, + }, + { + "kokoro-tts", + "Kokoro-82M TTS GGUF model and default af_heart voice pack", + "cstr/kokoro-82m-GGUF + cstr/kokoro-voices-GGUF", + "82M q8_0 / af_heart", + "", + { + {"models/tts/kokoro/kokoro-82m-q8_0.gguf", "about 135 MiB"}, + {"models/tts/kokoro/kokoro-voice-af_heart.gguf", "about 510 KiB"}, + }, +#ifdef _WIN32 + "powershell -ExecutionPolicy Bypass -File scripts/download-kokoro-tts-gguf.ps1", +#else + "scripts/download-kokoro-tts-gguf.sh", +#endif + {"kokoro", "kokoro-82m"}, + }, + { + "qwen3-tts-0.6b-customvoice", + "Qwen3-TTS 0.6B CustomVoice talker and tokenizer/codec", + "cstr/qwen3-tts-0.6b-customvoice-GGUF + cstr/qwen3-tts-tokenizer-12hz-GGUF", + "0.6B CustomVoice q8_0", + "", + { + {"models/tts/qwen3-tts-0.6b-customvoice/qwen3-tts-12hz-0.6b-customvoice-q8_0.gguf", "about 923 MiB"}, + {"models/tts/qwen3-tts-0.6b-customvoice/qwen3-tts-tokenizer-12hz.gguf", "about 342 MiB"}, + }, +#ifdef _WIN32 + "powershell -ExecutionPolicy Bypass -File scripts/download-qwen3-tts-gguf.ps1", +#else + "scripts/download-qwen3-tts-gguf.sh", +#endif + {"qwen3-tts", "qwen3tts", "qwen3-tts-customvoice", "qwen3-tts-cv"}, + }, + { + "qwen3-tts-0.6b-base", + "Qwen3-TTS 0.6B Base talker and tokenizer/codec; requires a separate voice reference at runtime", + "cstr/qwen3-tts-0.6b-base-GGUF + cstr/qwen3-tts-tokenizer-12hz-GGUF", + "0.6B Base q8_0", + "", + { + {"models/tts/qwen3-tts-0.6b-base/qwen3-tts-12hz-0.6b-base-q8_0.gguf", ""}, + {"models/tts/qwen3-tts-0.6b-base/qwen3-tts-tokenizer-12hz.gguf", "about 342 MiB"}, + }, +#ifdef _WIN32 + "powershell -ExecutionPolicy Bypass -File scripts/download-qwen3-tts-gguf.ps1 models/tts/qwen3-tts-0.6b-base base q8_0", +#else + "scripts/download-qwen3-tts-gguf.sh models/tts/qwen3-tts-0.6b-base base q8_0", +#endif + {"qwen3-tts-base"}, }, }; return models; @@ -188,7 +288,7 @@ const std::vector & supported_models() { const ManagedModel * find_model(const std::string & name) { for (const ManagedModel & model : supported_models()) { - if (model.name == name) { + if (name_matches_model(model, name)) { return &model; } } @@ -268,8 +368,16 @@ int run_model_command( const ManagedModel * model = find_model(args[1]); if (!model) { - err << "Unknown model: " << args[1] << "\n" - << "Run 'vox model list' to see supported models.\n"; + err << "Unknown model: " << args[1] << "\n"; + const std::vector suggestions = suggest_models(args[1]); + if (!suggestions.empty()) { + err << "Did you mean:"; + for (const std::string & suggestion : suggestions) { + err << " " << suggestion; + } + err << "\n"; + } + err << "Run 'vox model list' to see supported models.\n"; return 1; } diff --git a/apps/model_manager.h b/apps/model_manager.h index 0641f0c..2b20e88 100644 --- a/apps/model_manager.h +++ b/apps/model_manager.h @@ -21,6 +21,7 @@ struct ManagedModel { std::string checksum; std::vector files; std::string download_command; + std::vector aliases; }; struct ManagedModelFileStatus { diff --git a/apps/vox.cpp b/apps/vox.cpp index 7257405..2bed140 100644 --- a/apps/vox.cpp +++ b/apps/vox.cpp @@ -175,16 +175,16 @@ const char * tts_engine_name(TtsEngine engine) { return "unknown"; } -const char * tts_download_script(TtsEngine engine) { +const char * tts_model_manager_name(TtsEngine engine) { switch (engine) { case TtsEngine::CosyVoice3: - return "scripts/download-cosyvoice3-tts-gguf.sh"; + return "cosyvoice3-tts"; case TtsEngine::Kokoro: - return "scripts/download-kokoro-tts-gguf.sh"; + return "kokoro-tts"; case TtsEngine::Qwen3Tts: - return "scripts/download-qwen3-tts-gguf.sh"; + return "qwen3-tts-0.6b-customvoice"; } - return "scripts/download-cosyvoice3-tts-gguf.sh"; + return "cosyvoice3-tts"; } std::string default_tts_language(const CliOptions & options, @@ -707,7 +707,7 @@ int main(int argc, char ** argv) { tts_model_path = resolve_model_path(cli.tts_model_path); if (!file_exists(tts_model_path)) { std::cerr << "Missing TTS model: " << tts_model_path << "\n" - << "Download: vox model download cosyvoice3-tts\n"; + << "Download: vox model download " << tts_model_manager_name(tts_engine) << "\n"; return 1; } diff --git a/tests/model_manager_test.cpp b/tests/model_manager_test.cpp index a636663..866d04e 100644 --- a/tests/model_manager_test.cpp +++ b/tests/model_manager_test.cpp @@ -31,6 +31,12 @@ int main() { const vox::app::model::ManagedModel * model = vox::app::model::find_model("whisper-base"); ok = expect(model != nullptr, "whisper-base should be supported") && ok; + const vox::app::model::ManagedModel * kokoro = vox::app::model::find_model("kokoro"); + ok = expect(kokoro != nullptr && kokoro->name == "kokoro-tts", "kokoro alias should resolve") && ok; + const vox::app::model::ManagedModel * qwen3_tts = vox::app::model::find_model("qwen3-tts"); + ok = expect(qwen3_tts != nullptr && qwen3_tts->name == "qwen3-tts-0.6b-customvoice", + "qwen3-tts alias should resolve to CustomVoice") && + ok; ok = expect(vox::app::model::find_model("missing-model") == nullptr, "unknown model should not resolve") && ok; if (!model) { return 1; @@ -48,7 +54,40 @@ int main() { std::ostringstream out; std::ostringstream err; - int result = vox::app::model::run_model_command({"list", "--installed"}, root, out, err); + int result = vox::app::model::run_model_command({"list"}, root, out, err); + ok = expect(result == 0, "list should succeed") && ok; + ok = expect(out.str().find("kokoro-tts") != std::string::npos, "list should include Kokoro") && ok; + ok = expect(out.str().find("qwen3-tts-0.6b-customvoice") != std::string::npos, + "list should include Qwen3-TTS CustomVoice") && + ok; + ok = expect(out.str().find("qwen3-tts-0.6b-base") != std::string::npos, "list should include Qwen3-TTS Base") && + ok; + + out.str(""); + out.clear(); + err.str(""); + err.clear(); + result = vox::app::model::run_model_command({"verify", "kokoro"}, root, out, err); + ok = expect(result == 1, "verify should fail for missing Kokoro files") && ok; + ok = expect(out.str().find("kokoro-tts (missing)") != std::string::npos, + "verify should print canonical Kokoro model details") && + ok; + + out.str(""); + out.clear(); + err.str(""); + err.clear(); + result = vox::app::model::run_model_command({"verify", "qwen3-tts-custom"}, root, out, err); + ok = expect(result == 1, "unknown similar model should fail") && ok; + ok = expect(err.str().find("Did you mean: qwen3-tts-0.6b-customvoice") != std::string::npos, + "unknown model should include suggestions") && + ok; + + out.str(""); + out.clear(); + err.str(""); + err.clear(); + result = vox::app::model::run_model_command({"list", "--installed"}, root, out, err); ok = expect(result == 0, "list --installed should succeed for incomplete models") && ok; ok = expect(out.str().find("whisper-base") != std::string::npos, "installed list should include incomplete model") && ok;