arcane-os 0.62.0 → 0.64.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 +40 -0
- package/README.md +1 -1
- package/browser-runtime/core/client.mjs +7 -2
- package/docs/reference/README.md +7 -0
- package/docs/reference/cli.md +5 -1
- package/docs/reference/core-client.md +11 -2
- package/docs/reference/core-native-packaging.md +57 -8
- package/docs/reference/core-repositories.md +149 -0
- package/docs/reference/inventory/package-api.json +163 -9
- package/docs/reference/local-ai.md +51 -7
- package/docs/reference/local-image-flux.md +154 -0
- package/docs/reference/local-image-generation.md +12 -0
- package/docs/reference/local-image-sdxl.md +139 -0
- package/docs/reference/local-whisper.md +219 -0
- package/docs/reference/native-speech.md +163 -0
- package/docs/reference/runtime-modules.md +16 -2
- package/docs/reference/sdk-api.md +335 -6
- package/package.json +5 -1
- package/runtime/arcane/components/speech.html +15 -3
- package/runtime/arcane/components/voice-transcription.html +19 -3
- package/runtime/arcane/modules/AI.js +250 -27
- 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/schemas/arcane-app.schema.json +53 -2
- package/schemas/arcane-package.schema.json +53 -2
- package/src/core/repositories.mjs +210 -0
- package/src/core/services/speech.mjs +361 -0
- package/src/local-ai/config.mjs +9 -3
- package/src/local-ai/dev.mjs +32 -2
- package/src/local-ai/image-library.mjs +9 -0
- package/src/local-ai/image-models/flux.mjs +31 -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 +61 -0
- package/src/local-ai/whisper/index.mjs +515 -0
- package/src/local-ai/whisper/install.mjs +274 -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 +489 -0
- package/src/native/portable-layout.mjs +37 -7
- package/src/native/windows-provider.mjs +39 -15
|
@@ -0,0 +1,489 @@
|
|
|
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
|
+
int threads = 1;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
struct Segment {
|
|
37
|
+
int index;
|
|
38
|
+
std::string text;
|
|
39
|
+
double start;
|
|
40
|
+
double end;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
struct Request {
|
|
44
|
+
std::string id;
|
|
45
|
+
std::string audio_path;
|
|
46
|
+
std::string language;
|
|
47
|
+
bool translate = false;
|
|
48
|
+
std::atomic<bool> cancelled{false};
|
|
49
|
+
std::vector<Segment> segments;
|
|
50
|
+
std::string text;
|
|
51
|
+
std::string detected_language;
|
|
52
|
+
std::string error;
|
|
53
|
+
std::string error_code;
|
|
54
|
+
double duration = 0;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
struct Commands {
|
|
58
|
+
std::mutex mutex;
|
|
59
|
+
std::condition_variable changed;
|
|
60
|
+
std::shared_ptr<Request> active;
|
|
61
|
+
bool stopping = false;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
struct NativeLog {
|
|
65
|
+
std::mutex mutex;
|
|
66
|
+
std::string pending;
|
|
67
|
+
std::string backend;
|
|
68
|
+
|
|
69
|
+
~NativeLog() {
|
|
70
|
+
whisper_log_set(nullptr, nullptr);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
std::mutex output_mutex;
|
|
75
|
+
|
|
76
|
+
std::string json_string(const std::string& value) {
|
|
77
|
+
std::ostringstream output;
|
|
78
|
+
output << '"';
|
|
79
|
+
for (const unsigned char character : value) {
|
|
80
|
+
switch (character) {
|
|
81
|
+
case '"': output << "\\\""; break;
|
|
82
|
+
case '\\': output << "\\\\"; break;
|
|
83
|
+
case '\n': output << "\\n"; break;
|
|
84
|
+
case '\r': output << "\\r"; break;
|
|
85
|
+
case '\t': output << "\\t"; break;
|
|
86
|
+
default:
|
|
87
|
+
if (character < 0x20) {
|
|
88
|
+
output << "\\u" << std::hex << std::setw(4) << std::setfill('0')
|
|
89
|
+
<< static_cast<unsigned int>(character) << std::dec;
|
|
90
|
+
} else {
|
|
91
|
+
output << character;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
output << '"';
|
|
96
|
+
return output.str();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
std::string decode_base64(const std::string& encoded) {
|
|
100
|
+
const std::string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
101
|
+
std::string decoded;
|
|
102
|
+
uint32_t accumulator = 0;
|
|
103
|
+
int remaining = 0;
|
|
104
|
+
bool padding = false;
|
|
105
|
+
for (const char character : encoded) {
|
|
106
|
+
if (character == '=') {
|
|
107
|
+
padding = true;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const auto digit = alphabet.find(character);
|
|
111
|
+
if (padding || digit == std::string::npos) {
|
|
112
|
+
throw std::runtime_error("Unreadable base64 command field.");
|
|
113
|
+
}
|
|
114
|
+
accumulator = (accumulator << 6) | static_cast<uint32_t>(digit);
|
|
115
|
+
remaining += 6;
|
|
116
|
+
if (remaining >= 8) {
|
|
117
|
+
remaining -= 8;
|
|
118
|
+
decoded.push_back(static_cast<char>((accumulator >> remaining) & 0xff));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return decoded;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
void emit(const std::string& record) {
|
|
125
|
+
const std::lock_guard<std::mutex> lock(output_mutex);
|
|
126
|
+
std::cout << record << '\n' << std::flush;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
void emit_error(const std::string& request_id, const std::string& message, const std::string& code = "") {
|
|
130
|
+
emit("{\"type\":\"error\",\"requestId\":" + json_string(request_id)
|
|
131
|
+
+ ",\"message\":" + json_string(message)
|
|
132
|
+
+ (code.empty() ? "" : ",\"code\":" + json_string(code)) + "}");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
std::string segment_json(const Segment& segment) {
|
|
136
|
+
std::ostringstream output;
|
|
137
|
+
output.imbue(std::locale::classic());
|
|
138
|
+
output << std::setprecision(std::numeric_limits<double>::max_digits10)
|
|
139
|
+
<< "{\"index\":" << segment.index << ",\"text\":" << json_string(segment.text)
|
|
140
|
+
<< ",\"start\":" << segment.start << ",\"end\":" << segment.end << '}';
|
|
141
|
+
return output.str();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
Segment read_segment(whisper_state* state, int index) {
|
|
145
|
+
return Segment{
|
|
146
|
+
index,
|
|
147
|
+
whisper_full_get_segment_text_from_state(state, index),
|
|
148
|
+
whisper_full_get_segment_t0_from_state(state, index) * 0.01,
|
|
149
|
+
whisper_full_get_segment_t1_from_state(state, index) * 0.01
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
void on_segment(whisper_context*, whisper_state* state, int added, void* data) {
|
|
154
|
+
Request& request = *static_cast<Request*>(data);
|
|
155
|
+
const int total = whisper_full_n_segments_from_state(state);
|
|
156
|
+
for (int index = total - added; index < total; ++index) {
|
|
157
|
+
const Segment segment = read_segment(state, index);
|
|
158
|
+
emit("{\"type\":\"segment\",\"requestId\":" + json_string(request.id)
|
|
159
|
+
+ ",\"index\":" + std::to_string(segment.index)
|
|
160
|
+
+ ",\"text\":" + json_string(segment.text)
|
|
161
|
+
+ ",\"start\":" + std::to_string(segment.start)
|
|
162
|
+
+ ",\"end\":" + std::to_string(segment.end) + "}");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
void on_progress(whisper_context*, whisper_state*, int progress, void* data) {
|
|
167
|
+
const Request& request = *static_cast<Request*>(data);
|
|
168
|
+
emit("{\"type\":\"progress\",\"requestId\":" + json_string(request.id)
|
|
169
|
+
+ ",\"progress\":" + std::to_string(progress) + "}");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
bool should_abort(void* data) {
|
|
173
|
+
return static_cast<Request*>(data)->cancelled.load();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
bool before_encoder(whisper_context*, whisper_state*, void* data) {
|
|
177
|
+
return !should_abort(data);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
void native_log(ggml_log_level, const char* text, void* data) {
|
|
181
|
+
NativeLog& log = *static_cast<NativeLog*>(data);
|
|
182
|
+
const std::lock_guard<std::mutex> lock(log.mutex);
|
|
183
|
+
std::cerr << text << std::flush;
|
|
184
|
+
log.pending += text;
|
|
185
|
+
std::string::size_type newline;
|
|
186
|
+
while ((newline = log.pending.find('\n')) != std::string::npos) {
|
|
187
|
+
const std::string line = log.pending.substr(0, newline);
|
|
188
|
+
log.pending.erase(0, newline + 1);
|
|
189
|
+
const std::string selected = "whisper_backend_init_gpu: using ";
|
|
190
|
+
const std::string failed = "whisper_backend_init_gpu: failed to initialize ";
|
|
191
|
+
if (line.compare(0, selected.size(), selected) == 0) {
|
|
192
|
+
const auto end = line.rfind(" backend");
|
|
193
|
+
if (end != std::string::npos) {
|
|
194
|
+
log.backend = line.substr(selected.size(), end - selected.size());
|
|
195
|
+
}
|
|
196
|
+
} else if (line.compare(0, failed.size(), failed) == 0
|
|
197
|
+
|| line == "whisper_backend_init_gpu: no GPU found") {
|
|
198
|
+
log.backend = "CPU";
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
std::vector<float> read_audio(Request& request) {
|
|
204
|
+
std::ifstream input(std::filesystem::u8path(request.audio_path), std::ios::binary);
|
|
205
|
+
if (!input) {
|
|
206
|
+
throw std::runtime_error("Could not open the selected decoded audio: " + request.audio_path);
|
|
207
|
+
}
|
|
208
|
+
std::vector<float> audio;
|
|
209
|
+
unsigned char sample[4];
|
|
210
|
+
while (input.read(reinterpret_cast<char*>(sample), sizeof(sample))) {
|
|
211
|
+
if (request.cancelled.load()) {
|
|
212
|
+
return {};
|
|
213
|
+
}
|
|
214
|
+
// This is the decoder's f32le transport representation, not a content
|
|
215
|
+
// policy or an audio normalization step.
|
|
216
|
+
const uint32_t bits = static_cast<uint32_t>(sample[0])
|
|
217
|
+
| (static_cast<uint32_t>(sample[1]) << 8)
|
|
218
|
+
| (static_cast<uint32_t>(sample[2]) << 16)
|
|
219
|
+
| (static_cast<uint32_t>(sample[3]) << 24);
|
|
220
|
+
float value;
|
|
221
|
+
std::memcpy(&value, &bits, sizeof(value));
|
|
222
|
+
audio.push_back(value);
|
|
223
|
+
}
|
|
224
|
+
if (input.bad() || input.gcount() != 0) {
|
|
225
|
+
throw std::runtime_error("The decoded audio ended inside an f32le sample or could not be read.");
|
|
226
|
+
}
|
|
227
|
+
return audio;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
void transcribe(whisper_context* context, const Configuration& configuration, Request& request) {
|
|
231
|
+
try {
|
|
232
|
+
if (request.cancelled.load()) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const std::vector<float> audio = read_audio(request);
|
|
236
|
+
if (request.cancelled.load()) {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
request.duration = static_cast<double>(audio.size()) / WHISPER_SAMPLE_RATE;
|
|
240
|
+
if (audio.size() > static_cast<size_t>(std::numeric_limits<int>::max())) {
|
|
241
|
+
throw std::runtime_error("The complete recording exceeds whisper_full's native signed-int sample argument; no audio was submitted or shortened.");
|
|
242
|
+
}
|
|
243
|
+
if (audio.empty()) {
|
|
244
|
+
throw std::runtime_error("The decoder returned no audio samples.");
|
|
245
|
+
}
|
|
246
|
+
if (!request.language.empty() && request.language != "auto"
|
|
247
|
+
&& whisper_lang_id(request.language.c_str()) < 0) {
|
|
248
|
+
throw std::runtime_error("Whisper does not recognize the requested language: " + request.language);
|
|
249
|
+
}
|
|
250
|
+
whisper_full_params parameters = whisper_full_default_params(WHISPER_SAMPLING_GREEDY);
|
|
251
|
+
parameters.n_threads = configuration.threads;
|
|
252
|
+
parameters.offset_ms = 0;
|
|
253
|
+
parameters.duration_ms = 0;
|
|
254
|
+
parameters.no_context = true;
|
|
255
|
+
parameters.translate = request.translate;
|
|
256
|
+
parameters.language = request.language.c_str();
|
|
257
|
+
parameters.print_realtime = false;
|
|
258
|
+
parameters.print_progress = false;
|
|
259
|
+
parameters.print_timestamps = false;
|
|
260
|
+
parameters.new_segment_callback = on_segment;
|
|
261
|
+
parameters.new_segment_callback_user_data = &request;
|
|
262
|
+
parameters.progress_callback = on_progress;
|
|
263
|
+
parameters.progress_callback_user_data = &request;
|
|
264
|
+
parameters.encoder_begin_callback = before_encoder;
|
|
265
|
+
parameters.encoder_begin_callback_user_data = &request;
|
|
266
|
+
parameters.abort_callback = should_abort;
|
|
267
|
+
parameters.abort_callback_user_data = &request;
|
|
268
|
+
const int result = whisper_full(context, parameters, audio.data(), static_cast<int>(audio.size()));
|
|
269
|
+
if (request.cancelled.load()) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (result != 0) {
|
|
273
|
+
request.error_code = "WHISPER_INFERENCE_FAILED";
|
|
274
|
+
throw std::runtime_error("whisper_full returned " + std::to_string(result) + ".");
|
|
275
|
+
}
|
|
276
|
+
const char* language = whisper_lang_str(whisper_full_lang_id(context));
|
|
277
|
+
request.detected_language = language == nullptr ? "" : language;
|
|
278
|
+
const int total = whisper_full_n_segments(context);
|
|
279
|
+
for (int index = 0; index < total; ++index) {
|
|
280
|
+
Segment segment{
|
|
281
|
+
index,
|
|
282
|
+
whisper_full_get_segment_text(context, index),
|
|
283
|
+
whisper_full_get_segment_t0(context, index) * 0.01,
|
|
284
|
+
whisper_full_get_segment_t1(context, index) * 0.01
|
|
285
|
+
};
|
|
286
|
+
request.text += segment.text;
|
|
287
|
+
request.segments.push_back(std::move(segment));
|
|
288
|
+
}
|
|
289
|
+
} catch (const std::exception& error) {
|
|
290
|
+
request.error = error.what();
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
void emit_result(const Request& request) {
|
|
295
|
+
if (request.cancelled.load()) {
|
|
296
|
+
emit("{\"type\":\"cancelled\",\"requestId\":" + json_string(request.id) + "}");
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (!request.error.empty()) {
|
|
300
|
+
emit_error(request.id, request.error, request.error_code);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
std::ostringstream output;
|
|
304
|
+
output.imbue(std::locale::classic());
|
|
305
|
+
output << std::setprecision(std::numeric_limits<double>::max_digits10)
|
|
306
|
+
<< "{\"type\":\"complete\",\"requestId\":" << json_string(request.id)
|
|
307
|
+
<< ",\"language\":" << json_string(request.detected_language)
|
|
308
|
+
<< ",\"duration\":" << request.duration
|
|
309
|
+
<< ",\"text\":" << json_string(request.text) << ",\"segments\":[";
|
|
310
|
+
for (size_t index = 0; index < request.segments.size(); ++index) {
|
|
311
|
+
if (index != 0) {
|
|
312
|
+
output << ',';
|
|
313
|
+
}
|
|
314
|
+
output << segment_json(request.segments[index]);
|
|
315
|
+
}
|
|
316
|
+
output << "]}";
|
|
317
|
+
emit(output.str());
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
std::vector<std::string> command_fields(const std::string& line) {
|
|
321
|
+
std::vector<std::string> fields;
|
|
322
|
+
size_t start = 0;
|
|
323
|
+
while (true) {
|
|
324
|
+
const size_t separator = line.find('\t', start);
|
|
325
|
+
fields.push_back(line.substr(start, separator == std::string::npos ? separator : separator - start));
|
|
326
|
+
if (separator == std::string::npos) {
|
|
327
|
+
return fields;
|
|
328
|
+
}
|
|
329
|
+
start = separator + 1;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
void read_commands(Commands& commands) {
|
|
334
|
+
std::string line;
|
|
335
|
+
while (std::getline(std::cin, line)) {
|
|
336
|
+
std::string request_id;
|
|
337
|
+
try {
|
|
338
|
+
const std::vector<std::string> fields = command_fields(line);
|
|
339
|
+
if (fields.size() > 1) {
|
|
340
|
+
request_id = fields[1];
|
|
341
|
+
}
|
|
342
|
+
std::unique_lock<std::mutex> lock(commands.mutex);
|
|
343
|
+
if (fields[0] == "shutdown") {
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
if (fields[0] == "cancel" && fields.size() == 2) {
|
|
347
|
+
if (commands.active && commands.active->id == request_id) {
|
|
348
|
+
commands.active->cancelled.store(true);
|
|
349
|
+
}
|
|
350
|
+
} else if (fields[0] == "transcribe" && fields.size() == 5) {
|
|
351
|
+
if (commands.active) {
|
|
352
|
+
throw std::runtime_error("The retained model is already transcribing an active request.");
|
|
353
|
+
}
|
|
354
|
+
const auto request = std::make_shared<Request>();
|
|
355
|
+
request->id = request_id;
|
|
356
|
+
request->audio_path = decode_base64(fields[2]);
|
|
357
|
+
request->language = decode_base64(fields[3]);
|
|
358
|
+
if (fields[4] != "0" && fields[4] != "1") {
|
|
359
|
+
throw std::runtime_error("Unreadable translation command field.");
|
|
360
|
+
}
|
|
361
|
+
request->translate = fields[4] == "1";
|
|
362
|
+
commands.active = request;
|
|
363
|
+
emit("{\"type\":\"accepted\",\"requestId\":" + json_string(request_id) + "}");
|
|
364
|
+
commands.changed.notify_one();
|
|
365
|
+
} else {
|
|
366
|
+
throw std::runtime_error("Unreadable Whisper command.");
|
|
367
|
+
}
|
|
368
|
+
} catch (const std::exception& error) {
|
|
369
|
+
emit_error(request_id, error.what());
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
const std::lock_guard<std::mutex> lock(commands.mutex);
|
|
373
|
+
commands.stopping = true;
|
|
374
|
+
if (commands.active) {
|
|
375
|
+
commands.active->cancelled.store(true);
|
|
376
|
+
}
|
|
377
|
+
commands.changed.notify_one();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
Configuration read_configuration(int argc, char** argv) {
|
|
381
|
+
Configuration configuration;
|
|
382
|
+
const unsigned int available = std::thread::hardware_concurrency();
|
|
383
|
+
configuration.threads = available == 0 ? 1 : static_cast<int>(available);
|
|
384
|
+
for (int index = 1; index < argc; index += 2) {
|
|
385
|
+
if (index + 1 == argc) {
|
|
386
|
+
throw std::runtime_error("A helper argument is missing its value.");
|
|
387
|
+
}
|
|
388
|
+
const std::string option = argv[index];
|
|
389
|
+
const std::string value = argv[index + 1];
|
|
390
|
+
if (option == "--model-base64") {
|
|
391
|
+
configuration.model = decode_base64(value);
|
|
392
|
+
} else if (option == "--runtime-base64") {
|
|
393
|
+
configuration.runtime = decode_base64(value);
|
|
394
|
+
} else if (option == "--backend") {
|
|
395
|
+
configuration.backend = value;
|
|
396
|
+
} else if (option == "--threads") {
|
|
397
|
+
configuration.threads = std::stoi(value);
|
|
398
|
+
} else {
|
|
399
|
+
throw std::runtime_error("Unknown helper argument: " + option);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (configuration.model.empty() || configuration.runtime.empty()) {
|
|
403
|
+
throw std::runtime_error("The selected model and runtime paths are required.");
|
|
404
|
+
}
|
|
405
|
+
if (configuration.backend != "gpu" && configuration.backend != "cpu") {
|
|
406
|
+
throw std::runtime_error("The requested backend must be gpu or cpu.");
|
|
407
|
+
}
|
|
408
|
+
if (configuration.threads < 1) {
|
|
409
|
+
throw std::runtime_error("Native inference requires a positive thread count.");
|
|
410
|
+
}
|
|
411
|
+
return configuration;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
int serve(const Configuration& configuration) {
|
|
415
|
+
NativeLog log;
|
|
416
|
+
whisper_log_set(native_log, &log);
|
|
417
|
+
emit("{\"type\":\"loading\",\"model\":" + json_string(configuration.model)
|
|
418
|
+
+ ",\"requestedBackend\":" + json_string(configuration.backend)
|
|
419
|
+
+ ",\"observedBackend\":null,\"backendEvidence\":null}");
|
|
420
|
+
ggml_backend_load_all_from_path(configuration.runtime.c_str());
|
|
421
|
+
whisper_context_params parameters = whisper_context_default_params();
|
|
422
|
+
parameters.use_gpu = configuration.backend == "gpu";
|
|
423
|
+
using Context = std::unique_ptr<whisper_context, decltype(&whisper_free)>;
|
|
424
|
+
Context context(whisper_init_from_file_with_params(configuration.model.c_str(), parameters), whisper_free);
|
|
425
|
+
if (!context) {
|
|
426
|
+
throw std::runtime_error("Whisper could not load the selected model.");
|
|
427
|
+
}
|
|
428
|
+
{
|
|
429
|
+
const std::lock_guard<std::mutex> lock(log.mutex);
|
|
430
|
+
// The public C API exposes available devices, not a context backend
|
|
431
|
+
// getter. This observation is explicitly attributed to completed native
|
|
432
|
+
// initialization logs; it does not claim every operation uses a GPU.
|
|
433
|
+
emit("{\"type\":\"ready\",\"model\":" + json_string(configuration.model)
|
|
434
|
+
+ ",\"requestedBackend\":" + json_string(configuration.backend)
|
|
435
|
+
+ ",\"observedBackend\":" + (log.backend.empty() ? "null" : json_string(log.backend))
|
|
436
|
+
+ ",\"backendEvidence\":" + (log.backend.empty() ? "null" : "\"runtime-log\"") + "}");
|
|
437
|
+
}
|
|
438
|
+
Commands commands;
|
|
439
|
+
std::thread reader(read_commands, std::ref(commands));
|
|
440
|
+
while (true) {
|
|
441
|
+
std::shared_ptr<Request> request;
|
|
442
|
+
{
|
|
443
|
+
std::unique_lock<std::mutex> lock(commands.mutex);
|
|
444
|
+
commands.changed.wait(lock, [&commands] { return commands.stopping || commands.active != nullptr; });
|
|
445
|
+
request = commands.active;
|
|
446
|
+
if (!request) {
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
// The command reader stays responsive while this exact worker owns the
|
|
451
|
+
// opaque context. Joining precedes every terminal event and model reuse.
|
|
452
|
+
try {
|
|
453
|
+
std::thread inference(transcribe, context.get(), std::cref(configuration), std::ref(*request));
|
|
454
|
+
inference.join();
|
|
455
|
+
} catch (const std::exception& error) {
|
|
456
|
+
request->error = error.what();
|
|
457
|
+
}
|
|
458
|
+
{
|
|
459
|
+
const std::lock_guard<std::mutex> lock(commands.mutex);
|
|
460
|
+
emit_result(*request);
|
|
461
|
+
commands.active.reset();
|
|
462
|
+
if (commands.stopping) {
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
reader.join();
|
|
468
|
+
context.reset();
|
|
469
|
+
whisper_log_set(nullptr, nullptr);
|
|
470
|
+
emit("{\"type\":\"stopped\"}");
|
|
471
|
+
return 0;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
} // namespace
|
|
475
|
+
|
|
476
|
+
int main(int argc, char** argv) {
|
|
477
|
+
#ifdef _WIN32
|
|
478
|
+
_setmode(_fileno(stdin), _O_BINARY);
|
|
479
|
+
_setmode(_fileno(stdout), _O_BINARY);
|
|
480
|
+
_setmode(_fileno(stderr), _O_BINARY);
|
|
481
|
+
#endif
|
|
482
|
+
try {
|
|
483
|
+
return serve(read_configuration(argc, argv));
|
|
484
|
+
} catch (const std::exception& error) {
|
|
485
|
+
whisper_log_set(nullptr, nullptr);
|
|
486
|
+
emit_error("", error.what());
|
|
487
|
+
return 1;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
@@ -153,6 +153,9 @@ export function coreEntrySource(application, version, services, {localAI, runtim
|
|
|
153
153
|
const imageSelected = localAI?.runtimes?.some(function selectedImage(requirement) {
|
|
154
154
|
return (typeof requirement === 'string' ? requirement : requirement.id) === 'stable-diffusion.cpp';
|
|
155
155
|
}) ?? false;
|
|
156
|
+
const whisperSelected = localAI?.runtimes?.some(function selectedWhisper(requirement) {
|
|
157
|
+
return (typeof requirement === 'string' ? requirement : requirement.id) === 'whisper.cpp';
|
|
158
|
+
}) ?? false;
|
|
156
159
|
const imports = services.map(function serviceImport(service, index) {
|
|
157
160
|
const specifier = '../app/' + service.module.split('/').map(encodeURIComponent).join('/');
|
|
158
161
|
return `import createService${index} from ${JSON.stringify(specifier)};`;
|
|
@@ -165,7 +168,8 @@ export function coreEntrySource(application, version, services, {localAI, runtim
|
|
|
165
168
|
const chatConfiguration = {
|
|
166
169
|
...localAI,
|
|
167
170
|
runtimes: localAI.runtimes.filter(function selectedChat(requirement) {
|
|
168
|
-
|
|
171
|
+
const id = typeof requirement === 'string' ? requirement : requirement.id;
|
|
172
|
+
return id !== 'stable-diffusion.cpp' && id !== 'whisper.cpp';
|
|
169
173
|
})
|
|
170
174
|
};
|
|
171
175
|
imports.push("import {createLocalAIService} from 'arcane-os/core/local-ai';");
|
|
@@ -177,6 +181,12 @@ export function coreEntrySource(application, version, services, {localAI, runtim
|
|
|
177
181
|
definitions.push(' modelAssets');
|
|
178
182
|
definitions.push(` createLocalImageService(JSON.parse(${JSON.stringify(JSON.stringify(localAI))}), {...context, modelAssets})`);
|
|
179
183
|
}
|
|
184
|
+
if (whisperSelected) {
|
|
185
|
+
imports.push("import path from 'node:path';");
|
|
186
|
+
imports.push("import {createSpeechService} from 'arcane-os/core/speech';");
|
|
187
|
+
imports.push("import {createWhisperRuntime} from 'arcane-os/local-ai/whisper';");
|
|
188
|
+
definitions.push(' createSpeechService({stt, signal: context.signal})');
|
|
189
|
+
}
|
|
180
190
|
if (packagedWeb) {
|
|
181
191
|
imports.push("import {createPackagedWebService} from 'arcane-os/core/packaged-web';");
|
|
182
192
|
definitions.push(" createPackagedWebService({artifactRoot: fileURLToPath(new URL('../', import.meta.url))}, context)");
|
|
@@ -200,18 +210,26 @@ export function coreEntrySource(application, version, services, {localAI, runtim
|
|
|
200
210
|
...(localAI === undefined ? [] : [
|
|
201
211
|
` runtimes: JSON.parse(${JSON.stringify(JSON.stringify(runtimes))}).map(function runtimeLocation(runtime) {`,
|
|
202
212
|
' const resolved = {...runtime};',
|
|
203
|
-
" for (const field of ['root', 'executable', 'modulePath', 'includeDirectory', 'libraryDirectory', 'binaryDirectory', 'cmakeDirectory', 'libraryPath', 'bindingModulePath']) {",
|
|
213
|
+
" for (const field of ['root', 'executable', 'modulePath', 'includeDirectory', 'libraryDirectory', 'binaryDirectory', 'cmakeDirectory', 'libraryPath', 'bindingModulePath', 'helperExecutable', 'helperRoot', 'decoderExecutable', 'decoderRoot']) {",
|
|
204
214
|
' if (runtime[field] !== undefined) resolved[field] = runtimePath(runtime[field]);',
|
|
205
215
|
' }',
|
|
206
216
|
' if (runtime.variants) resolved.variants = runtime.variants.map(function nativeVariant(variant) {',
|
|
207
|
-
'
|
|
217
|
+
' const resolvedVariant = {...variant};',
|
|
218
|
+
" for (const field of ['root', 'libraryPath', 'libraryDirectory', 'executable']) {",
|
|
219
|
+
' if (variant[field] !== undefined) resolvedVariant[field] = runtimePath(variant[field]);',
|
|
220
|
+
' }',
|
|
221
|
+
' return resolvedVariant;',
|
|
208
222
|
' });',
|
|
209
223
|
' if (runtime.models) resolved.models = runtime.models.map(function nativeModel(model) {',
|
|
210
|
-
' const
|
|
211
|
-
'
|
|
212
|
-
|
|
224
|
+
' const resolvedModel = {...model};',
|
|
225
|
+
' if (model.path !== undefined) resolvedModel.path = runtimePath(model.path);',
|
|
226
|
+
' if (model.resources !== undefined) {',
|
|
227
|
+
' resolvedModel.resources = {};',
|
|
228
|
+
' for (const [role, resource] of Object.entries(model.resources)) {',
|
|
229
|
+
" resolvedModel.resources[role] = typeof resource === 'string' ? runtimePath(resource) : resource;",
|
|
230
|
+
' }',
|
|
213
231
|
' }',
|
|
214
|
-
' return
|
|
232
|
+
' return resolvedModel;',
|
|
215
233
|
' });',
|
|
216
234
|
' return resolved;',
|
|
217
235
|
' }),'
|
|
@@ -220,6 +238,18 @@ export function coreEntrySource(application, version, services, {localAI, runtim
|
|
|
220
238
|
'};',
|
|
221
239
|
'',
|
|
222
240
|
...(imageSelected ? ['const modelAssets = createModelAssetService({appRoot: context.appRoot});', ''] : []),
|
|
241
|
+
...(whisperSelected ? [
|
|
242
|
+
'const stt = createWhisperRuntime(',
|
|
243
|
+
' {',
|
|
244
|
+
' runtime: context.runtimes.find(function selectedWhisperRuntime(runtime) {',
|
|
245
|
+
" return runtime.id === 'whisper.cpp';",
|
|
246
|
+
' }),',
|
|
247
|
+
" temporaryDirectory: path.join(context.stateRoot ?? path.join(context.appRoot, '.arcane'), 'speech', 'whisper'),",
|
|
248
|
+
' onEvent: context.onEvent',
|
|
249
|
+
' }',
|
|
250
|
+
');',
|
|
251
|
+
''
|
|
252
|
+
] : []),
|
|
223
253
|
'const host = startCoreHost({',
|
|
224
254
|
` application: JSON.parse(${JSON.stringify(JSON.stringify(application))}),`,
|
|
225
255
|
` version: ${JSON.stringify(version)},`,
|