Skip to content

Commit 5cfd0af

Browse files
authored
Add CLI model management commands
1 parent fce2e5f commit 5cfd0af

8 files changed

Lines changed: 496 additions & 3 deletions

File tree

CMakeLists.txt

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,7 @@ add_subdirectory(asr)
123123
add_subdirectory(translate)
124124
add_subdirectory(tts)
125125
add_subdirectory(pipeline)
126-
if(VOX_BUILD_APPS)
127-
add_subdirectory(apps)
128-
endif()
126+
add_subdirectory(apps)
129127

130128
if(VOX_BUILD_TESTS)
131129
enable_testing()

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,19 @@ cmake --build build --target vox -j
5757

5858
## Model
5959

60+
The `vox` CLI can list, download, verify, repair, and remove known local models:
61+
62+
```sh
63+
./build/bin/vox model list
64+
./build/bin/vox model download qwen3-asr-1.7b
65+
./build/bin/vox model verify qwen3-asr-1.7b
66+
./build/bin/vox model repair qwen3-asr-1.7b
67+
```
68+
69+
Model verification checks that expected files exist, are non-empty, and do not
70+
have leftover partial downloads. Checksums are reported when metadata is
71+
available; the current bundled manifests rely on file presence and size.
72+
6073
### Whisper ASR
6174

6275
Download or place a local Whisper GGML model under `models/`. For multilingual recognition, use a non-`.en` model.

apps/CMakeLists.txt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
1+
add_library(vox_model_manager STATIC
2+
model_manager.cpp
3+
)
4+
5+
target_include_directories(vox_model_manager
6+
PUBLIC
7+
"${CMAKE_CURRENT_SOURCE_DIR}"
8+
)
9+
10+
if(VOX_BUILD_APPS)
111
add_executable(vox
212
vox.cpp
313
microphone_audio_source.cpp
414
)
515

616
target_link_libraries(vox
717
PRIVATE
18+
vox_model_manager
819
vox_translation_pipeline
920
vox_sdl_audio
1021
)
@@ -18,3 +29,4 @@ target_compile_definitions(vox
1829
PRIVATE
1930
VOX_PROJECT_ROOT="${CMAKE_SOURCE_DIR}"
2031
)
32+
endif()

