arcane-os 0.63.0 → 0.65.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +48 -0
- package/README.md +1 -1
- package/browser-runtime/core/client.mjs +69 -7
- package/docs/guides/native-whisper-openvino.md +334 -0
- package/docs/reference/README.md +6 -0
- package/docs/reference/arcane-ollama.md +1 -1
- package/docs/reference/codex-app-server.md +269 -0
- package/docs/reference/core-client.md +71 -4
- package/docs/reference/core-development.md +157 -0
- package/docs/reference/core-native-packaging.md +48 -6
- package/docs/reference/core-repositories.md +258 -0
- package/docs/reference/inventory/package-api.json +199 -3
- package/docs/reference/inventory/runtime-modules.json +3 -2
- package/docs/reference/local-ai.md +77 -4
- package/docs/reference/local-image-flux.md +1 -1
- package/docs/reference/local-image-generation.md +11 -0
- package/docs/reference/local-image-sdxl.md +139 -0
- package/docs/reference/local-whisper.md +219 -0
- package/docs/reference/mcp-stdio.md +263 -0
- package/docs/reference/native-speech.md +51 -2
- package/docs/reference/runtime-components.md +18 -1
- package/docs/reference/runtime-modules.md +26 -4
- package/docs/reference/sdk-api.md +412 -2
- package/package.json +9 -2
- package/runtime/arcane/components/speech.html +15 -3
- package/runtime/arcane/components/voice-transcription.html +19 -3
- package/runtime/arcane/modules/AI.js +406 -40
- package/runtime/arcane/modules/AIProviderRuntime.js +20 -3
- package/runtime/arcane/modules/AIRuntimeState.js +15 -2
- package/runtime/arcane/modules/ComponentContracts.js +3 -0
- package/runtime/arcane/modules/Ollama.js +1 -1
- package/runtime/arcane/modules/OllamaModelIdentifier.js +13 -0
- package/schemas/arcane-app.schema.json +60 -2
- package/schemas/arcane-package.schema.json +60 -2
- package/src/codex/app-server.mjs +845 -0
- package/src/core/development.mjs +457 -0
- package/src/core/repositories.mjs +375 -0
- package/src/core/services/local-ai.mjs +80 -10
- package/src/core/services/speech.mjs +1 -0
- package/src/dev-server.mjs +3 -2
- package/src/index.mjs +1 -0
- package/src/local-ai/config.mjs +7 -3
- package/src/local-ai/dev.mjs +85 -401
- package/src/local-ai/image-library.mjs +9 -0
- package/src/local-ai/image-models/sdxl.mjs +18 -0
- package/src/local-ai/install.mjs +26 -7
- package/src/local-ai/whisper/build.mjs +48 -0
- package/src/local-ai/whisper/config.mjs +89 -0
- package/src/local-ai/whisper/index.mjs +572 -0
- package/src/local-ai/whisper/install.mjs +332 -0
- package/src/local-ai/whisper/native/CMakeLists.txt +98 -0
- package/src/local-ai/whisper/native/NOTICE +18 -0
- package/src/local-ai/whisper/native/README.md +104 -0
- package/src/local-ai/whisper/native/main.cpp +520 -0
- package/src/local-ai/whisper/openvino-build.mjs +138 -0
- package/src/mcp/stdio-server.mjs +579 -0
- package/src/native/portable-layout.mjs +39 -7
- package/src/toolchain.mjs +19 -8
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
#include <whisper.h>
|
|
2
|
+
#include <ggml-backend.h>
|
|
3
|
+
|
|
4
|
+
#include <atomic>
|
|
5
|
+
#include <condition_variable>
|
|
6
|
+
#include <cstdint>
|
|
7
|
+
#include <cstring>
|
|
8
|
+
#include <filesystem>
|
|
9
|
+
#include <fstream>
|
|
10
|
+
#include <iomanip>
|
|
11
|
+
#include <iostream>
|
|
12
|
+
#include <limits>
|
|
13
|
+
#include <locale>
|
|
14
|
+
#include <memory>
|
|
15
|
+
#include <mutex>
|
|
16
|
+
#include <sstream>
|
|
17
|
+
#include <stdexcept>
|
|
18
|
+
#include <string>
|
|
19
|
+
#include <thread>
|
|
20
|
+
#include <vector>
|
|
21
|
+
|
|
22
|
+
#ifdef _WIN32
|
|
23
|
+
#include <fcntl.h>
|
|
24
|
+
#include <io.h>
|
|
25
|
+
#endif
|
|
26
|
+
|
|
27
|
+
namespace {
|
|
28
|
+
|
|
29
|
+
struct Configuration {
|
|
30
|
+
std::string model;
|
|
31
|
+
std::string runtime;
|
|
32
|
+
std::string backend = "gpu";
|
|
33
|
+
std::string encoder_model;
|
|
34
|
+
std::string encoder_cache;
|
|
35
|
+
std::string encoder_device;
|
|
36
|
+
int threads = 1;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
struct Segment {
|
|
40
|
+
int index;
|
|
41
|
+
std::string text;
|
|
42
|
+
double start;
|
|
43
|
+
double end;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
struct Request {
|
|
47
|
+
std::string id;
|
|
48
|
+
std::string audio_path;
|
|
49
|
+
std::string language;
|
|
50
|
+
bool translate = false;
|
|
51
|
+
std::atomic<bool> cancelled{false};
|
|
52
|
+
std::vector<Segment> segments;
|
|
53
|
+
std::string text;
|
|
54
|
+
std::string detected_language;
|
|
55
|
+
std::string error;
|
|
56
|
+
std::string error_code;
|
|
57
|
+
double duration = 0;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
struct Commands {
|
|
61
|
+
std::mutex mutex;
|
|
62
|
+
std::condition_variable changed;
|
|
63
|
+
std::shared_ptr<Request> active;
|
|
64
|
+
bool stopping = false;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
struct NativeLog {
|
|
68
|
+
std::mutex mutex;
|
|
69
|
+
std::string pending;
|
|
70
|
+
std::string backend;
|
|
71
|
+
|
|
72
|
+
~NativeLog() {
|
|
73
|
+
whisper_log_set(nullptr, nullptr);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
std::mutex output_mutex;
|
|
78
|
+
|
|
79
|
+
std::string json_string(const std::string& value) {
|
|
80
|
+
std::ostringstream output;
|
|
81
|
+
output << '"';
|
|
82
|
+
for (const unsigned char character : value) {
|
|
83
|
+
switch (character) {
|
|
84
|
+
case '"': output << "\\\""; break;
|
|
85
|
+
case '\\': output << "\\\\"; break;
|
|
86
|
+
case '\n': output << "\\n"; break;
|
|
87
|
+
case '\r': output << "\\r"; break;
|
|
88
|
+
case '\t': output << "\\t"; break;
|
|
89
|
+
default:
|
|
90
|
+
if (character < 0x20) {
|
|
91
|
+
output << "\\u" << std::hex << std::setw(4) << std::setfill('0')
|
|
92
|
+
<< static_cast<unsigned int>(character) << std::dec;
|
|
93
|
+
} else {
|
|
94
|
+
output << character;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
output << '"';
|
|
99
|
+
return output.str();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
std::string decode_base64(const std::string& encoded) {
|
|
103
|
+
const std::string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
104
|
+
std::string decoded;
|
|
105
|
+
uint32_t accumulator = 0;
|
|
106
|
+
int remaining = 0;
|
|
107
|
+
bool padding = false;
|
|
108
|
+
for (const char character : encoded) {
|
|
109
|
+
if (character == '=') {
|
|
110
|
+
padding = true;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const auto digit = alphabet.find(character);
|
|
114
|
+
if (padding || digit == std::string::npos) {
|
|
115
|
+
throw std::runtime_error("Unreadable base64 command field.");
|
|
116
|
+
}
|
|
117
|
+
accumulator = (accumulator << 6) | static_cast<uint32_t>(digit);
|
|
118
|
+
remaining += 6;
|
|
119
|
+
if (remaining >= 8) {
|
|
120
|
+
remaining -= 8;
|
|
121
|
+
decoded.push_back(static_cast<char>((accumulator >> remaining) & 0xff));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return decoded;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
void emit(const std::string& record) {
|
|
128
|
+
const std::lock_guard<std::mutex> lock(output_mutex);
|
|
129
|
+
std::cout << record << '\n' << std::flush;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
void emit_error(const std::string& request_id, const std::string& message, const std::string& code = "") {
|
|
133
|
+
emit("{\"type\":\"error\",\"requestId\":" + json_string(request_id)
|
|
134
|
+
+ ",\"message\":" + json_string(message)
|
|
135
|
+
+ (code.empty() ? "" : ",\"code\":" + json_string(code)) + "}");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
std::string segment_json(const Segment& segment) {
|
|
139
|
+
std::ostringstream output;
|
|
140
|
+
output.imbue(std::locale::classic());
|
|
141
|
+
output << std::setprecision(std::numeric_limits<double>::max_digits10)
|
|
142
|
+
<< "{\"index\":" << segment.index << ",\"text\":" << json_string(segment.text)
|
|
143
|
+
<< ",\"start\":" << segment.start << ",\"end\":" << segment.end << '}';
|
|
144
|
+
return output.str();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
Segment read_segment(whisper_state* state, int index) {
|
|
148
|
+
return Segment{
|
|
149
|
+
index,
|
|
150
|
+
whisper_full_get_segment_text_from_state(state, index),
|
|
151
|
+
whisper_full_get_segment_t0_from_state(state, index) * 0.01,
|
|
152
|
+
whisper_full_get_segment_t1_from_state(state, index) * 0.01
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
void on_segment(whisper_context*, whisper_state* state, int added, void* data) {
|
|
157
|
+
Request& request = *static_cast<Request*>(data);
|
|
158
|
+
const int total = whisper_full_n_segments_from_state(state);
|
|
159
|
+
for (int index = total - added; index < total; ++index) {
|
|
160
|
+
const Segment segment = read_segment(state, index);
|
|
161
|
+
emit("{\"type\":\"segment\",\"requestId\":" + json_string(request.id)
|
|
162
|
+
+ ",\"index\":" + std::to_string(segment.index)
|
|
163
|
+
+ ",\"text\":" + json_string(segment.text)
|
|
164
|
+
+ ",\"start\":" + std::to_string(segment.start)
|
|
165
|
+
+ ",\"end\":" + std::to_string(segment.end) + "}");
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
void on_progress(whisper_context*, whisper_state*, int progress, void* data) {
|
|
170
|
+
const Request& request = *static_cast<Request*>(data);
|
|
171
|
+
emit("{\"type\":\"progress\",\"requestId\":" + json_string(request.id)
|
|
172
|
+
+ ",\"progress\":" + std::to_string(progress) + "}");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
bool should_abort(void* data) {
|
|
176
|
+
return static_cast<Request*>(data)->cancelled.load();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
bool before_encoder(whisper_context*, whisper_state*, void* data) {
|
|
180
|
+
return !should_abort(data);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
void native_log(ggml_log_level, const char* text, void* data) {
|
|
184
|
+
NativeLog& log = *static_cast<NativeLog*>(data);
|
|
185
|
+
const std::lock_guard<std::mutex> lock(log.mutex);
|
|
186
|
+
std::cerr << text << std::flush;
|
|
187
|
+
log.pending += text;
|
|
188
|
+
std::string::size_type newline;
|
|
189
|
+
while ((newline = log.pending.find('\n')) != std::string::npos) {
|
|
190
|
+
const std::string line = log.pending.substr(0, newline);
|
|
191
|
+
log.pending.erase(0, newline + 1);
|
|
192
|
+
const std::string selected = "whisper_backend_init_gpu: using ";
|
|
193
|
+
const std::string failed = "whisper_backend_init_gpu: failed to initialize ";
|
|
194
|
+
if (line.compare(0, selected.size(), selected) == 0) {
|
|
195
|
+
const auto end = line.rfind(" backend");
|
|
196
|
+
if (end != std::string::npos) {
|
|
197
|
+
log.backend = line.substr(selected.size(), end - selected.size());
|
|
198
|
+
}
|
|
199
|
+
} else if (line.compare(0, failed.size(), failed) == 0
|
|
200
|
+
|| line == "whisper_backend_init_gpu: no GPU found") {
|
|
201
|
+
log.backend = "CPU";
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
std::vector<float> read_audio(Request& request) {
|
|
207
|
+
std::ifstream input(std::filesystem::u8path(request.audio_path), std::ios::binary);
|
|
208
|
+
if (!input) {
|
|
209
|
+
throw std::runtime_error("Could not open the selected decoded audio: " + request.audio_path);
|
|
210
|
+
}
|
|
211
|
+
std::vector<float> audio;
|
|
212
|
+
unsigned char sample[4];
|
|
213
|
+
while (input.read(reinterpret_cast<char*>(sample), sizeof(sample))) {
|
|
214
|
+
if (request.cancelled.load()) {
|
|
215
|
+
return {};
|
|
216
|
+
}
|
|
217
|
+
// This is the decoder's f32le transport representation, not a content
|
|
218
|
+
// policy or an audio normalization step.
|
|
219
|
+
const uint32_t bits = static_cast<uint32_t>(sample[0])
|
|
220
|
+
| (static_cast<uint32_t>(sample[1]) << 8)
|
|
221
|
+
| (static_cast<uint32_t>(sample[2]) << 16)
|
|
222
|
+
| (static_cast<uint32_t>(sample[3]) << 24);
|
|
223
|
+
float value;
|
|
224
|
+
std::memcpy(&value, &bits, sizeof(value));
|
|
225
|
+
audio.push_back(value);
|
|
226
|
+
}
|
|
227
|
+
if (input.bad() || input.gcount() != 0) {
|
|
228
|
+
throw std::runtime_error("The decoded audio ended inside an f32le sample or could not be read.");
|
|
229
|
+
}
|
|
230
|
+
return audio;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
void transcribe(whisper_context* context, const Configuration& configuration, Request& request) {
|
|
234
|
+
try {
|
|
235
|
+
if (request.cancelled.load()) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const std::vector<float> audio = read_audio(request);
|
|
239
|
+
if (request.cancelled.load()) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
request.duration = static_cast<double>(audio.size()) / WHISPER_SAMPLE_RATE;
|
|
243
|
+
if (audio.size() > static_cast<size_t>(std::numeric_limits<int>::max())) {
|
|
244
|
+
throw std::runtime_error("The complete recording exceeds whisper_full's native signed-int sample argument; no audio was submitted or shortened.");
|
|
245
|
+
}
|
|
246
|
+
if (audio.empty()) {
|
|
247
|
+
throw std::runtime_error("The decoder returned no audio samples.");
|
|
248
|
+
}
|
|
249
|
+
if (!request.language.empty() && request.language != "auto"
|
|
250
|
+
&& whisper_lang_id(request.language.c_str()) < 0) {
|
|
251
|
+
throw std::runtime_error("Whisper does not recognize the requested language: " + request.language);
|
|
252
|
+
}
|
|
253
|
+
whisper_full_params parameters = whisper_full_default_params(WHISPER_SAMPLING_GREEDY);
|
|
254
|
+
parameters.n_threads = configuration.threads;
|
|
255
|
+
parameters.offset_ms = 0;
|
|
256
|
+
parameters.duration_ms = 0;
|
|
257
|
+
parameters.no_context = true;
|
|
258
|
+
parameters.translate = request.translate;
|
|
259
|
+
parameters.language = request.language.c_str();
|
|
260
|
+
parameters.print_realtime = false;
|
|
261
|
+
parameters.print_progress = false;
|
|
262
|
+
parameters.print_timestamps = false;
|
|
263
|
+
parameters.new_segment_callback = on_segment;
|
|
264
|
+
parameters.new_segment_callback_user_data = &request;
|
|
265
|
+
parameters.progress_callback = on_progress;
|
|
266
|
+
parameters.progress_callback_user_data = &request;
|
|
267
|
+
parameters.encoder_begin_callback = before_encoder;
|
|
268
|
+
parameters.encoder_begin_callback_user_data = &request;
|
|
269
|
+
parameters.abort_callback = should_abort;
|
|
270
|
+
parameters.abort_callback_user_data = &request;
|
|
271
|
+
const int result = whisper_full(context, parameters, audio.data(), static_cast<int>(audio.size()));
|
|
272
|
+
if (request.cancelled.load()) {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (result != 0) {
|
|
276
|
+
request.error_code = "WHISPER_INFERENCE_FAILED";
|
|
277
|
+
throw std::runtime_error("whisper_full returned " + std::to_string(result) + ".");
|
|
278
|
+
}
|
|
279
|
+
const char* language = whisper_lang_str(whisper_full_lang_id(context));
|
|
280
|
+
request.detected_language = language == nullptr ? "" : language;
|
|
281
|
+
const int total = whisper_full_n_segments(context);
|
|
282
|
+
for (int index = 0; index < total; ++index) {
|
|
283
|
+
Segment segment{
|
|
284
|
+
index,
|
|
285
|
+
whisper_full_get_segment_text(context, index),
|
|
286
|
+
whisper_full_get_segment_t0(context, index) * 0.01,
|
|
287
|
+
whisper_full_get_segment_t1(context, index) * 0.01
|
|
288
|
+
};
|
|
289
|
+
request.text += segment.text;
|
|
290
|
+
request.segments.push_back(std::move(segment));
|
|
291
|
+
}
|
|
292
|
+
} catch (const std::exception& error) {
|
|
293
|
+
request.error = error.what();
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
void emit_result(const Request& request) {
|
|
298
|
+
if (request.cancelled.load()) {
|
|
299
|
+
emit("{\"type\":\"cancelled\",\"requestId\":" + json_string(request.id) + "}");
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (!request.error.empty()) {
|
|
303
|
+
emit_error(request.id, request.error, request.error_code);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
std::ostringstream output;
|
|
307
|
+
output.imbue(std::locale::classic());
|
|
308
|
+
output << std::setprecision(std::numeric_limits<double>::max_digits10)
|
|
309
|
+
<< "{\"type\":\"complete\",\"requestId\":" << json_string(request.id)
|
|
310
|
+
<< ",\"language\":" << json_string(request.detected_language)
|
|
311
|
+
<< ",\"duration\":" << request.duration
|
|
312
|
+
<< ",\"text\":" << json_string(request.text) << ",\"segments\":[";
|
|
313
|
+
for (size_t index = 0; index < request.segments.size(); ++index) {
|
|
314
|
+
if (index != 0) {
|
|
315
|
+
output << ',';
|
|
316
|
+
}
|
|
317
|
+
output << segment_json(request.segments[index]);
|
|
318
|
+
}
|
|
319
|
+
output << "]}";
|
|
320
|
+
emit(output.str());
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
std::vector<std::string> command_fields(const std::string& line) {
|
|
324
|
+
std::vector<std::string> fields;
|
|
325
|
+
size_t start = 0;
|
|
326
|
+
while (true) {
|
|
327
|
+
const size_t separator = line.find('\t', start);
|
|
328
|
+
fields.push_back(line.substr(start, separator == std::string::npos ? separator : separator - start));
|
|
329
|
+
if (separator == std::string::npos) {
|
|
330
|
+
return fields;
|
|
331
|
+
}
|
|
332
|
+
start = separator + 1;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
void read_commands(Commands& commands) {
|
|
337
|
+
std::string line;
|
|
338
|
+
while (std::getline(std::cin, line)) {
|
|
339
|
+
std::string request_id;
|
|
340
|
+
try {
|
|
341
|
+
const std::vector<std::string> fields = command_fields(line);
|
|
342
|
+
if (fields.size() > 1) {
|
|
343
|
+
request_id = fields[1];
|
|
344
|
+
}
|
|
345
|
+
std::unique_lock<std::mutex> lock(commands.mutex);
|
|
346
|
+
if (fields[0] == "shutdown") {
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
if (fields[0] == "cancel" && fields.size() == 2) {
|
|
350
|
+
if (commands.active && commands.active->id == request_id) {
|
|
351
|
+
commands.active->cancelled.store(true);
|
|
352
|
+
}
|
|
353
|
+
} else if (fields[0] == "transcribe" && fields.size() == 5) {
|
|
354
|
+
if (commands.active) {
|
|
355
|
+
throw std::runtime_error("The retained model is already transcribing an active request.");
|
|
356
|
+
}
|
|
357
|
+
const auto request = std::make_shared<Request>();
|
|
358
|
+
request->id = request_id;
|
|
359
|
+
request->audio_path = decode_base64(fields[2]);
|
|
360
|
+
request->language = decode_base64(fields[3]);
|
|
361
|
+
if (fields[4] != "0" && fields[4] != "1") {
|
|
362
|
+
throw std::runtime_error("Unreadable translation command field.");
|
|
363
|
+
}
|
|
364
|
+
request->translate = fields[4] == "1";
|
|
365
|
+
commands.active = request;
|
|
366
|
+
emit("{\"type\":\"accepted\",\"requestId\":" + json_string(request_id) + "}");
|
|
367
|
+
commands.changed.notify_one();
|
|
368
|
+
} else {
|
|
369
|
+
throw std::runtime_error("Unreadable Whisper command.");
|
|
370
|
+
}
|
|
371
|
+
} catch (const std::exception& error) {
|
|
372
|
+
emit_error(request_id, error.what());
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
const std::lock_guard<std::mutex> lock(commands.mutex);
|
|
376
|
+
commands.stopping = true;
|
|
377
|
+
if (commands.active) {
|
|
378
|
+
commands.active->cancelled.store(true);
|
|
379
|
+
}
|
|
380
|
+
commands.changed.notify_one();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
Configuration read_configuration(int argc, char** argv) {
|
|
384
|
+
Configuration configuration;
|
|
385
|
+
const unsigned int available = std::thread::hardware_concurrency();
|
|
386
|
+
configuration.threads = available == 0 ? 1 : static_cast<int>(available);
|
|
387
|
+
for (int index = 1; index < argc; index += 2) {
|
|
388
|
+
if (index + 1 == argc) {
|
|
389
|
+
throw std::runtime_error("A helper argument is missing its value.");
|
|
390
|
+
}
|
|
391
|
+
const std::string option = argv[index];
|
|
392
|
+
const std::string value = argv[index + 1];
|
|
393
|
+
if (option == "--model-base64") {
|
|
394
|
+
configuration.model = decode_base64(value);
|
|
395
|
+
} else if (option == "--runtime-base64") {
|
|
396
|
+
configuration.runtime = decode_base64(value);
|
|
397
|
+
} else if (option == "--backend") {
|
|
398
|
+
configuration.backend = value;
|
|
399
|
+
} else if (option == "--encoder-model-base64") {
|
|
400
|
+
configuration.encoder_model = decode_base64(value);
|
|
401
|
+
} else if (option == "--encoder-cache-base64") {
|
|
402
|
+
configuration.encoder_cache = decode_base64(value);
|
|
403
|
+
} else if (option == "--encoder-device") {
|
|
404
|
+
configuration.encoder_device = value;
|
|
405
|
+
} else if (option == "--threads") {
|
|
406
|
+
configuration.threads = std::stoi(value);
|
|
407
|
+
} else {
|
|
408
|
+
throw std::runtime_error("Unknown helper argument: " + option);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (configuration.model.empty() || configuration.runtime.empty()) {
|
|
412
|
+
throw std::runtime_error("The selected model and runtime paths are required.");
|
|
413
|
+
}
|
|
414
|
+
if (configuration.backend != "gpu" && configuration.backend != "cpu") {
|
|
415
|
+
throw std::runtime_error("The requested backend must be gpu or cpu.");
|
|
416
|
+
}
|
|
417
|
+
if (!configuration.encoder_model.empty() || !configuration.encoder_cache.empty()
|
|
418
|
+
|| !configuration.encoder_device.empty()) {
|
|
419
|
+
if (configuration.encoder_device != "NPU" || configuration.encoder_model.empty()) {
|
|
420
|
+
throw std::runtime_error("The OpenVINO encoder requires its selected model path and NPU device.");
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
if (configuration.threads < 1) {
|
|
424
|
+
throw std::runtime_error("Native inference requires a positive thread count.");
|
|
425
|
+
}
|
|
426
|
+
return configuration;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
int serve(const Configuration& configuration) {
|
|
430
|
+
NativeLog log;
|
|
431
|
+
whisper_log_set(native_log, &log);
|
|
432
|
+
emit("{\"type\":\"loading\",\"model\":" + json_string(configuration.model)
|
|
433
|
+
+ ",\"requestedBackend\":" + json_string(configuration.backend)
|
|
434
|
+
+ ",\"observedBackend\":null,\"backendEvidence\":null"
|
|
435
|
+
+ ",\"requestedEncoder\":" + (configuration.encoder_device.empty() ? "null" : json_string(configuration.encoder_device))
|
|
436
|
+
+ ",\"observedEncoder\":null,\"encoderEvidence\":null}");
|
|
437
|
+
ggml_backend_load_all_from_path(configuration.runtime.c_str());
|
|
438
|
+
whisper_context_params parameters = whisper_context_default_params();
|
|
439
|
+
parameters.use_gpu = configuration.backend == "gpu";
|
|
440
|
+
using Context = std::unique_ptr<whisper_context, decltype(&whisper_free)>;
|
|
441
|
+
Context context(whisper_init_from_file_with_params(configuration.model.c_str(), parameters), whisper_free);
|
|
442
|
+
if (!context) {
|
|
443
|
+
throw std::runtime_error("Whisper could not load the selected model.");
|
|
444
|
+
}
|
|
445
|
+
if (!configuration.encoder_device.empty()) {
|
|
446
|
+
const int result = whisper_ctx_init_openvino_encoder(context.get(), configuration.encoder_model.c_str(),
|
|
447
|
+
"NPU", configuration.encoder_cache.empty() ? nullptr : configuration.encoder_cache.c_str());
|
|
448
|
+
if (result != 0) {
|
|
449
|
+
emit_error("", "whisper_ctx_init_openvino_encoder for NPU returned " + std::to_string(result) + ".",
|
|
450
|
+
"WHISPER_ENCODER_UNAVAILABLE");
|
|
451
|
+
return 1;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
{
|
|
455
|
+
const std::lock_guard<std::mutex> lock(log.mutex);
|
|
456
|
+
// The public C API exposes available devices, not a context backend
|
|
457
|
+
// getter. This observation is explicitly attributed to completed native
|
|
458
|
+
// initialization logs; it does not claim every operation uses a GPU.
|
|
459
|
+
// A successful OpenVINO call establishes encoder initialization on the
|
|
460
|
+
// explicit NPU device, independently of decoder/backend observation.
|
|
461
|
+
emit("{\"type\":\"ready\",\"model\":" + json_string(configuration.model)
|
|
462
|
+
+ ",\"requestedBackend\":" + json_string(configuration.backend)
|
|
463
|
+
+ ",\"observedBackend\":" + (log.backend.empty() ? "null" : json_string(log.backend))
|
|
464
|
+
+ ",\"backendEvidence\":" + (log.backend.empty() ? "null" : "\"runtime-log\"")
|
|
465
|
+
+ ",\"requestedEncoder\":" + (configuration.encoder_device.empty() ? "null" : json_string(configuration.encoder_device))
|
|
466
|
+
+ ",\"observedEncoder\":" + (configuration.encoder_device.empty() ? "null" : json_string(configuration.encoder_device))
|
|
467
|
+
+ ",\"encoderEvidence\":" + (configuration.encoder_device.empty() ? "null" : "\"openvino-initialization\"") + "}");
|
|
468
|
+
}
|
|
469
|
+
Commands commands;
|
|
470
|
+
std::thread reader(read_commands, std::ref(commands));
|
|
471
|
+
while (true) {
|
|
472
|
+
std::shared_ptr<Request> request;
|
|
473
|
+
{
|
|
474
|
+
std::unique_lock<std::mutex> lock(commands.mutex);
|
|
475
|
+
commands.changed.wait(lock, [&commands] { return commands.stopping || commands.active != nullptr; });
|
|
476
|
+
request = commands.active;
|
|
477
|
+
if (!request) {
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
// The command reader stays responsive while this exact worker owns the
|
|
482
|
+
// opaque context. Joining precedes every terminal event and model reuse.
|
|
483
|
+
try {
|
|
484
|
+
std::thread inference(transcribe, context.get(), std::cref(configuration), std::ref(*request));
|
|
485
|
+
inference.join();
|
|
486
|
+
} catch (const std::exception& error) {
|
|
487
|
+
request->error = error.what();
|
|
488
|
+
}
|
|
489
|
+
{
|
|
490
|
+
const std::lock_guard<std::mutex> lock(commands.mutex);
|
|
491
|
+
emit_result(*request);
|
|
492
|
+
commands.active.reset();
|
|
493
|
+
if (commands.stopping) {
|
|
494
|
+
break;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
reader.join();
|
|
499
|
+
context.reset();
|
|
500
|
+
whisper_log_set(nullptr, nullptr);
|
|
501
|
+
emit("{\"type\":\"stopped\"}");
|
|
502
|
+
return 0;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
} // namespace
|
|
506
|
+
|
|
507
|
+
int main(int argc, char** argv) {
|
|
508
|
+
#ifdef _WIN32
|
|
509
|
+
_setmode(_fileno(stdin), _O_BINARY);
|
|
510
|
+
_setmode(_fileno(stdout), _O_BINARY);
|
|
511
|
+
_setmode(_fileno(stderr), _O_BINARY);
|
|
512
|
+
#endif
|
|
513
|
+
try {
|
|
514
|
+
return serve(read_configuration(argc, argv));
|
|
515
|
+
} catch (const std::exception& error) {
|
|
516
|
+
whisper_log_set(nullptr, nullptr);
|
|
517
|
+
emit_error("", error.what());
|
|
518
|
+
return 1;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import {cp, mkdir, readdir, readFile, writeFile} from 'node:fs/promises';
|
|
3
|
+
import Is from 'strong-type';
|
|
4
|
+
import {runProcess} from '../../process.mjs';
|
|
5
|
+
|
|
6
|
+
const is = new Is(false);
|
|
7
|
+
|
|
8
|
+
/** Produce the optional Windows Intel NPU encoder runtime from installed upstream sources. */
|
|
9
|
+
export async function buildWhisperOpenVinoRuntime({
|
|
10
|
+
sourceDirectory,
|
|
11
|
+
openvinoDirectory,
|
|
12
|
+
cudaDirectory,
|
|
13
|
+
outputRoot,
|
|
14
|
+
cmake = 'cmake',
|
|
15
|
+
generator,
|
|
16
|
+
cmakeArgs = [],
|
|
17
|
+
env,
|
|
18
|
+
signal,
|
|
19
|
+
onEvent
|
|
20
|
+
} = {}) {
|
|
21
|
+
if (!is.string(sourceDirectory) || !is.string(openvinoDirectory) || !is.string(outputRoot)) {
|
|
22
|
+
throw new TypeError('Whisper sourceDirectory, OpenVINO openvinoDirectory, and outputRoot are required.');
|
|
23
|
+
}
|
|
24
|
+
if (process.platform !== 'win32' || process.arch !== 'x64') {
|
|
25
|
+
throw new Error('This optional OpenVINO producer currently targets Windows x64.');
|
|
26
|
+
}
|
|
27
|
+
const output = path.resolve(outputRoot);
|
|
28
|
+
const source = path.join(output, 'source');
|
|
29
|
+
const build = path.join(output, 'build');
|
|
30
|
+
const root = path.join(output, 'runtime');
|
|
31
|
+
const libraryDirectory = path.join(root, 'bin');
|
|
32
|
+
const toolkit = path.resolve(openvinoDirectory);
|
|
33
|
+
const options = {env, signal, onEvent};
|
|
34
|
+
signal?.throwIfAborted();
|
|
35
|
+
await cp(path.resolve(sourceDirectory), source, {recursive: true});
|
|
36
|
+
signal?.throwIfAborted();
|
|
37
|
+
await prepareOpenVinoSource(source);
|
|
38
|
+
const configure = await runProcess(cmake, [
|
|
39
|
+
'-S', source,
|
|
40
|
+
'-B', build,
|
|
41
|
+
...(generator ? ['-G', generator] : []),
|
|
42
|
+
'-DCMAKE_BUILD_TYPE=Release',
|
|
43
|
+
`-DCMAKE_RUNTIME_OUTPUT_DIRECTORY=${path.join(build, 'bin')}`,
|
|
44
|
+
`-DCMAKE_RUNTIME_OUTPUT_DIRECTORY_RELEASE=${path.join(build, 'bin')}`,
|
|
45
|
+
'-DBUILD_SHARED_LIBS=ON',
|
|
46
|
+
'-DWHISPER_OPENVINO=ON',
|
|
47
|
+
'-DWHISPER_BUILD_IS_DEV=OFF',
|
|
48
|
+
'-DWHISPER_BUILD_TESTS=OFF',
|
|
49
|
+
'-DWHISPER_BUILD_EXAMPLES=OFF',
|
|
50
|
+
'-DWHISPER_BUILD_SERVER=OFF',
|
|
51
|
+
'-DGGML_BACKEND_DL=ON',
|
|
52
|
+
'-DGGML_CPU=ON',
|
|
53
|
+
'-DGGML_NATIVE=OFF',
|
|
54
|
+
'-DGGML_CUDA=OFF',
|
|
55
|
+
'-DGGML_OPENVINO=OFF',
|
|
56
|
+
'-DGGML_OPENMP_FETCH=OFF',
|
|
57
|
+
'-DGGML_CCACHE=OFF',
|
|
58
|
+
`-DOpenVINO_DIR=${path.join(toolkit, 'runtime', 'cmake')}`,
|
|
59
|
+
...cmakeArgs
|
|
60
|
+
], options);
|
|
61
|
+
const compile = await runProcess(cmake, [
|
|
62
|
+
'--build', build, '--config', 'Release', '--target', 'whisper', 'ggml-cpu'
|
|
63
|
+
], options);
|
|
64
|
+
signal?.throwIfAborted();
|
|
65
|
+
await mkdir(libraryDirectory, {recursive: true});
|
|
66
|
+
await mkdir(path.join(root, 'lib'), {recursive: true});
|
|
67
|
+
await mkdir(path.join(root, 'notices', 'whisper.cpp'), {recursive: true});
|
|
68
|
+
// Keep all selected upstream runtime DLLs together for Windows and plugin lookup.
|
|
69
|
+
await cp(path.join(build, 'bin'), libraryDirectory, {recursive: true});
|
|
70
|
+
await copyImportLibraries(build, path.join(root, 'lib'));
|
|
71
|
+
await cp(path.join(toolkit, 'runtime', 'bin', 'intel64', 'Release'), libraryDirectory, {recursive: true});
|
|
72
|
+
const tbbDirectory = path.join(toolkit, 'runtime', '3rdparty', 'tbb', 'bin');
|
|
73
|
+
for (const member of await readdir(tbbDirectory, {withFileTypes: true})) {
|
|
74
|
+
if (member.isFile() && member.name.endsWith('.dll') && !member.name.endsWith('_debug.dll')) {
|
|
75
|
+
await cp(path.join(tbbDirectory, member.name), path.join(libraryDirectory, member.name));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (cudaDirectory) {
|
|
79
|
+
// The matching upstream plugin supplies CUDA without compiling a new backend.
|
|
80
|
+
for (const name of [
|
|
81
|
+
'ggml-cuda.dll', 'cublas64_12.dll', 'cublasLt64_12.dll', 'cudart64_12.dll',
|
|
82
|
+
'nvblas64_12.dll', 'nvrtc-builtins64_124.dll', 'nvrtc64_120_0.dll'
|
|
83
|
+
]) {
|
|
84
|
+
await cp(path.join(cudaDirectory, name), path.join(libraryDirectory, name));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
await cp(path.join(toolkit, 'docs', 'licensing'), path.join(root, 'notices', 'openvino'), {recursive: true});
|
|
88
|
+
await cp(path.join(source, 'LICENSE'), path.join(root, 'notices', 'whisper.cpp', 'LICENSE'));
|
|
89
|
+
await writeFile(path.join(root, 'notices', 'whisper.cpp', 'CHANGES.txt'),
|
|
90
|
+
'whisper.cpp 1.9.4: propagate OpenVINO encoder inference failure and send both OpenVINO exception diagnostics to stderr.\n');
|
|
91
|
+
signal?.throwIfAborted();
|
|
92
|
+
return {
|
|
93
|
+
version: '1.9.4',
|
|
94
|
+
openvinoVersion: '2026.3.1',
|
|
95
|
+
platform: process.platform,
|
|
96
|
+
architecture: process.arch,
|
|
97
|
+
encoder: 'openvino-npu',
|
|
98
|
+
backend: cudaDirectory ? 'cuda' : 'cpu',
|
|
99
|
+
backends: cudaDirectory ? ['cuda', 'cpu'] : ['cpu'],
|
|
100
|
+
root,
|
|
101
|
+
directory: root,
|
|
102
|
+
libraryDirectory,
|
|
103
|
+
sourceDirectory: source,
|
|
104
|
+
diagnostics: {configure, compile}
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function prepareOpenVinoSource(source) {
|
|
109
|
+
const whisperPath = path.join(source, 'src', 'whisper.cpp');
|
|
110
|
+
const encoderPath = path.join(source, 'src', 'openvino', 'whisper-openvino-encoder.cpp');
|
|
111
|
+
const whisper = await readFile(whisperPath, 'utf8');
|
|
112
|
+
const call = ' whisper_openvino_encode(wstate.ctx_openvino, mel, wstate.embd_enc);';
|
|
113
|
+
if (!whisper.includes(call)) {
|
|
114
|
+
throw new Error('The selected Whisper source does not contain the expected OpenVINO encoder call.');
|
|
115
|
+
}
|
|
116
|
+
await writeFile(whisperPath, whisper.replace(call,
|
|
117
|
+
' if (!whisper_openvino_encode(wstate.ctx_openvino, mel, wstate.embd_enc)) {\n return false;\n }'));
|
|
118
|
+
const encoder = await readFile(encoderPath, 'utf8');
|
|
119
|
+
const compileDiagnostic = 'std::cout << "in openvino encoder compile routine: exception: "';
|
|
120
|
+
const inferenceDiagnostic = 'std::cout << "in openvino encode inference execution routine: exception: "';
|
|
121
|
+
if (!encoder.includes(compileDiagnostic) || !encoder.includes(inferenceDiagnostic)) {
|
|
122
|
+
throw new Error('The selected Whisper source does not contain both expected OpenVINO diagnostics.');
|
|
123
|
+
}
|
|
124
|
+
await writeFile(encoderPath, encoder
|
|
125
|
+
.replace(compileDiagnostic, compileDiagnostic.replace('std::cout', 'std::cerr'))
|
|
126
|
+
.replace(inferenceDiagnostic, inferenceDiagnostic.replace('std::cout', 'std::cerr')));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function copyImportLibraries(directory, destination) {
|
|
130
|
+
for (const member of await readdir(directory, {withFileTypes: true})) {
|
|
131
|
+
const source = path.join(directory, member.name);
|
|
132
|
+
if (member.isDirectory()) {
|
|
133
|
+
await copyImportLibraries(source, destination);
|
|
134
|
+
} else if (member.name === 'whisper.lib' || /^ggml(?:-.*)?\.lib$/.test(member.name)) {
|
|
135
|
+
await cp(source, path.join(destination, member.name));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|