arcane-os 0.57.0 → 0.59.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 +37 -0
- package/README.md +6 -2
- package/browser-runtime/ai/core-onnx.mjs +96 -0
- package/browser-runtime/ai/onnx-tensors.mjs +85 -0
- package/browser-runtime/core/client.mjs +152 -17
- package/docs/architecture.md +5 -2
- package/docs/platform-targets.md +30 -13
- package/docs/reference/README.md +22 -3
- package/docs/reference/availability-and-normalization.md +14 -7
- package/docs/reference/cli.md +22 -10
- package/docs/reference/core-client.md +87 -0
- package/docs/reference/core-native-packaging.md +129 -9
- package/docs/reference/core-preferences.md +49 -0
- package/docs/reference/diarization.md +206 -0
- package/docs/reference/git-text-snapshot.md +104 -0
- package/docs/reference/inventory/package-api.json +205 -23
- package/docs/reference/local-ai.md +145 -11
- package/docs/reference/protocols.md +28 -19
- package/docs/reference/pwa.md +74 -4
- package/docs/reference/sdk-api.md +589 -39
- package/package.json +11 -3
- package/schemas/arcane-app.schema.json +3 -3
- package/schemas/arcane-package.schema.json +2 -2
- package/schemas/native-build-plan.schema.json +27 -1
- package/schemas/target-adapter.schema.json +2 -0
- package/src/app-descriptor.mjs +1 -1
- package/src/cli/main.mjs +14 -4
- package/src/constants.mjs +2 -0
- package/src/core/host.mjs +14 -5
- package/src/core/hosts/macos/ArcaneHost.h +86 -0
- package/src/core/hosts/macos/ArcaneHost.m +455 -0
- package/src/core/hosts/macos/ArcaneLauncher.m +432 -0
- package/src/core/hosts/windows/ArcaneLauncher.cs +4 -1
- package/src/core/services/local-ai.mjs +82 -4
- package/src/core/services/packaged-web.mjs +89 -0
- package/src/core/services/preferences.mjs +201 -0
- package/src/core-classic-source.mjs +2 -2
- package/src/dev-server.mjs +2 -5
- package/src/diarization/build.mjs +35 -0
- package/src/diarization/index.mjs +432 -0
- package/src/diarization/native/CMakeLists.txt +9 -0
- package/src/diarization/native/main.cpp +269 -0
- package/src/diarization/service.mjs +129 -0
- package/src/git-text-snapshot.mjs +265 -0
- package/src/import-map.mjs +9 -4
- package/src/index.mjs +1 -0
- package/src/local-ai/config.mjs +1 -1
- package/src/local-ai/install.mjs +134 -19
- package/src/local-ai/onnx-worker.mjs +85 -0
- package/src/local-ai/onnx.mjs +368 -0
- package/src/native/macos-provider.mjs +291 -0
- package/src/native/portable-layout.mjs +10 -2
- package/src/native/portable-provider.mjs +10 -5
- package/src/native-plan.mjs +9 -1
- package/src/native-provider-loader.mjs +3 -1
- package/src/process.mjs +123 -17
- package/src/scaffold.mjs +1 -1
- package/src/targets/index.mjs +11 -1
- package/src/templates/workspace-template.mjs +6 -5
- package/src/toolchain.mjs +42 -13
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
#include <nemo_speech/diar.h>
|
|
2
|
+
|
|
3
|
+
#include <cstdint>
|
|
4
|
+
#include <cstring>
|
|
5
|
+
#include <iomanip>
|
|
6
|
+
#include <iostream>
|
|
7
|
+
#include <limits>
|
|
8
|
+
#include <map>
|
|
9
|
+
#include <memory>
|
|
10
|
+
#include <sstream>
|
|
11
|
+
#include <stdexcept>
|
|
12
|
+
#include <string>
|
|
13
|
+
#include <vector>
|
|
14
|
+
|
|
15
|
+
#ifdef _WIN32
|
|
16
|
+
#include <fcntl.h>
|
|
17
|
+
#include <io.h>
|
|
18
|
+
#include <windows.h>
|
|
19
|
+
#endif
|
|
20
|
+
|
|
21
|
+
namespace {
|
|
22
|
+
|
|
23
|
+
using StreamHandle = std::unique_ptr<nemo_speech_diar_stream, decltype(&nemo_speech_diar_stream_close)>;
|
|
24
|
+
using ModelHandle = std::unique_ptr<nemo_speech_diar_model, decltype(&nemo_speech_diar_destroy)>;
|
|
25
|
+
|
|
26
|
+
struct Stream {
|
|
27
|
+
StreamHandle handle;
|
|
28
|
+
int sample_rate;
|
|
29
|
+
bool probabilities;
|
|
30
|
+
bool finished = false;
|
|
31
|
+
int64_t delivered_frames = 0;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
void check(nemo_speech_asr_status status) {
|
|
35
|
+
if (status != NEMO_SPEECH_ASR_OK) {
|
|
36
|
+
throw std::runtime_error(nemo_speech_asr_last_error());
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
std::string json_string(const std::string& value) {
|
|
41
|
+
std::ostringstream output;
|
|
42
|
+
output << '"';
|
|
43
|
+
for (const unsigned char character : value) {
|
|
44
|
+
switch (character) {
|
|
45
|
+
case '"': output << "\\\""; break;
|
|
46
|
+
case '\\': output << "\\\\"; break;
|
|
47
|
+
case '\n': output << "\\n"; break;
|
|
48
|
+
case '\r': output << "\\r"; break;
|
|
49
|
+
case '\t': output << "\\t"; break;
|
|
50
|
+
default:
|
|
51
|
+
if (character < 0x20) {
|
|
52
|
+
output << "\\u" << std::hex << std::setw(4) << std::setfill('0')
|
|
53
|
+
<< static_cast<unsigned int>(character) << std::dec;
|
|
54
|
+
} else {
|
|
55
|
+
output << character;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
output << '"';
|
|
60
|
+
return output.str();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
void emit_error(uint64_t request, const std::string& message) {
|
|
64
|
+
std::cout << "{\"request\":" << request << ",\"error\":" << json_string(message)
|
|
65
|
+
<< "}\n" << std::flush;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
void emit_ack(uint64_t request) {
|
|
69
|
+
std::cout << "{\"request\":" << request << ",\"ok\":true}\n" << std::flush;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Only transport framing uses sample count * sizeof(float). Samples are decoded
|
|
73
|
+
// losslessly from little-endian float32; the caller's audio is never normalized.
|
|
74
|
+
std::vector<float> read_audio(size_t samples) {
|
|
75
|
+
std::vector<float> audio(samples);
|
|
76
|
+
std::vector<unsigned char> encoded(samples * sizeof(float));
|
|
77
|
+
if (!std::cin.read(reinterpret_cast<char*>(encoded.data()), encoded.size())) {
|
|
78
|
+
throw std::runtime_error("Diarization input ended during an audio frame.");
|
|
79
|
+
}
|
|
80
|
+
for (size_t index = 0; index < samples; ++index) {
|
|
81
|
+
const auto* value = encoded.data() + index * sizeof(float);
|
|
82
|
+
const uint32_t bits = static_cast<uint32_t>(value[0])
|
|
83
|
+
| (static_cast<uint32_t>(value[1]) << 8)
|
|
84
|
+
| (static_cast<uint32_t>(value[2]) << 16)
|
|
85
|
+
| (static_cast<uint32_t>(value[3]) << 24);
|
|
86
|
+
std::memcpy(&audio[index], &bits, sizeof(float));
|
|
87
|
+
}
|
|
88
|
+
return audio;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
void emit_result(uint64_t request, Stream& stream, int speakers, double seconds_per_frame) {
|
|
92
|
+
const int64_t frame_count = nemo_speech_diar_frame_count(stream.handle.get());
|
|
93
|
+
if (!stream.finished && frame_count == stream.delivered_frames) {
|
|
94
|
+
// Many small caller pushes only fill the native right-context buffer.
|
|
95
|
+
// Do not re-scan and serialize unchanged history for those pushes.
|
|
96
|
+
emit_ack(request);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
size_t count = 0;
|
|
100
|
+
check(nemo_speech_diar_segments(stream.handle.get(), nullptr, nullptr, 0, &count));
|
|
101
|
+
std::vector<nemo_speech_diar_segment> segments(count);
|
|
102
|
+
if (count != 0) {
|
|
103
|
+
check(nemo_speech_diar_segments(stream.handle.get(), nullptr, segments.data(), count, &count));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const int64_t retained_start = nemo_speech_diar_frame_probs_start(stream.handle.get());
|
|
107
|
+
std::vector<float> probabilities;
|
|
108
|
+
if (stream.probabilities) {
|
|
109
|
+
if (retained_start > stream.delivered_frames) {
|
|
110
|
+
throw std::runtime_error("Upstream compacted probability frames before they could be delivered.");
|
|
111
|
+
}
|
|
112
|
+
probabilities.resize(static_cast<size_t>(frame_count - retained_start) * speakers);
|
|
113
|
+
if (!probabilities.empty()) {
|
|
114
|
+
check(nemo_speech_diar_frame_probs(stream.handle.get(), probabilities.data(), probabilities.size()));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Build one complete protocol record before writing it. A C-ABI failure
|
|
119
|
+
// therefore cannot leave a partially emitted result on stdout.
|
|
120
|
+
std::ostringstream output;
|
|
121
|
+
output << std::setprecision(std::numeric_limits<double>::max_digits10);
|
|
122
|
+
output << "{\"request\":" << request << ",\"result\":{\"final\":"
|
|
123
|
+
<< (stream.finished ? "true" : "false")
|
|
124
|
+
<< ",\"speakers\":" << speakers
|
|
125
|
+
<< ",\"secondsPerFrame\":" << seconds_per_frame
|
|
126
|
+
<< ",\"frameCount\":" << frame_count << ",\"segments\":[";
|
|
127
|
+
for (size_t index = 0; index < count; ++index) {
|
|
128
|
+
const auto& segment = segments[index];
|
|
129
|
+
if (index != 0) output << ',';
|
|
130
|
+
output << "{\"speaker\":" << segment.speaker << ",\"startTime\":" << segment.start_time
|
|
131
|
+
<< ",\"endTime\":" << segment.end_time << '}';
|
|
132
|
+
}
|
|
133
|
+
output << ']';
|
|
134
|
+
if (stream.probabilities) {
|
|
135
|
+
output << ",\"probabilities\":{\"startFrame\":" << stream.delivered_frames
|
|
136
|
+
<< ",\"values\":[";
|
|
137
|
+
const size_t first = static_cast<size_t>(stream.delivered_frames - retained_start) * speakers;
|
|
138
|
+
for (size_t index = first; index < probabilities.size(); ++index) {
|
|
139
|
+
if (index != first) output << ',';
|
|
140
|
+
output << probabilities[index];
|
|
141
|
+
}
|
|
142
|
+
output << "]}";
|
|
143
|
+
}
|
|
144
|
+
output << "}}\n";
|
|
145
|
+
std::cout << output.str() << std::flush;
|
|
146
|
+
stream.delivered_frames = frame_count;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
int serve(const std::string& model_path) {
|
|
150
|
+
#ifdef _WIN32
|
|
151
|
+
_setmode(_fileno(stdin), _O_BINARY);
|
|
152
|
+
_setmode(_fileno(stdout), _O_BINARY);
|
|
153
|
+
#endif
|
|
154
|
+
nemo_speech_diar_model_config configuration = {};
|
|
155
|
+
configuration.size = sizeof(configuration);
|
|
156
|
+
configuration.model_path = model_path.c_str();
|
|
157
|
+
configuration.gpu = -1;
|
|
158
|
+
configuration.preset = "v3-streaming";
|
|
159
|
+
configuration.left_context_frames = -1;
|
|
160
|
+
nemo_speech_diar_model* raw_model = nullptr;
|
|
161
|
+
check(nemo_speech_diar_create(&configuration, &raw_model));
|
|
162
|
+
const ModelHandle model(raw_model, nemo_speech_diar_destroy);
|
|
163
|
+
const int speakers = nemo_speech_diar_num_speakers(model.get());
|
|
164
|
+
const double seconds_per_frame = nemo_speech_diar_seconds_per_frame(model.get());
|
|
165
|
+
|
|
166
|
+
// Streams are destroyed before their retained model. The C ABI serializes
|
|
167
|
+
// model compute internally; one command loop also preserves each stream's
|
|
168
|
+
// required single-threaded access without duplicating the native scheduler.
|
|
169
|
+
std::map<uint64_t, Stream> streams;
|
|
170
|
+
std::cout << "{\"ready\":true,\"speakers\":" << speakers
|
|
171
|
+
<< ",\"secondsPerFrame\":" << std::setprecision(17) << seconds_per_frame
|
|
172
|
+
<< "}\n" << std::flush;
|
|
173
|
+
|
|
174
|
+
std::string line;
|
|
175
|
+
while (std::getline(std::cin, line)) {
|
|
176
|
+
uint64_t request = 0;
|
|
177
|
+
uint64_t id = 0;
|
|
178
|
+
std::string operation;
|
|
179
|
+
std::istringstream command(line);
|
|
180
|
+
if (!(command >> operation >> request >> id)) {
|
|
181
|
+
emit_error(request, "Unreadable diarization command.");
|
|
182
|
+
return 1;
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
if (operation == "open") {
|
|
186
|
+
int sample_rate = 0;
|
|
187
|
+
int probabilities = 0;
|
|
188
|
+
if (!(command >> sample_rate >> probabilities)) {
|
|
189
|
+
throw std::runtime_error("Unreadable stream configuration.");
|
|
190
|
+
}
|
|
191
|
+
if (streams.count(id)) throw std::runtime_error("The stream is already open.");
|
|
192
|
+
nemo_speech_diar_stream* raw_stream = nullptr;
|
|
193
|
+
check(nemo_speech_diar_stream_open(model.get(), &raw_stream));
|
|
194
|
+
streams.emplace(id, Stream{
|
|
195
|
+
StreamHandle(raw_stream, nemo_speech_diar_stream_close), sample_rate, probabilities != 0
|
|
196
|
+
});
|
|
197
|
+
emit_ack(request);
|
|
198
|
+
} else if (operation == "close") {
|
|
199
|
+
streams.erase(id);
|
|
200
|
+
emit_ack(request);
|
|
201
|
+
} else {
|
|
202
|
+
// Consume the complete framed audio before looking up its owner,
|
|
203
|
+
// so an operation error cannot desynchronize the command stream.
|
|
204
|
+
std::vector<float> audio;
|
|
205
|
+
if (operation == "push") {
|
|
206
|
+
size_t samples = 0;
|
|
207
|
+
if (!(command >> samples)) throw std::runtime_error("Unreadable audio sample count.");
|
|
208
|
+
audio = read_audio(samples);
|
|
209
|
+
}
|
|
210
|
+
Stream& stream = streams.at(id);
|
|
211
|
+
if (stream.finished) throw std::runtime_error("The stream has already finished.");
|
|
212
|
+
if (operation == "push") {
|
|
213
|
+
check(nemo_speech_diar_stream_push_f32(
|
|
214
|
+
stream.handle.get(), audio.data(), audio.size(), stream.sample_rate
|
|
215
|
+
));
|
|
216
|
+
} else if (operation == "finish") {
|
|
217
|
+
check(nemo_speech_diar_stream_finish(stream.handle.get()));
|
|
218
|
+
stream.finished = true;
|
|
219
|
+
} else {
|
|
220
|
+
throw std::runtime_error("Unknown diarization operation.");
|
|
221
|
+
}
|
|
222
|
+
emit_result(request, stream, speakers, seconds_per_frame);
|
|
223
|
+
}
|
|
224
|
+
} catch (const std::exception& error) {
|
|
225
|
+
emit_error(request, error.what());
|
|
226
|
+
if (!std::cin) return 1;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// EOF is the owner's graceful shutdown. No stream outlives the model, and
|
|
230
|
+
// there is no claim that EOF interrupts an active native inference call.
|
|
231
|
+
return 0;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
int run(const std::string& model_path) {
|
|
235
|
+
try {
|
|
236
|
+
return serve(model_path);
|
|
237
|
+
} catch (const std::exception& error) {
|
|
238
|
+
emit_error(0, error.what());
|
|
239
|
+
return 1;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
} // namespace
|
|
244
|
+
|
|
245
|
+
#ifdef _WIN32
|
|
246
|
+
int wmain(int argc, wchar_t** argv) {
|
|
247
|
+
if (argc != 2) {
|
|
248
|
+
emit_error(0, "Usage: arcane-diarization MODEL.gguf");
|
|
249
|
+
return 1;
|
|
250
|
+
}
|
|
251
|
+
const int length = WideCharToMultiByte(CP_UTF8, 0, argv[1], -1, nullptr, 0, nullptr, nullptr);
|
|
252
|
+
if (length == 0) {
|
|
253
|
+
emit_error(0, "Windows could not encode the selected model path as UTF-8.");
|
|
254
|
+
return 1;
|
|
255
|
+
}
|
|
256
|
+
std::string model_path(length, '\0');
|
|
257
|
+
WideCharToMultiByte(CP_UTF8, 0, argv[1], -1, model_path.data(), length, nullptr, nullptr);
|
|
258
|
+
model_path.pop_back();
|
|
259
|
+
return run(model_path);
|
|
260
|
+
}
|
|
261
|
+
#else
|
|
262
|
+
int main(int argc, char** argv) {
|
|
263
|
+
if (argc != 2) {
|
|
264
|
+
emit_error(0, "Usage: arcane-diarization MODEL.gguf");
|
|
265
|
+
return 1;
|
|
266
|
+
}
|
|
267
|
+
return run(argv[1]);
|
|
268
|
+
}
|
|
269
|
+
#endif
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import Is from 'strong-type';
|
|
3
|
+
import {createDiarization} from './index.mjs';
|
|
4
|
+
import {serializeCoreError} from '../../browser-runtime/core/contracts.mjs';
|
|
5
|
+
|
|
6
|
+
const is = new Is(false);
|
|
7
|
+
|
|
8
|
+
/** Synchronous Core service factory; loading waits only at diarization calls. */
|
|
9
|
+
export function createDiarizationService(options = {}, launch = {}) {
|
|
10
|
+
const workspaceRoot = launch.workspaceRoot ?? launch.appRoot ?? process.cwd();
|
|
11
|
+
const streams = new Map();
|
|
12
|
+
let nextStream = 0;
|
|
13
|
+
let model = null;
|
|
14
|
+
let context = null;
|
|
15
|
+
let closing = null;
|
|
16
|
+
let unsubscribe = null;
|
|
17
|
+
|
|
18
|
+
function current() {
|
|
19
|
+
const state = model?.current() ?? {state: 'unloaded', model: 'nvidia/Nemotron-3-Diarization'};
|
|
20
|
+
return {...state, error: state.error ? serializeCoreError(state.error) : null};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function load() {
|
|
24
|
+
if (closing) throw new Error('The diarization service is closing.');
|
|
25
|
+
if (!model) {
|
|
26
|
+
model = createDiarization({
|
|
27
|
+
executable: resolveSelectedPath(options.executable, workspaceRoot, 'executable'),
|
|
28
|
+
modelPath: resolveSelectedPath(options.modelPath, workspaceRoot, 'modelPath'),
|
|
29
|
+
runtime: options.runtime,
|
|
30
|
+
onEvent: options.onEvent
|
|
31
|
+
});
|
|
32
|
+
unsubscribe = model.subscribe(function modelState() {
|
|
33
|
+
context?.emit('diarization.state', current());
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return model.ready;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function streamFor(id) {
|
|
40
|
+
const stream = streams.get(id);
|
|
41
|
+
if (!stream) throw new Error(`Diarization stream ${id} is not open.`);
|
|
42
|
+
return stream;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function open({sampleRate = 16000, probabilities = false} = {}) {
|
|
46
|
+
await load();
|
|
47
|
+
const streamId = ++nextStream;
|
|
48
|
+
const stream = await model.openStream({
|
|
49
|
+
sampleRate,
|
|
50
|
+
onUpdate: function update(result) {
|
|
51
|
+
context?.emit('diarization.update', {streamId, result});
|
|
52
|
+
},
|
|
53
|
+
onProbabilities: probabilities ? function probabilityFrames(result) {
|
|
54
|
+
context?.emit('diarization.probabilities', {streamId, ...result});
|
|
55
|
+
} : undefined
|
|
56
|
+
});
|
|
57
|
+
streams.set(streamId, stream);
|
|
58
|
+
return {streamId, sampleRate};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function push({streamId, audio}) {
|
|
62
|
+
return streamFor(streamId).push(decodeAudio(audio));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function finish({streamId}) {
|
|
66
|
+
const stream = streamFor(streamId);
|
|
67
|
+
try { return await stream.finish(); } finally { streams.delete(streamId); }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function cancel({streamId}) {
|
|
71
|
+
const stream = streams.get(streamId);
|
|
72
|
+
if (stream) {
|
|
73
|
+
try { await stream.cancel(); } finally { streams.delete(streamId); }
|
|
74
|
+
}
|
|
75
|
+
return {streamId, cancelled: true};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function recording({audio, sampleRate = 16000}, {signal}) {
|
|
79
|
+
await load();
|
|
80
|
+
return model.diarize({audio: decodeAudio(audio), sampleRate, signal});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function drain() {
|
|
84
|
+
if (closing) return closing;
|
|
85
|
+
closing = (async function drainService() {
|
|
86
|
+
try { await model?.close(); } finally {
|
|
87
|
+
unsubscribe?.();
|
|
88
|
+
streams.clear();
|
|
89
|
+
}
|
|
90
|
+
})();
|
|
91
|
+
return closing;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
name: 'diarization',
|
|
96
|
+
start: function attachRuntime(owner) { context = owner; },
|
|
97
|
+
methods: {
|
|
98
|
+
'diarization.status': current,
|
|
99
|
+
'diarization.load': {lifetime: 'service', handle: load},
|
|
100
|
+
'diarization.open': {lifetime: 'service', handle: open},
|
|
101
|
+
'diarization.push': {lifetime: 'service', handle: push},
|
|
102
|
+
'diarization.finish': {lifetime: 'service', handle: finish},
|
|
103
|
+
'diarization.cancel': {lifetime: 'service', handle: cancel},
|
|
104
|
+
'diarization.recording': recording
|
|
105
|
+
},
|
|
106
|
+
drain,
|
|
107
|
+
dispose: drain
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function resolveSelectedPath(value, workspaceRoot, name) {
|
|
112
|
+
if (!is.string(value)) throw new TypeError(`Diarization requires ${name}.`);
|
|
113
|
+
return path.resolve(workspaceRoot, value);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function decodeAudio(audio) {
|
|
117
|
+
if (audio?.encoding !== 'f32le' || !is.string(audio.data)) {
|
|
118
|
+
throw new TypeError('Core audio requires {encoding: "f32le", data: BASE64}.');
|
|
119
|
+
}
|
|
120
|
+
const encoded = Buffer.from(audio.data, 'base64');
|
|
121
|
+
// Framing belongs to the transport. A partial sample is not usable audio.
|
|
122
|
+
if (encoded.length % 4 !== 0) throw new TypeError('The f32le transport ends during a sample.');
|
|
123
|
+
const samples = new Float32Array(encoded.length / 4);
|
|
124
|
+
const bits = new Uint32Array(samples.buffer);
|
|
125
|
+
for (let index = 0; index < samples.length; index++) bits[index] = encoded.readUInt32LE(index * 4);
|
|
126
|
+
return samples;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export default createDiarizationService;
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import Is from 'strong-type';
|
|
2
|
+
import {mkdir, readdir} from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {runProcess} from './process.mjs';
|
|
5
|
+
import {ArcaneError, ERROR_CODES, throwIfAborted} from './errors.mjs';
|
|
6
|
+
import {createEventQueue} from './event-queue.mjs';
|
|
7
|
+
|
|
8
|
+
const is = new Is(false);
|
|
9
|
+
const snapshotRef = 'refs/arcane/text-snapshot';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Read complete selected text from a fetched revision, never a working tree.
|
|
13
|
+
* One application service owns each dedicated cache directory. Construction
|
|
14
|
+
* performs no I/O; refresh coalesces callers and close drains accepted work.
|
|
15
|
+
*/
|
|
16
|
+
export function createGitTextSnapshot({cacheDirectory, remote, ref, selectPath, onEvent, run = runProcess} = {}) {
|
|
17
|
+
for (const [name, value] of Object.entries({cacheDirectory, remote, ref})) {
|
|
18
|
+
if (!is.string(value) || value === '') throw new TypeError(`${name} must be a nonempty string.`);
|
|
19
|
+
}
|
|
20
|
+
if (!is.function(selectPath)) throw new TypeError('selectPath must be a function.');
|
|
21
|
+
const directory = path.resolve(cacheDirectory);
|
|
22
|
+
let prepared = false;
|
|
23
|
+
let retained = null;
|
|
24
|
+
let active = null;
|
|
25
|
+
let closing = null;
|
|
26
|
+
|
|
27
|
+
async function git(args, options = {}) {
|
|
28
|
+
let outputFailure;
|
|
29
|
+
const execution = {...options};
|
|
30
|
+
if (options.onOutput) {
|
|
31
|
+
execution.onOutput = async function consumeGitOutput(record) {
|
|
32
|
+
try { await options.onOutput(record); }
|
|
33
|
+
catch (error) { outputFailure ??= error; throw error; }
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
return await run('git', ['--git-dir', directory, ...args], execution);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
// runProcess first drains its child and preserves complete transport
|
|
40
|
+
// failures. Keep the Git parser's public code with that full cause.
|
|
41
|
+
if (outputFailure instanceof ArcaneError) {
|
|
42
|
+
throw new ArcaneError(outputFailure.code, outputFailure.message,
|
|
43
|
+
{details: outputFailure.details, cause: error});
|
|
44
|
+
}
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function prepare(events) {
|
|
50
|
+
if (prepared) return;
|
|
51
|
+
await mkdir(directory, {recursive: true});
|
|
52
|
+
if ((await readdir(directory)).length === 0) {
|
|
53
|
+
await run('git', ['init', '--bare', directory], {onEvent: events.send});
|
|
54
|
+
}
|
|
55
|
+
const result = await git(['rev-parse', '--is-bare-repository'], {onEvent: events.send});
|
|
56
|
+
// This is the selected cache's functional role: never reinterpret an
|
|
57
|
+
// existing working checkout as disposable object-cache storage.
|
|
58
|
+
if (result.stdout.trim() !== 'true') {
|
|
59
|
+
throw new ArcaneError('ARCANE_GIT_SNAPSHOT_CACHE_INVALID', 'The snapshot cache must be a bare Git repository.');
|
|
60
|
+
}
|
|
61
|
+
prepared = true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function acquire() {
|
|
65
|
+
const events = createEventQueue(onEvent);
|
|
66
|
+
try {
|
|
67
|
+
await events.send({type: 'git.snapshot.refreshing', message: 'Refreshing the selected repository revision.', data: {ref}});
|
|
68
|
+
await prepare(events);
|
|
69
|
+
await git(['fetch', '--no-tags', '--no-write-fetch-head', '--', remote, `+${ref}:${snapshotRef}`], {onEvent: events.send});
|
|
70
|
+
const selected = await git(['rev-parse', '--verify', `${snapshotRef}^{commit}`], {onEvent: events.send});
|
|
71
|
+
const revision = selected.stdout.trim();
|
|
72
|
+
if (retained?.revision === revision) {
|
|
73
|
+
await events.send({type: 'git.snapshot.completed', message: 'The selected repository revision is unchanged.', data: {revision, reused: true}});
|
|
74
|
+
await events.drain();
|
|
75
|
+
return retained;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const entries = [];
|
|
79
|
+
let treeParts = [];
|
|
80
|
+
await git(['ls-tree', '-r', '-z', '--full-tree', revision], {
|
|
81
|
+
outputEncoding: {stdout: null},
|
|
82
|
+
captureOutput: {stdout: false},
|
|
83
|
+
emitOutputEvents: {stdout: false},
|
|
84
|
+
onEvent: events.send,
|
|
85
|
+
onOutput: async function readTree({stream, chunk}) {
|
|
86
|
+
if (stream !== 'stdout') return;
|
|
87
|
+
let start = 0;
|
|
88
|
+
for (let end = chunk.indexOf(0); end !== -1; end = chunk.indexOf(0, start)) {
|
|
89
|
+
treeParts.push(chunk.subarray(start, end));
|
|
90
|
+
const record = Buffer.concat(treeParts);
|
|
91
|
+
treeParts = [];
|
|
92
|
+
const tab = record.indexOf(9);
|
|
93
|
+
if (tab < 0) throw protocolError('Git returned an unreadable tree record.', {record});
|
|
94
|
+
const [mode, type, object] = record.subarray(0, tab).toString('ascii').split(' ');
|
|
95
|
+
const filename = decodeText(record.subarray(tab + 1), 'repository path');
|
|
96
|
+
if (await selectPath(filename)) {
|
|
97
|
+
if (type !== 'blob') throw new ArcaneError('ARCANE_GIT_SNAPSHOT_NOT_TEXT',
|
|
98
|
+
`The selected path is a ${type}, not a text blob.`, {details: {path: filename, mode, type}});
|
|
99
|
+
entries.push({path: filename, object});
|
|
100
|
+
}
|
|
101
|
+
start = end + 1;
|
|
102
|
+
}
|
|
103
|
+
if (start < chunk.length) treeParts.push(chunk.subarray(start));
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
if (treeParts.length) throw protocolError('Git ended before its tree record delimiter.', {record: Buffer.concat(treeParts)});
|
|
107
|
+
|
|
108
|
+
const reader = createBlobReader(entries);
|
|
109
|
+
if (entries.length) {
|
|
110
|
+
async function* requestedObjects() {
|
|
111
|
+
for (const entry of entries) yield `${entry.object}\n`;
|
|
112
|
+
}
|
|
113
|
+
await git(['cat-file', '--batch'], {
|
|
114
|
+
input: requestedObjects(),
|
|
115
|
+
outputEncoding: {stdout: null},
|
|
116
|
+
captureOutput: {stdout: false},
|
|
117
|
+
emitOutputEvents: {stdout: false},
|
|
118
|
+
onEvent: events.send,
|
|
119
|
+
onOutput: function readBlobs({stream, chunk}) {
|
|
120
|
+
if (stream === 'stdout') reader.write(chunk);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
const result = {revision, files: reader.finish()};
|
|
125
|
+
await events.send({type: 'git.snapshot.completed', message: 'Repository text snapshot is ready.', data: {revision, reused: false}});
|
|
126
|
+
await events.drain();
|
|
127
|
+
retained = result;
|
|
128
|
+
return result;
|
|
129
|
+
} catch (error) {
|
|
130
|
+
try {
|
|
131
|
+
await events.send({type: 'git.snapshot.failed', message: 'Repository text snapshot failed.', data: {error}});
|
|
132
|
+
await events.drain();
|
|
133
|
+
} catch (observerError) {
|
|
134
|
+
if (observerError !== error) throw new AggregateError([error, observerError], 'Snapshot operation and event delivery failed.');
|
|
135
|
+
}
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function refresh({signal} = {}) {
|
|
141
|
+
try {
|
|
142
|
+
throwIfAborted(signal);
|
|
143
|
+
if (closing) throw new ArcaneError('CORE_CLOSING', 'The Git snapshot owner is closing.');
|
|
144
|
+
} catch (error) {
|
|
145
|
+
return Promise.reject(error);
|
|
146
|
+
}
|
|
147
|
+
if (!active) {
|
|
148
|
+
// Install ownership before an observer can issue another refresh.
|
|
149
|
+
active = Promise.resolve().then(acquire);
|
|
150
|
+
const accepted = active;
|
|
151
|
+
function settled() { if (active === accepted) active = null; }
|
|
152
|
+
accepted.then(settled, settled);
|
|
153
|
+
}
|
|
154
|
+
return waitForSnapshot(active, signal).then(function copyResult(snapshot) {
|
|
155
|
+
return {revision: snapshot.revision, files: snapshot.files.map(function copyFile(file) { return {...file}; })};
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function close() {
|
|
160
|
+
if (!closing) {
|
|
161
|
+
closing = (active ?? Promise.resolve()).then(function drained() { retained = null; });
|
|
162
|
+
}
|
|
163
|
+
return closing;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {refresh, close, drain: close, dispose: close};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function waitForSnapshot(operation, signal) {
|
|
170
|
+
if (!signal) return operation;
|
|
171
|
+
return new Promise(function wait(resolve, reject) {
|
|
172
|
+
function abort() {
|
|
173
|
+
signal.removeEventListener('abort', abort);
|
|
174
|
+
reject(new ArcaneError(ERROR_CODES.cancelled, 'The snapshot caller was cancelled.', {cause: signal.reason, exitCode: 130}));
|
|
175
|
+
}
|
|
176
|
+
signal.addEventListener('abort', abort, {once: true});
|
|
177
|
+
operation.then(function completed(result) {
|
|
178
|
+
signal.removeEventListener('abort', abort);
|
|
179
|
+
if (!signal.aborted) resolve(result);
|
|
180
|
+
}, function failed(error) {
|
|
181
|
+
signal.removeEventListener('abort', abort);
|
|
182
|
+
if (!signal.aborted) reject(error);
|
|
183
|
+
});
|
|
184
|
+
if (signal.aborted) abort();
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function decodeText(input, filename) {
|
|
189
|
+
try {
|
|
190
|
+
// ignoreBOM preserves an authored leading U+FEFF instead of consuming it.
|
|
191
|
+
return new TextDecoder('utf-8', {fatal: true, ignoreBOM: true}).decode(input);
|
|
192
|
+
} catch (cause) {
|
|
193
|
+
throw new ArcaneError('ARCANE_GIT_SNAPSHOT_NOT_TEXT', 'The selected Git content cannot be represented as UTF-8 text.',
|
|
194
|
+
{cause, details: {path: filename}});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function protocolError(message, details) {
|
|
199
|
+
return new ArcaneError('ARCANE_GIT_SNAPSHOT_PROTOCOL', message, {details});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function createBlobReader(entries) {
|
|
203
|
+
const files = [];
|
|
204
|
+
let headerParts = [];
|
|
205
|
+
let state = 'header';
|
|
206
|
+
let remaining = 0n;
|
|
207
|
+
let content = [];
|
|
208
|
+
let decoder;
|
|
209
|
+
|
|
210
|
+
function write(chunk) {
|
|
211
|
+
let offset = 0;
|
|
212
|
+
while (offset < chunk.length) {
|
|
213
|
+
if (state === 'header') {
|
|
214
|
+
const newline = chunk.indexOf(10, offset);
|
|
215
|
+
if (newline === -1) { headerParts.push(chunk.subarray(offset)); return; }
|
|
216
|
+
headerParts.push(chunk.subarray(offset, newline));
|
|
217
|
+
const header = Buffer.concat(headerParts).toString('ascii');
|
|
218
|
+
headerParts = [];
|
|
219
|
+
const [object, type, extent] = header.split(' ');
|
|
220
|
+
const entry = entries[files.length];
|
|
221
|
+
if (!entry || object !== entry.object || type !== 'blob' || !/^\d+$/u.test(extent)) {
|
|
222
|
+
throw protocolError('Git returned an unexpected blob response.', {header, path: entry?.path});
|
|
223
|
+
}
|
|
224
|
+
// Git requires this encoded extent to delimit the raw body.
|
|
225
|
+
// It never enters product metadata, admission or progress.
|
|
226
|
+
remaining = BigInt(extent);
|
|
227
|
+
decoder = new TextDecoder('utf-8', {fatal: true, ignoreBOM: true});
|
|
228
|
+
content = [];
|
|
229
|
+
offset = newline + 1;
|
|
230
|
+
state = remaining === 0n ? 'delimiter' : 'body';
|
|
231
|
+
} else if (state === 'body') {
|
|
232
|
+
const available = chunk.length - offset;
|
|
233
|
+
const taking = remaining < BigInt(available) ? Number(remaining) : available;
|
|
234
|
+
try {
|
|
235
|
+
content.push(decoder.decode(chunk.subarray(offset, offset + taking), {stream: true}));
|
|
236
|
+
} catch (cause) {
|
|
237
|
+
throw new ArcaneError('ARCANE_GIT_SNAPSHOT_NOT_TEXT', 'The selected Git blob is not UTF-8 text.',
|
|
238
|
+
{cause, details: {path: entries[files.length].path}});
|
|
239
|
+
}
|
|
240
|
+
offset += taking;
|
|
241
|
+
remaining -= BigInt(taking);
|
|
242
|
+
if (remaining === 0n) state = 'delimiter';
|
|
243
|
+
} else {
|
|
244
|
+
if (chunk[offset] !== 10) throw protocolError('Git returned an unreadable blob delimiter.', {path: entries[files.length].path});
|
|
245
|
+
try { content.push(decoder.decode()); }
|
|
246
|
+
catch (cause) {
|
|
247
|
+
throw new ArcaneError('ARCANE_GIT_SNAPSHOT_NOT_TEXT', 'The selected Git blob ended inside a UTF-8 character.',
|
|
248
|
+
{cause, details: {path: entries[files.length].path}});
|
|
249
|
+
}
|
|
250
|
+
files.push({path: entries[files.length].path, content: content.join('')});
|
|
251
|
+
offset += 1;
|
|
252
|
+
state = 'header';
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function finish() {
|
|
258
|
+
if (state !== 'header' || headerParts.length || files.length !== entries.length) {
|
|
259
|
+
throw protocolError('Git ended before all requested text blobs were returned.', {paths: entries.map(function filename(entry) { return entry.path; })});
|
|
260
|
+
}
|
|
261
|
+
return files;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return {write, finish};
|
|
265
|
+
}
|