apps/model_manager.cpp

Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
#include "model_manager.h"
2+
3+
#include <cstdlib>
4+
#include <cstdio>
5+
#include <fstream>
6+
#include <iostream>
7+
#include <iterator>
8+
#include <system_error>
9+
10+
namespace vox::app::model {
11+
namespace {
12+
13+
std::filesystem::path model_path(const std::filesystem::path & project_root, const std::string & relative_path) {
14+
return project_root / std::filesystem::path(relative_path);
15+
}
16+
17+
std::string format_size(uintmax_t bytes) {
18+
const char * units[] = {"B", "KiB", "MiB", "GiB"};
19+
double value = static_cast<double>(bytes);
20+
size_t unit = 0;
21+
while (value >= 1024.0 && unit + 1 < std::size(units)) {
22+
value /= 1024.0;
23+
++unit;
24+
}
25+
char buffer[64];
26+
if (unit == 0) {
27+
std::snprintf(buffer, sizeof(buffer), "%llu %s", static_cast<unsigned long long>(bytes), units[unit]);
28+
} else {
29+
std::snprintf(buffer, sizeof(buffer), "%.1f %s", value, units[unit]);
30+
}
31+
return buffer;
32+
}
33+
34+
const char * status_name(const ManagedModelStatus & status) {
35+
if (status.complete) {
36+
return "installed";
37+
}
38+
if (status.installed || status.has_partial_download) {
39+
return "incomplete";
40+
}
41+
return "missing";
42+
}
43+
44+
void print_model_usage(std::ostream & out) {
45+
out << "usage: vox model <command> [model-name]\n"
46+
<< "\n"
47+
<< "commands:\n"
48+
<< " list [--installed] list supported models and local status\n"
49+
<< " download <name> download a supported model\n"
50+
<< " verify <name> verify local model files exist and are non-empty\n"
51+
<< " repair <name> remove incomplete files, then download missing files\n"
52+
<< " remove <name> remove local files for a model\n"
53+
<< "\n"
54+
<< "Run 'vox model list' to see model names.\n";
55+
}
56+
57+
int run_download_command(const ManagedModel & model, const std::filesystem::path & project_root, std::ostream & err) {
58+
if (model.download_command.empty()) {
59+
err << "No download command is available for " << model.name << ".\n";
60+
return 1;
61+
}
62+
63+
std::error_code ec;
64+
const std::filesystem::path previous = std::filesystem::current_path(ec);
65+
if (ec) {
66+
err << "Could not read current directory: " << ec.message() << "\n";
67+
return 1;
68+
}
69+
std::filesystem::current_path(project_root, ec);
70+
if (ec) {
71+
err << "Could not enter project directory " << project_root << ": " << ec.message() << "\n";
72+
return 1;
73+
}
74+
const int result = std::system(model.download_command.c_str());
75+
std::filesystem::current_path(previous, ec);
76+
if (result != 0) {
77+
err << "Download command failed for " << model.name << ".\n";
78+
return 1;
79+
}
80+
return 0;
81+
}
82+
83+
void print_model_details(const ManagedModel & model, const ManagedModelStatus & status, std::ostream & out) {
84+
out << model.name << " (" << status_name(status) << ")\n"
85+
<< " description: " << model.description << "\n"
86+
<< " source: " << model.source << "\n"
87+
<< " version: " << model.version << "\n"
88+
<< " checksum: " << (model.checksum.empty() ? "unavailable" : model.checksum) << "\n";
89+
for (size_t i = 0; i < model.files.size(); ++i) {
90+
const ManagedModelFile & file = model.files[i];
91+
const ManagedModelFileStatus & file_status = status.files[i];
92+
out << " file: " << file_status.path.string()
93+
<< " [" << (file_status.complete ? "ok" : (file_status.exists ? "empty" : "missing")) << "]";
94+
if (file_status.exists) {
95+
out << " size=" << format_size(file_status.size);
96+
} else if (!file.size_hint.empty()) {
97+
out << " expected=" << file.size_hint;
98+
}
99+
out << "\n";
100+
}
101+
if (status.has_partial_download) {
102+
out << " partial download detected; run 'vox model repair " << model.name << "'.\n";
103+
}
104+
}
105+
106+
int require_model_name(const std::vector<std::string> & args, std::ostream & err) {
107+
if (args.size() < 2) {
108+
err << "Missing model name.\n";
109+
return 1;
110+
}
111+
if (args.size() > 2) {
112+
err << "Too many arguments for model command.\n";
113+
return 1;
114+
}
115+
return 0;
116+
}
117+
118+
} // namespace
119+
120+
const std::vector<ManagedModel> & supported_models() {
121+
static const std::vector<ManagedModel> models = {
122+
{
123+
"qwen3-asr-1.7b",
124+
"Default Qwen3-ASR GGUF model and multimodal projector",
125+
"ggml-org/Qwen3-ASR-1.7B-GGUF",
126+
"1.7B Q8_0",
127+
"",
128+
{
129+
{"models/asr/qwen3-asr-1.7b/Qwen3-ASR-1.7B-Q8_0.gguf", "about 2.17 GB"},
130+
{"models/asr/qwen3-asr-1.7b/mmproj-Qwen3-ASR-1.7B-Q8_0.gguf", "about 356 MB"},
131+
},
132+
"scripts/download-qwen3-asr-gguf.sh",
133+
},
134+
{
135+
"qwen3-asr-0.6b",
136+
"Smaller Qwen3-ASR GGUF model and multimodal projector",
137+
"ggml-org/Qwen3-ASR-0.6B-GGUF",
138+
"0.6B Q8_0",
139+
"",
140+
{
141+
{"models/asr/qwen3-asr-0.6b/Qwen3-ASR-0.6B-Q8_0.gguf", ""},
142+
{"models/asr/qwen3-asr-0.6b/mmproj-Qwen3-ASR-0.6B-Q8_0.gguf", ""},
143+
},
144+
"scripts/download-qwen3-asr-gguf.sh 0.6B Q8_0 models/asr/qwen3-asr-0.6b",
145+
},
146+
{
147+
"whisper-base",
148+
"Whisper.cpp base GGML ASR model",
149+
"ggerganov/whisper.cpp",
150+
"base",
151+
"",
152+
{{"models/ggml-base.bin", ""}},
153+
"./external/whisper.cpp/models/download-ggml-model.sh base models",
154+
},
155+
{
156+
"hymt-translate",
157+
"Tencent HY-MT1.5 translation GGUF model",
158+
"tencent/HY-MT1.5-1.8B-GGUF",
159+
"1.8B Q4_K_M",
160+
"",
161+
{{"models/translate/HY-MT1.5-1.8B-Q4_K_M.gguf", "about 1.13 GB"}},
162+
"scripts/download-hymt-gguf.sh",
163+
},
164+
{
165+
"cosyvoice3-tts",
166+
"Minimum baked-voice CosyVoice3 TTS GGUF set",
167+
"cstr/cosyvoice3-0.5b-2512-GGUF",
168+
"q4_k/q8_0/f16",
169+
"",
170+
{
171+
{"models/tts/cosyvoice3/cosyvoice3-llm-q4_k.gguf", ""},
172+
{"models/tts/cosyvoice3/cosyvoice3-flow-q8_0.gguf", ""},
173+
{"models/tts/cosyvoice3/cosyvoice3-hift-f16.gguf", ""},
174+
{"models/tts/cosyvoice3/cosyvoice3-voices.gguf", ""},
175+
},
176+
"scripts/download-cosyvoice3-tts-gguf.sh",
177+
},
178+
};
179+
return models;
180+
}
181+
182+
const ManagedModel * find_model(const std::string & name) {
183+
for (const ManagedModel & model : supported_models()) {
184+
if (model.name == name) {
185+
return &model;
186+
}
187+
}
188+
return nullptr;
189+
}
190+
191+
ManagedModelStatus inspect_model(const ManagedModel & model, const std::filesystem::path & project_root) {
192+
ManagedModelStatus status;
193+
status.model = &model;
194+
status.complete = true;
195+
for (const ManagedModelFile & file : model.files) {
196+
ManagedModelFileStatus file_status;
197+
file_status.path = model_path(project_root, file.relative_path);
198+
199+
std::error_code ec;
200+
file_status.exists = std::filesystem::is_regular_file(file_status.path, ec);
201+
if (file_status.exists) {
202+
file_status.size = std::filesystem::file_size(file_status.path, ec);
203+
if (ec) {
204+
file_status.size = 0;
205+
}
206+
file_status.complete = file_status.size > 0;
207+
status.installed = true;
208+
}
209+
status.complete = status.complete && file_status.complete;
210+
211+
const std::filesystem::path partial = file_status.path.string() + ".part";
212+
if (std::filesystem::exists(partial, ec)) {
213+
status.has_partial_download = true;
214+
}
215+
status.files.push_back(file_status);
216+
}
217+
status.complete = status.complete && !status.has_partial_download;
218+
return status;
219+
}
220+
221+
int run_model_command(
222+
const std::vector<std::string> & args,
223+
const std::filesystem::path & project_root,
224+
std::ostream & out,
225+
std::ostream & err) {
226+
if (args.empty() || args[0] == "-h" || args[0] == "--help" || args[0] == "help") {
227+
print_model_usage(out);
228+
return 0;
229+
}
230+
231+
const std::string command = args[0];
232+
if (command == "list") {
233+
bool installed_only = false;
234+
if (args.size() > 2 || (args.size() == 2 && args[1] != "--installed")) {
235+
err << "usage: vox model list [--installed]\n";
236+
return 1;
237+
}
238+
installed_only = args.size() == 2;
239+
for (const ManagedModel & model : supported_models()) {
240+
const ManagedModelStatus status = inspect_model(model, project_root);
241+
if (installed_only && !status.complete) {
242+
continue;
243+
}
244+
out << model.name << "\t" << status_name(status) << "\t" << model.version << "\t";
245+
if (!model.files.empty()) {
246+
out << model_path(project_root, model.files.front().relative_path).string();
247+
}
248+
out << "\n";
249+
}
250+
return 0;
251+
}
252+
253+
if (command != "download" && command != "verify" && command != "repair" && command != "remove") {
254+
err << "Unknown model command: " << command << "\n";
255+
print_model_usage(err);
256+
return 1;
257+
}
258+
if (require_model_name(args, err) != 0) {
259+
return 1;
260+
}
261+
262+
const ManagedModel * model = find_model(args[1]);
263+
if (!model) {
264+
err << "Unknown model: " << args[1] << "\n"
265+
<< "Run 'vox model list' to see supported models.\n";
266+
return 1;
267+
}
268+
269+
if (command == "verify") {
270+
const ManagedModelStatus status = inspect_model(*model, project_root);
271+
print_model_details(*model, status, out);
272+
if (!status.complete) {
273+
err << "Model is missing or incomplete. Run 'vox model repair " << model->name << "'.\n";
274+
return 1;
275+
}
276+
return 0;
277+
}
278+
279+
if (command == "remove") {
280+
std::error_code ec;
281+
for (const ManagedModelFile & file : model->files) {
282+
const std::filesystem::path path = model_path(project_root, file.relative_path);
283+
const bool removed = std::filesystem::remove(path, ec);
284+
if (ec) {
285+
err << "Could not remove " << path << ": " << ec.message() << "\n";
286+
return 1;
287+
}
288+
std::filesystem::remove(path.string() + ".part", ec);
289+
out << (removed ? "Removed: " : "Not installed: ") << path.string() << "\n";
290+
}
291+
return 0;
292+
}
293+
294+
if (command == "repair") {
295+
std::error_code ec;
296+
const ManagedModelStatus status = inspect_model(*model, project_root);
297+
for (size_t i = 0; i < model->files.size(); ++i) {
298+
const std::filesystem::path path = status.files[i].path;
299+
if (status.files[i].exists && !status.files[i].complete) {
300+
std::filesystem::remove(path, ec);
301+
if (ec) {
302+
err << "Could not remove incomplete file " << path << ": " << ec.message() << "\n";
303+
return 1;
304+
}
305+
out << "Removed incomplete file: " << path.string() << "\n";
306+
}
307+
std::filesystem::remove(path.string() + ".part", ec);
308+
}
309+
}
310+
311+
const ManagedModelStatus before = inspect_model(*model, project_root);
312+
if (before.complete) {
313+
out << "Model already installed: " << model->name << "\n";
314+
return 0;
315+
}
316+
out << "Downloading " << model->name << " using: " << model->download_command << "\n";
317+
return run_download_command(*model, project_root, err);
318+
}
319+
320+
} // namespace vox::app::model

0 commit comments

Comments
 (0)