diff --git a/.gitignore b/.gitignore index 803a7bd4..1d575eca 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,10 @@ __pycache__/ !/webui/logs/.gitkeep /webui/third_party/ /webui/llm_api_key.txt +/webui/native/node_modules/ +/webui/native/.svelte-kit/ +/webui/native/dist/* +!/webui/native/dist/index.html # written by the in-UI language picker; per-machine, not a project setting /webui/configs/ui_language.json # personal voice recording — stays local, repo is public diff --git a/CMakeLists.txt b/CMakeLists.txt index d6deebd2..094a210c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1231,12 +1231,29 @@ if (ENGINE_ENABLE_OPENMP) target_link_libraries(audiocpp_cli PRIVATE OpenMP::OpenMP_CXX) endif() +set(AUDIOCPP_UI_DIST "${CMAKE_CURRENT_SOURCE_DIR}/webui/native/dist/index.html") +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${AUDIOCPP_UI_DIST}") +if (EXISTS "${AUDIOCPP_UI_DIST}") + file(READ "${AUDIOCPP_UI_DIST}" AUDIOCPP_UI_HEX HEX) +else() + string(HEX "audio.cpp

Embedded WebUI assets are unavailable.

" AUDIOCPP_UI_HEX) +endif() +string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," AUDIOCPP_UI_BYTES "${AUDIOCPP_UI_HEX}") +string(REPLACE "," ",\n" AUDIOCPP_UI_BYTES "${AUDIOCPP_UI_BYTES}") +configure_file( + "${CMAKE_CURRENT_SOURCE_DIR}/app/server/audiocpp_ui_asset.h.in" + "${CMAKE_CURRENT_BINARY_DIR}/generated/audiocpp_ui_asset.h" + @ONLY +) + add_executable(audiocpp_server app/server/main.cpp app/server/config.cpp app/server/http.cpp + app/server/model_installer.cpp app/server/multipart.cpp app/server/runtime.cpp + app/server/ui_assets.cpp app/cli/args.cpp app/cli/request.cpp app/streaming/pcm_source.cpp @@ -1244,6 +1261,7 @@ add_executable(audiocpp_server ) target_link_libraries(audiocpp_server PRIVATE engine_runtime ggml) +target_include_directories(audiocpp_server PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated") if (WIN32) target_link_libraries(audiocpp_server PRIVATE ws2_32) endif() @@ -2037,6 +2055,18 @@ if (ENGINE_BUILD_TESTS) if (ENGINE_ENABLE_OPENMP) target_link_libraries(parakeet_parity_dump PRIVATE OpenMP::OpenMP_CXX) endif() + + add_executable(server_model_installer_test + tests/unittests/test_server_model_installer.cpp + app/server/model_installer.cpp + ) + target_include_directories(server_model_installer_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/app/server) + target_link_libraries(server_model_installer_test PRIVATE Threads::Threads) + + add_test( + NAME server_model_installer_test + COMMAND server_model_installer_test + ) endif() if (ENGINE_BUILD_EXAMPLES) diff --git a/README.md b/README.md index 9237a334..dd4b9666 100644 --- a/README.md +++ b/README.md @@ -145,9 +145,24 @@ package notes. ## WebUI ![Maintained by contributors](https://img.shields.io/badge/maintained%20by-contributors-brightgreen) -audio.cpp includes a Gradio WebUI for trying local models from the browser, managing downloads, and running common TTS/ASR/audio workflows without writing CLI commands. +`audiocpp_server` includes an embedded SvelteKit/TypeScript WebUI for running local TTS, cloning, ASR, +generation, conversion, separation, VAD, diarization, and alignment workflows. The production UI is compiled +into the server binary, so using it requires neither Python nor separate frontend files: -The WebUI lives in [webui/](webui/). See [webui/README.md](webui/README.md) for setup, launch commands, and model-download notes. +```bash +audiocpp_server --ui --backend cuda +``` + +Open `http://127.0.0.1:8080`. Starting with `--ui` and no server config enables on-demand model +load/unload and temporary browser uploads. Existing static server configurations also expose the UI by default; +add `--ui-management` when that instance should permit model switching. + +The native UI also exposes background model download/preparation, long-text split-and-merge synthesis, a +browser-local saved voice library, microphone recording, and near-live ASR input. Some model preparation jobs invoke +the repository's Python model manager because those packages require Hugging Face download or checkpoint conversion; +model inference and the embedded UI remain Python-free. The previous Python/Gradio interface remains available for +compatibility. See [webui/README.md](webui/README.md) for native and legacy launch commands, model notes, and frontend +development instructions. Huge thanks to [@kigner](https://github.com/kigner) for the original [audio.cpp-webui](https://github.com/kigner/audio.cpp-webui), and to [@patrickjchen](https://github.com/patrickjchen) for porting and integrating it into audio.cpp. diff --git a/app/server/audiocpp_ui_asset.h.in b/app/server/audiocpp_ui_asset.h.in new file mode 100644 index 00000000..5bf252ac --- /dev/null +++ b/app/server/audiocpp_ui_asset.h.in @@ -0,0 +1,12 @@ +#pragma once + +#include + +namespace minitts::server { + +inline constexpr unsigned char kAudioCppUiHtml[] = { +@AUDIOCPP_UI_BYTES@ +}; +inline constexpr std::size_t kAudioCppUiHtmlSize = sizeof(kAudioCppUiHtml); + +} // namespace minitts::server diff --git a/app/server/config.cpp b/app/server/config.cpp index 1caf4d5d..ae4f8d1c 100644 --- a/app/server/config.cpp +++ b/app/server/config.cpp @@ -223,6 +223,8 @@ ServerConfig load_server_config(const std::filesystem::path & path) { config.host = engine::io::json::optional_string(root, "host", config.host); config.port = engine::io::json::optional_i32(root, "port", config.port); config.cors_origins = engine::io::json::optional_string(root, "cors_origins", config.cors_origins); + config.ui_enabled = engine::io::json::optional_bool(root, "ui", config.ui_enabled); + config.ui_management = engine::io::json::optional_bool(root, "ui_management", config.ui_management); config.backend = parse_server_backend(engine::io::json::optional_string(root, "backend", "cuda")); config.device = engine::io::json::optional_i32(root, "device", config.device); config.threads = engine::io::json::optional_i32(root, "threads", config.threads); @@ -249,8 +251,11 @@ ServerConfig load_server_config(const std::filesystem::path & path) { } const auto * models = root.find("models"); - if (models == nullptr || !models->is_array() || models->as_array().empty()) { - throw std::runtime_error("server config requires a non-empty models array"); + if (models == nullptr || !models->is_array()) { + throw std::runtime_error("server config requires a models array"); + } + if (models->as_array().empty() && !config.ui_management) { + throw std::runtime_error("server config requires a non-empty models array unless ui_management is enabled"); } for (const auto & item : models->as_array()) { ServerModelConfig model; diff --git a/app/server/config.h b/app/server/config.h index 22b25c07..867fccd7 100644 --- a/app/server/config.h +++ b/app/server/config.h @@ -67,6 +67,8 @@ struct ServerConfig { std::string host = "127.0.0.1"; int port = 8080; std::string cors_origins = ""; + bool ui_enabled = true; + bool ui_management = false; engine::core::BackendType backend = engine::core::BackendType::Cuda; int device = 0; int threads = 1; diff --git a/app/server/http.cpp b/app/server/http.cpp index 304864f8..fe2b1c92 100644 --- a/app/server/http.cpp +++ b/app/server/http.cpp @@ -65,12 +65,18 @@ const char * status_text(int status) noexcept { switch (status) { case 200: return "OK"; + case 204: + return "No Content"; case 400: return "Bad Request"; + case 403: + return "Forbidden"; case 404: return "Not Found"; case 405: return "Method Not Allowed"; + case 413: + return "Payload Too Large"; case 500: return "Internal Server Error"; case 503: diff --git a/app/server/main.cpp b/app/server/main.cpp index 87ff25e2..695082bb 100644 --- a/app/server/main.cpp +++ b/app/server/main.cpp @@ -41,13 +41,33 @@ bool has_arg(int argc, char ** argv, const std::string & name) { return false; } +std::filesystem::path executable_directory(const char * argv0) { + if (argv0 == nullptr || *argv0 == '\0') { + return std::filesystem::current_path(); + } + std::error_code ec; + auto path = std::filesystem::absolute(std::filesystem::path(argv0), ec); + if (ec) { + return std::filesystem::current_path(); + } + path = path.lexically_normal(); + if (std::filesystem::is_regular_file(path, ec)) { + return path.parent_path(); + } + return std::filesystem::current_path(); +} + void print_help() { std::cout - << "audiocpp_server --config [--host ] [--port ] [--backend ]\n" + << "audiocpp_server [--config ] [--ui] [--host ] [--port ] [--backend ]\n" << " [--device ] [--threads ] [--busy-timeout-ms ]\n" << " [--model-spec-override ]\n" << " [--log] [--log-file ]\n" << " [--cors-origins ]\n" + << " --ui serve the embedded WebUI; without --config, start\n" + << " as a native model-management host\n" + << " --no-ui disable the embedded WebUI\n" + << " --ui-management allow WebUI model load/unload and temporary uploads\n" << " --backend cpu|cuda|hip|rocm|vulkan|metal default cuda (rocm is an alias for hip)\n" << " --busy-timeout-ms fail a request with 503 when the model has been\n" << " busy this long; default 300000, 0 disables\n" @@ -55,8 +75,19 @@ void print_help() { << " requests from any origin for trusted local demos only\n" << "\n" << "Endpoints:\n" + << " GET / embedded WebUI (enabled by default with a config)\n" << " GET /health\n" << " GET /v1/models\n" + << " POST /v1/models/load available with --ui-management\n" + << " POST /v1/models/unload available with --ui-management\n" + << " POST /v1/ui/upload available with --ui-management\n" + << " POST /v1/ui/models/install background package download/preparation\n" + << " POST /v1/ui/models/delete remove one installed package precision\n" + << " GET /v1/ui/models-root current and binary-local default models folders\n" + << " POST /v1/ui/models-root select a models folder (empty path restores default)\n" + << " POST /v1/ui/browse-directories list local folders for the native folder picker\n" + << " GET /v1/ui/models/install-status[?id=]\n" + << " GET /v1/ui/models/package-sizes package sizes from metadata-only checks\n" << " GET /v1/audio/voices?model=\n" << " POST /v1/audio/speech\n" << " POST /v1/audio/transcriptions\n" @@ -75,8 +106,9 @@ int main(int argc, char ** argv) { return 0; } const auto config_path = arg_value(argc, argv, "--config"); - if (!config_path.has_value()) { - throw std::runtime_error("missing required --config argument"); + const bool ui_requested = has_arg(argc, argv, "--ui"); + if (!config_path.has_value() && !ui_requested) { + throw std::runtime_error("missing required --config argument (or use --ui for the native WebUI)"); } const auto log_file = arg_value(argc, argv, "--log-file"); engine::debug::configure_logging(engine::debug::LoggingConfig{ @@ -94,7 +126,22 @@ int main(int argc, char ** argv) { std::signal(SIGPIPE, SIG_IGN); #endif - auto config = minitts::server::load_server_config(*config_path); + auto config = config_path.has_value() + ? minitts::server::load_server_config(*config_path) + : minitts::server::ServerConfig{}; + if (!config_path.has_value()) { + config.ui_management = true; + config.lazy_load = true; + } + if (ui_requested) { + config.ui_enabled = true; + } + if (has_arg(argc, argv, "--no-ui")) { + config.ui_enabled = false; + } + if (has_arg(argc, argv, "--ui-management")) { + config.ui_management = true; + } if (const auto host = arg_value(argc, argv, "--host")) { config.host = *host; } @@ -129,7 +176,11 @@ int main(int argc, char ** argv) { throw std::runtime_error("--busy-timeout-ms must be >= 0 (0 disables the guard)"); } - minitts::server::ServerState state(config, std::filesystem::current_path()); + const auto ui_resource_anchor = executable_directory(argc > 0 ? argv[0] : nullptr); + minitts::server::ServerState state( + config, + std::filesystem::current_path(), + ui_resource_anchor); minitts::server::serve_http(config.host, config.port, state, shutdown_requested, config.max_request_body_bytes); return 0; } catch (const std::exception & ex) { diff --git a/app/server/model_installer.cpp b/app/server/model_installer.cpp new file mode 100644 index 00000000..5a8dd5b2 --- /dev/null +++ b/app/server/model_installer.cpp @@ -0,0 +1,581 @@ +#include "model_installer.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace minitts::server { +namespace { + +std::string json_quote(std::string_view value) { + std::string result; + result.reserve(value.size() + 2); + result.push_back('"'); + for (const unsigned char ch : value) { + switch (ch) { + case '"': result += "\\\""; break; + case '\\': result += "\\\\"; break; + case '\b': result += "\\b"; break; + case '\f': result += "\\f"; break; + case '\n': result += "\\n"; break; + case '\r': result += "\\r"; break; + case '\t': result += "\\t"; break; + default: + if (ch < 0x20) { + constexpr char hex[] = "0123456789abcdef"; + result += "\\u00"; + result.push_back(hex[(ch >> 4) & 0xf]); + result.push_back(hex[ch & 0xf]); + } else { + result.push_back(static_cast(ch)); + } + } + } + result.push_back('"'); + return result; +} + +bool valid_package_id(const std::string & value) { + if (value.empty() || value.size() > 128) { + return false; + } + for (const unsigned char ch : value) { + if (!(std::isalnum(ch) || ch == '_' || ch == '-')) { + return false; + } + } + return true; +} + +void validate_argument(const std::string & value, const char * name) { + if (value.find_first_of("\r\n") != std::string::npos) { + throw std::runtime_error(std::string(name) + " contains an invalid newline"); + } +#ifdef _WIN32 + if (value.find_first_of("\"&|<>^") != std::string::npos) { + throw std::runtime_error(std::string(name) + " contains an unsupported command character"); + } +#endif +} + +std::string shell_quote(const std::string & value) { +#ifdef _WIN32 + return "\"" + value + "\""; +#else + std::string result = "'"; + for (const char ch : value) { + if (ch == '\'') { + result += "'\\''"; + } else { + result.push_back(ch); + } + } + return result + "'"; +#endif +} + +std::string python_command() { + if (const char * configured = std::getenv("AUDIOCPP_PYTHON")) { + const std::string value(configured); + validate_argument(value, "AUDIOCPP_PYTHON"); + return shell_quote(value); + } +#ifdef _WIN32 + return "python"; +#else + return "python3"; +#endif +} + +std::string read_log_tail_text(const std::filesystem::path & path) { + std::ifstream input(path, std::ios::binary); + if (!input) { + return {}; + } + input.seekg(0, std::ios::end); + const auto size = input.tellg(); + constexpr std::streamoff kTailBytes = 65536; + if (size > kTailBytes) { + input.seekg(size - kTailBytes); + } else { + input.seekg(0); + } + std::ostringstream content; + content << input.rdbuf(); + return content.str(); +} + +struct LogSnapshot { + std::string message; + uint64_t downloaded_bytes = 0; + uint64_t total_bytes = 0; + bool has_progress = false; +}; + +LogSnapshot inspect_log(const std::filesystem::path & path) { + LogSnapshot snapshot; + std::istringstream lines(read_log_tail_text(path)); + std::string line; + constexpr std::string_view marker = "AUDIOCPP_PROGRESS "; + while (std::getline(lines, line)) { + if (!line.empty() && line.back() == '\r') { + line.pop_back(); + } + if (line.empty()) { + continue; + } + if (line.rfind(marker.data(), 0) != 0) { + snapshot.message = line; + continue; + } + uint64_t downloaded = 0; + uint64_t total = 0; + bool parsed_downloaded = false; + std::istringstream fields(line.substr(marker.size())); + std::string field; + while (fields >> field) { + const auto separator = field.find('='); + if (separator == std::string::npos) { + continue; + } + try { + const auto value = std::stoull(field.substr(separator + 1)); + const auto name = field.substr(0, separator); + if (name == "downloaded") { + downloaded = value; + parsed_downloaded = true; + } else if (name == "total") { + total = value; + } + } catch (const std::exception &) { + // A partially written progress line is expected while the worker is + // appending to the log. Keep the previous complete marker. + } + } + if (parsed_downloaded) { + snapshot.downloaded_bytes = downloaded; + snapshot.total_bytes = total; + snapshot.has_progress = true; + } + } + return snapshot; +} + +int64_t now_ms() { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); +} + +std::filesystem::path make_job_root() { + const auto temporary = std::filesystem::temp_directory_path(); + std::random_device random; + for (int attempt = 0; attempt < 32; ++attempt) { + const auto ticks = std::chrono::high_resolution_clock::now().time_since_epoch().count(); + const auto candidate = temporary / + ("audiocpp-model-installer-" + std::to_string(ticks) + "-" + + std::to_string(static_cast(random()))); + std::error_code error; + if (std::filesystem::create_directory(candidate, error)) { + return candidate; + } + if (error) { + throw std::runtime_error( + "failed to create model installer temporary directory: " + error.message()); + } + } + throw std::runtime_error("failed to allocate a unique model installer temporary directory"); +} + +} // namespace + +struct ModelInstaller::State { + struct Job { + std::string package_id; + std::string state = "queued"; + std::string message; + std::filesystem::path log_path; + int exit_code = -1; + int64_t started_at_ms = 0; + int64_t finished_at_ms = 0; + uint64_t downloaded_bytes = 0; + uint64_t total_bytes = 0; + }; + + std::filesystem::path repository_root; + std::filesystem::path models_root; + std::filesystem::path job_root; + mutable std::mutex mutex; + std::map jobs; + std::string size_state = "idle"; + std::string size_message = "Package sizes have not been checked"; + std::filesystem::path size_output_path; + std::filesystem::path size_error_path; + std::filesystem::path installed_output_path; + uint64_t size_generation = 0; + + ~State() { + std::error_code error; + std::filesystem::remove_all(job_root, error); + } +}; + +ModelInstaller::ModelInstaller( + std::filesystem::path repository_root, + std::filesystem::path models_root) + : state_(std::make_shared()) { + state_->repository_root = std::filesystem::absolute(std::move(repository_root)).lexically_normal(); + state_->models_root = std::filesystem::absolute(std::move(models_root)).lexically_normal(); + state_->job_root = make_job_root(); + std::filesystem::create_directories(state_->models_root); + state_->size_output_path = state_->job_root / "package-sizes.json"; + state_->size_error_path = state_->job_root / "package-sizes.log"; + state_->installed_output_path = state_->job_root / "installed-packages.json"; +} + +ModelInstaller::~ModelInstaller() = default; + +bool ModelInstaller::has_active_jobs() const { + std::lock_guard lock(state_->mutex); + for (const auto & [id, job] : state_->jobs) { + (void) id; + if (job.state == "queued" || job.state == "running") { + return true; + } + } + return false; +} + +std::string ModelInstaller::start( + const std::string & package_id, + const std::string & source_file, + const std::string & output_file, + const std::string & source_directory, + const std::string & variant, + bool overwrite) { + if (!valid_package_id(package_id)) { + throw std::runtime_error("invalid model-manager package id"); + } + validate_argument(source_directory, "source directory"); + validate_argument(source_file, "source file"); + validate_argument(output_file, "output file"); + validate_argument(variant, "variant"); + + const bool legacy_conversion = + !source_directory.empty() || !source_file.empty() || !output_file.empty() || !variant.empty(); + const auto script = state_->repository_root / "tools" / + (legacy_conversion ? "model_manager_deprecated.py" : "model_manager_v2.py"); + if (!std::filesystem::is_regular_file(script)) { + throw std::runtime_error( + "model preparation helper was not found at " + script.string() + + "; run the server from an updated audio.cpp source or portable bundle root"); + } + + std::filesystem::path source_path; + if (!source_directory.empty()) { + source_path = std::filesystem::path(source_directory); + if (source_path.is_relative()) { + source_path = state_->repository_root / source_path; + } + source_path = std::filesystem::absolute(source_path).lexically_normal(); + } + auto resolve_optional_path = [this](const std::string & value) { + if (value.empty()) { + return std::filesystem::path{}; + } + auto path = std::filesystem::path(value); + if (path.is_relative()) { + path = state_->repository_root / path; + } + return std::filesystem::absolute(path).lexically_normal(); + }; + const auto source_file_path = resolve_optional_path(source_file); + const auto output_file_path = resolve_optional_path(output_file); + + const auto log_path = state_->job_root / (package_id + ".log"); + { + std::lock_guard lock(state_->mutex); + const auto existing = state_->jobs.find(package_id); + if (existing != state_->jobs.end() && + (existing->second.state == "queued" || existing->second.state == "running")) { + throw std::runtime_error("installation is already running for " + package_id); + } + State::Job job; + job.package_id = package_id; + job.message = "Waiting for model preparation worker"; + job.log_path = log_path; + state_->jobs[package_id] = std::move(job); + } + + const auto shared = state_; + std::thread([shared, package_id, source_file_path, output_file_path, source_path, variant, overwrite, + legacy_conversion, script, log_path]() { + try { + { + std::lock_guard lock(shared->mutex); + auto & job = shared->jobs.at(package_id); + job.state = "running"; + job.message = "Downloading and preparing model files"; + job.started_at_ms = now_ms(); + } + + std::string command = python_command() + " -u " + shell_quote(script.string()) + + " install " + shell_quote(package_id) + + " --models-root " + shell_quote(shared->models_root.string()); + if (!legacy_conversion) { + command += " --progress"; + } + if (overwrite) { + command += " --overwrite"; + } + if (legacy_conversion && !source_path.empty()) { + command += " --source-dir " + shell_quote(source_path.string()); + } + if (legacy_conversion && !source_file_path.empty()) { + command += " --source-file " + shell_quote(source_file_path.string()); + } + if (legacy_conversion && !output_file_path.empty()) { + command += " --output-file " + shell_quote(output_file_path.string()); + } + if (legacy_conversion && !variant.empty()) { + command += " --variant " + shell_quote(variant); + } + command += " > " + shell_quote(log_path.string()) + " 2>&1"; + + const int result = std::system(command.c_str()); + const auto log = inspect_log(log_path); + std::lock_guard lock(shared->mutex); + auto & job = shared->jobs.at(package_id); + job.exit_code = result; + job.finished_at_ms = now_ms(); + job.state = result == 0 ? "complete" : "failed"; + job.downloaded_bytes = log.downloaded_bytes; + job.total_bytes = log.total_bytes; + job.message = !log.message.empty() + ? log.message + : (result == 0 ? "Model installation completed" : "Model installation failed"); + if (result == 0) { + ++shared->size_generation; + shared->size_state = "idle"; + shared->size_message = "Package inventory will be refreshed"; + } + } catch (const std::exception & error) { + std::lock_guard lock(shared->mutex); + auto & job = shared->jobs.at(package_id); + job.state = "failed"; + job.message = error.what(); + job.exit_code = -1; + job.finished_at_ms = now_ms(); + } + }).detach(); + + return status(package_id); +} + +std::string ModelInstaller::status(const std::string & package_id) const { + std::lock_guard lock(state_->mutex); + auto job_json = [](const State::Job & job) { + std::string message = job.message; + uint64_t downloaded_bytes = job.downloaded_bytes; + uint64_t total_bytes = job.total_bytes; + if (job.state == "running") { + const auto log = inspect_log(job.log_path); + if (!log.message.empty()) { + message = log.message; + } + if (log.has_progress) { + downloaded_bytes = log.downloaded_bytes; + total_bytes = log.total_bytes; + } + } + int progress_percent = -1; + if (job.state == "queued") { + progress_percent = 0; + } else if (job.state == "complete") { + progress_percent = 100; + } else if (total_bytes > 0) { + progress_percent = static_cast(std::min(100, downloaded_bytes * 100 / total_bytes)); + } + return std::string("{\"id\":") + json_quote(job.package_id) + + ",\"state\":" + json_quote(job.state) + + ",\"message\":" + json_quote(message) + + ",\"exit_code\":" + std::to_string(job.exit_code) + + ",\"downloaded_bytes\":" + std::to_string(downloaded_bytes) + + ",\"total_bytes\":" + std::to_string(total_bytes) + + ",\"progress_percent\":" + std::to_string(progress_percent) + + ",\"started_at_ms\":" + std::to_string(job.started_at_ms) + + ",\"finished_at_ms\":" + std::to_string(job.finished_at_ms) + "}"; + }; + + if (!package_id.empty()) { + const auto found = state_->jobs.find(package_id); + if (found == state_->jobs.end()) { + return "{\"id\":" + json_quote(package_id) + + ",\"state\":\"idle\",\"message\":\"Not started\",\"exit_code\":-1," + "\"downloaded_bytes\":0,\"total_bytes\":0,\"progress_percent\":-1," + "\"started_at_ms\":0,\"finished_at_ms\":0}"; + } + return job_json(found->second); + } + + std::string result = "{\"data\":["; + bool first = true; + for (const auto & item : state_->jobs) { + if (!first) { + result += ","; + } + first = false; + result += job_json(item.second); + } + return result + "]}"; +} + +std::string ModelInstaller::remove(const std::string & package_id) { + if (!valid_package_id(package_id)) { + throw std::runtime_error("invalid model-manager package id"); + } + const auto script = state_->repository_root / "tools" / "model_manager_v2.py"; + if (!std::filesystem::is_regular_file(script)) { + throw std::runtime_error("model removal helper was not found at " + script.string()); + } + { + std::lock_guard lock(state_->mutex); + const auto job = state_->jobs.find(package_id); + if (job != state_->jobs.end() && + (job->second.state == "queued" || job->second.state == "running")) { + throw std::runtime_error("installation is still running for " + package_id); + } + } + + const auto log_path = state_->job_root / (package_id + "-uninstall.log"); + std::string command = python_command() + " -u " + shell_quote(script.string()) + + " uninstall " + shell_quote(package_id) + + " --models-root " + shell_quote(state_->models_root.string()) + + " > " + shell_quote(log_path.string()) + " 2>&1"; + const int result = std::system(command.c_str()); + std::string message = read_log_tail_text(log_path); + while (!message.empty() && std::isspace(static_cast(message.back()))) { + message.pop_back(); + } + if (result != 0) { + throw std::runtime_error(message.empty() ? "model package removal failed" : message); + } + { + std::lock_guard lock(state_->mutex); + state_->jobs.erase(package_id); + ++state_->size_generation; + state_->size_state = "idle"; + state_->size_message = "Package inventory will be refreshed"; + } + return "{\"id\":" + json_quote(package_id) + + ",\"removed\":true,\"message\":" + + json_quote(message.empty() ? "Model package removed" : message) + "}"; +} + +std::string ModelInstaller::package_sizes() { + bool start_scan = false; + uint64_t scan_generation = 0; + std::filesystem::path size_output_path; + std::filesystem::path size_error_path; + std::filesystem::path installed_output_path; + { + std::lock_guard lock(state_->mutex); + if (state_->size_state == "idle") { + scan_generation = ++state_->size_generation; + const auto suffix = std::to_string(scan_generation); + state_->size_output_path = state_->job_root / ("package-sizes-" + suffix + ".json"); + state_->size_error_path = state_->job_root / ("package-sizes-" + suffix + ".log"); + state_->installed_output_path = state_->job_root / ("installed-packages-" + suffix + ".json"); + size_output_path = state_->size_output_path; + size_error_path = state_->size_error_path; + installed_output_path = state_->installed_output_path; + state_->size_state = "running"; + state_->size_message = "Checking package sizes"; + start_scan = true; + std::ofstream(state_->size_output_path, std::ios::trunc); + std::ofstream(state_->size_error_path, std::ios::trunc); + std::ofstream(state_->installed_output_path, std::ios::trunc); + } + } + + if (start_scan) { + const auto script = state_->repository_root / "tools" / "model_manager_v2.py"; + if (!std::filesystem::is_regular_file(script)) { + std::lock_guard lock(state_->mutex); + if (state_->size_generation == scan_generation) { + state_->size_state = "failed"; + state_->size_message = "Package size helper was not found at " + script.string(); + } + } else { + const auto shared = state_; + std::thread([shared, script, scan_generation, size_output_path, size_error_path, + installed_output_path]() { + try { + std::string installed_command = python_command() + " -u " + shell_quote(script.string()) + + " installed --json --models-root " + shell_quote(shared->models_root.string()) + + " > " + shell_quote(installed_output_path.string()) + + " 2>> " + shell_quote(size_error_path.string()); + (void) std::system(installed_command.c_str()); + std::string command = python_command() + " -u " + shell_quote(script.string()) + + " sizes --json --models-root " + shell_quote(shared->models_root.string()) + + " > " + shell_quote(size_output_path.string()) + + " 2> " + shell_quote(size_error_path.string()); + const int result = std::system(command.c_str()); + std::string output = read_log_tail_text(size_output_path); + while (!output.empty() && std::isspace(static_cast(output.back()))) { + output.pop_back(); + } + const bool valid_output = !output.empty() && output.front() == '[' && output.back() == ']'; + std::lock_guard lock(shared->mutex); + if (shared->size_generation != scan_generation) { + return; + } + if (result == 0 && valid_output) { + shared->size_state = "complete"; + shared->size_message = "Package sizes are ready"; + } else { + shared->size_state = "failed"; + shared->size_message = read_log_tail_text(size_error_path); + if (shared->size_message.empty()) { + shared->size_message = "Package size check failed"; + } + } + } catch (const std::exception & error) { + std::lock_guard lock(shared->mutex); + if (shared->size_generation == scan_generation) { + shared->size_state = "failed"; + shared->size_message = error.what(); + } + } + }).detach(); + } + } + + std::lock_guard lock(state_->mutex); + std::string data = "[]"; + if (state_->size_state == "complete" || state_->size_state == "running") { + const auto & source = state_->size_state == "complete" + ? state_->size_output_path + : state_->installed_output_path; + data = read_log_tail_text(source); + while (!data.empty() && std::isspace(static_cast(data.back()))) { + data.pop_back(); + } + if (data.empty() || data.front() != '[' || data.back() != ']') { + data = "[]"; + } + } + return "{\"state\":" + json_quote(state_->size_state) + + ",\"message\":" + json_quote(state_->size_message) + + ",\"data\":" + data + "}"; +} + +} // namespace minitts::server diff --git a/app/server/model_installer.h b/app/server/model_installer.h new file mode 100644 index 00000000..c65672c1 --- /dev/null +++ b/app/server/model_installer.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include + +namespace minitts::server { + +// Runs the repository model manager outside the request thread. The native UI +// owns the workflow and status display. Normal packages use model_manager_v2; +// requests with converter inputs use the deprecated manager until those legacy +// preparation workflows have migrated to package specs. +class ModelInstaller { +public: + ModelInstaller(std::filesystem::path repository_root, std::filesystem::path models_root); + ~ModelInstaller(); + + ModelInstaller(const ModelInstaller &) = delete; + ModelInstaller & operator=(const ModelInstaller &) = delete; + + std::string start( + const std::string & package_id, + const std::string & source_file, + const std::string & output_file, + const std::string & source_directory, + const std::string & variant, + bool overwrite); + std::string status(const std::string & package_id = {}) const; + std::string package_sizes(); + std::string remove(const std::string & package_id); + bool has_active_jobs() const; + +private: + struct State; + std::shared_ptr state_; +}; + +} // namespace minitts::server diff --git a/app/server/runtime.cpp b/app/server/runtime.cpp index 46ce9755..4caab7d4 100644 --- a/app/server/runtime.cpp +++ b/app/server/runtime.cpp @@ -1,6 +1,7 @@ #include "runtime.h" #include "multipart.h" +#include "ui_assets.h" #include "../cli/request.h" #include "../streaming/pcm_source.h" @@ -24,6 +25,7 @@ #include #include #include +#include #include #include @@ -57,6 +59,47 @@ std::filesystem::path resolve_path(const std::filesystem::path & base, const std return path.is_absolute() ? path : base / path; } +std::unordered_map options_from_object(const Value * value); + +std::string safe_upload_name(std::string value) { + value = std::filesystem::path(value).filename().string(); + for (char & ch : value) { + const auto uch = static_cast(ch); + if (!(std::isalnum(uch) || ch == '.' || ch == '-' || ch == '_')) { + ch = '_'; + } + } + if (value.empty() || value == "." || value == "..") { + value = "audio.wav"; + } + return value; +} + +ServerModelConfig model_config_from_json( + const Value & body, + const std::filesystem::path & request_base, + bool lazy) { + ServerModelConfig model; + model.id = engine::io::json::require_string(body, "id"); + model.path = resolve_path(request_base, engine::io::json::require_string(body, "path")); + model.family = engine::io::json::require_string(body, "family"); + model.task = engine::io::json::optional_string(body, "task", model.task); + model.mode = engine::io::json::optional_string(body, "mode", model.mode); + model.lazy = lazy; + if (const auto * value = body.find("model_spec_override")) { + model.model_spec_override = resolve_path(request_base, value->as_string()); + } + if (const auto * value = body.find("config")) { + model.config_id = value->as_string(); + } + if (const auto * value = body.find("weight")) { + model.weight_id = value->as_string(); + } + model.load_options = options_from_object(body.find("load_options")); + model.session_options = options_from_object(body.find("session_options")); + return model; +} + // Minimal application/x-www-form-urlencoded query string lookup, e.g. // query_param("model=pocket-tts&foo=bar", "model") -> "pocket-tts". std::string query_param(const std::string & query, const std::string & key) { @@ -629,37 +672,123 @@ engine::runtime::TaskRequest build_openai_transcription_request( return request; } +template +std::optional find_ancestor( + std::filesystem::path start, + Predicate predicate) { + std::error_code ec; + start = std::filesystem::absolute(std::move(start), ec).lexically_normal(); + if (ec) { + return std::nullopt; + } + for (;;) { + if (predicate(start)) { + return start; + } + const auto parent = start.parent_path(); + if (parent.empty() || parent == start) { + return std::nullopt; + } + start = parent; + } +} + +template +std::optional find_from_roots( + const std::filesystem::path & request_base, + const std::filesystem::path & resource_anchor, + Predicate predicate) { + if (auto found = find_ancestor(request_base, predicate)) { + return found; + } + if (!resource_anchor.empty()) { + return find_ancestor(resource_anchor, predicate); + } + return std::nullopt; +} + } // namespace -ServerState::ServerState(ServerConfig config, std::filesystem::path request_base) +ServerState::ServerState( + ServerConfig config, + std::filesystem::path request_base, + std::filesystem::path ui_resource_anchor) : config_(std::move(config)), - request_base_(std::move(request_base)) { + request_base_(std::filesystem::absolute(std::move(request_base)).lexically_normal()) { if (config_.backend != engine::core::BackendType::Cuda) { std::cerr << "audio.cpp is optimized for CUDA. The " << backend_name(config_.backend) << " server backend is intended for portability and testing, but performance and model coverage may be lower than CUDA.\n"; } + if (config_.ui_management) { + repository_root_ = find_from_roots( + request_base_, + ui_resource_anchor, + [](const std::filesystem::path & root) { + return std::filesystem::is_regular_file(root / "tools" / "model_manager_v2.py") && + std::filesystem::is_directory(root / "model_specs"); + }).value_or(request_base_); + const auto binary_directory = ui_resource_anchor.empty() + ? request_base_ + : std::filesystem::absolute(ui_resource_anchor).lexically_normal(); + default_models_root_ = (binary_directory / "models").lexically_normal(); + models_root_ = default_models_root_; + request_base_ = binary_directory; + std::filesystem::create_directories(models_root_); + std::cerr + << "native WebUI model root: " << models_root_ << "\n" + << "native WebUI package resources: " << repository_root_ << "\n"; + upload_root_ = std::filesystem::temp_directory_path() / + ("audiocpp-ui-" + std::to_string( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count())); + std::filesystem::create_directories(upload_root_); + model_installer_ = std::make_unique( + repository_root_, + models_root_); + } load_models(); } +ServerState::~ServerState() { + if (!upload_root_.empty()) { + std::error_code ec; + std::filesystem::remove_all(upload_root_, ec); + } +} + HttpResponse ServerState::handle(const HttpRequest & request) { HttpResponse response; const std::string allowed_origin = get_allowed_origin(request); try { log_request_body_if_enabled(config_, request); - if (request.method == "OPTIONS" && !allowed_origin.empty()) { + if (request.method == "OPTIONS" && (!allowed_origin.empty() || config_.ui_enabled)) { response.status = 204; response.content_type = "text/plain"; response.headers["Access-Control-Allow-Headers"] = "*"; response.headers["Access-Control-Allow-Methods"] = "GET, POST"; } + else if (request.method == "GET" && (request.path == "/" || request.path == "/index.html")) { + response = handle_ui_asset(); + } + else if (request.method == "GET" && request.path == "/favicon.ico") { + response.status = 204; + response.content_type = "image/x-icon"; + } else if (request.method == "GET" && request.path == "/health") { + size_t model_count = 0; + { + std::lock_guard lock(models_mutex_); + model_count = models_.size(); + } response = json_response( "{\"status\":\"ok\",\"backend\":\"" + std::string(backend_name(config_.backend)) + "\",\"models\":" + - std::to_string(models_.size()) + + std::to_string(model_count) + + ",\"ui\":" + (config_.ui_enabled ? "true" : "false") + + ",\"ui_management\":" + (config_.ui_management ? "true" : "false") + "}"); } else if (request.method == "GET" && request.path == "/v1/models") { @@ -668,6 +797,39 @@ HttpResponse ServerState::handle(const HttpRequest & request) { else if (request.method == "GET" && request.path == "/v1/audio/voices") { response = handle_voices(request); } + else if (request.method == "POST" && request.path == "/v1/models/load") { + response = handle_model_load(request.body); + } + else if (request.method == "POST" && request.path == "/v1/models/unload") { + response = handle_model_unload(request.body); + } + else if (request.method == "POST" && request.path == "/v1/ui/path-status") { + response = handle_path_status(request.body); + } + else if (request.method == "POST" && request.path == "/v1/ui/upload") { + response = handle_ui_upload(request); + } + else if (request.method == "GET" && request.path == "/v1/ui/models-root") { + response = handle_models_root_get(); + } + else if (request.method == "POST" && request.path == "/v1/ui/models-root") { + response = handle_models_root_set(request.body); + } + else if (request.method == "POST" && request.path == "/v1/ui/browse-directories") { + response = handle_directory_browser(request.body); + } + else if (request.method == "POST" && request.path == "/v1/ui/models/install") { + response = handle_model_install(request.body); + } + else if (request.method == "POST" && request.path == "/v1/ui/models/delete") { + response = handle_model_remove(request.body); + } + else if (request.method == "GET" && request.path == "/v1/ui/models/install-status") { + response = handle_model_install_status(request); + } + else if (request.method == "GET" && request.path == "/v1/ui/models/package-sizes") { + response = handle_model_package_sizes(); + } else if (request.method == "POST" && request.path == "/v1/audio/speech") { response = handle_speech(request.body); } @@ -703,16 +865,10 @@ HttpResponse ServerState::handle(const HttpRequest & request) { void ServerState::load_models() { for (auto & config : config_.models) { - auto loaded = std::make_unique(); - loaded->config = std::move(config); - loaded->task = engine::runtime::TaskSpec{ - engine::runtime::parse_voice_task_kind(loaded->config.task), - engine::runtime::parse_run_mode(loaded->config.mode), - }; + auto loaded = make_model(std::move(config)); if (!model_index_.emplace(loaded->config.id, models_.size()).second) { throw std::runtime_error("duplicate server model id: " + loaded->config.id); } - load_voice_presets(*loaded); if (!loaded->config.lazy) { ensure_model_loaded_locked(*loaded); } @@ -720,6 +876,394 @@ void ServerState::load_models() { } } +std::unique_ptr ServerState::make_model(ServerModelConfig config) { + auto loaded = std::make_unique(); + loaded->config = std::move(config); + loaded->task = engine::runtime::TaskSpec{ + engine::runtime::parse_voice_task_kind(loaded->config.task), + engine::runtime::parse_run_mode(loaded->config.mode), + }; + load_voice_presets(*loaded); + return loaded; +} + +HttpResponse ServerState::handle_model_load(const std::string & body_text) { + if (!config_.ui_management) { + return error_response(403, "dynamic model management is disabled", "forbidden"); + } + const auto body = engine::io::json::parse(body_text); + auto requested = model_config_from_json(body, request_base_, false); + requested.path = resolve_ui_model_path(engine::io::json::require_string(body, "path")); + + LoadedModel * existing = nullptr; + { + std::lock_guard state_lock(models_mutex_); + const auto found = model_index_.find(requested.id); + if (found != model_index_.end()) { + existing = models_.at(found->second).get(); + } + } + + if (existing != nullptr) { + BusyGuard::Lock run_lock = acquire_model_run(*existing, std::nullopt); + std::unique_lock metadata_lock(existing->metadata_mutex); + const bool changed = + existing->config.path != requested.path || + existing->config.family != requested.family || + existing->config.task != requested.task || + existing->config.mode != requested.mode || + existing->config.load_options != requested.load_options || + existing->config.session_options != requested.session_options || + existing->config.model_spec_override != requested.model_spec_override; + if (changed) { + existing->streaming = nullptr; + existing->offline = nullptr; + existing->session.reset(); + existing->model.reset(); + existing->loaded.store(false); + existing->voice_presets.clear(); + existing->default_voice_preset.reset(); + existing->config = std::move(requested); + existing->task = engine::runtime::TaskSpec{ + engine::runtime::parse_voice_task_kind(existing->config.task), + engine::runtime::parse_run_mode(existing->config.mode), + }; + load_voice_presets(*existing); + } + ensure_model_loaded_locked(*existing); + return json_response( + "{\"id\":" + json_quote(existing->config.id) + + ",\"loaded\":true,\"reconfigured\":" + (changed ? "true" : "false") + "}"); + } + + auto loaded = make_model(std::move(requested)); + ensure_model_loaded_locked(*loaded); + const std::string id = loaded->config.id; + { + std::lock_guard state_lock(models_mutex_); + if (model_index_.find(id) != model_index_.end()) { + throw std::runtime_error("model id was registered concurrently: " + id); + } + model_index_.emplace(id, models_.size()); + models_.push_back(std::move(loaded)); + } + return json_response("{\"id\":" + json_quote(id) + ",\"loaded\":true,\"reconfigured\":false}"); +} + +std::filesystem::path ServerState::resolve_ui_model_path(const std::filesystem::path & path) const { + if (path.is_absolute()) { + return path; + } + + const auto relative = path.lexically_normal(); + const auto generic = relative.generic_string(); + if (!repository_root_.empty() && + (generic == "assets/framework" || generic.rfind("assets/framework/", 0) == 0)) { + return (repository_root_ / relative).lexically_normal(); + } + return (request_base_ / relative).lexically_normal(); +} + +HttpResponse ServerState::handle_model_unload(const std::string & body_text) { + if (!config_.ui_management) { + return error_response(403, "dynamic model management is disabled", "forbidden"); + } + const auto body = engine::io::json::parse(body_text); + const std::string id = engine::io::json::require_string(body, "id"); + LoadedModel * model = nullptr; + { + std::lock_guard state_lock(models_mutex_); + const auto found = model_index_.find(id); + if (found == model_index_.end()) { + return error_response(404, "unknown model id: " + id, "not_found"); + } + model = models_.at(found->second).get(); + } + BusyGuard::Lock run_lock = acquire_model_run(*model, std::nullopt); + model->streaming = nullptr; + model->offline = nullptr; + model->session.reset(); + model->model.reset(); + model->loaded.store(false); + return json_response("{\"id\":" + json_quote(id) + ",\"loaded\":false}"); +} + +HttpResponse ServerState::handle_path_status(const std::string & body_text) const { + if (!config_.ui_management) { + return error_response(403, "UI path inspection is disabled", "forbidden"); + } + const auto body = engine::io::json::parse(body_text); + const auto path = resolve_ui_model_path(engine::io::json::require_string(body, "path")); + std::error_code ec; + const bool exists = std::filesystem::exists(path, ec); + const bool directory = exists && std::filesystem::is_directory(path, ec); + const bool regular_file = exists && std::filesystem::is_regular_file(path, ec); + return json_response( + "{\"path\":" + json_quote(path.string()) + + ",\"exists\":" + (exists ? "true" : "false") + + ",\"directory\":" + (directory ? "true" : "false") + + ",\"file\":" + (regular_file ? "true" : "false") + "}"); +} + +HttpResponse ServerState::handle_ui_upload(const HttpRequest & request) { + if (!config_.ui_management) { + return error_response(403, "UI uploads are disabled", "forbidden"); + } + if (request.body.empty()) { + return error_response(400, "upload body is empty", "invalid_request_error"); + } + constexpr size_t kMaxUploadBytes = size_t{2} * 1024 * 1024 * 1024; + if (request.body.size() > kMaxUploadBytes) { + return error_response(413, "upload exceeds the 2 GiB limit", "invalid_request_error"); + } + std::string filename = "audio.wav"; + if (const auto it = request.headers.find("x-audiocpp-filename"); it != request.headers.end()) { + filename = safe_upload_name(it->second); + } + const auto id = next_upload_id_.fetch_add(1); + const auto path = upload_root_ / (std::to_string(id) + "-" + filename); + std::ofstream out(path, std::ios::binary); + if (!out) { + throw std::runtime_error("could not create temporary upload: " + path.string()); + } + out.write(request.body.data(), static_cast(request.body.size())); + if (!out) { + throw std::runtime_error("could not write temporary upload: " + path.string()); + } + return json_response( + "{\"path\":" + json_quote(path.string()) + + ",\"bytes\":" + std::to_string(request.body.size()) + "}"); +} + +HttpResponse ServerState::handle_model_install(const std::string & body_text) { + if (!config_.ui_management) { + return error_response(403, "UI model installation is disabled", "forbidden"); + } + const auto body = engine::io::json::parse(body_text); + const std::string package_id = engine::io::json::require_string(body, "id"); + const std::string source_directory = + engine::io::json::optional_string(body, "source_directory", ""); + const std::string source_file = + engine::io::json::optional_string(body, "source_file", ""); + const std::string output_file = + engine::io::json::optional_string(body, "output_file", ""); + const std::string variant = + engine::io::json::optional_string(body, "variant", ""); + const bool overwrite = + engine::io::json::optional_bool(body, "overwrite", false); + std::lock_guard lock(model_installer_mutex_); + if (!model_installer_) { + return error_response(403, "UI model installation is disabled", "forbidden"); + } + return json_response(model_installer_->start( + package_id, + source_file, + output_file, + source_directory, + variant, + overwrite)); +} + +HttpResponse ServerState::handle_model_remove(const std::string & body_text) { + if (!config_.ui_management) { + return error_response(403, "UI model removal is disabled", "forbidden"); + } + const auto body = engine::io::json::parse(body_text); + const std::string package_id = engine::io::json::require_string(body, "id"); + std::lock_guard lock(model_installer_mutex_); + if (!model_installer_) { + return error_response(403, "UI model removal is disabled", "forbidden"); + } + return json_response(model_installer_->remove(package_id)); +} + +HttpResponse ServerState::handle_model_install_status(const HttpRequest & request) const { + if (!config_.ui_management) { + return error_response(403, "UI model installation is disabled", "forbidden"); + } + std::lock_guard lock(model_installer_mutex_); + if (!model_installer_) { + return error_response(403, "UI model installation is disabled", "forbidden"); + } + return json_response(model_installer_->status(query_param(request.query, "id"))); +} + +HttpResponse ServerState::handle_model_package_sizes() { + if (!config_.ui_management) { + return error_response(403, "UI model installation is disabled", "forbidden"); + } + std::lock_guard lock(model_installer_mutex_); + if (!model_installer_) { + return error_response(403, "UI model installation is disabled", "forbidden"); + } + return json_response(model_installer_->package_sizes()); +} + +HttpResponse ServerState::handle_models_root_get() const { + if (!config_.ui_management) { + return error_response(403, "UI model management is disabled", "forbidden"); + } + std::lock_guard lock(model_installer_mutex_); + if (!model_installer_) { + return error_response(403, "UI model management is disabled", "forbidden"); + } + return json_response( + "{\"models_root\":" + json_quote(models_root_.string()) + + ",\"default_models_root\":" + json_quote(default_models_root_.string()) + + ",\"is_default\":" + (models_root_ == default_models_root_ ? "true" : "false") + "}"); +} + +HttpResponse ServerState::handle_models_root_set(const std::string & body_text) { + if (!config_.ui_management) { + return error_response(403, "UI model management is disabled", "forbidden"); + } + const auto body = engine::io::json::parse(body_text); + const auto requested_text = engine::io::json::optional_string(body, "path", ""); + auto requested = requested_text.empty() + ? default_models_root_ + : std::filesystem::path(requested_text); + if (requested.is_relative()) { + requested = request_base_ / requested; + } + requested = std::filesystem::absolute(requested).lexically_normal(); + std::lock_guard lock(model_installer_mutex_); + if (!model_installer_) { + return error_response(403, "UI model management is disabled", "forbidden"); + } + if (requested == models_root_) { + return json_response( + "{\"models_root\":" + json_quote(models_root_.string()) + + ",\"default_models_root\":" + json_quote(default_models_root_.string()) + + ",\"is_default\":" + (models_root_ == default_models_root_ ? "true" : "false") + "}"); + } + if (model_installer_->has_active_jobs()) { + return error_response( + 409, + "cannot change the models folder while a package download is running", + "model_install_active"); + } + + std::error_code error; + if (std::filesystem::exists(requested, error) && !std::filesystem::is_directory(requested, error)) { + return error_response(400, "models folder path is not a directory: " + requested.string(), "invalid_request_error"); + } + std::filesystem::create_directories(requested, error); + if (error) { + return error_response( + 400, + "could not create models folder '" + requested.string() + "': " + error.message(), + "invalid_request_error"); + } + + models_root_ = requested; + model_installer_ = std::make_unique(repository_root_, models_root_); + std::cerr << "native WebUI model root changed to: " << models_root_ << "\n"; + return json_response( + "{\"models_root\":" + json_quote(models_root_.string()) + + ",\"default_models_root\":" + json_quote(default_models_root_.string()) + + ",\"is_default\":" + (models_root_ == default_models_root_ ? "true" : "false") + "}"); +} + +HttpResponse ServerState::handle_directory_browser(const std::string & body_text) const { + if (!config_.ui_management) { + return error_response(403, "UI directory browsing is disabled", "forbidden"); + } + const auto body = engine::io::json::parse(body_text); + const auto requested_text = engine::io::json::optional_string(body, "path", ""); + + std::filesystem::path current; + { + std::lock_guard lock(model_installer_mutex_); + current = requested_text.empty() ? models_root_ : std::filesystem::path(requested_text); + } + if (current.is_relative()) { + current = request_base_ / current; + } + current = std::filesystem::absolute(current).lexically_normal(); + + std::error_code error; + if (!std::filesystem::is_directory(current, error)) { + return error_response( + 400, + "folder is not accessible: " + current.string(), + "invalid_request_error"); + } + + std::vector roots; +#ifdef _WIN32 + for (char drive = 'A'; drive <= 'Z'; ++drive) { + const auto root = std::filesystem::path(std::string(1, drive) + ":\\"); + error.clear(); + if (std::filesystem::is_directory(root, error)) { + roots.push_back(root); + } + } +#else + roots.emplace_back("/"); +#endif + + struct DirectoryEntry { + std::string name; + std::filesystem::path path; + }; + std::vector directories; + error.clear(); + std::filesystem::directory_iterator iterator( + current, + std::filesystem::directory_options::skip_permission_denied, + error); + const std::filesystem::directory_iterator end; + while (!error && iterator != end) { + std::error_code entry_error; + if (iterator->is_directory(entry_error)) { + directories.push_back(DirectoryEntry{ + iterator->path().filename().string(), + iterator->path().lexically_normal(), + }); + } + iterator.increment(error); + } + std::sort(directories.begin(), directories.end(), [](const auto & left, const auto & right) { + return left.name < right.name; + }); + + auto parent = current.parent_path(); + if (parent == current) { + parent.clear(); + } + std::string response = + "{\"current\":" + json_quote(current.string()) + + ",\"parent\":" + json_quote(parent.string()) + + ",\"roots\":["; + for (size_t index = 0; index < roots.size(); ++index) { + if (index > 0) response += ","; + response += json_quote(roots[index].string()); + } + response += "],\"directories\":["; + for (size_t index = 0; index < directories.size(); ++index) { + if (index > 0) response += ","; + response += "{\"name\":" + json_quote(directories[index].name) + + ",\"path\":" + json_quote(directories[index].path.string()) + "}"; + } + return json_response(response + "]}"); +} + +HttpResponse ServerState::handle_ui_asset() const { + if (!config_.ui_enabled) { + return error_response(404, "WebUI is disabled", "not_found"); + } + HttpResponse response; + response.status = 200; + response.content_type = "text/html; charset=utf-8"; + const auto html = embedded_ui_html(); + response.body.assign(html.data(), html.size()); + response.headers["Cache-Control"] = "no-cache"; + response.headers["Content-Security-Policy"] = + "default-src 'self' 'unsafe-inline' blob: data:; connect-src 'self'; media-src 'self' blob: data:"; + response.headers["X-Content-Type-Options"] = "nosniff"; + return response; +} + ServerState::LoadedModel::RuntimeVoicePreset ServerState::load_runtime_voice_preset( const ServerModelConfig::VoicePreset & preset) const { LoadedModel::RuntimeVoicePreset out; @@ -807,6 +1351,7 @@ void ServerState::ensure_model_loaded_locked(LoadedModel & model) { model.session = std::move(session); model.offline = offline; model.streaming = streaming; + model.loaded.store(true); } LiveIngestLimits ServerState::live_ingest_limits(const HttpRequest & request) const { @@ -823,6 +1368,7 @@ LiveIngestLimits ServerState::live_ingest_limits(const HttpRequest & request) co ServerState::LoadedModel & ServerState::require_model(const Value & body) { const std::string id = engine::io::json::require_string(body, "model"); + std::lock_guard state_lock(models_mutex_); const auto it = model_index_.find(id); if (it == model_index_.end()) { throw std::runtime_error("unknown model id: " + id); @@ -850,6 +1396,7 @@ const ServerState::LoadedModel::RuntimeVoicePreset * ServerState::select_voice_p } engine::runtime::TaskRequest ServerState::build_speech_request(const LoadedModel & model, const Value & body) const { + std::shared_lock metadata_lock(model.metadata_mutex); engine::runtime::TaskRequest request; request.text_input = engine::runtime::Transcript{ engine::io::json::require_string(body, "input"), @@ -935,16 +1482,24 @@ struct ServerState::TimedTaskResult { std::optional ttft_ms; }; -int ServerState::model_busy_timeout_ceiling(const LoadedModel & model) const { - return model.config.busy_timeout_ms.value_or(config_.busy_timeout_ms); +engine::runtime::RunMode ServerState::model_run_mode(const LoadedModel & model) const { + std::shared_lock metadata_lock(model.metadata_mutex); + return model.task.mode; } BusyGuard::Lock ServerState::acquire_model_run( LoadedModel & model, std::optional request_timeout_ms) { - const int timeout_ms = - resolve_busy_timeout_ms(model_busy_timeout_ceiling(model), request_timeout_ms); - return model.busy.acquire(timeout_ms, model.config.id); + int timeout_ms = 0; + std::string model_id; + { + std::shared_lock metadata_lock(model.metadata_mutex); + timeout_ms = resolve_busy_timeout_ms( + model.config.busy_timeout_ms.value_or(config_.busy_timeout_ms), + request_timeout_ms); + model_id = model.config.id; + } + return model.busy.acquire(timeout_ms, model_id); } ServerState::TimedTaskResult ServerState::run_model( @@ -1024,7 +1579,7 @@ HttpResponse ServerState::handle_speech(const std::string & body_text) { return handle_speech_stream(model, request, body); } const auto busy_timeout_ms = parse_busy_timeout_override(body); - const auto timed_result = model.task.mode == engine::runtime::RunMode::Streaming + const auto timed_result = model_run_mode(model) == engine::runtime::RunMode::Streaming ? run_streaming_model(model, request, {}, busy_timeout_ms) : run_model(model, request, busy_timeout_ms); const auto & audio = select_audio_output(timed_result.result); @@ -1047,7 +1602,7 @@ HttpResponse ServerState::handle_speech_stream( LoadedModel & model, const engine::runtime::TaskRequest & request, const Value & body) { - if (model.task.mode != engine::runtime::RunMode::Streaming) { + if (model_run_mode(model) != engine::runtime::RunMode::Streaming) { throw std::runtime_error("speech streaming requires a model configured with mode=streaming"); } const auto stream_format = engine::io::json::optional_string(body, "stream_format", "sse"); @@ -1219,7 +1774,7 @@ HttpResponse ServerState::run_transcription( LoadedModel & model, const engine::runtime::TaskRequest & request, std::optional busy_timeout_ms) { - const auto timed_result = model.task.mode == engine::runtime::RunMode::Streaming + const auto timed_result = model_run_mode(model) == engine::runtime::RunMode::Streaming ? run_streaming_model(model, request, {}, busy_timeout_ms) : run_model(model, request, busy_timeout_ms); const auto & result = timed_result.result; @@ -1238,7 +1793,7 @@ HttpResponse ServerState::run_transcription_stream( LoadedModel & model, const engine::runtime::TaskRequest & request, std::optional busy_timeout_ms) { - if (model.task.mode != engine::runtime::RunMode::Streaming) { + if (model_run_mode(model) != engine::runtime::RunMode::Streaming) { throw std::runtime_error("transcription stream=true requires a model configured with mode=streaming"); } LoadedModel * model_ptr = &model; @@ -1430,7 +1985,7 @@ HttpResponse ServerState::handle_generic_run(const std::string & body_text) { request_json != nullptr ? *request_json : body, request_base_)); const auto busy_timeout_ms = parse_busy_timeout_override(body); - const auto timed_result = model.task.mode == engine::runtime::RunMode::Streaming + const auto timed_result = model_run_mode(model) == engine::runtime::RunMode::Streaming ? run_streaming_model(model, request, {}, busy_timeout_ms) : run_model(model, request, busy_timeout_ms); return json_response(task_result_json(timed_result.result, timed_result.wall_ms)); @@ -1472,6 +2027,7 @@ HttpResponse ServerState::handle_generic_stream(const std::string & body_text) { // potentially Open WebUI) that call GET /v1/audio/voices?model= to populate a voice picker // instead of guessing generic names like "alloy"/"nova". HttpResponse ServerState::handle_voices(const HttpRequest & request) const { + std::lock_guard state_lock(models_mutex_); const std::string model_id = query_param(request.query, "model"); std::vector voices; @@ -1485,11 +2041,13 @@ HttpResponse ServerState::handle_voices(const HttpRequest & request) const { model_idx = 0; } if (model_idx != SIZE_MAX) { - for (const auto & [name, preset] : models_.at(model_idx)->voice_presets) { + const auto & model = *models_.at(model_idx); + std::shared_lock metadata_lock(model.metadata_mutex); + for (const auto & [name, preset] : model.voice_presets) { (void) preset; voices.push_back(name); } - const auto embeddings_dir = models_.at(model_idx)->config.path / "embeddings"; + const auto embeddings_dir = model.config.path / "embeddings"; std::error_code ec; if (std::filesystem::is_directory(embeddings_dir, ec)) { for (const auto & entry : std::filesystem::directory_iterator(embeddings_dir, ec)) { @@ -1515,6 +2073,7 @@ HttpResponse ServerState::handle_voices(const HttpRequest & request) const { } std::string ServerState::models_json() const { + std::lock_guard state_lock(models_mutex_); std::ostringstream out; out << "{\"object\":\"list\",\"data\":["; for (size_t i = 0; i < models_.size(); ++i) { @@ -1522,12 +2081,15 @@ std::string ServerState::models_json() const { out << ","; } const auto & model = *models_[i]; + std::shared_lock metadata_lock(model.metadata_mutex); out << "{\"id\":" << json_quote(model.config.id) << ",\"object\":\"model\"" << ",\"owned_by\":\"engine\"" << ",\"family\":" << json_quote(model.config.family) << ",\"task\":" << json_quote(engine::runtime::to_string(model.task.task)) << ",\"mode\":" << json_quote(engine::runtime::to_string(model.task.mode)) + << ",\"loaded\":" << (model.loaded.load() ? "true" : "false") + << ",\"path\":" << json_quote(model.config.path.string()) << "}"; } out << "]}"; diff --git a/app/server/runtime.h b/app/server/runtime.h index 298dfe36..6ddfdd29 100644 --- a/app/server/runtime.h +++ b/app/server/runtime.h @@ -3,6 +3,7 @@ #include "busy_guard.h" #include "config.h" #include "http.h" +#include "model_installer.h" #include "../streaming/streaming.h" @@ -16,6 +17,7 @@ #include #include #include +#include #include #include @@ -23,7 +25,11 @@ namespace minitts::server { class ServerState final : public IHttpHandler { public: - ServerState(ServerConfig config, std::filesystem::path request_base); + ServerState( + ServerConfig config, + std::filesystem::path request_base, + std::filesystem::path ui_resource_anchor = {}); + ~ServerState() override; HttpResponse handle(const HttpRequest & request) override; @@ -47,6 +53,8 @@ class ServerState final : public IHttpHandler { std::unique_ptr session; engine::runtime::IOfflineVoiceTaskSession * offline = nullptr; engine::runtime::IStreamingVoiceTaskSession * streaming = nullptr; + std::atomic loaded{false}; + mutable std::shared_mutex metadata_mutex; std::unordered_map voice_presets; std::optional default_voice_preset; // Serializes runs on this model and bounds how long a caller waits for its @@ -61,9 +69,23 @@ class ServerState final : public IHttpHandler { // Server policy for this model: its own busy_timeout_ms if set, else the // top-level config value. - int model_busy_timeout_ceiling(const LoadedModel & model) const; + engine::runtime::RunMode model_run_mode(const LoadedModel & model) const; void load_models(); + std::unique_ptr make_model(ServerModelConfig config); + std::filesystem::path resolve_ui_model_path(const std::filesystem::path & path) const; + HttpResponse handle_model_load(const std::string & body_text); + HttpResponse handle_model_unload(const std::string & body_text); + HttpResponse handle_path_status(const std::string & body_text) const; + HttpResponse handle_ui_upload(const HttpRequest & request); + HttpResponse handle_model_install(const std::string & body_text); + HttpResponse handle_model_remove(const std::string & body_text); + HttpResponse handle_model_install_status(const HttpRequest & request) const; + HttpResponse handle_model_package_sizes(); + HttpResponse handle_models_root_get() const; + HttpResponse handle_models_root_set(const std::string & body_text); + HttpResponse handle_directory_browser(const std::string & body_text) const; + HttpResponse handle_ui_asset() const; LoadedModel::RuntimeVoicePreset load_runtime_voice_preset(const ServerModelConfig::VoicePreset & preset) const; void load_voice_presets(LoadedModel & model) const; void ensure_model_loaded_locked(LoadedModel & model); @@ -132,6 +154,14 @@ class ServerState final : public IHttpHandler { std::filesystem::path request_base_; std::vector> models_; std::unordered_map model_index_; + mutable std::mutex models_mutex_; + std::filesystem::path upload_root_; + std::filesystem::path repository_root_; + std::filesystem::path default_models_root_; + std::filesystem::path models_root_; + mutable std::mutex model_installer_mutex_; + std::unique_ptr model_installer_; + std::atomic next_upload_id_{1}; }; } // namespace minitts::server diff --git a/app/server/ui_assets.cpp b/app/server/ui_assets.cpp new file mode 100644 index 00000000..8ba18ae3 --- /dev/null +++ b/app/server/ui_assets.cpp @@ -0,0 +1,14 @@ +#include "ui_assets.h" + +#include "audiocpp_ui_asset.h" + +namespace minitts::server { + +std::string_view embedded_ui_html() noexcept { + return { + reinterpret_cast(kAudioCppUiHtml), + kAudioCppUiHtmlSize, + }; +} + +} // namespace minitts::server diff --git a/app/server/ui_assets.h b/app/server/ui_assets.h new file mode 100644 index 00000000..3773f358 --- /dev/null +++ b/app/server/ui_assets.h @@ -0,0 +1,9 @@ +#pragma once + +#include + +namespace minitts::server { + +std::string_view embedded_ui_html() noexcept; + +} // namespace minitts::server diff --git a/model_specs/pocket_tts.json b/model_specs/pocket_tts.json index 65f66d06..305fcaef 100644 --- a/model_specs/pocket_tts.json +++ b/model_specs/pocket_tts.json @@ -198,7 +198,8 @@ ], "download": { "kind": "huggingface_snapshot", - "repo": "kyutai/pocket-tts" + "repo": "kyutai/pocket-tts", + "gated": true } } ], diff --git a/scripts/package_windows_prebuilt.ps1 b/scripts/package_windows_prebuilt.ps1 index eac939bb..c2f5d293 100644 --- a/scripts/package_windows_prebuilt.ps1 +++ b/scripts/package_windows_prebuilt.ps1 @@ -155,6 +155,19 @@ Server: .\audiocpp_server.exe --config C:\path\to\server.json ``` +Native WebUI (no Python required): + +```powershell +.\audiocpp_server.exe --ui --backend cuda +``` + +Then open `http://127.0.0.1:8080`. + +Inference and the WebUI do not require Python. The optional **Install / prepare** +button uses the bundled `tools\model_manager_v2.py` for normal downloads and +`tools\model_manager_deprecated.py` for legacy conversion workflows; install Python dependencies +only when downloading or converting packages through that button. + ## Notes - Models are not bundled. @@ -195,6 +208,19 @@ Server: .\audiocpp_server.exe --config C:\path\to\server.json ``` +Native WebUI (no Python required): + +```powershell +.\audiocpp_server.exe --ui --backend cpu +``` + +Then open `http://127.0.0.1:8080`. + +Inference and the WebUI do not require Python. The optional **Install / prepare** +button uses the bundled `tools\model_manager_v2.py` for normal downloads and +`tools\model_manager_deprecated.py` for legacy conversion workflows; install Python dependencies +only when downloading or converting packages through that button. + ## Notes - Models are not bundled. @@ -282,6 +308,23 @@ function New-PrebuiltPackage { $stageDir = Join-Path $OutputDir $packageName Copy-TreeContents $sourceBin $stageDir + # The embedded UI runs entirely from audiocpp_server.exe. Keep the small + # preparation helpers and package specs beside portable builds so the Models + # page can install current packages and retain legacy converter workflows. + $stageTools = Join-Path $stageDir "tools" + New-Item -ItemType Directory -Force -Path $stageTools | Out-Null + Copy-Item -LiteralPath (Join-Path $repoRoot "tools\model_manager_v2.py") -Destination $stageTools -Force + Copy-Item -LiteralPath (Join-Path $repoRoot "tools\model_manager_deprecated.py") -Destination $stageTools -Force + $communityTools = Join-Path $repoRoot "tools\community_models" + if (Test-Path -LiteralPath $communityTools) { + Copy-TreeContents $communityTools (Join-Path $stageTools "community_models") + } + Copy-TreeContents (Join-Path $repoRoot "model_specs") (Join-Path $stageDir "model_specs") + $modelManagerAssets = Join-Path $repoRoot "assets\model_manager" + if (Test-Path -LiteralPath $modelManagerAssets) { + Copy-TreeContents $modelManagerAssets (Join-Path $stageDir "assets\model_manager") + } + $crtDir = Find-VcRedistDir "Microsoft.VC143.CRT" $ompDir = Find-VcRedistDir "Microsoft.VC143.OpenMP" Copy-RequiredDll "MSVCP140.dll" @($crtDir) $stageDir | Out-Null diff --git a/tests/unittests/test_server_config.cpp b/tests/unittests/test_server_config.cpp index da1933f3..e56e9764 100644 --- a/tests/unittests/test_server_config.cpp +++ b/tests/unittests/test_server_config.cpp @@ -336,6 +336,41 @@ void test_negative_per_model_busy_timeout_is_rejected() { require(rejected, "negative per-model busy_timeout_ms is rejected, naming the model"); } +void test_ui_configuration() { + const auto root = make_temp_root(); + const auto config_path = write_config( + root, + "ui.json", + R"JSON({ + "ui": false, + "ui_management": true, + "models": [] +})JSON"); + + const auto config = minitts::server::load_server_config(config_path); + require(!config.ui_enabled, "ui=false disables the embedded WebUI"); + require(config.ui_management, "ui_management=true enables dynamic model management"); + require(config.models.empty(), "management hosts may start without configured models"); +} + +void test_empty_models_require_ui_management() { + const auto root = make_temp_root(); + const auto config_path = write_config( + root, + "empty_models.json", + R"JSON({ + "models": [] +})JSON"); + + bool rejected = false; + try { + (void) minitts::server::load_server_config(config_path); + } catch (const std::runtime_error & error) { + rejected = std::string(error.what()).find("ui_management") != std::string::npos; + } + require(rejected, "an empty static server config requires ui_management"); +} + // A request may shorten its own wait but must never lengthen it past server policy, // otherwise a client could reintroduce the unbounded hang the guard prevents. void test_request_timeout_is_clamped_to_policy() { @@ -388,6 +423,8 @@ int main() { test_negative_busy_timeout_is_rejected(); test_per_model_busy_timeout(); test_negative_per_model_busy_timeout_is_rejected(); + test_ui_configuration(); + test_empty_models_require_ui_management(); test_request_timeout_is_clamped_to_policy(); test_model_run_overrun_predicate(); } catch (const std::exception & error) { diff --git a/tests/unittests/test_server_model_installer.cpp b/tests/unittests/test_server_model_installer.cpp new file mode 100644 index 00000000..ba1a2b6f --- /dev/null +++ b/tests/unittests/test_server_model_installer.cpp @@ -0,0 +1,214 @@ +#include "model_installer.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +void require(bool condition, const std::string & message) { + if (!condition) { + throw std::runtime_error(message); + } +} + +std::filesystem::path make_root() { + const auto suffix = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + const auto root = std::filesystem::temp_directory_path() / + ("audiocpp-model-installer-test-" + std::to_string(suffix)); + std::filesystem::create_directories(root); + return root; +} + +void test_idle_status_and_validation() { + const auto root = make_root(); + try { + minitts::server::ModelInstaller installer(root, root / "models"); + const auto idle = installer.status("qwen3_asr_0_6b"); + require(idle.find("\"state\":\"idle\"") != std::string::npos, "unknown jobs report idle"); + require(idle.find("\"progress_percent\":-1") != std::string::npos, "idle jobs have no progress"); + + bool invalid_rejected = false; + try { + (void) installer.start("bad & package", "", "", "", "", false); + } catch (const std::runtime_error &) { + invalid_rejected = true; + } + require(invalid_rejected, "unsafe package ids are rejected"); + + bool missing_helper_reported = false; + try { + (void) installer.start("qwen3_asr_0_6b", "", "", "", "", false); + } catch (const std::runtime_error & error) { + missing_helper_reported = + std::string(error.what()).find("model_manager_v2.py") != std::string::npos; + } + require(missing_helper_reported, "a missing v2 preparation helper has a useful error"); + + std::filesystem::create_directories(root / "tools"); + { + std::ofstream script(root / "tools" / "model_manager_v2.py", std::ios::binary); + script + << "import sys, time\n" + << "if len(sys.argv) > 1 and sys.argv[1] == 'sizes':\n" + << " time.sleep(0.15)\n" + << " print('[{\\\"id\\\":\\\"demo_q8_0\\\",\\\"size_bytes\\\":25,\\\"state\\\":\\\"ok\\\",\\\"message\\\":\\\"\\\",\\\"installed\\\":true}]', flush=True)\n" + << " raise SystemExit(0)\n" + << "if len(sys.argv) > 1 and sys.argv[1] == 'installed':\n" + << " print('[{\\\"id\\\":\\\"demo_q8_0\\\",\\\"size_bytes\\\":null,\\\"state\\\":\\\"pending\\\",\\\"message\\\":\\\"\\\",\\\"installed\\\":true}]', flush=True)\n" + << " raise SystemExit(0)\n" + << "if len(sys.argv) > 1 and sys.argv[1] == 'uninstall':\n" + << " print('removed test package', flush=True)\n" + << " raise SystemExit(0)\n" + << "print('preparing package', flush=True)\n" + << "print('AUDIOCPP_PROGRESS downloaded=25 total=100', flush=True)\n" + << "time.sleep(0.35)\n" + << "print('AUDIOCPP_PROGRESS downloaded=100 total=100', flush=True)\n" + << "print('installed test package', flush=True)\n"; + } + const auto started = installer.start("qwen3_asr_0_6b", "", "", "", "", false); + require(started.find("\"state\":\"queued\"") != std::string::npos || + started.find("\"state\":\"running\"") != std::string::npos, + "a valid install starts in the background"); + bool observed_progress = false; + bool completed = false; + for (int attempt = 0; attempt < 100; ++attempt) { + const auto current = installer.status("qwen3_asr_0_6b"); + observed_progress = observed_progress || + current.find("\"downloaded_bytes\":25") != std::string::npos; + if (current.find("\"state\":\"complete\"") != std::string::npos) { + require(current.find("\"downloaded_bytes\":100") != std::string::npos, + "completed jobs preserve downloaded bytes"); + require(current.find("\"total_bytes\":100") != std::string::npos, + "completed jobs preserve total bytes"); + require(current.find("\"progress_percent\":100") != std::string::npos, + "completed jobs report 100 percent"); + completed = true; + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + require(observed_progress, "running progress markers are exposed through status JSON"); + require(completed, "the background test installation completes"); + + const auto initial_sizes = installer.package_sizes(); + require(initial_sizes.find("\"state\":\"running\"") != std::string::npos, + "package size scan starts in the background"); + const auto removed = installer.remove("qwen3_asr_0_6b"); + require(removed.find("\"removed\":true") != std::string::npos, + "a package can be removed while an older metadata scan is running"); + require(removed.find("removed test package") != std::string::npos, + "package removal reports the manager result"); + + bool sizes_completed = false; + for (int attempt = 0; attempt < 100; ++attempt) { + const auto sizes = installer.package_sizes(); + if (sizes.find("\"state\":\"complete\"") != std::string::npos) { + require(sizes.find("\"id\":\"demo_q8_0\"") != std::string::npos, + "package size metadata includes its package id"); + require(sizes.find("\"size_bytes\":25") != std::string::npos, + "package size metadata includes the summed byte count"); + require(sizes.find("\"installed\":true") != std::string::npos, + "package metadata includes its installed state"); + sizes_completed = true; + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + require(sizes_completed, "the background package size scan completes"); + + const auto other_root = make_root(); + try { + std::filesystem::create_directories(other_root / "tools"); + { + std::ofstream script(other_root / "tools" / "model_manager_v2.py", std::ios::binary); + script + << "import sys\n" + << "row = '[{\\\"id\\\":\\\"other_q8_0\\\",\\\"size_bytes\\\":50," + "\\\"state\\\":\\\"ok\\\",\\\"message\\\":\\\"\\\"," + "\\\"installed\\\":false}]'\n" + << "print(row, flush=True)\n"; + } + { + minitts::server::ModelInstaller other(other_root, other_root / "models"); + (void) other.package_sizes(); + bool other_completed = false; + for (int attempt = 0; attempt < 100; ++attempt) { + const auto sizes = other.package_sizes(); + if (sizes.find("\"state\":\"complete\"") != std::string::npos) { + require(sizes.find("\"id\":\"other_q8_0\"") != std::string::npos, + "a second installer reads its own metadata output"); + other_completed = true; + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + require(other_completed, "the second package size scan completes"); + } + const auto original_sizes = installer.package_sizes(); + require(original_sizes.find("\"id\":\"demo_q8_0\"") != std::string::npos, + "installer instances keep isolated metadata output files"); + require(original_sizes.find("\"id\":\"other_q8_0\"") == std::string::npos, + "another installer cannot overwrite a live inventory snapshot"); + } catch (...) { + std::error_code ec; + std::filesystem::remove_all(other_root, ec); + throw; + } + { + std::error_code ec; + std::filesystem::remove_all(other_root, ec); + } + + const auto restarted = installer.start("qwen3_asr_0_6b", "", "", "", "", false); + require(restarted.find("\"state\":\"queued\"") != std::string::npos || + restarted.find("\"state\":\"running\"") != std::string::npos, + "a completed package can be prepared again"); + bool repeated_completed = false; + for (int attempt = 0; attempt < 100; ++attempt) { + const auto current = installer.status("qwen3_asr_0_6b"); + if (current.find("\"state\":\"complete\"") != std::string::npos) { + repeated_completed = true; + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + require(repeated_completed, "the repeated package preparation completes"); + const auto refreshed_sizes = installer.package_sizes(); + require(refreshed_sizes.find("\"state\":\"running\"") != std::string::npos, + "a successful install invalidates the cached package inventory"); + + bool missing_legacy_helper_reported = false; + try { + (void) installer.start("qwen3_asr_0_6b", "checkpoint.bin", "", "", "", false); + } catch (const std::runtime_error & error) { + missing_legacy_helper_reported = + std::string(error.what()).find("model_manager_deprecated.py") != std::string::npos; + } + require(missing_legacy_helper_reported, "converter inputs select the legacy preparation helper"); + } catch (...) { + std::error_code ec; + std::filesystem::remove_all(root, ec); + throw; + } + std::error_code ec; + std::filesystem::remove_all(root, ec); +} + +} // namespace + +int main() { + try { + test_idle_status_and_validation(); + } catch (const std::exception & error) { + std::cerr << error.what() << '\n'; + return 1; + } + std::cout << "server_model_installer_test passed\n"; + return 0; +} diff --git a/tools/model_manager_v2.py b/tools/model_manager_v2.py index 63af948b..b39969d5 100644 --- a/tools/model_manager_v2.py +++ b/tools/model_manager_v2.py @@ -2,6 +2,7 @@ from __future__ import annotations import argparse +from concurrent.futures import ThreadPoolExecutor import json import os import shutil @@ -165,15 +166,17 @@ def check_remote_file(package: PackageRecord, remote_path: str) -> int | None: raise ManagerError(f"remote file is not accessible: {repo}/{remote_path} ({error.code})") from error -def download_file(package: PackageRecord, remote_path: str, output_path: Path) -> None: +def download_file(package: PackageRecord, remote_path: str, output_path: Path, progress=None) -> None: repo = package.download["repo"] revision = package.download.get("revision", "main") request = Request(hf_url(repo, revision, remote_path), headers=http_headers()) try: with urlopen(request, timeout=300) as response: - expected = response.headers.get("Content-Length") + expected_header = response.headers.get("Content-Length") + expected = int(expected_header) if expected_header is not None else None output_path.parent.mkdir(parents=True, exist_ok=True) total = 0 + last_report = 0 with output_path.open("wb") as handle: while True: chunk = response.read(1024 * 1024) @@ -181,7 +184,12 @@ def download_file(package: PackageRecord, remote_path: str, output_path: Path) - break handle.write(chunk) total += len(chunk) - if expected is not None and total != int(expected): + if progress is not None and total - last_report >= 8 * 1024 * 1024: + progress(total, expected) + last_report = total + if progress is not None: + progress(total, expected) + if expected is not None and total != expected: raise ManagerError(f"downloaded size mismatch for {output_path}: {total} != {expected}") except HTTPError as error: if package.download.get("gated") is True and error.code in (401, 403): @@ -225,22 +233,96 @@ def install_package(package: PackageRecord, args: argparse.Namespace) -> None: if args.dry_run or args.check: return - if final_dir.exists() and not args.overwrite: - raise ManagerError(f"target already exists: {final_dir} (use --overwrite)") + existing_outputs = [output for _remote, output in plan if output.exists()] + if existing_outputs and not args.overwrite: + if len(existing_outputs) == len(plan) and all(output.is_file() for output in existing_outputs): + print(f"already installed {package.id} -> {final_dir}") + return + raise ManagerError(f"some package files already exist in: {final_dir} (use --overwrite)") models_root.mkdir(parents=True, exist_ok=True) staging = Path(tempfile.mkdtemp(prefix=f".{package.target_directory.replace('/', '_')}.", dir=models_root)) try: + progress_enabled = bool(getattr(args, "progress", False)) + expected_sizes: list[int | None] = [] + if progress_enabled: + expected_sizes = [check_remote_file(package, remote) for remote, _output in plan] + total_expected = sum(size for size in expected_sizes if size is not None) + if any(size is None for size in expected_sizes): + total_expected = 0 + completed = 0 + + def emit_progress(downloaded: int) -> None: + print( + f"AUDIOCPP_PROGRESS downloaded={downloaded} total={total_expected}", + flush=True, + ) + + if progress_enabled: + emit_progress(0) for remote, output in plan: - download_file(package, remote, staging / output.relative_to(final_dir)) - if final_dir.exists(): - shutil.rmtree(final_dir) - staging.rename(final_dir) + destination = staging / output.relative_to(final_dir) + if progress_enabled: + download_file( + package, + remote, + destination, + lambda file_bytes, _expected, base=completed: emit_progress(base + file_bytes), + ) + completed += destination.stat().st_size + emit_progress(completed) + else: + download_file(package, remote, destination) + if not final_dir.exists(): + final_dir.parent.mkdir(parents=True, exist_ok=True) + staging.rename(final_dir) + else: + # Precision variants commonly share one target directory. Merge the + # fully downloaded staging tree so Q8 and F16/BF16 can coexist. An + # overwrite replaces only this package's files, preserving siblings. + for staged_file in sorted(path for path in staging.rglob("*") if path.is_file()): + relative = staged_file.relative_to(staging) + destination = final_dir / relative + destination.parent.mkdir(parents=True, exist_ok=True) + if destination.exists(): + if not args.overwrite: + raise ManagerError(f"package file already exists: {destination} (use --overwrite)") + destination.unlink() + staged_file.replace(destination) + shutil.rmtree(staging) except Exception: shutil.rmtree(staging, ignore_errors=True) raise print(f"installed {package.id} -> {final_dir}") +def uninstall_package(package: PackageRecord, args: argparse.Namespace) -> None: + target_dir = validate_relative_path(package.target_directory, "target_directory") + models_root = Path(args.models_root) + final_dir = models_root / target_dir + package_files = [final_dir / stripped_path(remote, package.strip_prefix) for remote in package.files] + existing = [path for path in package_files if path.is_file() or path.is_symlink()] + if not existing: + raise ManagerError(f"package is not installed: {package.id}") + + for path in existing: + path.unlink() + + # Remove only directories made empty by this package. Other precision + # variants and unrelated files in the shared target remain untouched. + cleanup: set[Path] = set() + for path in package_files: + parent = path.parent + while parent != final_dir.parent: + cleanup.add(parent) + parent = parent.parent + for directory in sorted(cleanup, key=lambda path: len(path.parts), reverse=True): + try: + directory.rmdir() + except OSError: + pass + print(f"removed {package.id} -> {final_dir}") + + def command_list(records: list[PackageRecord], args: argparse.Namespace) -> None: rows = [ { @@ -291,6 +373,78 @@ def command_info(records: list[PackageRecord], args: argparse.Namespace) -> None print(f"file: {remote}") +def package_is_installed(package: PackageRecord, models_root: Path | None) -> bool: + if models_root is None: + return False + target_dir = validate_relative_path(package.target_directory, "target_directory") + final_dir = models_root / target_dir + if not final_dir.is_dir(): + return False + return bool(package.files) and all( + (final_dir / stripped_path(remote_path, package.strip_prefix)).is_file() + for remote_path in package.files + ) + + +def package_size_record(package: PackageRecord, models_root: Path | None = None) -> dict[str, Any]: + installed = package_is_installed(package, models_root) + try: + ensure_hf_package(package) + total = 0 + unknown = False + for remote_path in package.files: + size = check_remote_file(package, remote_path) + if size is None: + unknown = True + else: + total += size + state = "gated" if unknown and package.download.get("gated") is True else "unknown" if unknown else "ok" + return { + "id": package.id, + "size_bytes": None if unknown else total, + "state": state, + "message": "Hugging Face access and a valid token are required" if state == "gated" else "", + "installed": installed, + } + except ManagerError as error: + return { + "id": package.id, + "size_bytes": None, + "state": "error", + "message": str(error), + "installed": installed, + } + + +def command_sizes(records: list[PackageRecord], args: argparse.Namespace) -> None: + selected = records + if args.package: + requested = set(args.package) + selected = [record for record in records if record.id in requested] + missing = sorted(requested - {record.id for record in selected}) + if missing: + raise ManagerError(f"unknown package ids: {', '.join(missing)}") + models_root = Path(args.models_root) if args.models_root else None + with ThreadPoolExecutor(max_workers=max(1, args.jobs)) as pool: + rows = list(pool.map(lambda package: package_size_record(package, models_root), selected)) + print(json.dumps(rows, ensure_ascii=False)) + + +def command_installed(records: list[PackageRecord], args: argparse.Namespace) -> None: + models_root = Path(args.models_root) + rows = [ + { + "id": package.id, + "size_bytes": None, + "state": "pending", + "message": "", + "installed": package_is_installed(package, models_root), + } + for package in records + ] + print(json.dumps(rows, ensure_ascii=False)) + + def make_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Install audio.cpp model packages from model_specs/*.json.") parser.add_argument("--specs-dir", default=str(DEFAULT_SPECS_DIR), help="directory containing model spec JSON files") @@ -305,6 +459,22 @@ def make_parser() -> argparse.ArgumentParser: info_parser.add_argument("--precision") info_parser.add_argument("--json", action="store_true") + sizes_parser = sub.add_parser("sizes", help="check package download sizes without downloading files") + sizes_parser.add_argument("package", nargs="*", help="optional package ids; default checks every package") + sizes_parser.add_argument("--jobs", type=int, default=12, help="parallel metadata checks") + sizes_parser.add_argument("--models-root", help="also report packages whose required files are installed") + sizes_parser.add_argument("--json", action="store_true", help="retained for command symmetry; output is JSON") + + installed_parser = sub.add_parser("installed", help="report locally installed packages without network access") + installed_parser.add_argument("--models-root", default="models") + installed_parser.add_argument("--json", action="store_true", help="retained for command symmetry; output is JSON") + + uninstall_parser = sub.add_parser("uninstall", help="remove only the files belonging to one installed package") + uninstall_parser.add_argument("package", help="package id or family") + uninstall_parser.add_argument("--format") + uninstall_parser.add_argument("--precision") + uninstall_parser.add_argument("--models-root", default="models") + install_parser = sub.add_parser("install", help="install one Hugging Face snapshot package") install_parser.add_argument("package", help="package id or family") install_parser.add_argument("--format") @@ -313,6 +483,11 @@ def make_parser() -> argparse.ArgumentParser: install_parser.add_argument("--overwrite", action="store_true") install_parser.add_argument("--dry-run", action="store_true") install_parser.add_argument("--check", action="store_true", help="check remote files without downloading") + install_parser.add_argument( + "--progress", + action="store_true", + help="emit machine-readable AUDIOCPP_PROGRESS lines while downloading", + ) return parser @@ -325,6 +500,12 @@ def main() -> int: command_list(records, args) elif args.command == "info": command_info(records, args) + elif args.command == "sizes": + command_sizes(records, args) + elif args.command == "installed": + command_installed(records, args) + elif args.command == "uninstall": + uninstall_package(select_package(records, args), args) elif args.command == "install": install_package(select_package(records, args), args) else: diff --git a/webui/README.md b/webui/README.md index bf6d17d3..2897ff72 100644 --- a/webui/README.md +++ b/webui/README.md @@ -1,9 +1,90 @@ -# audio.cpp WebUI Launcher Guide +# audio.cpp WebUI Guide > **语言 / Language:** **English** · [中文](README.zh.md) -The `webui/` directory holds the Python dependencies, launch scripts, and model-download wrappers needed to run the WebUI. -The launch scripts can be **double-clicked** or invoked from a command line / PowerShell. +audio.cpp now has two browser interfaces: + +- **Native WebUI (recommended):** a SvelteKit/TypeScript single-page app embedded directly in + `audiocpp_server`. It needs no Python or frontend files at runtime. +- **Legacy Gradio WebUI:** the original Python interface and helper workflows in this directory. + +## Native embedded WebUI + +Build `audiocpp_server` normally, then start the native WebUI host: + +```powershell +.\build\windows-cuda-release\bin\audiocpp_server.exe --ui --backend cuda +``` + +```bash +./build/bin/audiocpp_server --ui --backend cuda +``` + +Open **http://127.0.0.1:8080**. With no `--config`, `--ui` enables on-demand model load/unload and +temporary audio uploads automatically. By default, model packages are stored in the `models/` directory beside +`audiocpp_server`. The Models page has a **Models folder** setting and native **Browse** dialog when another +location is preferred; **Apply** switches downloads, installed-package detection, deletion, and Studio model paths together, while **Use default** +returns to the binary-local directory. The selected custom location is remembered in that browser. + +The standalone UI host still searches upward from the working directory and executable location for package +resources (`tools/` plus `model_specs/`), so a development binary can be started directly from `build/.../bin`. + +The same UI can front an existing server config: + +```bash +audiocpp_server --config server.json +``` + +Configured models keep their existing eager/lazy behavior. Add `--ui-management` to allow the UI to load, +switch, and unload catalog models. Use `--no-ui` to retain an API-only server. + +The native UI supports the shared catalog, model-specific controls, file decoding in the browser, TTS and voice +cloning, transcription, generic audio tasks, multiple separation outputs, structured results, and request timing. +It also includes: + +- model download/preparation jobs with ordered GGUF Q8, GGUF FP16/BF16, and safetensors choices, + fast required-file checks that disable choices already downloaded, metadata-only package-size checks, + and card-local byte/percentage progress on the Models page; +- GGUF precision variants can coexist in a shared package directory; the selected button stores the exact + GGUF file path, and overwriting one precision does not remove its sibling variants; +- downloaded choices expose a confirmation-gated trash action that removes only that package's declared files; +- sentence-aware long-text synthesis and browser-side WAV merging; +- microphone capture for source and reference audio; +- a saved voice library backed by browser IndexedDB; +- four-second near-live microphone transcription for streaming-capable ASR models. + +Uploaded request files are placed in a per-process temporary directory and deleted when the server exits. Saved +voices stay in the browser profile and are not uploaded until selected for a request. + +The server and embedded interface need no Python at runtime. The **Install / prepare** action invokes +`tools/model_manager_v2.py` for normal spec-backed downloads. When source/output/variant converter inputs are supplied, +it falls back to `tools/model_manager_deprecated.py` for legacy preparation workflows that have not migrated yet. +Set `AUDIOCPP_PYTHON` when the desired interpreter is not `python` on Windows or `python3` on Unix. Pure inference, +loading an existing folder, and standalone GGUF operation do not use either helper. The Models page exposes optional +source directory, source checkpoint, output, variant, and overwrite inputs for specialized preparation workflows. +Package ids and install directories are resolved from `model_specs/*.json` while the frontend is built, so older +catalog aliases continue to select the current precision-qualified package id. + +### Frontend development + +Node.js is only needed to modify the frontend, never to run the compiled server: + +```bash +cd webui/native +npm ci +npm run check +npm run build +``` + +The build creates the single-file `webui/native/dist/index.html`. CMake converts that file to an embedded byte +array when configuring `audiocpp_server`; rerun CMake/build after changing it. For live frontend development, +run `npm run dev`; Vite proxies `/health` and `/v1` to a server on port 8080. + +## Legacy Gradio WebUI + +The Gradio interface remains for compatibility and translated legacy workflows. It is no longer required for the +model installer, long-text synthesis, saved voices, or microphone input described above. The remaining sections +describe that interface. Its launch scripts can be **double-clicked** or invoked from a command line or PowerShell. | Script | Purpose | Typical command | |---|---|---| diff --git a/webui/model_manager_webui.py b/webui/model_manager_webui.py index 765f538c..2554a941 100644 --- a/webui/model_manager_webui.py +++ b/webui/model_manager_webui.py @@ -2,6 +2,7 @@ from __future__ import annotations import argparse +from concurrent.futures import ThreadPoolExecutor import json import os import shutil @@ -165,15 +166,17 @@ def check_remote_file(package: PackageRecord, remote_path: str) -> int | None: raise ManagerError(f"remote file is not accessible: {repo}/{remote_path} ({error.code})") from error -def download_file(package: PackageRecord, remote_path: str, output_path: Path) -> None: +def download_file(package: PackageRecord, remote_path: str, output_path: Path, progress=None) -> None: repo = package.download["repo"] revision = package.download.get("revision", "main") request = Request(hf_url(repo, revision, remote_path), headers=http_headers()) try: with urlopen(request, timeout=300) as response: - expected = response.headers.get("Content-Length") + expected_header = response.headers.get("Content-Length") + expected = int(expected_header) if expected_header is not None else None output_path.parent.mkdir(parents=True, exist_ok=True) total = 0 + last_report = 0 with output_path.open("wb") as handle: while True: chunk = response.read(1024 * 1024) @@ -181,7 +184,12 @@ def download_file(package: PackageRecord, remote_path: str, output_path: Path) - break handle.write(chunk) total += len(chunk) - if expected is not None and total != int(expected): + if progress is not None and total - last_report >= 8 * 1024 * 1024: + progress(total, expected) + last_report = total + if progress is not None: + progress(total, expected) + if expected is not None and total != expected: raise ManagerError(f"downloaded size mismatch for {output_path}: {total} != {expected}") except HTTPError as error: if package.download.get("gated") is True and error.code in (401, 403): @@ -225,22 +233,96 @@ def install_package(package: PackageRecord, args: argparse.Namespace) -> None: if args.dry_run or args.check: return - if final_dir.exists() and not args.overwrite: - raise ManagerError(f"target already exists: {final_dir} (use --overwrite)") + existing_outputs = [output for _remote, output in plan if output.exists()] + if existing_outputs and not args.overwrite: + if len(existing_outputs) == len(plan) and all(output.is_file() for output in existing_outputs): + print(f"already installed {package.id} -> {final_dir}") + return + raise ManagerError(f"some package files already exist in: {final_dir} (use --overwrite)") models_root.mkdir(parents=True, exist_ok=True) staging = Path(tempfile.mkdtemp(prefix=f".{package.target_directory.replace('/', '_')}.", dir=models_root)) try: + progress_enabled = bool(getattr(args, "progress", False)) + expected_sizes: list[int | None] = [] + if progress_enabled: + expected_sizes = [check_remote_file(package, remote) for remote, _output in plan] + total_expected = sum(size for size in expected_sizes if size is not None) + if any(size is None for size in expected_sizes): + total_expected = 0 + completed = 0 + + def emit_progress(downloaded: int) -> None: + print( + f"AUDIOCPP_PROGRESS downloaded={downloaded} total={total_expected}", + flush=True, + ) + + if progress_enabled: + emit_progress(0) for remote, output in plan: - download_file(package, remote, staging / output.relative_to(final_dir)) - if final_dir.exists(): - shutil.rmtree(final_dir) - staging.rename(final_dir) + destination = staging / output.relative_to(final_dir) + if progress_enabled: + download_file( + package, + remote, + destination, + lambda file_bytes, _expected, base=completed: emit_progress(base + file_bytes), + ) + completed += destination.stat().st_size + emit_progress(completed) + else: + download_file(package, remote, destination) + if not final_dir.exists(): + final_dir.parent.mkdir(parents=True, exist_ok=True) + staging.rename(final_dir) + else: + # Precision variants commonly share one target directory. Merge the + # fully downloaded staging tree so Q8 and F16/BF16 can coexist. An + # overwrite replaces only this package's files, preserving siblings. + for staged_file in sorted(path for path in staging.rglob("*") if path.is_file()): + relative = staged_file.relative_to(staging) + destination = final_dir / relative + destination.parent.mkdir(parents=True, exist_ok=True) + if destination.exists(): + if not args.overwrite: + raise ManagerError(f"package file already exists: {destination} (use --overwrite)") + destination.unlink() + staged_file.replace(destination) + shutil.rmtree(staging) except Exception: shutil.rmtree(staging, ignore_errors=True) raise print(f"installed {package.id} -> {final_dir}") +def uninstall_package(package: PackageRecord, args: argparse.Namespace) -> None: + target_dir = validate_relative_path(package.target_directory, "target_directory") + models_root = Path(args.models_root) + final_dir = models_root / target_dir + package_files = [final_dir / stripped_path(remote, package.strip_prefix) for remote in package.files] + existing = [path for path in package_files if path.is_file() or path.is_symlink()] + if not existing: + raise ManagerError(f"package is not installed: {package.id}") + + for path in existing: + path.unlink() + + # Remove only directories made empty by this package. Other precision + # variants and unrelated files in the shared target remain untouched. + cleanup: set[Path] = set() + for path in package_files: + parent = path.parent + while parent != final_dir.parent: + cleanup.add(parent) + parent = parent.parent + for directory in sorted(cleanup, key=lambda path: len(path.parts), reverse=True): + try: + directory.rmdir() + except OSError: + pass + print(f"removed {package.id} -> {final_dir}") + + def command_list(records: list[PackageRecord], args: argparse.Namespace) -> None: rows = [ { @@ -291,6 +373,78 @@ def command_info(records: list[PackageRecord], args: argparse.Namespace) -> None print(f"file: {remote}") +def package_is_installed(package: PackageRecord, models_root: Path | None) -> bool: + if models_root is None: + return False + target_dir = validate_relative_path(package.target_directory, "target_directory") + final_dir = models_root / target_dir + if not final_dir.is_dir(): + return False + return bool(package.files) and all( + (final_dir / stripped_path(remote_path, package.strip_prefix)).is_file() + for remote_path in package.files + ) + + +def package_size_record(package: PackageRecord, models_root: Path | None = None) -> dict[str, Any]: + installed = package_is_installed(package, models_root) + try: + ensure_hf_package(package) + total = 0 + unknown = False + for remote_path in package.files: + size = check_remote_file(package, remote_path) + if size is None: + unknown = True + else: + total += size + state = "gated" if unknown and package.download.get("gated") is True else "unknown" if unknown else "ok" + return { + "id": package.id, + "size_bytes": None if unknown else total, + "state": state, + "message": "Hugging Face access and a valid token are required" if state == "gated" else "", + "installed": installed, + } + except ManagerError as error: + return { + "id": package.id, + "size_bytes": None, + "state": "error", + "message": str(error), + "installed": installed, + } + + +def command_sizes(records: list[PackageRecord], args: argparse.Namespace) -> None: + selected = records + if args.package: + requested = set(args.package) + selected = [record for record in records if record.id in requested] + missing = sorted(requested - {record.id for record in selected}) + if missing: + raise ManagerError(f"unknown package ids: {', '.join(missing)}") + models_root = Path(args.models_root) if args.models_root else None + with ThreadPoolExecutor(max_workers=max(1, args.jobs)) as pool: + rows = list(pool.map(lambda package: package_size_record(package, models_root), selected)) + print(json.dumps(rows, ensure_ascii=False)) + + +def command_installed(records: list[PackageRecord], args: argparse.Namespace) -> None: + models_root = Path(args.models_root) + rows = [ + { + "id": package.id, + "size_bytes": None, + "state": "pending", + "message": "", + "installed": package_is_installed(package, models_root), + } + for package in records + ] + print(json.dumps(rows, ensure_ascii=False)) + + def make_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Install audio.cpp model packages from model_specs/*.json.") parser.add_argument("--specs-dir", default=str(DEFAULT_SPECS_DIR), help="directory containing model spec JSON files") @@ -305,6 +459,22 @@ def make_parser() -> argparse.ArgumentParser: info_parser.add_argument("--precision") info_parser.add_argument("--json", action="store_true") + sizes_parser = sub.add_parser("sizes", help="check package download sizes without downloading files") + sizes_parser.add_argument("package", nargs="*", help="optional package ids; default checks every package") + sizes_parser.add_argument("--jobs", type=int, default=12, help="parallel metadata checks") + sizes_parser.add_argument("--models-root", help="also report packages whose required files are installed") + sizes_parser.add_argument("--json", action="store_true", help="retained for command symmetry; output is JSON") + + installed_parser = sub.add_parser("installed", help="report locally installed packages without network access") + installed_parser.add_argument("--models-root", default="models") + installed_parser.add_argument("--json", action="store_true", help="retained for command symmetry; output is JSON") + + uninstall_parser = sub.add_parser("uninstall", help="remove only the files belonging to one installed package") + uninstall_parser.add_argument("package", help="package id or family") + uninstall_parser.add_argument("--format") + uninstall_parser.add_argument("--precision") + uninstall_parser.add_argument("--models-root", default="models") + install_parser = sub.add_parser("install", help="install one Hugging Face snapshot package") install_parser.add_argument("package", help="package id or family") install_parser.add_argument("--format") @@ -313,6 +483,11 @@ def make_parser() -> argparse.ArgumentParser: install_parser.add_argument("--overwrite", action="store_true") install_parser.add_argument("--dry-run", action="store_true") install_parser.add_argument("--check", action="store_true", help="check remote files without downloading") + install_parser.add_argument( + "--progress", + action="store_true", + help="emit machine-readable AUDIOCPP_PROGRESS lines while downloading", + ) return parser @@ -325,6 +500,12 @@ def main() -> int: command_list(records, args) elif args.command == "info": command_info(records, args) + elif args.command == "sizes": + command_sizes(records, args) + elif args.command == "installed": + command_installed(records, args) + elif args.command == "uninstall": + uninstall_package(select_package(records, args), args) elif args.command == "install": install_package(select_package(records, args), args) else: diff --git a/webui/native/dist/index.html b/webui/native/dist/index.html new file mode 100644 index 00000000..a53e5548 --- /dev/null +++ b/webui/native/dist/index.html @@ -0,0 +1,117 @@ + + + + + + + + + + + +
+ +
+ + diff --git a/webui/native/package-lock.json b/webui/native/package-lock.json new file mode 100644 index 00000000..4a315649 --- /dev/null +++ b/webui/native/package-lock.json @@ -0,0 +1,1673 @@ +{ + "name": "audiocpp-native-webui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "audiocpp-native-webui", + "version": "0.1.0", + "devDependencies": { + "@sveltejs/adapter-static": "^3.0.9", + "@sveltejs/kit": "^2.37.0", + "@sveltejs/vite-plugin-svelte": "^6.1.0", + "svelte": "^5.38.0", + "svelte-check": "^4.3.1", + "typescript": "^5.9.2", + "vite": "^7.1.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", + "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-static": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz", + "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.70.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.1.tgz", + "integrity": "sha512-nY9SPHGOZro3doud9vZXDBwl9tCZIouuJztjgSHs6PAIrv9M/z5O7eOhPV5xU7CgVHA976Jwu3BA1hIFvXztkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.9", + "@types/cookie": "^0.6.0", + "acorn": "^8.16.0", + "cookie": "^0.6.0", + "devalue": "^5.8.1", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.1.tgz", + "integrity": "sha512-5m3B2cbqQ4TbwW6Xkh66Ntw6dD7gNc77cCxABTTesWcq9jxIzMgTk97pZx5vEtvQx8iokgi7GIphqZe+PGwcZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz", + "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz", + "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "obug": "^2.1.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/devalue": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.2.tgz", + "integrity": "sha512-DObPPAfdtFbXjxLqK8s2Xk9ZuWz5+ZoFEhC7J76es4GU/rEiXwHTmbImoCdyoCOcBH1UF3+Cz6Z2sYD4hyl5TA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.0.tgz", + "integrity": "sha512-GQ/7RN8uOtEfNpzZzBMTzW9JBcX42oaSVtPzdF+6cEL8pqIL094iUpr9jzYGn4O4P/1S60dJ6izyT8F4LYARng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.56.8", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.8.tgz", + "integrity": "sha512-PY8LOw7xP6c8IOiVqdo0sbbZVYhXRSfklOQLAUyGBKqjTX0wx/z4l/9J+PmBpmlLnxzEb1NqltxQ5/wZme/Cmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.3.tgz", + "integrity": "sha512-DHdTCGX62R0fCxBEaT+USdASAnoaRBaaNczkRJl0K7o3WyoCeVUbVxo6fKqpOll/B+WMWCsiFK0eFrJSNBKZIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.0", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/webui/native/package.json b/webui/native/package.json new file mode 100644 index 00000000..8fc8ff4f --- /dev/null +++ b/webui/native/package.json @@ -0,0 +1,20 @@ +{ + "name": "audiocpp-native-webui", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json" + }, + "devDependencies": { + "@sveltejs/adapter-static": "^3.0.9", + "@sveltejs/kit": "^2.37.0", + "@sveltejs/vite-plugin-svelte": "^6.1.0", + "svelte": "^5.38.0", + "svelte-check": "^4.3.1", + "typescript": "^5.9.2", + "vite": "^7.1.3" + } +} diff --git a/webui/native/src/app.css b/webui/native/src/app.css new file mode 100644 index 00000000..2ddd5129 --- /dev/null +++ b/webui/native/src/app.css @@ -0,0 +1,230 @@ +:root { + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #dce8fa; + background: #07101f; + font-synthesis: none; + --bg: #07101f; + --panel: #0d192b; + --panel-2: #101f34; + --line: #233652; + --muted: #8ca3c3; + --cyan: #42e8d5; + --blue: #6aa8ff; + --danger: #ff778b; +} + +* { box-sizing: border-box; } +body { margin: 0; min-width: 320px; min-height: 100vh; background: + radial-gradient(circle at 82% -10%, rgba(45, 107, 189, .2), transparent 30rem), + var(--bg); } +button, input, textarea, select { font: inherit; } +button { color: inherit; } + +.topbar { + height: 58px; padding: 0 max(20px, calc((100vw - 1600px) / 2)); + display: flex; align-items: center; gap: 26px; border-bottom: 1px solid var(--line); + background: rgba(7, 16, 31, .88); backdrop-filter: blur(18px); position: sticky; top: 0; z-index: 20; +} +.brand { display: flex; align-items: center; gap: 9px; min-width: 190px; } +.brand .mark { width: 30px; height: 30px; display: grid; place-items: center; font-weight: 900; + color: #06141a; background: linear-gradient(135deg, var(--cyan), var(--blue)); border-radius: 8px; } +.brand strong { display: block; font-size: 15px; letter-spacing: -.02em; } +.brand span { display: block; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .13em; } +nav { display: flex; height: 100%; } +nav button { border: 0; border-bottom: 2px solid transparent; background: transparent; padding: 0 16px; color: var(--muted); cursor: pointer; } +nav button:hover, nav button.active { color: white; } +nav button.active { border-color: var(--cyan); } +.server-pill { margin-left: auto; padding: 6px 10px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); font-size: 11px; text-transform: uppercase; } +.server-pill i { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: #64748b; margin-right: 7px; } +.server-pill.online i { background: var(--cyan); box-shadow: 0 0 12px var(--cyan); } + +main { max-width: 1600px; margin: auto; padding: 18px 20px 36px; } +.workflow-tabs { + display: grid; grid-template-columns: repeat(7, minmax(126px, 1fr)); gap: 6px; + height: auto; margin: 0 0 16px; overflow-x: auto; scrollbar-width: thin; +} +.workflow-tabs button { + display: flex; align-items: center; justify-content: space-between; gap: 8px; + min-width: 126px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; + background: rgba(13,25,43,.8); color: var(--muted); font-size: 11px; text-align: left; +} +.workflow-tabs button:hover, .workflow-tabs button.active { + color: #f4f8ff; border-color: #3d718f; background: rgba(20,48,78,.95); +} +.workflow-tabs button.active { box-shadow: inset 0 -2px 0 var(--cyan); } +.workflow-tabs small { + display: grid; place-items: center; min-width: 18px; height: 18px; padding: 0 5px; + border-radius: 999px; color: #9fc0e7; background: #071426; font-size: 10px; +} +.hero { display: flex; justify-content: space-between; align-items: center; gap: 22px; padding: 1px 4px 16px; } +.eyebrow { color: var(--cyan)!important; font-size: 11px!important; font-weight: 800; letter-spacing: .18em; margin: 0 0 9px!important; } +h1 { font-size: clamp(28px, 3vw, 40px); line-height: 1; letter-spacing: -.04em; margin: 0 0 8px; color: #f4f8ff; } +.hero p, .page-head p { margin: 0; max-width: 720px; color: var(--muted); font-size: 13px; } +.hero-stat { width: min(360px, 32vw); padding: 11px 14px; background: linear-gradient(135deg, rgba(66,232,213,.1), rgba(106,168,255,.05)); border: 1px solid var(--line); border-radius: 11px; } +.hero-stat span, .hero-stat small { display: block; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .1em; } +.hero-stat strong { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin: 3px 0 5px; font-size: 14px; } +.hero-stat small.ready { color: var(--cyan); } + +.studio-grid { display: grid; grid-template-columns: 255px minmax(390px, 1.05fr) minmax(340px, .85fr); gap: 11px; align-items: start; } +.panel { background: linear-gradient(180deg, rgba(16,31,52,.98), rgba(11,23,40,.98)); border: 1px solid var(--line); border-radius: 11px; box-shadow: 0 10px 32px rgba(0,0,0,.16); } +.model-rail, .controls, .output { padding: 14px; } +.output { position: sticky; top: 69px; min-height: 470px; } +label { display: block; color: #b8cae4; font-size: 11px; font-weight: 650; margin: 11px 0 5px; } +label:first-child { margin-top: 0; } +label span { color: var(--muted); font-weight: 400; } +input, textarea, select { width: 100%; color: #edf5ff; background: #081426; border: 1px solid #2a405f; border-radius: 7px; padding: 7px 9px; outline: none; transition: border .15s, box-shadow .15s; font-size: 13px; } +textarea { resize: vertical; line-height: 1.4; } +input:focus, textarea:focus, select:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(106,168,255,.12); } +select option:disabled { color: #45566f; background: #091423; } +input.file { padding: 6px; color: var(--muted); } +input.file::file-selector-button { border: 0; border-radius: 5px; color: #07101f; background: #a8c8f6; padding: 5px 8px; margin-right: 8px; cursor: pointer; font-weight: 700; } +.path-state { display: flex; justify-content: space-between; gap: 5px; color: var(--muted); font-size: 10px; padding: 6px 2px 1px; } +.path-state .good { color: var(--cyan); }.path-state .bad { color: var(--danger); } +.button-row { display: flex; gap: 7px; margin-top: 11px; } +button { border: 1px solid var(--line); border-radius: 7px; background: #14243b; padding: 7px 10px; cursor: pointer; transition: transform .12s, border-color .12s, background .12s; font-size: 13px; } +button:hover:not(:disabled) { transform: translateY(-1px); border-color: #4f719d; background: #1a304e; } +button:disabled { opacity: .45; cursor: not-allowed; } +button.primary, button.run { color: #041619; background: linear-gradient(135deg, var(--cyan), #5ac7ff); border: 0; font-weight: 800; } +.button-row button { flex: 1; } +.studio-package-buttons { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 5px; margin-top: 11px; } +.studio-package-buttons button, .single-model-toggle { position: relative; min-width: 0; min-height: 38px; padding: 6px 4px; font-size: 9px; line-height: 1.15; } +.studio-package-buttons button.selected-package:not(:disabled) { border-color: #347d8f; color: var(--cyan); box-shadow: inset 0 0 0 1px rgba(66,232,213,.1); } +.studio-package-buttons button:disabled { color: #637188; background: #101b2d; opacity: .48; } +.studio-package-buttons button.resident, .single-model-toggle.resident { padding-right: 12px; border-color: #347d8f; color: #bffef5; background: #173749; opacity: 1; } +.studio-package-buttons button.resident::after, .single-model-toggle.resident::after { content: ""; position: absolute; top: 5px; right: 5px; width: 6px; height: 6px; border-radius: 50%; background: var(--cyan); box-shadow: 0 0 8px rgba(66,232,213,.9); } +.single-model-toggle { width: 100%; margin-top: 11px; } +.hint { margin-top: 12px; padding: 9px; border-left: 2px solid var(--blue); background: rgba(106,168,255,.07); color: var(--muted); font-size: 11px; line-height: 1.45; white-space: pre-wrap; } +.section-title { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; } +.section-title span { color: var(--cyan); font-size: 10px; letter-spacing: .14em; font-weight: 800; } +.section-title h2 { font-size: 17px; margin: 2px 0 0; color: #f3f7ff; } +.task-chip { border: 1px solid var(--line); border-radius: 999px; padding: 5px 8px; color: var(--muted)!important; letter-spacing: .08em!important; text-transform: uppercase; } +.field-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0 12px; } +.reference-input-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 10px; } +.long-text-row { display: grid; grid-template-columns: 1fr 150px; gap: 10px; align-items: end; margin-top: 2px; } +.long-text-row > .toggle { margin: 0 0 8px; } +.media-actions { display: flex; align-items: center; gap: 7px; margin-top: 6px; } +.media-actions button { padding: 7px 10px; font-size: 11px; } +.media-actions > span { min-width: 0; color: var(--muted); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +button.danger { color: #ffd8df; border-color: rgba(255,119,139,.45); background: rgba(127,30,50,.25); } +.recording-dot::before { content: ""; display: inline-block; width: 7px; height: 7px; margin-right: 6px; border-radius: 50%; background: var(--danger); box-shadow: 0 0 9px var(--danger); } +.live-card { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 8px; padding: 9px; border: 1px solid rgba(66,232,213,.24); border-radius: 8px; background: rgba(66,232,213,.05); } +.live-card strong, .live-card small { display: block; } +.live-card strong { font-size: 12px; } +.live-card small { max-width: 380px; margin-top: 3px; color: var(--muted); font-size: 10px; line-height: 1.4; } +.voice-library { display: grid; grid-template-columns: 1fr 1fr auto; gap: 7px; align-items: end; margin-top: 2px; } +.library-actions { display: flex; gap: 6px; padding-bottom: 1px; } +.library-actions button { white-space: nowrap; } +details { margin-top: 11px; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; } +summary { cursor: pointer; padding: 8px 10px; color: #b8cae4; font-size: 11px; font-weight: 700; background: rgba(5,13,26,.35); } +summary span { float: right; color: var(--muted); font-weight: 500; } +.parameter-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1px 10px; padding: 0 10px 10px; } +.parameter-grid .wide { grid-column: 1/-1; } +.parameter-grid small { display: block; color: var(--muted); font-size: 10px; line-height: 1.4; margin-top: 5px; } +.range { display: grid; grid-template-columns: 1fr 46px; gap: 8px; align-items: center; } +.range input { padding: 0; accent-color: var(--cyan); } +.range output { font: 12px ui-monospace, monospace; text-align: right; color: var(--cyan); } +.toggle { display: flex; align-items: center; gap: 8px; margin-top: 8px; font-weight: 500; } +.toggle input { position: absolute; opacity: 0; width: 1px; } +.toggle span { width: 34px; height: 19px; border-radius: 20px; background: #263b57; position: relative; } +.toggle span::after { content: ""; position: absolute; top: 3px; left: 3px; width: 13px; height: 13px; border-radius: 50%; background: #b8cae4; transition: .15s; } +.toggle input:checked + span { background: #197f7c; }.toggle input:checked + span::after { transform: translateX(15px); background: white; } +.code, pre { font: 12px/1.5 ui-monospace, SFMono-Regular, Consolas, monospace; } +details > .code { border: 0; border-radius: 0; } +.runbar { display: grid; grid-template-columns: 120px 74px 1fr; gap: 7px; align-items: center; margin-top: 13px; } +.run { display: flex; align-items: center; justify-content: space-between; padding: 9px 11px; } +kbd { font: 9px ui-monospace, monospace; padding: 2px 4px; border: 1px solid rgba(0,0,0,.25); border-radius: 4px; } +.status { color: var(--muted); font-size: 12px; padding-left: 8px; overflow-wrap: anywhere; } +.status.busy { color: var(--cyan); } +.status.warning { color: #ff9aaa; font-weight: 600; } +.status.error { color: var(--danger); font-weight: 600; } +.empty-output { min-height: 120px; display: grid; place-content: center; text-align: center; color: var(--muted); } +.empty-output .wave { font-size: 62px; line-height: .7; color: #24446b; } +.audio-list { display: grid; gap: 12px; } +.audio-list article { padding: 12px; border: 1px solid var(--line); border-radius: 10px; background: #081426; } +.audio-list article > div { display: flex; justify-content: space-between; margin-bottom: 9px; font-size: 12px; } +.audio-list a { color: var(--cyan); text-decoration: none; } +audio { width: 100%; height: 38px; } +.transcript { margin-top: 14px; } +pre { background: #071220; border: 1px solid var(--line); border-radius: 8px; padding: 10px; overflow: auto; max-height: 250px; color: #9fbbdc; white-space: pre-wrap; overflow-wrap: anywhere; } + +.page-head { padding: 18px 4px 28px; }.page-head h1 { font-size: 42px; } +.models-folder-options { display: grid; grid-template-columns: minmax(300px, 1fr) auto auto auto; gap: 8px; align-items: end; padding: 15px; margin-bottom: 13px; } +.models-folder-options label { margin-top: 0; } +.models-folder-options > button { min-width: 94px; margin-bottom: 18px; } +.models-folder-field small { display: block; min-height: 14px; margin-top: 4px; color: var(--muted); font: 9px ui-monospace, monospace; overflow-wrap: anywhere; } +.model-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; align-items: stretch; } +.model-grid article { display: grid; grid-template-columns: 58px minmax(0, 1fr) auto; gap: 14px; align-items: center; padding: 15px; border: 1px solid var(--line); border-radius: 13px; background: var(--panel); } +.model-grid article.selected { border-color: #3b718c; box-shadow: inset 0 0 0 1px rgba(66,232,213,.18); } +.model-icon { display: grid; place-items: center; height: 50px; border-radius: 10px; background: #122b45; color: var(--cyan); font-size: 10px; font-weight: 900; letter-spacing: .1em; } +.model-copy span, .model-actions small { color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .1em; } +.model-copy h3 { font-size: 15px; margin: 4px 0; }.model-copy p { color: var(--muted); font: 11px ui-monospace, monospace; margin: 0; overflow-wrap: anywhere; } +.model-actions { display: grid; gap: 6px; width: clamp(280px, 17vw, 324px); min-width: 0; }.model-actions small { text-align: right; } +.model-actions button { padding: 7px; font-size: 11px; } +.package-buttons { display: grid; grid-template-columns: repeat(3, minmax(76px, 1fr)); gap: 5px; } +.package-choice { position: relative; height: 48px; min-width: 0; } +.package-install { display: flex; width: 100%; height: 48px; min-width: 0; min-height: 0; padding-top: 4px; padding-bottom: 4px; flex-direction: column; align-items: center; justify-content: center; gap: 2px; white-space: nowrap; } +.package-install > span:first-child { line-height: 1.1; } +.package-install.preferred { border-color: #347d8f; color: var(--cyan); box-shadow: inset 0 0 0 1px rgba(66,232,213,.1); } +.package-install.downloaded { padding-right: 23px; border-color: #50708b; background: color-mix(in srgb, #102238 80%, white 20%); color: #9dc5ff; white-space: normal; } +.package-install.downloaded:hover:not(:disabled) { border-color: #6398b2; background: color-mix(in srgb, #142b43 76%, white 24%); } +.package-install.downloaded.preferred { border-color: #347d8f; color: var(--cyan); box-shadow: inset 0 0 0 1px rgba(66,232,213,.16); } +.model-actions .package-delete { position: absolute; top: 3px; right: 3px; z-index: 1; display: grid; width: 18px; height: 18px; min-height: 0; padding: 2px; place-items: center; border-color: transparent; background: transparent; color: #7189a8; } +.model-actions .package-delete:hover:not(:disabled) { border-color: rgba(239,107,124,.55); background: rgba(239,107,124,.12); color: var(--danger); transform: none; } +.package-delete svg { width: 12px; height: 12px; fill: currentColor; } +.package-size { color: var(--muted); font-size: 8px; font-weight: 500; letter-spacing: 0; text-transform: none; } +.install-progress { grid-column: 1/-1; width: 100%; min-width: 0; max-width: 100%; } +.install-progress-head { display: flex; justify-content: space-between; gap: 8px; color: var(--muted); font-size: 9px; } +.install-progress-head strong { color: var(--blue); text-transform: uppercase; }.install-progress.complete .install-progress-head strong { color: var(--cyan); }.install-progress.failed .install-progress-head strong { color: var(--danger); } +.install-progress-track { position: relative; height: 6px; margin: 5px 0 4px; overflow: hidden; border: 1px solid #28425f; border-radius: 999px; background: #071220; } +.install-progress-track span { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #1c9cba, var(--cyan)); transition: width .25s ease; } +.install-progress.failed .install-progress-track span { background: var(--danger); }.install-progress.complete .install-progress-track span { background: var(--cyan); } +.install-progress-track.indeterminate span { width: 35%!important; animation: install-scan 1.2s ease-in-out infinite; } +.install-status { min-width: 0; max-width: 100%; color: var(--muted); font-size: 9px; line-height: 1.35; text-align: left; overflow-wrap: anywhere; word-break: break-word; white-space: normal; } +@keyframes install-scan { from { transform: translateX(-110%); } to { transform: translateX(300%); } } +.folder-browser-backdrop { position: fixed; inset: 0; z-index: 100; display: grid; padding: 24px; place-items: center; background: rgba(2, 8, 17, .78); backdrop-filter: blur(5px); } +.folder-browser-dialog { display: grid; grid-template-rows: auto auto auto auto minmax(180px, 1fr) auto; width: min(760px, 94vw); max-height: min(720px, 90vh); padding: 18px; } +.folder-browser-dialog > header { display: flex; align-items: start; justify-content: space-between; gap: 20px; } +.folder-browser-dialog > header span { color: var(--cyan); font-size: 9px; font-weight: 800; letter-spacing: .14em; } +.folder-browser-dialog > header h2 { margin: 3px 0 12px; font-size: 20px; } +.folder-browser-dialog > header button { padding: 5px 9px; font-size: 11px; } +.folder-browser-location { padding: 9px 11px; border: 1px solid var(--line); border-radius: 7px; background: #071220; color: #b8cae4; font: 11px ui-monospace, monospace; overflow-wrap: anywhere; } +.folder-browser-roots, .folder-browser-toolbar { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } +.folder-browser-roots button, .folder-browser-toolbar button { padding: 5px 9px; font-size: 10px; } +.folder-browser-list { display: grid; align-content: start; gap: 4px; margin-top: 10px; padding: 5px; overflow-y: auto; border: 1px solid var(--line); border-radius: 8px; background: #071220; } +.folder-browser-list button { display: flex; align-items: center; gap: 9px; min-width: 0; padding: 8px 10px; border-color: transparent; background: transparent; text-align: left; } +.folder-browser-list button span:last-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.folder-icon { color: var(--cyan); font: 10px ui-monospace, monospace; } +.folder-browser-empty, .folder-browser-error { display: grid; min-height: 180px; margin-top: 10px; place-items: center; border: 1px solid var(--line); border-radius: 8px; color: var(--muted); font-size: 12px; } +.folder-browser-error { color: var(--danger); } +.folder-browser-dialog > footer { display: flex; justify-content: flex-end; gap: 8px; max-width: none; margin-top: 12px; padding: 0; border: 0; } +.log-panel { padding: 20px; }.runtime-cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 14px; } +.runtime-cards div { padding: 16px; background: #081426; border: 1px solid var(--line); border-radius: 10px; } +.runtime-cards span { display: block; color: var(--muted); font-size: 10px; text-transform: uppercase; }.runtime-cards strong { display: block; margin-top: 6px; font-size: 21px; text-transform: capitalize; } +.logs { min-height: 480px; max-height: 70vh; } +footer { display: flex; justify-content: space-between; max-width: 1600px; margin: auto; padding: 14px 20px 20px; border-top: 1px solid var(--line); color: #607a9d; font-size: 10px; } + +@media (max-width: 1200px) { + .workflow-tabs { grid-template-columns: repeat(7, minmax(150px, 1fr)); } + .studio-grid { grid-template-columns: 270px 1fr; } + .output { grid-column: 1/-1; position: static; min-height: 300px; } + .empty-output { min-height: 120px; } +} +@media (max-width: 820px) { + .topbar { padding: 0 14px; gap: 8px; }.brand { min-width: auto; }.brand span { display: none; } + nav { margin-left: auto; } nav button { padding: 0 10px; }.server-pill { display: none; } + main { padding: 22px 12px 40px; }.workflow-tabs { margin-bottom: 22px; }.hero { align-items: start; flex-direction: column; }.hero-stat { width: 100%; } + .studio-grid { grid-template-columns: 1fr; }.output { grid-column: auto; }.field-grid, .parameter-grid { grid-template-columns: 1fr; } + .voice-library { grid-template-columns: 1fr 1fr; }.library-actions { grid-column: 1/-1; } + .runbar { grid-template-columns: 1fr 90px; }.status { grid-column: 1/-1; } + .model-grid { grid-template-columns: 1fr; }.runtime-cards { grid-template-columns: repeat(2, 1fr); } + .models-folder-options { grid-template-columns: 1fr auto auto auto; } +} +@media (max-width: 520px) { + .brand strong { font-size: 14px; } nav button { font-size: 12px; padding: 0 7px; } + .model-grid article { grid-template-columns: 45px 1fr; }.model-actions { grid-column: 1/-1; width: 100%; min-width: 0; } + .models-folder-options { grid-template-columns: repeat(3, 1fr); } + .models-folder-field { grid-column: 1/-1; } + .models-folder-options > button { width: 100%; margin-bottom: 0; } + .long-text-row, .voice-library, .reference-input-grid { grid-template-columns: 1fr; }.long-text-row > .toggle { margin-top: 12px; } + footer { display: block; } footer span { display: block; margin-top: 5px; } +} diff --git a/webui/native/src/app.html b/webui/native/src/app.html new file mode 100644 index 00000000..ce24aa8f --- /dev/null +++ b/webui/native/src/app.html @@ -0,0 +1,13 @@ + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/webui/native/src/lib/api.ts b/webui/native/src/lib/api.ts new file mode 100644 index 00000000..7db9bf2c --- /dev/null +++ b/webui/native/src/lib/api.ts @@ -0,0 +1,184 @@ +import type { LoadedModel, ServerHealth } from './types'; + +async function errorFrom(response: Response): Promise { + let message = `${response.status} ${response.statusText}`; + try { + const body = await response.json(); + message = body?.error?.message || body?.message || message; + } catch { + const text = await response.text(); + if (text) message = text; + } + return new Error(message); +} + +export async function jsonRequest( + path: string, + init: RequestInit = {}, + signal?: AbortSignal +): Promise { + const headers = new Headers(init.headers); + if (init.body && !(init.body instanceof FormData) && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + const response = await fetch(path, { ...init, headers, signal }); + if (!response.ok) throw await errorFrom(response); + return response.json() as Promise; +} + +export async function health(): Promise { + return jsonRequest('/health'); +} + +export async function models(): Promise { + const response = await jsonRequest<{ data: LoadedModel[] }>('/v1/models'); + return response.data; +} + +export async function loadModel(body: Record): Promise { + await jsonRequest('/v1/models/load', { method: 'POST', body: JSON.stringify(body) }); +} + +export async function unloadModel(id: string): Promise { + await jsonRequest('/v1/models/unload', { + method: 'POST', + body: JSON.stringify({ id }) + }); +} + +export async function pathStatus(path: string): Promise<{ exists: boolean; directory: boolean; file: boolean }> { + return jsonRequest('/v1/ui/path-status', { + method: 'POST', + body: JSON.stringify({ path }) + }); +} + +export interface ModelInstallJob { + id: string; + state: 'idle' | 'queued' | 'running' | 'complete' | 'failed'; + message: string; + exit_code: number; + downloaded_bytes: number; + total_bytes: number; + progress_percent: number; + started_at_ms: number; + finished_at_ms: number; +} + +export async function installModelPackage(body: { id: string }): Promise { + return jsonRequest('/v1/ui/models/install', { + method: 'POST', + body: JSON.stringify(body) + }); +} + +export async function deleteModelPackage(id: string): Promise<{ id: string; removed: boolean; message: string }> { + return jsonRequest('/v1/ui/models/delete', { + method: 'POST', + body: JSON.stringify({ id }) + }); +} + +export async function modelInstallJobs(): Promise { + const response = await jsonRequest<{ data: ModelInstallJob[] }>('/v1/ui/models/install-status'); + return response.data; +} + +export interface ModelPackageSize { + id: string; + size_bytes: number | null; + state: 'pending' | 'ok' | 'gated' | 'unknown' | 'error'; + message: string; + installed: boolean; +} + +export interface ModelPackageSizesResponse { + state: 'idle' | 'running' | 'complete' | 'failed'; + message: string; + data: ModelPackageSize[]; +} + +export async function modelPackageSizes(): Promise { + return jsonRequest('/v1/ui/models/package-sizes'); +} + +export interface ModelsRootResponse { + models_root: string; + default_models_root: string; + is_default: boolean; +} + +export async function getModelsRoot(): Promise { + return jsonRequest('/v1/ui/models-root'); +} + +export async function setModelsRoot(path = ''): Promise { + return jsonRequest('/v1/ui/models-root', { + method: 'POST', + body: JSON.stringify({ path }) + }); +} + +export interface DirectoryBrowserResponse { + current: string; + parent: string; + roots: string[]; + directories: Array<{ name: string; path: string }>; +} + +export async function browseDirectories(path = ''): Promise { + return jsonRequest('/v1/ui/browse-directories', { + method: 'POST', + body: JSON.stringify({ path }) + }); +} + +export async function uploadWav(blob: Blob, filename: string, signal?: AbortSignal): Promise { + const response = await jsonRequest<{ path: string }>('/v1/ui/upload', { + method: 'POST', + headers: { + 'Content-Type': 'audio/wav', + 'X-AudioCPP-Filename': filename + }, + body: blob + }, signal); + return response.path; +} + +export async function speech(body: Record, signal?: AbortSignal) { + const response = await fetch('/v1/audio/speech', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal + }); + if (!response.ok) throw await errorFrom(response); + return { + blob: await response.blob(), + wallMs: response.headers.get('X-AudioCPP-Wall-Ms'), + rtf: response.headers.get('X-AudioCPP-RTF') + }; +} + +export async function transcription(body: Record, signal?: AbortSignal) { + return jsonRequest>('/v1/audio/transcriptions', { + method: 'POST', + body: JSON.stringify(body) + }, signal); +} + +export async function runTask(body: Record, signal?: AbortSignal) { + return jsonRequest>('/v1/tasks/run', { + method: 'POST', + body: JSON.stringify(body) + }, signal); +} + +export function base64AudioUrl(data: string): string { + const binary = atob(data); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return URL.createObjectURL(new Blob([bytes], { type: 'audio/wav' })); +} diff --git a/webui/native/src/lib/audio.ts b/webui/native/src/lib/audio.ts new file mode 100644 index 00000000..9af714a3 --- /dev/null +++ b/webui/native/src/lib/audio.ts @@ -0,0 +1,88 @@ +function writeAscii(view: DataView, offset: number, text: string) { + for (let index = 0; index < text.length; index += 1) { + view.setUint8(offset + index, text.charCodeAt(index)); + } +} + +export function encodePcm16Wav(buffer: AudioBuffer): Blob { + const channels = buffer.numberOfChannels; + const frames = buffer.length; + const bytes = new ArrayBuffer(44 + frames * channels * 2); + const view = new DataView(bytes); + writeAscii(view, 0, 'RIFF'); + view.setUint32(4, 36 + frames * channels * 2, true); + writeAscii(view, 8, 'WAVE'); + writeAscii(view, 12, 'fmt '); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, channels, true); + view.setUint32(24, buffer.sampleRate, true); + view.setUint32(28, buffer.sampleRate * channels * 2, true); + view.setUint16(32, channels * 2, true); + view.setUint16(34, 16, true); + writeAscii(view, 36, 'data'); + view.setUint32(40, frames * channels * 2, true); + + const samples = Array.from({ length: channels }, (_, channel) => buffer.getChannelData(channel)); + let offset = 44; + for (let frame = 0; frame < frames; frame += 1) { + for (let channel = 0; channel < channels; channel += 1) { + const sample = Math.max(-1, Math.min(1, samples[channel][frame])); + view.setInt16(offset, sample < 0 ? sample * 32768 : sample * 32767, true); + offset += 2; + } + } + return new Blob([bytes], { type: 'audio/wav' }); +} + +export async function concatenateAudioBlobs(blobs: Blob[]): Promise { + if (!blobs.length) throw new Error('No audio chunks were generated.'); + if (blobs.length === 1) return blobs[0]; + + const context = new AudioContext(); + try { + const decoded = await Promise.all(blobs.map(async (blob) => + context.decodeAudioData(await blob.arrayBuffer()))); + const sampleRate = decoded[0].sampleRate; + const channels = decoded[0].numberOfChannels; + for (const chunk of decoded) { + if (chunk.sampleRate !== sampleRate || chunk.numberOfChannels !== channels) { + throw new Error('Generated chunks use different audio formats and cannot be joined.'); + } + } + const totalFrames = decoded.reduce((sum, chunk) => sum + chunk.length, 0); + const merged = context.createBuffer(channels, totalFrames, sampleRate); + let offset = 0; + for (const chunk of decoded) { + for (let channel = 0; channel < channels; channel += 1) { + merged.copyToChannel(chunk.getChannelData(channel), channel, offset); + } + offset += chunk.length; + } + return encodePcm16Wav(merged); + } finally { + await context.close(); + } +} + +export async function browserDecodeToWav(file: File, targetSampleRate?: number): Promise { + if (!targetSampleRate && (file.type === 'audio/wav' || file.name.toLowerCase().endsWith('.wav'))) { + return file; + } + const context = new AudioContext(); + try { + const decoded = await context.decodeAudioData(await file.arrayBuffer()); + if (!targetSampleRate || decoded.sampleRate === targetSampleRate) { + return encodePcm16Wav(decoded); + } + const frames = Math.max(1, Math.ceil(decoded.duration * targetSampleRate)); + const offline = new OfflineAudioContext(decoded.numberOfChannels, frames, targetSampleRate); + const source = offline.createBufferSource(); + source.buffer = decoded; + source.connect(offline.destination); + source.start(); + return encodePcm16Wav(await offline.startRendering()); + } finally { + await context.close(); + } +} diff --git a/webui/native/src/lib/catalog.ts b/webui/native/src/lib/catalog.ts new file mode 100644 index 00000000..1034d2f9 --- /dev/null +++ b/webui/native/src/lib/catalog.ts @@ -0,0 +1,152 @@ +import rawCatalog from '../../../configs/models_catalog.json'; +import rawParams from '../../../configs/model_params.json'; +import type { CatalogEntry, InstallPackageChoice, ParamSpec } from './types'; + +interface PackageEntry { + family: string; + id: string; + target_directory: string; + format: string; + precision: string; + files?: string[]; + strip_prefix?: string; + default?: boolean; +} + +interface PackageSpec { + family: string; + packages?: Array>; +} + +const specModules = import.meta.glob('../../../../model_specs/*.json', { + eager: true, + import: 'default' +}) as Record; + +// Package ids and install locations are sourced from model_specs at frontend +// build time. This keeps the embedded catalog aligned when package ids gain a +// precision/format suffix, without needing model_specs files at UI runtime. +const packages: PackageEntry[] = Object.values(specModules).flatMap((spec) => + (spec.packages || []).map((entry) => ({ ...entry, family: spec.family })) +); + +const cleanPath = (value: string) => value + .replace(/\\/g, '/') + .replace(/^\.\//, '') + .replace(/^models\//i, '') + .replace(/\/$/, '') + .toLowerCase(); + +const cleanId = (value: string) => value.toLowerCase().replace(/[^a-z0-9]/g, ''); + +function preferredPackage(entries: PackageEntry[]): PackageEntry | undefined { + return entries.find((entry) => entry.default) || + entries.find((entry) => entry.precision === 'q8_0') || + entries[0]; +} + +function relatedPackages(entry: CatalogEntry): PackageEntry[] { + const family = packages.filter((candidate) => candidate.family === entry.family); + if (!family.length) return []; + if (!entry.download_id) return family; + const exact = family.find((candidate) => candidate.id === entry.download_id); + if (exact) { + const stem = exact.id.replace(/_(?:q8_0|q8|f16|fp16|bf16|safetensors|orig)$/i, ''); + const matches = family.filter((candidate) => + candidate.id.replace(/_(?:q8_0|q8|f16|fp16|bf16|safetensors|orig)$/i, '') === stem); + return matches.length ? matches : [exact]; + } + + // Resolve a legacy family/variant id before considering its old target + // directory. A directory such as models/pocket-tts may identify the gated + // upstream safetensors package, while the family default is the public GGUF + // package intended by the catalog's generic `pocket_tts` download id. + const legacyId = cleanId(entry.download_id); + const legacyMatches = family.filter((candidate) => { + const currentId = cleanId(candidate.id); + return currentId.startsWith(legacyId) || legacyId.startsWith(currentId); + }); + if (legacyMatches.length) return legacyMatches; + + const target = cleanPath(entry.path); + const targetMatches = family.filter((candidate) => cleanPath(candidate.target_directory) === target); + if (targetMatches.length) { + const stems = new Set(targetMatches.map((candidate) => + candidate.id.replace(/_(?:q8_0|q8|f16|fp16|bf16|safetensors|orig)$/i, ''))); + const matches = family.filter((candidate) => stems.has( + candidate.id.replace(/_(?:q8_0|q8|f16|fp16|bf16|safetensors|orig)$/i, ''))); + return matches.length ? matches : targetMatches; + } + return family; +} + +function packageLabel(entry: PackageEntry): string { + if (entry.format === 'safetensors') return 'Safetensors'; + if (entry.precision === 'q8_0' || entry.precision === 'q8') return 'GGUF Q8'; + if (entry.precision === 'bf16') return 'GGUF BF16'; + if (entry.precision === 'f16' || entry.precision === 'fp16') return 'GGUF FP16'; + return `GGUF ${entry.precision.toUpperCase()}`; +} + +function packageModelPath(entry: PackageEntry): string { + if (entry.format !== 'gguf' || entry.files?.length !== 1) { + return `models/${entry.target_directory}`; + } + let relative = entry.files[0].replace(/\\/g, '/'); + const prefix = (entry.strip_prefix || '').replace(/\\/g, '/').replace(/\/$/, ''); + if (prefix && relative.startsWith(`${prefix}/`)) relative = relative.slice(prefix.length + 1); + return `models/${entry.target_directory}/${relative}`.replace(/\/+/g, '/'); +} + +function installChoices(entry: CatalogEntry): InstallPackageChoice[] { + const related = relatedPackages(entry); + const q8 = preferredPackage(related.filter((candidate) => + candidate.format === 'gguf' && ['q8_0', 'q8'].includes(candidate.precision))); + const fp16 = preferredPackage(related.filter((candidate) => + candidate.format === 'gguf' && ['f16', 'fp16'].includes(candidate.precision))) || + preferredPackage(related.filter((candidate) => + candidate.format === 'gguf' && candidate.precision === 'bf16')); + const otherGguf = !q8 && !fp16 + ? preferredPackage(related.filter((candidate) => candidate.format === 'gguf')) + : undefined; + const safetensors = preferredPackage(related.filter((candidate) => candidate.format === 'safetensors')); + return [q8, fp16, otherGguf, safetensors] + .filter((candidate): candidate is PackageEntry => candidate !== undefined) + .map((candidate) => ({ + id: candidate.id, + label: packageLabel(candidate), + path: packageModelPath(candidate), + format: candidate.format, + precision: candidate.precision + })); +} + +export const catalog = (rawCatalog.models as CatalogEntry[]).map((entry) => { + const choices = installChoices(entry); + const installPackage = choices[0]; + return { + ...entry, + display_name: entry.display_name_en || entry.display_name, + download_id: installPackage?.id || entry.download_id, + install_packages: choices, + path: installPackage?.path || entry.path + }; +}); + +export const parameterCatalog = rawParams as unknown as Record; + +export const taskLabels: Record = { + tts: 'Text to speech', + clon: 'Voice cloning', + asr: 'Transcription', + gen: 'Music & sound', + vc: 'Voice conversion', + svc: 'Singing conversion', + s2s: 'Speech editing', + sep: 'Source separation', + vad: 'Voice activity', + diar: 'Speaker diarization', + align: 'Forced alignment', + vdes: 'Voice design', + spk: 'Speaker analysis' +}; diff --git a/webui/native/src/lib/text.ts b/webui/native/src/lib/text.ts new file mode 100644 index 00000000..038a715a --- /dev/null +++ b/webui/native/src/lib/text.ts @@ -0,0 +1,61 @@ +const speakerLine = /^\s*(Speaker\s+\d+\s*:)\s*(.*)$/i; +const sentenceParts = /[^。!?!?;;…]*[。!?!?;;…]+|[^。!?!?;;…]+$/g; + +function splitLongLine(line: string, budget: number): string[] { + const match = speakerLine.exec(line); + const prefix = match ? `${match[1]} ` : ''; + const body = match ? match[2] : line.trim(); + const sentences = body.match(sentenceParts) || [body]; + const chunks: string[] = []; + let current = ''; + + for (const sentence of sentences) { + if (current && current.length + sentence.length > budget) { + chunks.push(prefix + current); + current = ''; + } + if (sentence.length <= budget) { + current += sentence; + continue; + } + if (current) { + chunks.push(prefix + current); + current = ''; + } + for (let offset = 0; offset < sentence.length; offset += budget) { + chunks.push(prefix + sentence.slice(offset, offset + budget)); + } + } + if (current) chunks.push(prefix + current); + return chunks.length ? chunks : [line]; +} + +export function splitTtsChunks(text: string, budget: number): string[] { + const units: string[] = []; + for (const line of text.split(/\r?\n/)) { + if (!line.trim()) continue; + units.push(...(line.length > budget ? splitLongLine(line, budget) : [line])); + } + + const chunks: string[] = []; + let current: string[] = []; + let currentLength = 0; + for (const unit of units) { + const separator = current.length ? 1 : 0; + if (current.length && currentLength + separator + unit.length > budget) { + chunks.push(current.join('\n')); + current = []; + currentLength = 0; + } + current.push(unit); + currentLength += (current.length > 1 ? 1 : 0) + unit.length; + } + if (current.length) chunks.push(current.join('\n')); + return chunks.length ? chunks : text.trim() ? [text] : []; +} + +export function defaultChunkBudget(family: string): number { + if (family === 'vibevoice') return 600; + if (family === 'voxcpm2') return 60; + return 1000; +} diff --git a/webui/native/src/lib/types.ts b/webui/native/src/lib/types.ts new file mode 100644 index 00000000..da4eee94 --- /dev/null +++ b/webui/native/src/lib/types.ts @@ -0,0 +1,66 @@ +export type StringMap = Record; + +export interface InstallPackageChoice { + id: string; + label: string; + path: string; + format: string; + precision: string; +} + +export interface CatalogEntry { + id: string; + display_name: string; + display_name_en?: string; + family: string; + path: string; + task: string; + mode: string; + download_id?: string; + install_packages?: InstallPackageChoice[]; + min_vram_gb?: number; + input_hint?: string; + input_hint_en?: string; + default_options?: Record; + load_options?: StringMap; + session_options?: StringMap; +} + +export interface ParamSpec { + name: string; + type: 'slider' | 'number' | 'bool' | 'text' | 'choice'; + label: string; + label_en?: string; + info?: string; + info_en?: string; + default?: unknown; + minimum?: number; + maximum?: number; + step?: number; + choices?: Array; + placeholder?: string; + placeholder_en?: string; + lines?: number; +} + +export interface LoadedModel { + id: string; + family: string; + task: string; + mode: string; + path: string; + loaded: boolean; +} + +export interface ServerHealth { + status: string; + backend: string; + models: number; + ui: boolean; + ui_management: boolean; +} + +export interface AudioOutput { + id: string; + url: string; +} diff --git a/webui/native/src/lib/voices.ts b/webui/native/src/lib/voices.ts new file mode 100644 index 00000000..a71d9aff --- /dev/null +++ b/webui/native/src/lib/voices.ts @@ -0,0 +1,53 @@ +export interface SavedVoice { + id: string; + name: string; + transcript: string; + audio: Blob; + createdAt: number; +} + +const databaseName = 'audiocpp-native-studio'; +const storeName = 'voices'; + +function openDatabase(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(databaseName, 1); + request.onupgradeneeded = () => { + if (!request.result.objectStoreNames.contains(storeName)) { + request.result.createObjectStore(storeName, { keyPath: 'id' }); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error || new Error('Could not open the voice library.')); + }); +} + +function transaction( + mode: IDBTransactionMode, + operation: (store: IDBObjectStore) => IDBRequest +): Promise { + return openDatabase().then((database) => new Promise((resolve, reject) => { + const tx = database.transaction(storeName, mode); + const request = operation(tx.objectStore(storeName)); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error || new Error('Voice library operation failed.')); + tx.oncomplete = () => database.close(); + tx.onerror = () => { + database.close(); + reject(tx.error || new Error('Voice library transaction failed.')); + }; + })); +} + +export async function listVoices(): Promise { + const voices = await transaction('readonly', (store) => store.getAll()); + return voices.sort((left, right) => right.createdAt - left.createdAt); +} + +export function saveVoice(voice: SavedVoice): Promise { + return transaction('readwrite', (store) => store.put(voice)); +} + +export function deleteVoice(id: string): Promise { + return transaction('readwrite', (store) => store.delete(id)); +} diff --git a/webui/native/src/routes/+page.svelte b/webui/native/src/routes/+page.svelte new file mode 100644 index 00000000..fb68c173 --- /dev/null +++ b/webui/native/src/routes/+page.svelte @@ -0,0 +1,1675 @@ + + +audio.cpp · Native Studio + + +
+
+
A
+
+ audio.cpp + Native Studio +
+
+ +
+ {server?.backend || 'offline'} +
+
+ +
+ {#if tab === 'studio'} + + +
+
+

LOCAL AUDIO INTELLIGENCE

+

{selectedId ? taskLabels[selected?.task] : 'Audio studio'}

+

One native server, one embedded interface, no Python between your browser and the model.

+
+
+ Model + {selectedId ? selected.display_name : 'No model selected'} + {selectedId ? (isLoaded ? 'Resident' : installed === false ? 'Not installed' : 'Available') : 'Choose an installed model'} +
+
+ +
+ + +
+ {#if selectedId} +
+
REQUEST

Input & controls

+ {selected?.task} +
+ + {#if showsText} + + + {/if} + + {#if ['tts', 'clon'].includes(selected.task)} +
+ +
+ + +
+
+ {/if} + + {#if selected.task === 'gen'} + + + {/if} + + {#if selected.task === 'asr'} + + + {/if} + + {#if selected.task === 'vdes'} + + + {/if} + +
+ {#if ['tts', 'clon', 'asr', 'gen', 's2s', 'align', 'vdes'].includes(selected.task)} +
+ + +
+ {/if} + {#if ['tts', 'clon', 'gen', 's2s', 'vdes'].includes(selected.task)} +
+ + +
+
+ + +
+ {/if} + {#if selected.task === 'gen'} +
+ + +
+ {/if} +
+ + {#if acceptsSource} + + sourceFile = event.currentTarget.files?.[0] || null} /> +
+ {#if recordingTarget === 'source'} + + Recording microphone + {:else} + + {#if sourceFile}{sourceFile.name}{/if} + {/if} +
+ {#if supportsLiveAsr} +
+
+ Live microphone transcription + Processes consecutive four-second requests using the model's streaming mode. +
+ {#if liveRecording} + + {:else} + + {/if} +
+ {/if} + {/if} + + {#if needsVoice} +
+
+ + chooseVoiceReference(event.currentTarget.files?.[0] || null)} /> +
+
+ + chooseReferenceText(event.currentTarget.files?.[0] || null)} /> +
+
+
+ {#if recordingTarget === 'voice'} + + Recording voice reference + {:else} + + {#if voiceFile}{voiceFile.name}{/if} + {/if} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ {/if} + + {#if paramSpecs.length} +
+ Model parameters {paramSpecs.length} +
+ {#each paramSpecs as spec} +
+ + {#if spec.type === 'bool'} + + {:else if spec.type === 'choice'} + + {:else if spec.type === 'slider'} +
+ advancedValues = {...advancedValues, [spec.name]: event.currentTarget.valueAsNumber}} /> + {String(advancedValues[spec.name])} +
+ {:else} + advancedValues = {...advancedValues, + [spec.name]: spec.type === 'number' ? event.currentTarget.valueAsNumber : event.currentTarget.value}} /> + {/if} + {#if spec.info_en || spec.info}{spec.info_en || spec.info}{/if} +
+ {/each} +
+
+ {/if} + +
+ Additional options JSON + +
+ +
+ + +
{status}
+
+ {:else} +
+
REQUEST

No model selected

+
+
+

Choose a downloaded model from the Model menu, or install one from the Models tab.

+
+ {/if} +
+ +
+
+
RESULT

Output

+ {#if outputAudio.length}{outputAudio.length} track{outputAudio.length === 1 ? '' : 's'}{/if} +
+ {#if outputAudio.length} +
+ {#each outputAudio as output} + + {/each} +
+ {:else} +

Generated audio and structured results appear here.

+ {/if} + {#if outputText}{/if} + {#if outputJson}
{outputJson}
{/if} +
+
+ {:else if tab === 'models'} +
+

MODEL LIBRARY

Local packages

+

Download and manage model packages without leaving the native interface.

+
+
+
+ + + {#if defaultModelsFolder}Default: {defaultModelsFolder}{/if} +
+ + + +
+
+ {#each catalog as entry} + {@const installJob = displayInstallJob(entry, installJobs)} + {@const packageChoices = entry.install_packages || []} +
+
{entry.task.toUpperCase()}
+
+ {taskLabels[entry.task] || entry.task} +

{entry.display_name}

+

{selectedModelPath(entry)}

+
+
+ VRAM ~{entry.min_vram_gb || '?'} GB + {#if packageChoices.length} +
+ {#each packageChoices as choice} +
+ + {#if packageSizes[choice.id]?.installed} + + {/if} +
+ {/each} +
+ {#if installJob && installJob.state !== 'complete'} +
+
+ {installJob.state} + {installProgressLabel(installJob)} +
+
+ +
+
{installJob.message}
+
+ {/if} + {/if} +
+
+ {/each} +
+ {:else} +

RUNTIME

Session log

Browser-side lifecycle and request events.

+
+
+
Status{server?.status || 'offline'}
+
Backend{server?.backend || '—'}
+
Registered{loadedModels.length}
+
Resident{loadedModels.filter((model) => model.loaded).length}
+
+
{logs.length ? logs.join('\n') : 'No events yet.'}
+
+ {/if} +
+ +{#if folderBrowserOpen} +
+ +
+{/if} + + diff --git a/webui/native/src/routes/+page.ts b/webui/native/src/routes/+page.ts new file mode 100644 index 00000000..ceccaaf6 --- /dev/null +++ b/webui/native/src/routes/+page.ts @@ -0,0 +1,2 @@ +export const prerender = true; +export const ssr = false; diff --git a/webui/native/svelte.config.js b/webui/native/svelte.config.js new file mode 100644 index 00000000..303b3035 --- /dev/null +++ b/webui/native/svelte.config.js @@ -0,0 +1,21 @@ +import adapter from '@sveltejs/adapter-static'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + kit: { + adapter: adapter({ + pages: 'dist', + assets: 'dist', + fallback: 'index.html', + strict: true + }), + output: { + bundleStrategy: 'inline' + }, + paths: { + relative: true + } + } +}; + +export default config; diff --git a/webui/native/tsconfig.json b/webui/native/tsconfig.json new file mode 100644 index 00000000..43447105 --- /dev/null +++ b/webui/native/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/webui/native/vite.config.ts b/webui/native/vite.config.ts new file mode 100644 index 00000000..a300180c --- /dev/null +++ b/webui/native/vite.config.ts @@ -0,0 +1,15 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit()], + server: { + proxy: { + '/health': 'http://127.0.0.1:8080', + '/v1': 'http://127.0.0.1:8080' + } + }, + build: { + target: 'es2022' + } +}); diff --git a/webui/test_model_manager_webui.py b/webui/test_model_manager_webui.py index 3b5e1dce..17301510 100644 --- a/webui/test_model_manager_webui.py +++ b/webui/test_model_manager_webui.py @@ -10,6 +10,8 @@ import sys import tempfile import unittest +from contextlib import redirect_stdout +from io import StringIO from pathlib import Path from types import SimpleNamespace @@ -65,6 +67,37 @@ def test_non_huggingface_package_is_rejected(self): with self.assertRaises(mmw.ManagerError): mmw.ensure_hf_package(package) + def test_package_size_record_sums_metadata_without_downloading(self): + original = mmw.check_remote_file + self.addCleanup(setattr, mmw, "check_remote_file", original) + mmw.check_remote_file = lambda _package, remote: 10 if remote == "a" else 15 + row = mmw.package_size_record(_package(files=("a", "b"))) + self.assertEqual(row["state"], "ok") + self.assertEqual(row["size_bytes"], 25) + + def test_gated_package_size_is_reported_without_failing_the_scan(self): + original = mmw.check_remote_file + self.addCleanup(setattr, mmw, "check_remote_file", original) + mmw.check_remote_file = lambda _package, _remote: None + row = mmw.package_size_record(_package(download={ + "kind": "huggingface_snapshot", "repo": "gated/demo", "gated": True})) + self.assertEqual(row["state"], "gated") + self.assertIsNone(row["size_bytes"]) + + def test_package_status_requires_every_file_before_reporting_installed(self): + root = Path(tempfile.mkdtemp(prefix="audiocpp_package_status_test_")) + self.addCleanup(shutil.rmtree, root, True) + package = _package(files=("Demo-GGUF/model-q8_0.gguf", "Demo-GGUF/config.json")) + target = root / "Demo-GGUF" + target.mkdir() + (target / "model-q8_0.gguf").write_bytes(b"gguf") + original = mmw.check_remote_file + self.addCleanup(setattr, mmw, "check_remote_file", original) + mmw.check_remote_file = lambda _package, _remote: 4 + self.assertFalse(mmw.package_size_record(package, root)["installed"]) + (target / "config.json").write_text("{}", encoding="utf-8") + self.assertTrue(mmw.package_size_record(package, root)["installed"]) + class InstallPlacementTests(unittest.TestCase): def setUp(self): @@ -74,22 +107,28 @@ def setUp(self): original = mmw.download_file self.addCleanup(setattr, mmw, "download_file", original) - def fake_download(package, remote_path, output_path): + def fake_download(package, remote_path, output_path, progress=None): self.calls.append((remote_path, output_path.name)) output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_bytes(b"gguf") + if progress is not None: + progress(4, 4) mmw.download_file = fake_download - def _install(self, package, overwrite=False): + def _install(self, package, overwrite=False, progress=False): args = SimpleNamespace( models_root=self.root, overwrite=overwrite, check=False, dry_run=False, + progress=progress, ) mmw.install_package(package, args) + def _uninstall(self, package): + mmw.uninstall_package(package, SimpleNamespace(models_root=self.root)) + def test_strip_prefix_file_lands_at_its_installed_path(self): self._install(_package()) self.assertTrue(os.path.isfile(os.path.join(self.root, "Demo-GGUF", "model-q8_0.gguf"))) @@ -102,17 +141,87 @@ def test_nested_paths_below_the_stripped_prefix_are_preserved(self): self._install(package) self.assertTrue(os.path.isfile(os.path.join(self.root, "Demo-GGUF", "tokenizer", "config.json"))) + def test_nested_target_directory_creates_its_parent_before_atomic_rename(self): + package = _package( + target_directory="Demo-GGUF/english", + files=("Demo-GGUF/english/model-q8_0.gguf",), + strip_prefix="Demo-GGUF/english") + self._install(package) + self.assertTrue(os.path.isfile(os.path.join( + self.root, "Demo-GGUF", "english", "model-q8_0.gguf"))) + def test_packages_without_a_strip_prefix_are_unchanged(self): package = _package(files=("config.json",), strip_prefix="") self._install(package) self.assertTrue(os.path.isfile(os.path.join(self.root, "Demo-GGUF", "config.json"))) - def test_existing_target_requires_overwrite(self): - os.makedirs(os.path.join(self.root, "Demo-GGUF")) - with self.assertRaises(mmw.ManagerError): - self._install(_package(), overwrite=False) + def test_complete_existing_package_is_an_idempotent_success(self): + target = Path(self.root) / "Demo-GGUF" + target.mkdir() + (target / "model-q8_0.gguf").write_bytes(b"old") + self._install(_package(), overwrite=False) + self.assertEqual((target / "model-q8_0.gguf").read_bytes(), b"old") + self.assertEqual(self.calls, []) self._install(_package(), overwrite=True) - self.assertTrue(os.path.isfile(os.path.join(self.root, "Demo-GGUF", "model-q8_0.gguf"))) + self.assertEqual((target / "model-q8_0.gguf").read_bytes(), b"gguf") + + def test_partial_existing_package_still_requires_overwrite(self): + target = Path(self.root) / "Demo-GGUF" + target.mkdir() + (target / "model-q8_0.gguf").write_bytes(b"old") + package = _package(files=("Demo-GGUF/model-q8_0.gguf", "Demo-GGUF/config.json")) + with self.assertRaises(mmw.ManagerError): + self._install(package, overwrite=False) + + def test_precision_variants_can_share_a_target_directory(self): + self._install(_package()) + self._install(_package( + id="demo_f16", + precision="f16", + files=("Demo-GGUF/model-f16.gguf",), + )) + target = Path(self.root) / "Demo-GGUF" + self.assertTrue((target / "model-q8_0.gguf").is_file()) + self.assertTrue((target / "model-f16.gguf").is_file()) + + def test_overwrite_preserves_other_precision_variants(self): + target = Path(self.root) / "Demo-GGUF" + target.mkdir() + (target / "model-q8_0.gguf").write_bytes(b"q8") + (target / "model-f16.gguf").write_bytes(b"old") + self._install(_package( + id="demo_f16", + precision="f16", + files=("Demo-GGUF/model-f16.gguf",), + ), overwrite=True) + self.assertEqual((target / "model-q8_0.gguf").read_bytes(), b"q8") + self.assertEqual((target / "model-f16.gguf").read_bytes(), b"gguf") + + def test_uninstall_removes_only_selected_precision(self): + q8 = _package() + f16 = _package(id="demo_f16", precision="f16", files=("Demo-GGUF/model-f16.gguf",)) + self._install(q8) + self._install(f16) + self._uninstall(q8) + target = Path(self.root) / "Demo-GGUF" + self.assertFalse((target / "model-q8_0.gguf").exists()) + self.assertTrue((target / "model-f16.gguf").is_file()) + + def test_uninstall_last_package_removes_empty_target(self): + package = _package() + self._install(package) + self._uninstall(package) + self.assertFalse((Path(self.root) / "Demo-GGUF").exists()) + + def test_progress_mode_emits_downloaded_and_total_bytes(self): + original = mmw.check_remote_file + self.addCleanup(setattr, mmw, "check_remote_file", original) + mmw.check_remote_file = lambda _package, _remote: 4 + output = StringIO() + with redirect_stdout(output): + self._install(_package(), progress=True) + self.assertIn("AUDIOCPP_PROGRESS downloaded=0 total=4", output.getvalue()) + self.assertIn("AUDIOCPP_PROGRESS downloaded=4 total=4", output.getvalue()) if __name__ == "__main__":