arcane-os 0.35.0 → 0.36.1
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 +32 -0
- package/README.md +1 -1
- package/docs/reference/inventory/runtime-components.json +13 -6
- package/docs/reference/inventory/runtime-modules.json +28 -4
- package/docs/reference/runtime-components.md +202 -22
- package/docs/reference/runtime-modules.md +109 -1
- package/package.json +2 -2
- package/runtime/arcane/components/data-view.html +18 -2
- package/runtime/arcane/components/file-manager.html +512 -88
- package/runtime/arcane/components/voice-transcription.html +611 -55
- package/runtime/arcane/entities/File.js +14 -0
- package/runtime/arcane/modules/ComponentContracts.js +32 -1
- package/runtime/arcane/modules/ContinuousVoiceCapture.js +446 -0
- package/runtime/arcane/modules/VoiceCaptureWorklet.js +194 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// AudioWorklet is a native isolated realm: this processor has no app imports.
|
|
2
|
+
// Its silent output keeps capture driven by the audio clock without feedback.
|
|
3
|
+
class ArcaneVoiceCaptureProcessor extends AudioWorkletProcessor {
|
|
4
|
+
#preRoll;
|
|
5
|
+
#preRollLength = 0;
|
|
6
|
+
#preRollOffset = 0;
|
|
7
|
+
#quietFrames;
|
|
8
|
+
#chunkFrames;
|
|
9
|
+
#activityThreshold;
|
|
10
|
+
#active = false;
|
|
11
|
+
#hasActivity = false;
|
|
12
|
+
#quietCount = 0;
|
|
13
|
+
#sampleCount = 0;
|
|
14
|
+
#sequence = 0;
|
|
15
|
+
#buffer = null;
|
|
16
|
+
#view = null;
|
|
17
|
+
#stopped = false;
|
|
18
|
+
|
|
19
|
+
constructor(options) {
|
|
20
|
+
super();
|
|
21
|
+
const configuration = options.processorOptions;
|
|
22
|
+
this.#preRoll = new Float32Array(
|
|
23
|
+
Math.round(configuration.preRollMs * sampleRate / 1000)
|
|
24
|
+
);
|
|
25
|
+
this.#quietFrames = Math.max(1, Math.round(configuration.quietMs * sampleRate / 1000));
|
|
26
|
+
this.#chunkFrames = Math.max(1, Math.round(configuration.chunkMs * sampleRate / 1000));
|
|
27
|
+
this.#activityThreshold = configuration.activityThreshold;
|
|
28
|
+
this.port.onmessage = this.#receiveControl.bind(this);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
#receiveControl(event) {
|
|
32
|
+
if (this.#stopped || event.data.type !== 'stop') return;
|
|
33
|
+
this.#stopped = true;
|
|
34
|
+
try {
|
|
35
|
+
this.#emit('stop');
|
|
36
|
+
this.#release();
|
|
37
|
+
this.port.postMessage(
|
|
38
|
+
{type: 'stopped'}
|
|
39
|
+
);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
this.#fail(error);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
process(inputs, outputs) {
|
|
46
|
+
if (this.#stopped) return false;
|
|
47
|
+
for (const output of outputs) {
|
|
48
|
+
for (const channel of output) channel.fill(0);
|
|
49
|
+
}
|
|
50
|
+
const samples = inputs[0]?.[0];
|
|
51
|
+
if (!samples) return true;
|
|
52
|
+
try {
|
|
53
|
+
for (const sample of samples) this.#capture(sample);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
this.#fail(error);
|
|
56
|
+
}
|
|
57
|
+
return !this.#stopped;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#capture(sample) {
|
|
61
|
+
const activity = Math.abs(sample) > this.#activityThreshold;
|
|
62
|
+
if (!this.#active) {
|
|
63
|
+
if (!activity) {
|
|
64
|
+
this.#retainPreRoll(sample);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
this.#active = true;
|
|
68
|
+
this.#beginSegment();
|
|
69
|
+
const oldest = this.#preRoll.length
|
|
70
|
+
? (this.#preRollOffset - this.#preRollLength + this.#preRoll.length) % this.#preRoll.length
|
|
71
|
+
: 0;
|
|
72
|
+
for (let index = 0; index < this.#preRollLength; index += 1) {
|
|
73
|
+
this.#append(this.#preRoll[(oldest + index) % this.#preRoll.length]);
|
|
74
|
+
}
|
|
75
|
+
this.#preRollLength = 0;
|
|
76
|
+
this.#preRollOffset = 0;
|
|
77
|
+
} else if (!this.#buffer) {
|
|
78
|
+
this.#beginSegment();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
this.#append(sample);
|
|
82
|
+
this.#hasActivity = this.#hasActivity || activity;
|
|
83
|
+
this.#quietCount = activity ? 0 : this.#quietCount + 1;
|
|
84
|
+
if (this.#quietCount >= this.#quietFrames) {
|
|
85
|
+
this.#emit('pause');
|
|
86
|
+
this.#active = false;
|
|
87
|
+
this.#quietCount = 0;
|
|
88
|
+
} else if (this.#sampleCount >= this.#chunkFrames && this.#hasActivity) {
|
|
89
|
+
// Continue at the next sample. Emitted audio never enters pre-roll.
|
|
90
|
+
this.#emit('periodic');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
#retainPreRoll(sample) {
|
|
95
|
+
if (!this.#preRoll.length) return;
|
|
96
|
+
this.#preRoll[this.#preRollOffset] = sample;
|
|
97
|
+
this.#preRollOffset = (this.#preRollOffset + 1) % this.#preRoll.length;
|
|
98
|
+
this.#preRollLength = Math.min(this.#preRollLength + 1, this.#preRoll.length);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
#beginSegment() {
|
|
102
|
+
// Preserve all requested pre-roll even when it exceeds chunkMs. After a
|
|
103
|
+
// periodic boundary, retain quiet audio until activity or the pause so
|
|
104
|
+
// a silence-only continuation never produces a clip.
|
|
105
|
+
const frames = Math.max(this.#chunkFrames, this.#quietFrames + 1, this.#preRollLength + 1);
|
|
106
|
+
this.#buffer = new ArrayBuffer(56 + frames * 4);
|
|
107
|
+
this.#view = new DataView(this.#buffer);
|
|
108
|
+
this.#sampleCount = 0;
|
|
109
|
+
this.#hasActivity = false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
#append(sample) {
|
|
113
|
+
// WAV framing owns little-endian Float32 encoding; sample values are
|
|
114
|
+
// unchanged, without integer quantization or amplitude clamping.
|
|
115
|
+
this.#view.setFloat32(56 + this.#sampleCount * 4, sample, true);
|
|
116
|
+
this.#sampleCount += 1;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#emit(reason) {
|
|
120
|
+
if (!this.#hasActivity || !this.#sampleCount) {
|
|
121
|
+
this.#buffer = null;
|
|
122
|
+
this.#view = null;
|
|
123
|
+
this.#sampleCount = 0;
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const dataLength = this.#sampleCount * 4;
|
|
127
|
+
// This is the RIFF transport field's representable range, not a
|
|
128
|
+
// capture-duration policy. The caller can select a shorter chunk.
|
|
129
|
+
if (dataLength > 0xffffffff - 48) {
|
|
130
|
+
throw new RangeError('This segment cannot be represented by RIFF/WAVE; select a shorter chunk duration.');
|
|
131
|
+
}
|
|
132
|
+
this.#writeText(0, 'RIFF');
|
|
133
|
+
this.#view.setUint32(4, 48 + dataLength, true);
|
|
134
|
+
this.#writeText(8, 'WAVE');
|
|
135
|
+
this.#writeText(12, 'fmt ');
|
|
136
|
+
this.#view.setUint32(16, 16, true);
|
|
137
|
+
this.#view.setUint16(20, 3, true);
|
|
138
|
+
this.#view.setUint16(22, 1, true);
|
|
139
|
+
this.#view.setUint32(24, sampleRate, true);
|
|
140
|
+
this.#view.setUint32(28, sampleRate * 4, true);
|
|
141
|
+
this.#view.setUint16(32, 4, true);
|
|
142
|
+
this.#view.setUint16(34, 32, true);
|
|
143
|
+
this.#writeText(36, 'fact');
|
|
144
|
+
this.#view.setUint32(40, 4, true);
|
|
145
|
+
this.#view.setUint32(44, this.#sampleCount, true);
|
|
146
|
+
this.#writeText(48, 'data');
|
|
147
|
+
this.#view.setUint32(52, dataLength, true);
|
|
148
|
+
const audio = this.#sampleCount === (this.#buffer.byteLength - 56) / 4
|
|
149
|
+
? this.#buffer
|
|
150
|
+
: this.#buffer.slice(0, 56 + dataLength);
|
|
151
|
+
this.#sequence += 1;
|
|
152
|
+
this.port.postMessage(
|
|
153
|
+
{
|
|
154
|
+
type: 'segment',
|
|
155
|
+
audio,
|
|
156
|
+
sampleRate,
|
|
157
|
+
sequence: this.#sequence,
|
|
158
|
+
reason,
|
|
159
|
+
durationMs: this.#sampleCount * 1000 / sampleRate
|
|
160
|
+
},
|
|
161
|
+
[audio]
|
|
162
|
+
);
|
|
163
|
+
this.#buffer = null;
|
|
164
|
+
this.#view = null;
|
|
165
|
+
this.#sampleCount = 0;
|
|
166
|
+
this.#hasActivity = false;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
#writeText(offset, text) {
|
|
170
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
171
|
+
this.#view.setUint8(offset + index, text.charCodeAt(index));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
#fail(error) {
|
|
176
|
+
this.#stopped = true;
|
|
177
|
+
this.#release();
|
|
178
|
+
this.port.postMessage(
|
|
179
|
+
{type: 'error', error}
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
#release() {
|
|
184
|
+
this.#buffer = null;
|
|
185
|
+
this.#view = null;
|
|
186
|
+
this.#preRoll = new Float32Array(0);
|
|
187
|
+
this.#preRollLength = 0;
|
|
188
|
+
this.#sampleCount = 0;
|
|
189
|
+
this.#hasActivity = false;
|
|
190
|
+
this.#active = false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
registerProcessor('arcane-continuous-voice-capture', ArcaneVoiceCaptureProcessor);
|