From 60048aa4105bb44ff982bc36a23fa2c6522f6fe0 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Wed, 28 Oct 2026 08:03:17 +0100 Subject: [PATCH] fix(captions): read whisper-stt paths as UTF-8 on Windows The helper read --model, --vad-model and the OPENSCREEN_* variables through char** argv and getenv(), which Windows hands over in the ANSI code page. whisper.cpp converts every path as UTF-8, so a model under a non-ASCII profile folder crashed the boot with 0xC0000409. Read the wide command line and environment instead, open the CTC model and the temp WAV through wide paths too. Refs #1405 --- electron/native/whisper-stt/CMakeLists.txt | 2 + .../native/whisper-stt/src/ctc_aligner.cpp | 8 ++ electron/native/whisper-stt/src/main.cpp | 85 ++++++++++++++----- 3 files changed, 76 insertions(+), 19 deletions(-) diff --git a/electron/native/whisper-stt/CMakeLists.txt b/electron/native/whisper-stt/CMakeLists.txt index a22d02eed5..1213778dc5 100644 --- a/electron/native/whisper-stt/CMakeLists.txt +++ b/electron/native/whisper-stt/CMakeLists.txt @@ -195,6 +195,8 @@ if(WIN32) NOMINMAX WIN32_LEAN_AND_MEAN _WIN32_WINNT=0x0A00 ) set_target_properties(whisper-stt-server PROPERTIES WIN32_EXECUTABLE OFF) + # CommandLineToArgvW, for the UTF-8 argv (issue #1405). + target_link_libraries(whisper-stt-server PRIVATE shell32) endif() # ponytail: whisper.cpp's `vulkan-shaders-gen` sub-project (only built when diff --git a/electron/native/whisper-stt/src/ctc_aligner.cpp b/electron/native/whisper-stt/src/ctc_aligner.cpp index 18d5145965..b03b6acbe6 100644 --- a/electron/native/whisper-stt/src/ctc_aligner.cpp +++ b/electron/native/whisper-stt/src/ctc_aligner.cpp @@ -16,6 +16,7 @@ #include #include #include +#include namespace { @@ -349,7 +350,14 @@ CtcModelPtr ctc_load(const std::string& path, bool use_gpu, int threads, std::st return nullptr; } { + // The path is UTF-8, which Windows' fopen reads in the ANSI code page + // (issue #1405). Not ggml_fopen: the helper links the CRT statically, so + // a FILE* opened inside ggml.dll belongs to another CRT and crashes here. +#ifdef _WIN32 + FILE* f = _wfopen(std::filesystem::path(std::u8string(path.begin(), path.end())).c_str(), L"rb"); +#else FILE* f = std::fopen(path.c_str(), "rb"); +#endif if (!f) { err = "cannot open " + path; return nullptr; diff --git a/electron/native/whisper-stt/src/main.cpp b/electron/native/whisper-stt/src/main.cpp index 5fc4061b9d..38eaa56cb1 100644 --- a/electron/native/whisper-stt/src/main.cpp +++ b/electron/native/whisper-stt/src/main.cpp @@ -52,6 +52,7 @@ #include #include #include +#include #include #include #include @@ -62,6 +63,11 @@ #include #include +#ifdef _WIN32 +#include +#include +#endif + namespace { void log(const std::string& msg) { @@ -69,6 +75,43 @@ void log(const std::string& msg) { std::cerr.flush(); } +// whisper.cpp and ggml read every path as UTF-8, but Windows hands a console +// program its argv and getenv() in the ANSI code page: a model under a +// non-ASCII profile folder aborted the boot (issue #1405). On Windows the arguments and +// the environment are read wide and converted, elsewhere they already are UTF-8. +#ifdef _WIN32 +std::string utf8(const wchar_t* w) { + const int n = WideCharToMultiByte(CP_UTF8, 0, w, -1, nullptr, 0, nullptr, nullptr); + if (n <= 1) return {}; + std::string s(static_cast(n - 1), '\0'); + WideCharToMultiByte(CP_UTF8, 0, w, -1, s.data(), n, nullptr, nullptr); + return s; +} +#endif + +std::vector utf8_args(int argc, char** argv) { +#ifdef _WIN32 + int wargc = 0; + if (wchar_t** wargv = CommandLineToArgvW(GetCommandLineW(), &wargc)) { + std::vector args; + for (int i = 0; i < wargc; ++i) args.push_back(utf8(wargv[i])); + LocalFree(wargv); + return args; + } +#endif + return {argv, argv + argc}; +} + +std::optional utf8_env(const char* name) { +#ifdef _WIN32 + const std::wstring wname(name, name + std::strlen(name)); + if (const wchar_t* p = _wgetenv(wname.c_str())) return utf8(p); +#else + if (const char* p = std::getenv(name)) return p; +#endif + return std::nullopt; +} + std::string json_escape(const std::string& s) { std::string out; out.reserve(s.size() + 8); @@ -98,10 +141,10 @@ std::string json_escape(const std::string& s) { // miniaudio, but pulling in the full miniaudio.h (4 MB header) just to read // a 16 kHz mono PCM16 stream would be silly. The format is dead simple; this // parser is the same one the POC harness shipped. -bool read_wav_pcm16(const std::string& path, std::vector& pcm, +bool read_wav_pcm16(const std::filesystem::path& path, std::vector& pcm, int& sample_rate_out, int& channels_out) { std::ifstream f(path, std::ios::binary); - if (!f) { log("cannot open " + path); return false; } + if (!f) { log("cannot open " + path.filename().string()); return false; } auto read_u32 = [&]()-> uint32_t { uint32_t v = 0; f.read(reinterpret_cast(&v), 4); return v; @@ -175,7 +218,7 @@ bool read_wav_pcm16(const std::string& path, std::vector& pcm, f.seekg(chunk_size + (chunk_size & 1), std::ios::cur); } } - log("data chunk not found in " + path); + log("data chunk not found in " + path.filename().string()); return false; } @@ -288,34 +331,36 @@ int main(int argc, char** argv) { int port = 0; int threads = std::max(1u, std::thread::hardware_concurrency()); - for (int i = 1; i < argc; ++i) { - const std::string a = argv[i]; - if (a == "--model" && i + 1 < argc) model_path = argv[++i]; - else if (a == "--vad-model" && i + 1 < argc) vad_model_path = argv[++i]; - else if (a == "--host" && i + 1 < argc) { host = argv[++i]; host_from_flag = true; } - else if (a == "--port" && i + 1 < argc) port = std::atoi(argv[++i]); - else if (a == "--threads" && i + 1 < argc) threads = std::atoi(argv[++i]); + const std::vector args = utf8_args(argc, argv); + const size_t n_args = args.size(); + for (size_t i = 1; i < n_args; ++i) { + const std::string& a = args[i]; + if (a == "--model" && i + 1 < n_args) model_path = args[++i]; + else if (a == "--vad-model" && i + 1 < n_args) vad_model_path = args[++i]; + else if (a == "--host" && i + 1 < n_args) { host = args[++i]; host_from_flag = true; } + else if (a == "--port" && i + 1 < n_args) port = std::atoi(args[++i].c_str()); + else if (a == "--threads" && i + 1 < n_args) threads = std::atoi(args[++i].c_str()); else if (a == "--cpu") force_cpu = true; } // ponytail: prefer env var (matches the prior native STT model env var // shape; the Node wrapper passes both ways). if (model_path.empty()) { - if (const char* p = std::getenv("OPENSCREEN_WHISPER_MODEL")) model_path = p; + if (auto p = utf8_env("OPENSCREEN_WHISPER_MODEL")) model_path = *p; } if (vad_model_path.empty()) { - if (const char* p = std::getenv("OPENSCREEN_VAD_MODEL")) vad_model_path = p; - else if (const char* p = std::getenv("OPENSCREEN_WHISPER_VAD_MODEL")) vad_model_path = p; + if (auto p = utf8_env("OPENSCREEN_VAD_MODEL")) vad_model_path = *p; + else if (auto q = utf8_env("OPENSCREEN_WHISPER_VAD_MODEL")) vad_model_path = *q; } if (port == 0) { - if (const char* p = std::getenv("OPENSCREEN_WHISPER_PORT")) port = std::atoi(p); + if (auto p = utf8_env("OPENSCREEN_WHISPER_PORT")) port = std::atoi(p->c_str()); } - if (const char* p = std::getenv("OPENSCREEN_WHISPER_THREADS")) threads = std::atoi(p); + if (auto p = utf8_env("OPENSCREEN_WHISPER_THREADS")) threads = std::atoi(p->c_str()); // Only a fallback, never an override: the app always passes --host 127.0.0.1, // and an env var that can widen a shipped binary's bind address behind an // explicit flag is a hole, not a knob. Anything but loopback is refused // outright — this server has no authentication of any kind. if (!host_from_flag) { - if (const char* p = std::getenv("OPENSCREEN_WHISPER_HOST")) host = p; + if (auto p = utf8_env("OPENSCREEN_WHISPER_HOST")) host = *p; } if (host != "127.0.0.1" && host != "::1" && host != "localhost") { std::cerr << "FATAL: refusing to bind " << host @@ -416,9 +461,11 @@ int main(int argc, char** argv) { static std::atomic tmp_wav_counter{0}; const auto tmp_wav_id = tmp_wav_counter.fetch_add(1, std::memory_order_relaxed); const auto tmp_wav_ts = std::chrono::high_resolution_clock::now().time_since_epoch().count(); - const std::string tmp_wav = (std::filesystem::temp_directory_path() / - ("openscreen-stt-" + std::to_string(tmp_wav_ts) + - "-" + std::to_string(tmp_wav_id) + ".wav")).string(); + // Kept a path, not .string(): that converts to the ANSI code page and + // throws on a temp folder under a non-ASCII profile (issue #1405). + const std::filesystem::path tmp_wav = std::filesystem::temp_directory_path() / + ("openscreen-stt-" + std::to_string(tmp_wav_ts) + + "-" + std::to_string(tmp_wav_id) + ".wav"); { std::ofstream out(tmp_wav, std::ios::binary); out.write(file_entry.content.data(),