From 1363b53c12e9453e8d95fda4521c3387d5195fc2 Mon Sep 17 00:00:00 2001 From: Cole Leavitt Date: Wed, 7 Jan 2026 00:43:35 -0700 Subject: [PATCH 1/5] feat(stt): add NVIDIA Canary STT engine support Add support for NVIDIA's Canary speech-to-text models via NeMo toolkit: - Canary 1B v2: 4.89% WER, 630x RTF (5x faster than Whisper) - Canary Qwen 2.5B: Higher accuracy variant for demanding use cases Both models use NeMo's EncDecMultiTaskModel architecture with automatic model download via HuggingFace. Supports GPU acceleration (CUDA/ROCm), translation (s2t_translation), and punctuation restoration. New files: - src/canary_engine.hpp: Engine class definition - src/canary_engine.cpp: NeMo Python integration via py_executor Modified: - models_manager.h/cpp: Add stt_canary engine type and feature flags - speech_service.cpp: Engine instantiation and type checking - CMakeLists.txt: Add canary_engine source files - config/models.json: Add both Canary model entries Requires: pip install nemo_toolkit[asr] --- CMakeLists.txt | 2 + config/models.json | 63 ++++-- src/canary_engine.cpp | 421 +++++++++++++++++++++++++++++++++++++++++ src/canary_engine.hpp | 55 ++++++ src/checksum_tools.cpp | 24 +++ src/checksum_tools.hpp | 4 +- src/engine_table.hxx | 10 +- src/models_manager.cpp | 351 ++++++++++++++++++++++++++++++---- src/models_manager.h | 1 + src/py_tools.cpp | 10 + src/py_tools.hpp | 1 + src/speech_service.cpp | 26 +++ 12 files changed, 911 insertions(+), 57 deletions(-) create mode 100644 src/canary_engine.cpp create mode 100644 src/canary_engine.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 92ddb554..86117a46 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -425,6 +425,8 @@ if(WITH_PY) ${sources_dir}/punctuator.cpp ${sources_dir}/fasterwhisper_engine.hpp ${sources_dir}/fasterwhisper_engine.cpp + ${sources_dir}/canary_engine.hpp + ${sources_dir}/canary_engine.cpp ${sources_dir}/mimic3_engine.hpp ${sources_dir}/mimic3_engine.cpp ${sources_dir}/parler_engine.cpp diff --git a/config/models.json b/config/models.json index 13678f79..0312cfaf 100644 --- a/config/models.json +++ b/config/models.json @@ -36239,16 +36239,43 @@ "speaker": "zm_yunxia", "options": "a" }, - { - "name": "中文 (Kokoro 82M Yunyang Male)", - "model_id": "zh_kokoro_82m_zm_yunyang", - "model_alias_of": "multilang_kokoro_82m", - "pack_id": "zh_kokoro_82m", - "lang_id": "zh", - "speaker": "zm_yunyang", - "options": "a" - } - ], + { + "name": "中文 (Kokoro 82M Yunyang Male)", + "model_id": "zh_kokoro_82m_zm_yunyang", + "model_alias_of": "multilang_kokoro_82m", + "pack_id": "zh_kokoro_82m", + "lang_id": "zh", + "speaker": "zm_yunyang", + "options": "a" + }, + { + "name": "Multilingual (Canary 1B v2)", + "model_id": "multilang_canary_1b_v2", + "file_name": "canary-1b-v2.nemo", + "engine": "stt_canary", + "lang_id": "multilang", + "info": "NVIDIA Canary 1B v2 - multilingual ASR and speech translation", + "options": "ti", + "score": 5, + "checksum": "sha256:ae5ef1bf06812a95a1594a8f5f0ee9c51f35418e5ba96939fa6b98ab00431094", + "checksum_quick": "647d6498", + "urls": [ + "hf://nvidia/canary-1b-v2/canary-1b-v2.nemo?revision=87bc52657add533cd0156b3fc1aef027280754bf" + ], + "size": "6358958080", + "features": [ + "high_quality", + "slow_processing", + "stt_punctuation" + ], + "license": { + "id": "CC-BY-4.0", + "name": "Creative Commons Attribution 4.0 International", + "url": "https://creativecommons.org/licenses/by/4.0/", + "accept_required": false + } + } + ], "packs": [ { "name": "English (April-ASR)", @@ -37171,14 +37198,14 @@ "engine": "stt_whisper", "lang_id": "yo" }, - { - "name": "中文 (FasterWhisper)", - "id": "zh_fasterwhisper", - "engine": "stt_fasterwhisper", - "lang_id": "zh" - }, - { - "name": "中文 (WhisperCpp)", + { + "name": "中文 (FasterWhisper)", + "id": "zh_fasterwhisper", + "engine": "stt_fasterwhisper", + "lang_id": "zh" + }, + { + "name": "中文 (WhisperCpp)", "id": "zh_whisper", "engine": "stt_whisper", "lang_id": "zh" diff --git a/src/canary_engine.cpp b/src/canary_engine.cpp new file mode 100644 index 00000000..8bae5692 --- /dev/null +++ b/src/canary_engine.cpp @@ -0,0 +1,421 @@ +/* Copyright (C) 2024-2025 Cole Leavitt + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +#include "canary_engine.hpp" + +#include + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "cpu_tools.hpp" +#include "gpu_tools.hpp" +#include "logger.hpp" +#include "py_executor.hpp" +#include "text_tools.hpp" + +using namespace pybind11::literals; + +namespace { +struct wav_header_t { + uint8_t riff[4] = {'R', 'I', 'F', 'F'}; + uint32_t chunk_size = 0; + uint8_t wave[4] = {'W', 'A', 'V', 'E'}; + uint8_t fmt[4] = {'f', 'm', 't', ' '}; + uint32_t fmt_size = 16; + uint16_t audio_format = 1; + uint16_t num_channels = 1; + uint32_t sample_rate = 0; + uint32_t bytes_per_sec = 0; + uint16_t block_align = 2; + uint16_t bits_per_sample = 16; + uint8_t data[4] = {'d', 'a', 't', 'a'}; + uint32_t data_size = 0; +}; + +std::string canary_source_lang(const stt_engine::config_t& config) { + auto lang = config.lang_code.empty() ? config.lang : config.lang_code; + if (lang.empty() || lang == "auto" || lang == "multilang") return "en"; + return lang; +} + +bool write_canary_wav(QTemporaryFile& file, const std::vector& samples, + uint32_t sample_rate) { + if (!file.open()) return false; + + wav_header_t header; + header.sample_rate = sample_rate; + header.data_size = + static_cast(samples.size() * sizeof(int16_t)); + header.chunk_size = header.data_size + sizeof(wav_header_t) - 8; + header.bytes_per_sec = sample_rate * sizeof(int16_t); + + if (file.write(reinterpret_cast(&header), sizeof(header)) != + static_cast(sizeof(header))) { + return false; + } + + for (auto sample : samples) { + sample = std::clamp(sample, -1.0F, 1.0F); + auto pcm = static_cast(sample * 32767.0F); + if (file.write(reinterpret_cast(&pcm), sizeof(pcm)) != + static_cast(sizeof(pcm))) { + return false; + } + } + + return file.flush(); +} +} + +canary_engine::canary_engine(config_t config, callbacks_t call_backs) + : stt_engine{std::move(config), std::move(call_backs)} { + m_speech_buf.reserve(m_speech_max_size); +} + +canary_engine::~canary_engine() { + LOGD("canary dtor"); + stop(); +} + +void canary_engine::stop() { + stt_engine::stop(); + + auto task = py_executor::instance()->execute([&]() { + try { + m_model.reset(); + py::module_::import("gc").attr("collect")(); + } catch (const std::exception& err) { + LOGE("py error: " << err.what()); + } + return std::any{}; + }); + + if (task) task->get(); + + LOGD("canary stopped"); +} + +void canary_engine::push_buf_to_audio_buf( + const std::vector& buf, + audio_buf_t& audio_buf) { + std::transform(buf.cbegin(), buf.cend(), std::back_inserter(audio_buf), + [](auto sample) { + return static_cast(sample) / + 32768.0F; + }); +} + +void canary_engine::push_buf_to_audio_buf(in_buf_t::buf_t::value_type* data, + in_buf_t::buf_t::size_type size, + audio_buf_t& audio_buf) { + audio_buf.reserve(audio_buf.size() + size); + for (size_t i = 0; i < size; ++i) { + audio_buf.push_back(static_cast(data[i]) / + 32768.0F); + } +} + +void canary_engine::reset_impl() { m_speech_buf.clear(); } + +void canary_engine::stop_processing_impl() { LOGD("canary cancel"); } + +void canary_engine::start_processing_impl() { create_model(); } + +void canary_engine::create_model() { + if (m_model) return; + + LOGD("creating canary model"); + + auto task = py_executor::instance()->execute([&]() { + auto n_threads = static_cast( + std::min(m_config.cpu_threads, + std::max(1U, std::thread::hardware_concurrency()))); + auto use_cuda = + m_config.use_gpu && ((m_config.gpu_device.api == gpu_api_t::cuda && + gpu_tools::has_cudnn()) || + (m_config.gpu_device.api == gpu_api_t::rocm && + gpu_tools::has_hip())); + + LOGD("cpu info: arch=" << cpu_tools::arch() + << ", cores=" << std::thread::hardware_concurrency()); + LOGD("using threads: " << n_threads << "/" + << std::thread::hardware_concurrency()); + LOGD("using device: " << (use_cuda ? "cuda" : "cpu") << " " + << m_config.gpu_device.id); + + try { + auto torch = py::module_::import("torch"); + auto nemo_asr = py::module_::import("nemo.collections.asr"); + + std::string device = + use_cuda ? "cuda:" + std::to_string(m_config.gpu_device.id) + : "cpu"; + auto torch_device = torch.attr("device")(device); + + const std::string model_file = m_config.model_files.model_file; + const bool is_local_model = + !model_file.empty() && + QFileInfo{QString::fromStdString(model_file)}.exists(); + if (!is_local_model) { + LOGE("canary model file not found: " << model_file); + return false; + } + + auto asr_model = nemo_asr.attr("models").attr("ASRModel"); + LOGD("restoring canary model from: " << model_file); + auto model = asr_model.attr("restore_from")( + "restore_path"_a = model_file, "map_location"_a = torch_device); + + model.attr("to")(torch_device); + model.attr("eval")(); + + if (use_cuda) { + torch.attr("cuda").attr("empty_cache")(); + } + + m_model.emplace(std::move(model)); + return true; + } catch (const std::exception& err) { + LOGE("py error: " << err.what()); + m_model.reset(); + return false; + } + }); + + if (!task || !std::any_cast(task->get())) { + LOGE("failed to create canary model"); + throw std::runtime_error{"failed to create canary model"}; + } + + LOGD("canary model created"); +} + +stt_engine::samples_process_result_t canary_engine::process_buff() { + if (!lock_buff_for_processing()) + return samples_process_result_t::wait_for_samples; + + auto eof = m_in_buf.eof; + auto sof = m_in_buf.sof; + + LOGD("process samples buf: mode=" + << m_config.speech_mode << ", in-buf size=" << m_in_buf.size + << ", speech-buf size=" << m_speech_buf.size() << ", sof=" << sof + << ", eof=" << eof); + + if (sof) { + m_speech_buf.clear(); + m_start_time.reset(); + m_vad.reset(); + reset_segment_counters(); + } + + m_denoiser.process(m_in_buf.buf.data(), m_in_buf.size); + + const auto& vad_buf = + m_vad.remove_silence(m_in_buf.buf.data(), m_in_buf.size); + + bool vad_status = !vad_buf.empty(); + + if (vad_status) { + LOGD("vad: speech detected"); + + if (m_config.speech_mode != speech_mode_t::manual && + m_config.speech_mode != speech_mode_t::single_sentence) + set_speech_detection_status( + speech_detection_status_t::speech_detected); + + if (m_config.text_format == text_format_t::raw) + push_buf_to_audio_buf(vad_buf, m_speech_buf); + else + push_buf_to_audio_buf(m_in_buf.buf.data(), m_in_buf.size, + m_speech_buf); + + restart_sentence_timer(); + } else { + LOGD("vad: no speech"); + + if (m_config.speech_mode == speech_mode_t::single_sentence && + m_speech_buf.empty() && sentence_timer_timed_out()) { + LOGD("sentence timeout"); + m_call_backs.sentence_timeout(); + } + + if (m_config.speech_mode == speech_mode_t::automatic) + set_speech_detection_status(speech_detection_status_t::no_speech); + + if (m_speech_buf.empty()) + m_segment_time_discarded_before += + (1000 * m_in_buf.size) / m_sample_rate; + else + m_segment_time_discarded_after += + (1000 * m_in_buf.size) / m_sample_rate; + } + + m_in_buf.clear(); + + auto decode_samples = [&] { + if (m_speech_buf.size() > m_speech_max_size) { + LOGD("speech buf reached max size"); + return true; + } + + if (m_speech_buf.empty()) return false; + + if ((m_config.speech_mode == speech_mode_t::manual || + m_speech_detection_status == + speech_detection_status_t::speech_detected) && + vad_status && !eof) + return false; + + if ((m_config.speech_mode == speech_mode_t::manual || + m_config.speech_mode == speech_mode_t::single_sentence) && + m_speech_detection_status == speech_detection_status_t::no_speech && + !eof) + return false; + + return true; + }(); + + if (!decode_samples) { + if (eof || (m_config.speech_mode == speech_mode_t::manual && + m_speech_detection_status == + speech_detection_status_t::no_speech)) { + flush(eof ? flush_t::eof : flush_t::regular); + free_buf(); + return samples_process_result_t::no_samples_needed; + } + + free_buf(); + return samples_process_result_t::wait_for_samples; + } + + if (m_thread_exit_requested) { + free_buf(); + return samples_process_result_t::no_samples_needed; + } + + set_state(state_t::decoding); + + if (!vad_status) { + set_speech_detection_status(speech_detection_status_t::no_speech); + } + + LOGD("speech frame: samples=" << m_speech_buf.size()); + + m_segment_time_offset += m_segment_time_discarded_before; + m_segment_time_discarded_before = 0; + + decode_speech(m_speech_buf); + + m_segment_time_offset += (m_segment_time_discarded_after + + (1000 * m_speech_buf.size() / m_sample_rate)); + m_segment_time_discarded_after = 0; + + set_state(state_t::idle); + + if (m_config.speech_mode == speech_mode_t::single_sentence && + (!m_intermediate_text || m_intermediate_text->empty())) { + LOGD("no speech decoded, forcing sentence timeout"); + m_call_backs.sentence_timeout(); + } + + m_speech_buf.clear(); + + flush(eof || m_config.speech_mode == speech_mode_t::single_sentence + ? flush_t::eof + : flush_t::regular); + + free_buf(); + + return samples_process_result_t::wait_for_samples; +} + +void canary_engine::decode_speech(const audio_buf_t& buf) { + LOGD("speech decoding started"); + + create_model(); + + auto decoding_start = std::chrono::steady_clock::now(); + QTemporaryFile tmp_file{QDir::temp().absoluteFilePath( + QStringLiteral("dsnote-canary-XXXXXX.wav"))}; + if (!write_canary_wav(tmp_file, buf, m_sample_rate)) { + LOGE("failed to write canary temp wav: " + << tmp_file.fileName().toStdString()); + return; + } + tmp_file.close(); + const auto tmp_path = tmp_file.fileName().toStdString(); + + auto task = py_executor::instance()->execute([&, tmp_path]() { + try { + std::string source_lang = canary_source_lang(m_config); + std::string target_lang = m_config.translate ? "en" : source_lang; + std::string task_type = m_config.translate ? "s2t_translation" : "asr"; + + py::list paths; + paths.append(tmp_path); + + auto result = m_model->attr("transcribe")( + paths, + "batch_size"_a = 1, + "source_lang"_a = source_lang, + "target_lang"_a = target_lang, + "task"_a = task_type, + "pnc"_a = m_config.has_option('i'), + "verbose"_a = false); + + std::string text; + if (py::isinstance(result) && py::len(result) > 0) { + text = result[py::int_(0)].cast(); + } + + rtrim(text); + ltrim(text); + + return std::pair(std::move(text), + std::move(source_lang)); + } catch (const std::exception& err) { + LOGE("canary py error: " << err.what()); + return std::pair({}, {}); + } + }); + + if (!task) return; + + auto [text, auto_lang] = + std::any_cast>(task->get()); + + if (m_thread_exit_requested) return; + + auto stats = report_stats( + buf.size(), m_sample_rate, + static_cast(std::max( + 0L, static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now() - decoding_start) + .count())))); + + auto result = merge_texts(m_intermediate_text.value_or(std::string{}), + std::move(text)); + + if (m_config.insert_stats) result.append(" " + stats); + +#ifdef DEBUG + LOGD("speech decoded: text=" << result); +#endif + + if (!m_intermediate_text || m_intermediate_text != result) + set_intermediate_text(result, auto_lang); +} diff --git a/src/canary_engine.hpp b/src/canary_engine.hpp new file mode 100644 index 00000000..b556e153 --- /dev/null +++ b/src/canary_engine.hpp @@ -0,0 +1,55 @@ +/* Copyright (C) 2024-2025 Cole Leavitt + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +#ifndef CANARY_ENGINE_H +#define CANARY_ENGINE_H + +#undef slots +#include +#include +#define slots Q_SLOTS + +#include +#include +#include +#include + +#include "stt_engine.hpp" + +namespace py = pybind11; + +class canary_engine : public stt_engine { + public: + canary_engine(config_t config, callbacks_t call_backs); + ~canary_engine() override; + + private: + using audio_buf_t = std::vector; + + inline static const size_t m_speech_max_size = m_sample_rate * 30; + inline static const int m_threads = 8; + + std::optional m_model; + audio_buf_t m_speech_buf; + + void create_model(); + samples_process_result_t process_buff() override; + void decode_speech(const audio_buf_t& buf); + static void push_buf_to_audio_buf( + const std::vector& buf, + audio_buf_t& audio_buf); + static void push_buf_to_audio_buf(in_buf_t::buf_t::value_type* data, + in_buf_t::buf_t::size_type size, + audio_buf_t& audio_buf); + + void reset_impl() override; + void stop_processing_impl() override; + void start_processing_impl() override; + void stop(); +}; + +#endif diff --git a/src/checksum_tools.cpp b/src/checksum_tools.cpp index ede9e4a9..7cf51392 100644 --- a/src/checksum_tools.cpp +++ b/src/checksum_tools.cpp @@ -9,8 +9,10 @@ #include +#include #include #include +#include #include #include #include @@ -87,10 +89,32 @@ QString make_checksum(const QString& file_or_dir) { return make_file_checksum(file_or_dir); } +QString make_sha256_checksum(const QString& file_or_dir) { + if (QFileInfo{file_or_dir}.isDir()) { + qWarning() << "sha256 checksum for directories is not supported:" + << file_or_dir; + return {}; + } + return make_file_sha256_checksum(file_or_dir); +} + QString make_file_checksum(const QString& file) { return number_to_hex_str(make_file_checksum_number(file)); } +QString make_file_sha256_checksum(const QString& file) { + QFile input{file}; + if (!input.open(QIODevice::ReadOnly)) { + qWarning() << "failed to open file:" << file; + return {}; + } + + QCryptographicHash hash{QCryptographicHash::Sha256}; + while (!input.atEnd()) hash.addData(input.read(65536)); + + return QString::fromLatin1(hash.result().toHex()); +} + QString make_dir_checksum(const QString& dir) { QDirIterator it{dir, QDir::Files | QDir::NoSymLinks | QDir::Readable, QDirIterator::Subdirectories}; diff --git a/src/checksum_tools.hpp b/src/checksum_tools.hpp index b18ab087..eeaf3557 100644 --- a/src/checksum_tools.hpp +++ b/src/checksum_tools.hpp @@ -12,11 +12,13 @@ namespace checksum_tools { QString make_checksum(const QString& file_or_dir); +QString make_sha256_checksum(const QString& file_or_dir); QString make_file_checksum(const QString& file); +QString make_file_sha256_checksum(const QString& file); QString make_dir_checksum(const QString& dir); QString make_quick_checksum(const QString& file_or_dir); QString make_file_quick_checksum(const QString& file); QString make_dir_quick_checksum(const QString& file); } // namespace checksum_tools -#endif // CHECKSUM_TOOLS_HPP +#endif // CHECKSUM_TOOLS_HPP diff --git a/src/engine_table.hxx b/src/engine_table.hxx index cb974f48..c5412c1b 100644 --- a/src/engine_table.hxx +++ b/src/engine_table.hxx @@ -36,7 +36,9 @@ #ifdef USE_PY #define STT_ENGINE_TABLE_PY \ X(fasterwhisper, stt, true, false, false, true, 9, slow_processing, \ - high_quality, "", 0) + high_quality, "", 0) \ + X(canary, stt, true, false, false, true, 21, slow_processing, \ + high_quality, ".nemo", 0) #else #define STT_ENGINE_TABLE_PY #endif @@ -145,7 +147,8 @@ X(whisperspeech, tts, 9, 0) \ X(parler, tts, 11, 0) \ X(f5, tts, 13, 0) \ - X(kokoro, tts, 15, 0) + X(kokoro, tts, 15, 0) \ + X(canary, stt, 17, 0) #else #define HW_CUDA_ENGINE_TABLE_PY #endif @@ -162,7 +165,8 @@ X(whisperspeech, tts, 10, 0) \ X(parler, tts, 12, 0) \ X(f5, tts, 14, 0) \ - X(kokoro, tts, 16, 0) + X(kokoro, tts, 16, 0) \ + X(canary, stt, 18, 0) #else #define HW_HIP_ENGINE_TABLE_PY #endif diff --git a/src/models_manager.cpp b/src/models_manager.cpp index 9bd7d816..bdacc210 100644 --- a/src/models_manager.cpp +++ b/src/models_manager.cpp @@ -30,6 +30,7 @@ #include #include #include +#include #include #include #include @@ -226,6 +227,98 @@ static void remove_file_or_dir(const QString& path) { QFile::remove(path); } +struct hf_file_ref_t { + QUrl endpoint; + QString repo_id; + QString revision; + QString filename; +}; + +static QUrl endpoint_from_url(const QUrl& url) { + QUrl endpoint; + endpoint.setScheme(url.scheme()); + endpoint.setHost(url.host()); + if (url.port() != -1) endpoint.setPort(url.port()); + return endpoint; +} + +static std::optional hf_file_ref_from_url(const QUrl& url) { + auto path_parts = url.path().split(QLatin1Char{'/'}, Qt::SkipEmptyParts); + + if (url.scheme() == QStringLiteral("hf")) { + QUrlQuery query{url}; + auto endpoint = + QUrl{query.hasQueryItem(QStringLiteral("endpoint")) + ? query.queryItemValue(QStringLiteral("endpoint")) + : QStringLiteral("https://huggingface.co")}; + auto revision = query.hasQueryItem(QStringLiteral("revision")) + ? query.queryItemValue(QStringLiteral("revision")) + : QStringLiteral("main"); + + QString repo_id; + QString filename; + if (url.host() == QStringLiteral("model") || + url.host() == QStringLiteral("models")) { + if (path_parts.size() < 3) return std::nullopt; + repo_id = path_parts.at(0) + QLatin1Char{'/'} + path_parts.at(1); + filename = path_parts.mid(2).join(QLatin1Char{'/'}); + } else { + if (url.host().isEmpty() || path_parts.size() < 2) + return std::nullopt; + repo_id = url.host() + QLatin1Char{'/'} + path_parts.at(0); + filename = path_parts.mid(1).join(QLatin1Char{'/'}); + } + + return hf_file_ref_t{std::move(endpoint), std::move(repo_id), + std::move(revision), std::move(filename)}; + } + + if ((url.scheme() == QStringLiteral("https") || + url.scheme() == QStringLiteral("http")) && + url.host() == QStringLiteral("huggingface.co")) { + auto resolve_idx = path_parts.indexOf(QStringLiteral("resolve")); + if (resolve_idx < 2 || path_parts.size() <= resolve_idx + 2) + return std::nullopt; + + auto repo_id = path_parts.at(0) + QLatin1Char{'/'} + path_parts.at(1); + auto revision = path_parts.at(resolve_idx + 1); + auto filename = path_parts.mid(resolve_idx + 2).join(QLatin1Char{'/'}); + return hf_file_ref_t{endpoint_from_url(url), std::move(repo_id), + std::move(revision), std::move(filename)}; + } + + return std::nullopt; +} + +static QUrl hf_resolve_url(const hf_file_ref_t& ref) { + auto url = ref.endpoint; + url.setPath(QStringLiteral("/%1/resolve/%2/%3") + .arg(ref.repo_id, ref.revision, ref.filename)); + return url; +} + +static void prepare_hf_metadata_request(QNetworkRequest& request) { +#if QT_VERSION >= QT_VERSION_CHECK(5, 9, 0) + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, + QNetworkRequest::ManualRedirectPolicy); +#endif + request.setRawHeader("Accept-Encoding", "identity"); +} + +static void prepare_download_request(QNetworkRequest& request) { +#if QT_VERSION < QT_VERSION_CHECK(5, 9, 0) + request.setAttribute(QNetworkRequest::FollowRedirectsAttribute, true); +#else + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, + QNetworkRequest::NoLessSafeRedirectPolicy); +#endif +} + +static void copy_dynamic_properties(const QObject* from, QObject* to) { + for (const auto& name : from->dynamicPropertyNames()) + to->setProperty(name.constData(), from->property(name.constData())); +} + models_manager::models_manager(QObject* parent) : QObject{parent} { #if QT_VERSION >= QT_VERSION_CHECK(5, 9, 0) m_nam.setRedirectPolicy(QNetworkRequest::NoLessSafeRedirectPolicy); @@ -753,14 +846,6 @@ void models_manager::download(const QString& id, download_type type, int part, ? model.size + sup_models_total_size(model.sup_models) : model.size; - QNetworkRequest request{url}; -#if QT_VERSION < QT_VERSION_CHECK(5, 9, 0) - request.setAttribute(QNetworkRequest::FollowRedirectsAttribute, true); -#else - request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, - QNetworkRequest::NoLessSafeRedirectPolicy); -#endif - if (type == download_type::all || type == download_type::model_sup) { path = model_path(model.file_name); checksum = model.checksum; @@ -820,6 +905,61 @@ void models_manager::download(const QString& id, download_type type, int part, return; } + auto set_reply_properties = [&](QNetworkReply* reply) { + reply->setProperty("out_path", path); + reply->setProperty("out_path_2", path_2); + reply->setProperty("model_id", id); + reply->setProperty("download_type", static_cast(type)); + reply->setProperty("download_next_type", static_cast(next_type)); + reply->setProperty("checksum", checksum); + reply->setProperty("checksum_2", checksum_2); + reply->setProperty("comp", static_cast(comp)); + reply->setProperty("size", size); + reply->setProperty("part", part); + reply->setProperty("next_part", next_part); + reply->setProperty("sup_idx", static_cast(sup_idx)); + reply->setProperty("next_sup_idx", + static_cast(next_sup_idx)); + reply->setProperty("path_in_archive", path_in_archive); + reply->setProperty("path_in_archive_2", path_in_archive_2); + }; + + auto mark_download_started = [&] { + if (!model.downloading) { + model.downloading = true; + update_dl_off(m_models); + emit download_started(id); + } + + if (part < 0) emit models_changed(); + }; + + if (auto hf_ref = hf_file_ref_from_url(url)) { + auto hf_url = hf_resolve_url(*hf_ref); + QNetworkRequest request{hf_url}; + prepare_hf_metadata_request(request); + + auto* reply = m_nam.head(request); + set_reply_properties(reply); + reply->setProperty("out_file_path", out_file_path); + + connect(reply, &QNetworkReply::finished, this, + [this, reply] { handle_hf_metadata_finished(reply); }); + connect(reply, &QNetworkReply::sslErrors, this, + &models_manager::handle_ssl_errors); + + qDebug() << "resolving hf download: url=" << hf_url + << ", configured url=" << url << ", type=" << type + << ", out path=" << path + << ", out file path=" << out_file_path; + + mark_download_started(); + return; + } + + QNetworkRequest request{url}; + prepare_download_request(request); + auto* out_file = new std::ofstream{out_file_path.toStdString(), std::ofstream::out}; @@ -830,21 +970,7 @@ void models_manager::download(const QString& id, download_type type, int part, reply->setProperty("out_file", QVariant::fromValue(static_cast(out_file))); - reply->setProperty("out_path", path); - reply->setProperty("out_path_2", path_2); - reply->setProperty("model_id", id); - reply->setProperty("download_type", static_cast(type)); - reply->setProperty("download_next_type", static_cast(next_type)); - reply->setProperty("checksum", checksum); - reply->setProperty("checksum_2", checksum_2); - reply->setProperty("comp", static_cast(comp)); - reply->setProperty("size", size); - reply->setProperty("part", part); - reply->setProperty("next_part", next_part); - reply->setProperty("sup_idx", static_cast(sup_idx)); - reply->setProperty("next_sup_idx", static_cast(next_sup_idx)); - reply->setProperty("path_in_archive", path_in_archive); - reply->setProperty("path_in_archive_2", path_in_archive_2); + set_reply_properties(reply); connect(reply, &QNetworkReply::downloadProgress, this, &models_manager::handle_download_progress); @@ -855,13 +981,7 @@ void models_manager::download(const QString& id, download_type type, int part, connect(reply, &QNetworkReply::sslErrors, this, &models_manager::handle_ssl_errors); - if (!model.downloading) { - model.downloading = true; - update_dl_off(m_models); - emit download_started(id); - } - - if (part < 0) emit models_changed(); + mark_download_started(); } void models_manager::handle_ssl_errors(const QList& errors) { @@ -881,16 +1001,172 @@ void models_manager::handle_download_ready_read() { } } +static bool is_sha256_checksum(const QString& checksum) { + return checksum.startsWith(QStringLiteral("sha256:"), Qt::CaseInsensitive); +} + +static QString normalized_checksum(QString checksum) { + checksum = checksum.trimmed().toLower(); + if (is_sha256_checksum(checksum)) + return QStringLiteral("sha256:") + checksum.mid(7); + return checksum; +} + +static QString make_checksum_for_expected(const QString& path, + const QString& expected) { + if (is_sha256_checksum(expected)) { + auto checksum = checksum_tools::make_sha256_checksum(path); + return checksum.isEmpty() ? QString{} + : QStringLiteral("sha256:") + checksum; + } + + return checksum_tools::make_checksum(path); +} + +static QString reply_header(const QNetworkReply* reply, + const QByteArray& header_name) { + for (const auto& name : reply->rawHeaderList()) { + if (!name.compare(header_name, Qt::CaseInsensitive)) + return QString::fromLatin1(reply->rawHeader(name)).trimmed(); + } + return {}; +} + +static QString hf_linked_checksum(const QNetworkReply* reply) { + auto etag = reply_header(reply, QByteArrayLiteral("x-linked-etag")); + if (etag.isEmpty()) etag = reply_header(reply, QByteArrayLiteral("etag")); + + etag = etag.trimmed().toLower(); + if (etag.startsWith(QStringLiteral("w/"))) etag.remove(0, 2); + if (etag.startsWith(QLatin1Char{'"'}) && etag.endsWith(QLatin1Char{'"'})) + etag = etag.mid(1, etag.size() - 2); + + if (etag.size() == 64) return QStringLiteral("sha256:") + etag; + return etag; +} + +void models_manager::handle_hf_metadata_finished(QNetworkReply* reply) { + auto id = reply->property("model_id").toString(); + auto path = reply->property("out_path").toString(); + auto comp = static_cast(reply->property("comp").toInt()); + auto part = reply->property("part").toInt(); + auto& model = m_models.at(id); + + auto finish_with_error = [&](bool report_error) { + remove_downloaded_files_on_error(path, comp, part); + if (report_error) emit download_error(id); + + reply->deleteLater(); + + model.downloading = false; + model.download_progress = 0.0; + model.downloaded_part_data = 0; + + update_dl_multi(m_models); + update_dl_off(m_models); + + emit models_changed(); + }; + + if (auto cancel = check_model_download_cancel(reply); + cancel || reply->error() != QNetworkReply::NoError) { + qWarning() << "hf metadata error:" << reply->error(); + finish_with_error(!cancel && + reply->error() != + QNetworkReply::OperationCanceledError); + return; + } + + auto location = reply_header(reply, QByteArrayLiteral("location")); + QUrl download_url; + if (location.isEmpty()) { + download_url = reply->url(); + } else { + auto target = QUrl{location}; + if (target.isRelative()) { + QNetworkRequest request{reply->url().resolved(target)}; + prepare_hf_metadata_request(request); + auto* next_reply = m_nam.head(request); + copy_dynamic_properties(reply, next_reply); + connect(next_reply, &QNetworkReply::finished, this, + [this, next_reply] { + handle_hf_metadata_finished(next_reply); + }); + connect(next_reply, &QNetworkReply::sslErrors, this, + &models_manager::handle_ssl_errors); + reply->deleteLater(); + return; + } + download_url = std::move(target); + } + + auto expected_checksum = + normalized_checksum(reply->property("checksum").toString()); + auto linked_checksum = normalized_checksum(hf_linked_checksum(reply)); + if (is_sha256_checksum(expected_checksum) && + is_sha256_checksum(linked_checksum) && + expected_checksum != linked_checksum) { + qWarning() << "hf metadata checksum mismatch:" << linked_checksum + << "(expected:" << expected_checksum << ")" << id; + finish_with_error(true); + return; + } + + bool size_ok = false; + auto linked_size = + reply_header(reply, QByteArrayLiteral("x-linked-size")).toLongLong( + &size_ok); + if (size_ok && linked_size > 0 && reply->property("size").toLongLong() <= 0) + reply->setProperty("size", linked_size); + + auto out_file_path = reply->property("out_file_path").toString(); + auto* out_file = + new std::ofstream{out_file_path.toStdString(), std::ofstream::out}; + if (!out_file->good()) { + qWarning() << "failed to open output file:" << out_file_path; + delete out_file; + finish_with_error(true); + return; + } + + QNetworkRequest request{download_url}; + prepare_download_request(request); + request.setRawHeader("Accept-Encoding", "identity"); + + auto* download_reply = m_nam.get(request); + copy_dynamic_properties(reply, download_reply); + download_reply->setProperty( + "out_file", QVariant::fromValue(static_cast(out_file))); + + connect(download_reply, &QNetworkReply::downloadProgress, this, + &models_manager::handle_download_progress); + connect(download_reply, &QNetworkReply::finished, this, + &models_manager::handle_download_finished); + connect(download_reply, &QNetworkReply::readyRead, this, + &models_manager::handle_download_ready_read); + connect(download_reply, &QNetworkReply::sslErrors, this, + &models_manager::handle_ssl_errors); + + qDebug() << "hf download resolved: url=" << download_url + << ", commit=" + << reply_header(reply, QByteArrayLiteral("x-repo-commit")) + << ", checksum=" << linked_checksum << ", size=" << linked_size + << ", out file path=" << out_file_path; + + reply->deleteLater(); +} + models_manager::checksum_check_t models_manager::check_checksum( const QString& path, const QString& checksum) { - auto real_checksum = checksum_tools::make_checksum(path); + auto expected_checksum = normalized_checksum(checksum); + auto real_checksum = make_checksum_for_expected(path, expected_checksum); auto real_quick_checksum = checksum_tools::make_quick_checksum(path); - bool ok = real_checksum == checksum; + bool ok = real_checksum == expected_checksum; if (!ok) { qWarning() << "checksum 1 is invalid:" << real_checksum << "(expected" - << checksum << ")"; + << expected_checksum << ")"; qDebug() << "quick checksum 1:" << real_quick_checksum; } @@ -1589,13 +1865,17 @@ bool models_manager::checksum_ok(const QString& checksum, if (checksum_quick.isEmpty()) { expected_checksum = checksum; - real_checksum = checksum_tools::make_checksum(model_path(file_name)); + real_checksum = + make_checksum_for_expected(model_path(file_name), checksum); } else { expected_checksum = checksum_quick; real_checksum = checksum_tools::make_quick_checksum(model_path(file_name)); } + expected_checksum = normalized_checksum(expected_checksum); + real_checksum = normalized_checksum(real_checksum); + auto ok = expected_checksum == real_checksum; if (ok) { @@ -2288,7 +2568,8 @@ void models_manager::add_implicit_model_options(priv_model_t& model) { !model.options.contains('c')) { // add char replacement option for all coqui tts models model.options.push_back('c'); - } else if ((model.engine == model_engine_t::stt_fasterwhisper) && + } else if ((model.engine == model_engine_t::stt_fasterwhisper || + model.engine == model_engine_t::stt_canary) && !model.disabled && !model.hidden && model.options.contains('t') && model.lang_id == "en") { // remove translate to english option for all english models diff --git a/src/models_manager.h b/src/models_manager.h index c3377960..230a93c8 100644 --- a/src/models_manager.h +++ b/src/models_manager.h @@ -367,6 +367,7 @@ class models_manager : public QObject, public singleton { size_t sup_idx); void handle_download_progress(qint64 received, qint64 real_total); void handle_download_finished(); + void handle_hf_metadata_finished(QNetworkReply* reply); void handle_download_ready_read(); void handle_ssl_errors(const QList& errors); static QString model_path(const QString& file_name); diff --git a/src/py_tools.cpp b/src/py_tools.cpp index a40b479f..2179cd96 100644 --- a/src/py_tools.cpp +++ b/src/py_tools.cpp @@ -36,6 +36,7 @@ std::ostream& operator<<(std::ostream& os, os << "py-version=" << availability.py_version << ", coqui-tts=" << availability.coqui_tts << ", faster-whisper=" << availability.faster_whisper + << ", nemo-asr=" << availability.nemo_asr << ", ctranslate2-cuda=" << availability.ctranslate2_cuda << ", mimic3-tts=" << availability.mimic3_tts << ", whisperspeech-tts=" << availability.whisperspeech_tts @@ -83,6 +84,7 @@ libs_availability_t libs_availability(libs_scan_type_t scan_type, availability.kokoro_zh = true; } availability.faster_whisper = true; + availability.nemo_asr = true; availability.mimic3_tts = true; availability.transformers = true; availability.unikud = true; @@ -243,6 +245,14 @@ libs_availability_t libs_availability(libs_scan_type_t scan_type, LOGD("faster-whisper check py error: " << err.what()); } + try { + LOGD("checking: nemo-asr"); + py::module_::import("nemo.collections.asr"); + availability.nemo_asr = true; + } catch (const std::exception& err) { + LOGD("nemo-asr check py error: " << err.what()); + } + try { LOGD("checking: transformers"); py::module_::import("transformers"); diff --git a/src/py_tools.hpp b/src/py_tools.hpp index 7ac76b51..55e4add5 100644 --- a/src/py_tools.hpp +++ b/src/py_tools.hpp @@ -26,6 +26,7 @@ struct libs_availability_t { bool torch_cuda = false; bool torch_hip = false; bool faster_whisper = false; + bool nemo_asr = false; bool ctranslate2_cuda = false; bool mimic3_tts = false; bool whisperspeech_tts = false; diff --git a/src/speech_service.cpp b/src/speech_service.cpp index 4707624f..6323f767 100644 --- a/src/speech_service.cpp +++ b/src/speech_service.cpp @@ -38,6 +38,7 @@ #include "whisper_engine.hpp" #ifdef USE_PY +#include "canary_engine.hpp" #include "coqui_engine.hpp" #include "f5_engine.hpp" #include "fasterwhisper_engine.hpp" @@ -1473,6 +1474,8 @@ QString speech_service::restart_stt_engine(speech_mode_t speech_mode, #ifdef USE_PY if (model_config->stt->engine == models_manager::model_engine_t::stt_fasterwhisper) { ENGINE_OPTS(fasterwhisper) + } else if (model_config->stt->engine == models_manager::model_engine_t::stt_canary) { + ENGINE_OPTS(canary) } #endif #undef ENGINE_OPTS @@ -3135,6 +3138,8 @@ QVariantMap speech_service::features_availability() { m_features_availability.insert( "faster-whisper-stt", QVariantList{py_availability->faster_whisper, "FasterWhisper STT"}); + m_features_availability.insert( + "canary-stt", QVariantList{py_availability->nemo_asr, "Canary STT"}); #ifdef ARCH_X86_64 bool stt_fasterwhisper_cuda = py_availability->faster_whisper && py_availability->ctranslate2_cuda && @@ -3159,6 +3164,26 @@ QVariantMap speech_service::features_availability() { hw_feature_flags |= settings::hw_feature_flags_t::hw_feature_stt_fasterwhisper_hip; } + bool stt_canary_cuda = + py_availability->nemo_asr && py_availability->torch_cuda && has_cuda; + bool stt_canary_hip = + py_availability->nemo_asr && py_availability->torch_hip && has_hip; + m_features_availability.insert( + "canary-stt-cuda", + QVariantList{stt_canary_cuda, + "Canary STT CUDA " + tr("HW acceleration")}); + m_features_availability.insert( + "canary-stt-hip", + QVariantList{stt_canary_hip, + "Canary STT ROCm " + tr("HW acceleration")}); + if (stt_canary_cuda) { + hw_feature_flags |= + settings::hw_feature_flags_t::HW_FEATURE(cuda, canary, stt); + } + if (stt_canary_hip) { + hw_feature_flags |= + settings::hw_feature_flags_t::HW_FEATURE(hip, canary, stt); + } #endif // ARCH_X86_64 m_features_availability.insert("punctuator", QVariantList{py_availability->transformers, @@ -3185,6 +3210,7 @@ QVariantMap speech_service::features_availability() { ma.tts_kokoro_ja = py_availability->kokoro_ja; ma.tts_kokoro_zh = py_availability->kokoro_zh; ma.stt_fasterwhisper = py_availability->faster_whisper; + ma.stt_canary = py_availability->nemo_asr; ma.ttt_hftc = py_availability->transformers; ma.ttt_unikud = py_availability->unikud; ma.option_r = py_availability->uroman; From df4d6744817efb85e4a1c61991df8011790c1c3f Mon Sep 17 00:00:00 2001 From: Cole Leavitt Date: Sun, 21 Jun 2026 19:47:47 -0400 Subject: [PATCH 2/5] Fix(stt): wire Canary against upstream settings --- src/models_manager.cpp | 2 ++ src/speech_service.cpp | 11 ++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/models_manager.cpp b/src/models_manager.cpp index bdacc210..a93029e2 100644 --- a/src/models_manager.cpp +++ b/src/models_manager.cpp @@ -2085,6 +2085,7 @@ models_manager::feature_flags models_manager::add_implicit_feature_flags( case model_engine_t::stt_whisper: #ifdef USE_PY case model_engine_t::stt_fasterwhisper: + case model_engine_t::stt_canary: #endif if (model_id.contains("tiny")) { existing_features = @@ -2549,6 +2550,7 @@ void models_manager::add_astrunc_model_options(priv_model_t& model) { return; \ } ASTRUNC_LANG_TABLE +#undef X } void models_manager::add_implicit_model_options(priv_model_t& model) { diff --git a/src/speech_service.cpp b/src/speech_service.cpp index 6323f767..a553e357 100644 --- a/src/speech_service.cpp +++ b/src/speech_service.cpp @@ -1475,7 +1475,16 @@ QString speech_service::restart_stt_engine(speech_mode_t speech_mode, if (model_config->stt->engine == models_manager::model_engine_t::stt_fasterwhisper) { ENGINE_OPTS(fasterwhisper) } else if (model_config->stt->engine == models_manager::model_engine_t::stt_canary) { - ENGINE_OPTS(canary) + if (settings::instance()->canary_use_gpu() && + settings::instance()->has_canary_gpu_device()) { + if (auto device = make_gpu_device( + settings::instance()->canary_gpu_device(), + settings::instance()->canary_auto_gpu_device())) { + config.gpu_device = std::move(*device); + config.gpu_device.flash_attn = false; + config.use_gpu = true; + } + } } #endif #undef ENGINE_OPTS From ad7118482dc81144030ec6b1ce40fd62100275e5 Mon Sep 17 00:00:00 2001 From: Cole Leavitt Date: Sun, 21 Jun 2026 20:27:20 -0400 Subject: [PATCH 3/5] Fix(stt): expose Canary without startup torch import --- config/models.json | 8 ++++ src/py_executor.cpp | 48 +++++++++++++++++++++ src/py_tools.cpp | 94 ++++++++++++++++++++++++++++-------------- src/speech_service.cpp | 6 +-- 4 files changed, 120 insertions(+), 36 deletions(-) diff --git a/config/models.json b/config/models.json index 0312cfaf..ff64773b 100644 --- a/config/models.json +++ b/config/models.json @@ -36274,6 +36274,14 @@ "url": "https://creativecommons.org/licenses/by/4.0/", "accept_required": false } + }, + { + "name": "English (Canary 1B v2)", + "model_id": "en_canary_1b_v2", + "model_alias_of": "multilang_canary_1b_v2", + "lang_id": "en", + "lang_code": "en", + "score": 5 } ], "packs": [ diff --git a/src/py_executor.cpp b/src/py_executor.cpp index 08f67d0f..7d57a212 100644 --- a/src/py_executor.cpp +++ b/src/py_executor.cpp @@ -9,11 +9,17 @@ #include +#include +#include +#include + #include +#include #include #include #include "logger.hpp" +#include "module_tools.hpp" #include "settings.h" py_executor::~py_executor() { @@ -75,6 +81,41 @@ static std::string add_to_env_path(const std::string& dir) { return new_path ? std::string{new_path} : std::string{}; } +static std::optional installed_venv_site_packages() { + auto share_dir = + module_tools::path_to_share_dir_for_path("venv/pyvenv.cfg"); + if (share_dir.isEmpty()) return std::nullopt; + + QDir python_libs{QDir{share_dir}.filePath("venv/lib")}; + if (!python_libs.exists()) return std::nullopt; + + auto site_packages_path = [](const QString& python_dir) { + QFileInfo site_packages{ + QDir{python_dir}.filePath("site-packages")}; + if (!site_packages.isDir()) return std::optional{}; + + auto path = site_packages.canonicalFilePath(); + if (path.isEmpty()) path = site_packages.absoluteFilePath(); + return std::optional{path.toStdString()}; + }; + + if (auto path = site_packages_path(python_libs.filePath( + QStringLiteral("python%1.%2").arg(PY_MAJOR_VERSION).arg(PY_MINOR_VERSION)))) { + return path; + } + + auto python_dirs = + python_libs.entryInfoList(QStringList{"python*"}, + QDir::Dirs | QDir::NoDotAndDotDot, + QDir::Name); + for (const auto& python_dir : python_dirs) { + if (auto path = site_packages_path(python_dir.absoluteFilePath())) + return path; + } + + return std::nullopt; +} + static void set_env(const char* name, const char* value) { auto* old_value = getenv(name); setenv(name, value, 1); @@ -96,6 +137,13 @@ void py_executor::loop() { py_tools::init_module(); + auto default_py_path = installed_venv_site_packages(); + if (default_py_path) { + LOGD("adding installed venv to PYTHONPATH: " << *default_py_path); + auto new_path = add_to_env_path(*default_py_path); + LOGD("new PYTHONPATH: " << new_path); + } + auto py_path = settings::instance()->py_path().toStdString(); if (!py_path.empty()) { LOGD("adding to PYTHONPATH: " << py_path); diff --git a/src/py_tools.cpp b/src/py_tools.cpp index 2179cd96..b2df0b46 100644 --- a/src/py_tools.cpp +++ b/src/py_tools.cpp @@ -15,9 +15,12 @@ #endif #include +#include #include #include +#include + #include "cpu_tools.hpp" #include "logger.hpp" #include "settings.h" @@ -62,6 +65,56 @@ std::ostream& operator<<(std::ostream& os, return os; } +#ifdef USE_PY +namespace { +bool py_module_spec_exists(const char* module_name) { + namespace py = pybind11; + + try { + auto importlib_util = py::module_::import("importlib.util"); + auto spec = importlib_util.attr("find_spec")(module_name); + return !spec.is_none(); + } catch (const std::exception& err) { + LOGD("python module spec check failed for " + << module_name << ": " << err.what()); + return false; + } +} + +bool py_package_subdirs_exist(const char* module_name, + std::initializer_list subdirs) { + namespace py = pybind11; + + try { + auto importlib_util = py::module_::import("importlib.util"); + auto spec = importlib_util.attr("find_spec")(module_name); + if (spec.is_none()) return false; + + auto locations = spec.attr("submodule_search_locations"); + if (locations.is_none()) return false; + + for (const auto location : locations) { + QDir dir{QString::fromStdString( + py::str(location).cast())}; + bool found = true; + for (auto* subdir : subdirs) { + if (!dir.cd(QString::fromLatin1(subdir))) { + found = false; + break; + } + } + if (found) return true; + } + } catch (const std::exception& err) { + LOGD("python package subdir check failed for " + << module_name << ": " << err.what()); + } + + return false; +} +} +#endif + namespace py_tools { libs_availability_t libs_availability(libs_scan_type_t scan_type, unsigned int scan_flags) { @@ -120,33 +173,9 @@ libs_availability_t libs_availability(libs_scan_type_t scan_type, if ((scan_flags & settings::ScanFlagNoTorchCuda) > 0) { LOGD("checking: torch cuda (skipped)"); - } else { - if (cpu_tools::cpuinfo().feature_flags & - cpu_tools::feature_flags_t::avx) { - try { - LOGD("checking: torch cuda"); - auto torch_cuda = py::module_::import("torch.cuda"); - auto torch_ver = py::module_::import("torch.version"); - if (torch_cuda.attr("is_available")().cast()) { - try { - auto cuda_ver = - torch_ver.attr("cuda").cast(); - LOGD("torch cuda version: " << cuda_ver); - availability.torch_cuda = !cuda_ver.empty(); - } catch ([[maybe_unused]] const py::cast_error& err) { - } - try { - auto hip_ver = - torch_ver.attr("hip").cast(); - LOGD("torch hip version: " << hip_ver); - availability.torch_hip = !hip_ver.empty(); - } catch ([[maybe_unused]] const py::cast_error& err) { - } - } - } catch (const std::exception& err) { - LOGD("torch cuda check py error: " << err.what()); - } - } + } else if (cpu_tools::cpuinfo().feature_flags & + cpu_tools::feature_flags_t::avx) { + LOGD("checking: torch cuda (deferred)"); } if ((scan_flags & settings::ScanFlagNoCt2Cuda) > 0) { @@ -247,8 +276,10 @@ libs_availability_t libs_availability(libs_scan_type_t scan_type, try { LOGD("checking: nemo-asr"); - py::module_::import("nemo.collections.asr"); - availability.nemo_asr = true; + auto has_nemo_asr = + py_package_subdirs_exist("nemo", {"collections", "asr"}); + auto has_torch = py_module_spec_exists("torch"); + availability.nemo_asr = has_nemo_asr && has_torch; } catch (const std::exception& err) { LOGD("nemo-asr check py error: " << err.what()); } @@ -374,12 +405,11 @@ libs_availability_t libs_availability(libs_scan_type_t scan_type, LOGD("py libs availability: [" << availability << "]"); try { - // release mem py::module_::import("gc").attr("collect")(); - py::module_::import("torch").attr("cuda").attr("empty_cache")(); } catch (const std::exception& err) { - LOGE("py error: " << err.what()); + LOGD("gc cleanup py error: " << err.what()); } + #endif return availability; diff --git a/src/speech_service.cpp b/src/speech_service.cpp index a553e357..499f1e32 100644 --- a/src/speech_service.cpp +++ b/src/speech_service.cpp @@ -3173,10 +3173,8 @@ QVariantMap speech_service::features_availability() { hw_feature_flags |= settings::hw_feature_flags_t::hw_feature_stt_fasterwhisper_hip; } - bool stt_canary_cuda = - py_availability->nemo_asr && py_availability->torch_cuda && has_cuda; - bool stt_canary_hip = - py_availability->nemo_asr && py_availability->torch_hip && has_hip; + bool stt_canary_cuda = py_availability->nemo_asr && has_cuda && has_cudnn; + bool stt_canary_hip = py_availability->nemo_asr && has_hip; m_features_availability.insert( "canary-stt-cuda", QVariantList{stt_canary_cuda, From 6bb5e7e92312d7c8ff875eee74fa9be0b0aff23e Mon Sep 17 00:00:00 2001 From: Cole Leavitt Date: Sun, 21 Jun 2026 20:50:47 -0400 Subject: [PATCH 4/5] Fix(build): make Canary runtime tooling reproducible --- CMakeLists.txt | 28 +++-- cmake/dbus_api.cmake | 24 ++-- cmake/translations.cmake | 16 ++- tools/install_canary_runtime.sh | 195 ++++++++++++++++++++++++++++++++ 4 files changed, 241 insertions(+), 22 deletions(-) create mode 100755 tools/install_canary_runtime.sh diff --git a/CMakeLists.txt b/CMakeLists.txt index 86117a46..f6bdca06 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -444,18 +444,24 @@ endif() configure_file(config.h.in config.h) # qt version selection +set(QT_VERSION_MAJOR "" CACHE STRING "Qt major version to use. Empty selects the platform default.") +set_property(CACHE QT_VERSION_MAJOR PROPERTY STRINGS "" 5 6) + if(WITH_SFOS) - # on sfos use qt5 - find_package(Qt5 COMPONENTS Core REQUIRED) - set(QT_VERSION_MAJOR 5) - message(STATUS "Using Qt5") -else() - # on everything else use qt6 - find_package(Qt6 COMPONENTS Core REQUIRED) - set(QT_VERSION_MAJOR 6) - message(STATUS "Using Qt6") + if(NOT "${QT_VERSION_MAJOR}" STREQUAL "" AND + NOT "${QT_VERSION_MAJOR}" STREQUAL "5") + message(FATAL_ERROR "Sailfish OS builds require Qt5") + endif() + set(QT_VERSION_MAJOR 5 CACHE STRING "Qt major version to use" FORCE) +elseif("${QT_VERSION_MAJOR}" STREQUAL "") + set(QT_VERSION_MAJOR 6 CACHE STRING "Qt major version to use" FORCE) +elseif(NOT "${QT_VERSION_MAJOR}" MATCHES "^[56]$") + message(FATAL_ERROR "QT_VERSION_MAJOR must be 5 or 6") endif() +find_package(Qt${QT_VERSION_MAJOR} COMPONENTS Core REQUIRED) +message(STATUS "Using Qt${QT_VERSION_MAJOR}") + include(${cmake_path}/dbus_api.cmake) add_library(dsnote_lib STATIC ${dsnote_lib_sources}) @@ -566,7 +572,7 @@ pkg_search_module(pulse REQUIRED libpulse) list(APPEND deps_libs ${pulse_LIBRARIES}) list(APPEND includes ${pulse_INCLUDE_DIRS}) -find_package(Qt${QT_VERSION_MAJOR} COMPONENTS Core Network Multimedia Qml Xml Sql Gui Quick DBus LinguistTools REQUIRED) +find_package(Qt${QT_VERSION_MAJOR} COMPONENTS Core Network Multimedia Qml Xml Sql Gui Quick DBus REQUIRED) list(APPEND deps_libs Qt${QT_VERSION_MAJOR}::Core Qt${QT_VERSION_MAJOR}::Network Qt${QT_VERSION_MAJOR}::Multimedia Qt${QT_VERSION_MAJOR}::Gui Qt${QT_VERSION_MAJOR}::Quick Qt${QT_VERSION_MAJOR}::DBus Qt${QT_VERSION_MAJOR}::Xml Qt${QT_VERSION_MAJOR}::Sql) @@ -605,7 +611,7 @@ if(WITH_DESKTOP) # Qt6 removed X11Extras - functionality moved to Qt6::Gui # Qt5 needs X11Extras - if(QT_VERSION_MAJOR EQUAL 5) + if("${QT_VERSION_MAJOR}" STREQUAL "5") find_package(Qt5 COMPONENTS X11Extras REQUIRED) list(APPEND deps_libs Qt5::X11Extras) endif() diff --git a/cmake/dbus_api.cmake b/cmake/dbus_api.cmake index 667c7a97..a18c1b9d 100644 --- a/cmake/dbus_api.cmake +++ b/cmake/dbus_api.cmake @@ -4,17 +4,23 @@ configure_file(${dbus_dir}/dsnote.xml.in ${dbus_dsnote_interface_file}) find_package(Qt${QT_VERSION_MAJOR} COMPONENTS DBus REQUIRED) -if(NOT DEFINED QT_CMAKE_EXPORT_NAMESPACE) +set(qdbusxml2cpp_target Qt${QT_VERSION_MAJOR}::qdbusxml2cpp) +if(TARGET ${qdbusxml2cpp_target}) + set(qdbusxml2cpp_bin ${qdbusxml2cpp_target}) +elseif(DEFINED QT_CMAKE_EXPORT_NAMESPACE AND + TARGET ${QT_CMAKE_EXPORT_NAMESPACE}::qdbusxml2cpp) + set(qdbusxml2cpp_bin ${QT_CMAKE_EXPORT_NAMESPACE}::qdbusxml2cpp) +else() unset(qdbusxml2cpp_bin CACHE) - find_program(qdbusxml2cpp_bin qdbusxml2cpp) - if(${qdbusxml2cpp_bin} MATCHES "-NOTFOUND$") - find_program(qdbusxml2cpp_bin qdbusxml2cpp-qt5) - if(${qdbusxml2cpp_bin} MATCHES "-NOTFOUND$") - message(FATAL_ERROR "qdbusxml2cpp not found but it is required") - endif() + find_program(qdbusxml2cpp_bin + NAMES qdbusxml2cpp qdbusxml2cpp-qt5 + HINTS + "/usr/lib64/qt${QT_VERSION_MAJOR}/bin" + "/usr/lib/qt${QT_VERSION_MAJOR}/bin" + "/usr/lib/${CMAKE_LIBRARY_ARCHITECTURE}/qt${QT_VERSION_MAJOR}/bin") + if(NOT qdbusxml2cpp_bin) + message(FATAL_ERROR "qdbusxml2cpp not found but it is required") endif() -else() - set(qdbusxml2cpp_bin ${QT_CMAKE_EXPORT_NAMESPACE}::qdbusxml2cpp) endif() add_custom_command( diff --git a/cmake/translations.cmake b/cmake/translations.cmake index ee5d7fb0..54ef2698 100644 --- a/cmake/translations.cmake +++ b/cmake/translations.cmake @@ -4,7 +4,19 @@ set(enabled_translations ar ca_ES cs de en es fr fr_CA it nl no pt_BR pl ru sv s set(enabled_translations ar ca_ES de en es fr fr_CA it nl no pt_BR pl ru sv sl tr_TR uk zh_CN zh_TW) # QT_VERSION_MAJOR is set in main CMakeLists.txt -find_package(Qt${QT_VERSION_MAJOR} COMPONENTS Core LinguistTools) +find_package(Qt${QT_VERSION_MAJOR} COMPONENTS Core LinguistTools QUIET) +if(NOT Qt${QT_VERSION_MAJOR}LinguistTools_FOUND) + if("${QT_VERSION_MAJOR}" STREQUAL "5") + message(FATAL_ERROR + "Qt5 LinguistTools is required to build translations. " + "Install the Qt5 translation tools package, e.g. " + "dev-qt/linguist-tools on Gentoo, qt5-linguist on Fedora, " + "or qttools5-dev-tools on Debian/Ubuntu.") + else() + message(FATAL_ERROR + "Qt6 LinguistTools is required to build translations.") + endif() +endif() set(ts_files "") foreach(lang ${enabled_translations}) @@ -25,7 +37,7 @@ function(ADD_TRANSLATIONS_RESOURCE res_file) set(${res_file} ${_res_file} PARENT_SCOPE) endfunction() -if(QT_VERSION_MAJOR EQUAL 6) +if("${QT_VERSION_MAJOR}" STREQUAL "6") qt6_create_translation(qm_files ${CMAKE_SOURCE_DIR}/src ${desktop_dir}/qml ${sfos_dir}/qml ${ts_files}) else() if(WITH_SFOS) diff --git a/tools/install_canary_runtime.sh b/tools/install_canary_runtime.sh new file mode 100755 index 00000000..6747f208 --- /dev/null +++ b/tools/install_canary_runtime.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash + +set -euo pipefail + +venv_dir="${DSNOTE_CANARY_VENV:-${DSNOTE_PREFIX:-${HOME}/.local}/share/dsnote/venv}" +python_bin="${PYTHON:-python3}" +force_repair=false +check_model="" + +usage() { + cat <<'EOF' +Usage: install_canary_runtime.sh [--venv PATH] [--python PYTHON] [--repair] [--check-model PATH] + +Creates or updates the Python venv used by dsnote's Canary STT engine. +The default venv path matches a user-prefix install: + ~/.local/share/dsnote/venv + +Options: + --check-model PATH Also restore a local Canary .nemo file on CPU. This is + slower, but catches import-time dependency corruption. + +Environment: + DSNOTE_PREFIX Install prefix used to derive share/dsnote/venv. + DSNOTE_CANARY_VENV Explicit venv path. + PYTHON Python executable used to create the venv. +EOF +} + +require_value() { + if [ "$#" -lt 2 ] || [ -z "$2" ]; then + echo "missing value for $1" >&2 + usage >&2 + exit 2 + fi +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --venv) + require_value "$@" + venv_dir="$2" + shift 2 + ;; + --python) + require_value "$@" + python_bin="$2" + shift 2 + ;; + --repair) + force_repair=true + shift + ;; + --check-model) + require_value "$@" + check_model="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [ -n "${check_model}" ] && [ ! -f "${check_model}" ]; then + echo "model file not found: ${check_model}" >&2 + exit 2 +fi + +packages=( + "numpy==2.3.5" + "torch==2.9.1" + "triton==3.5.1" + "cuda-python==13.1.1" + "huggingface-hub==0.36.0" + "fsspec==2024.12.0" + "pooch==1.8.2" +) + +repair_packages=( + "torch==2.9.1" + "numpy==2.3.5" + "triton==3.5.1" + "fsspec==2024.12.0" + "pooch==1.8.2" + "sympy==1.14.0" + "pyparsing==3.3.2" + "matplotlib==3.11.0" + "idna==3.18" + "certifi==2026.6.17" + "click==8.4.1" + "pyarrow==22.0.0" + "stack-data==0.6.3" + "pure-eval==0.2.3" +) + +venv_python="${venv_dir}/bin/python" + +create_venv() { + if [ ! -x "${venv_python}" ]; then + mkdir -p "$(dirname "${venv_dir}")" + "${python_bin}" -m venv "${venv_dir}" + fi +} + +nemo_runtime_installed() { + "${venv_python}" - <<'PY' +import importlib.metadata as metadata +import sys + +try: + version = metadata.version("nemo-toolkit") +except metadata.PackageNotFoundError: + sys.exit(1) + +sys.exit(0 if version == "2.6.0" else 1) +PY +} + +install_runtime() { + "${venv_python}" -m ensurepip --upgrade + "${venv_python}" -m pip install --upgrade \ + "pip>=26.0.1" \ + "setuptools==80.9.0" \ + "wheel" + if ! nemo_runtime_installed; then + "${venv_python}" -m pip install "nemo-toolkit[asr]==2.6.0" + fi + "${venv_python}" -m pip install "${packages[@]}" +} + +repair_runtime_payloads() { + "${venv_python}" -m pip install --force-reinstall --no-deps \ + "${repair_packages[@]}" +} + +verify_runtime() { + "${venv_python}" - <<'PY' +import importlib.metadata as metadata + +import cuda.bindings +import cuda.core +import librosa.filters +import nemo.collections.asr +import pooch.core +import torch + +print("torch", metadata.version("torch")) +print("nemo_toolkit", metadata.version("nemo_toolkit")) +print("cuda-python", metadata.version("cuda-python")) +PY + "${venv_python}" -m pip check +} + +verify_model_restore() { + if [ -z "${check_model}" ]; then + return 0 + fi + "${venv_python}" - "${check_model}" <<'PY' +import gc +import sys + +import torch +from nemo.collections.asr.models import ASRModel + +model_path = sys.argv[1] +model = ASRModel.restore_from(model_path, map_location=torch.device("cpu")) +model.eval() +print("restored_model", model.__class__.__name__) +del model +gc.collect() +PY +} + +create_venv +install_runtime + +if "${force_repair}"; then + repair_runtime_payloads +fi + +if ! verify_runtime; then + echo "Canary runtime import check failed; repairing package payloads." >&2 + repair_runtime_payloads + verify_runtime +fi + +verify_model_restore + +echo "Canary runtime ready: ${venv_dir}" From 655055070bda20ebb836346efd3c20b16b2fbc6c Mon Sep 17 00:00:00 2001 From: Cole Leavitt Date: Sun, 21 Jun 2026 21:18:12 -0400 Subject: [PATCH 5/5] Fix(stt): stabilize Canary transcription workflows --- src/canary_engine.cpp | 16 +++- src/cmd_options.hpp | 1 + src/main.cpp | 194 ++++++++++++++++++++++++++++++++++++++++-- src/settings.cpp | 2 +- 4 files changed, 203 insertions(+), 10 deletions(-) diff --git a/src/canary_engine.cpp b/src/canary_engine.cpp index 8bae5692..1bd3b6b2 100644 --- a/src/canary_engine.cpp +++ b/src/canary_engine.cpp @@ -50,6 +50,17 @@ std::string canary_source_lang(const stt_engine::config_t& config) { return lang; } +std::string canary_result_text(const py::handle& result) { + if (py::isinstance(result)) return result.cast(); + + if (py::hasattr(result, "text")) { + auto text = result.attr("text"); + if (!text.is_none()) return text.cast(); + } + + return py::str(result).cast(); +} + bool write_canary_wav(QTemporaryFile& file, const std::vector& samples, uint32_t sample_rate) { if (!file.open()) return false; @@ -363,6 +374,7 @@ void canary_engine::decode_speech(const audio_buf_t& buf) { std::string source_lang = canary_source_lang(m_config); std::string target_lang = m_config.translate ? "en" : source_lang; std::string task_type = m_config.translate ? "s2t_translation" : "asr"; + auto pnc = m_config.has_option('i') ? "yes" : "no"; py::list paths; paths.append(tmp_path); @@ -373,12 +385,12 @@ void canary_engine::decode_speech(const audio_buf_t& buf) { "source_lang"_a = source_lang, "target_lang"_a = target_lang, "task"_a = task_type, - "pnc"_a = m_config.has_option('i'), + "pnc"_a = pnc, "verbose"_a = false); std::string text; if (py::isinstance(result) && py::len(result) > 0) { - text = result[py::int_(0)].cast(); + text = canary_result_text(result[py::int_(0)]); } rtrim(text); diff --git a/src/cmd_options.hpp b/src/cmd_options.hpp index 3bb21e3c..c35afa73 100644 --- a/src/cmd_options.hpp +++ b/src/cmd_options.hpp @@ -42,6 +42,7 @@ struct options { QString action; QString text; QString model_id; + QString transcribe_file; QStringList files; QString log_file; QString output_file; diff --git a/src/main.cpp b/src/main.cpp index 724945ac..95802608 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -11,6 +11,8 @@ #include #include #include +#include +#include #include #include #include @@ -18,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -129,6 +132,13 @@ static cmd::options check_options(const QCoreApplication& app) { QStringLiteral("role")}; parser.addOption(print_active_model_opt); + QCommandLineOption transcribe_file_opt{ + QStringLiteral("transcribe-file"), + QStringLiteral("Transcribes an audio or video file and prints decoded " + "text to stdout."), + QStringLiteral("file")}; + parser.addOption(transcribe_file_opt); + QCommandLineOption action_opt{ QStringLiteral("action"), QStringLiteral("Invokes an . Supported actions are: %1.") @@ -140,6 +150,7 @@ static cmd::options check_options(const QCoreApplication& app) { QStringLiteral("id"), QStringLiteral( "Language or model id. Used together with " + "transcribe-file, " "start-listening-clipboard, start-reading-text, set-stt-model " "or set-tts-model action."), QStringLiteral("id")}; @@ -154,8 +165,8 @@ static cmd::options check_options(const QCoreApplication& app) { QCommandLineOption output_file_opt{ QStringLiteral("output-file"), - QStringLiteral("Save the synthesized speech in an audio file instead " - "of playing it aloud. Used together with " + QStringLiteral("Save transcription text or synthesized speech to a " + "file. Used together with transcribe-file, " "start-reading-clipboard or " "start-reading-text action."), QStringLiteral("output-file")}; @@ -239,6 +250,8 @@ static cmd::options check_options(const QCoreApplication& app) { } auto action = parser.value(action_opt); + auto transcribe_file = parser.value(transcribe_file_opt); + auto positional_files = parser.positionalArguments(); if (!action.isEmpty()) { if (true #define X(name, str) &&action.compare(str, Qt::CaseInsensitive) != 0 @@ -293,16 +306,56 @@ static cmd::options check_options(const QCoreApplication& app) { } } + if (!transcribe_file.isEmpty()) { + if (!options.action.isEmpty()) { + fmt::print(stderr, + "Option --{} cannot be used together with --{}.\n", + transcribe_file_opt.names().front().toStdString(), + action_opt.names().front().toStdString()); + options.valid = false; + } + if (parser.isSet(app_opt) || parser.isSet(sttservice_opt)) { + fmt::print(stderr, + "Option --{} runs in standalone mode and cannot be used " + "together with --app or --service.\n", + transcribe_file_opt.names().front().toStdString()); + options.valid = false; + } + if (!positional_files.isEmpty()) { + fmt::print(stderr, + "Option --{} cannot be used together with positional " + "files.\n", + transcribe_file_opt.names().front().toStdString()); + options.valid = false; + } + + auto id = parser.value(id_opt); + if (!id.isEmpty()) { + if (!text_tools::valid_model_id(id.toStdString())) { + fmt::print(stderr, + "Invalid language or model id (option --{}).\n", + id_opt.valueName().toStdString()); + options.valid = false; + } else { + options.model_id = id; + } + } + + options.transcribe_file = std::move(transcribe_file); + } + options.output_file = parser.value(output_file_opt); if (!options.output_file.isEmpty()) { if (action.compare("start-reading-clipboard", Qt::CaseInsensitive) != 0 && - action.compare("start-reading-text", Qt::CaseInsensitive) != 0) { + action.compare("start-reading-text", Qt::CaseInsensitive) != 0 && + options.transcribe_file.isEmpty()) { fmt::print( stderr, - "Option --{} can be used only with start-reading-clipboard and " - "start-reading-text actions.\n", - output_file_opt.valueName().toStdString()); + "Option --{} can be used only with --{} or with " + "start-reading-clipboard and start-reading-text actions.\n", + output_file_opt.valueName().toStdString(), + transcribe_file_opt.names().front().toStdString()); options.valid = false; } } @@ -361,7 +414,7 @@ static cmd::options check_options(const QCoreApplication& app) { options.hw_scan_off = parser.isSet(hwscanoff_opt); options.py_scan_off = parser.isSet(pyscanoff_opt); options.reset_models = parser.isSet(resetmodels_opt); - options.files = parser.positionalArguments(); + options.files = std::move(positional_files); #ifdef USE_DESKTOP options.start_in_tray = parser.isSet(start_in_tray_opt); #endif @@ -549,6 +602,126 @@ static void start_service(const cmd::options& options) { QGuiApplication::exec(); } +static int run_cli_transcribe(const cmd::options& options) { + auto* s = settings::instance(); + + if (options.hw_scan_off) { + s->disable_hw_scan(); + } + if (options.py_scan_off) { + s->disable_py_scan(); + } + + auto* service = speech_service::instance(); + + constexpr int invalid_task = -1; + QStringList decoded_chunks; + int task_id = invalid_task; + bool start_requested = false; + bool finished = false; + + auto finish = [&](int exit_code) { + if (finished) return; + finished = true; + QCoreApplication::exit(exit_code); + }; + + auto write_result = [&]() { + const auto text = decoded_chunks.join(QStringLiteral("\n")); + + if (options.output_file.isEmpty()) { + if (!text.isEmpty()) { + fmt::print("{}\n", text.toStdString()); + } + return true; + } + + QFile output_file{QFileInfo{options.output_file}.absoluteFilePath()}; + if (!output_file.open(QIODevice::WriteOnly | QIODevice::Text | + QIODevice::Truncate)) { + fmt::print(stderr, "Failed to write transcription output: {}\n", + output_file.errorString().toStdString()); + return false; + } + + output_file.write(text.toUtf8()); + if (!text.isEmpty() && !text.endsWith(QLatin1Char('\n'))) + output_file.write("\n"); + + return true; + }; + + QObject::connect(service, &speech_service::error, QCoreApplication::instance(), + [&](speech_service::error_t error) { + fmt::print(stderr, "Transcription failed: {}\n", + static_cast(error)); + finish(1); + }); + + QObject::connect(service, &speech_service::stt_text_decoded, + QCoreApplication::instance(), + [&](const QString& text, const QString&, int task) { + if (task == task_id && !text.isEmpty()) { + decoded_chunks.push_back(text); + } + }); + + QObject::connect(service, &speech_service::stt_file_transcribe_finished, + QCoreApplication::instance(), [&](int task) { + if (task == task_id) { + finish(write_result() ? 0 : 1); + } + }); + + auto start_transcription = [&, service]() { + if (start_requested) return; + + switch (service->state()) { + case speech_service::state_t::unknown: + case speech_service::state_t::busy: + return; + case speech_service::state_t::not_configured: + fmt::print(stderr, "Cannot transcribe file: service is not " + "configured.\n"); + finish(1); + return; + default: + break; + } + + QVariantMap transcribe_options; + transcribe_options.insert( + QStringLiteral("text_format"), + static_cast(settings::text_format_t::TextFormatRaw)); + transcribe_options.insert(QStringLiteral("stream_index"), 0); + transcribe_options.insert(QStringLiteral("insert_stats"), false); + + const auto file = + QFileInfo::exists(options.transcribe_file) + ? QFileInfo{options.transcribe_file}.absoluteFilePath() + : options.transcribe_file; + start_requested = true; + task_id = service->stt_transcribe_file(file, options.model_id, {}, + transcribe_options); + if (task_id == invalid_task) { + fmt::print(stderr, "Failed to start file transcription.\n"); + finish(1); + } + }; + + QObject::connect(service, &speech_service::state_changed, + QCoreApplication::instance(), start_transcription); + QTimer::singleShot(0, QCoreApplication::instance(), start_transcription); + QTimer::singleShot(60000, QCoreApplication::instance(), [&] { + if (!start_requested) { + fmt::print(stderr, "Timed out waiting for the speech service.\n"); + finish(1); + } + }); + + return QGuiApplication::exec(); +} + static void start_app(const cmd::options& options, app_server& dbus_app_server) { auto* s = settings::instance(); @@ -690,6 +863,13 @@ int main(int argc, char* argv[]) { settings::launch_mode = cmd_opts.launch_mode; + if (!cmd_opts.transcribe_file.isEmpty()) { + LOGD("starting cli file transcription"); + auto result = run_cli_transcribe(cmd_opts); + speech_service::remove_cached_files(); + std::quick_exit(result); + } + switch (cmd_opts.launch_mode) { case settings::launch_mode_t::service: LOGD("starting service"); diff --git a/src/settings.cpp b/src/settings.cpp index 3c122c46..86f9f140 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -738,7 +738,7 @@ void settings::set_mode(mode_t value) { settings::speech_mode_t settings::speech_mode() const { return static_cast( value(QStringLiteral("speech_mode"), - static_cast(speech_mode_t::SpeechSingleSentence)) + static_cast(speech_mode_t::SpeechAutomatic)) .toInt()); }