arcane-os 0.34.2 → 0.36.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/docs/reference/inventory/runtime-components.json +13 -6
- package/docs/reference/inventory/runtime-modules.json +29 -5
- package/docs/reference/runtime-components.md +202 -22
- package/docs/reference/runtime-modules.md +142 -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/modules/ComponentContracts.js +32 -1
- package/runtime/arcane/modules/ContinuousVoiceCapture.js +446 -0
- package/runtime/arcane/modules/DBOPFS.js +212 -148
- package/runtime/arcane/modules/VoiceCaptureWorklet.js +194 -0
|
@@ -1049,165 +1049,194 @@ class DBOPFS {
|
|
|
1049
1049
|
}
|
|
1050
1050
|
|
|
1051
1051
|
/**
|
|
1052
|
-
*
|
|
1052
|
+
* Creates a PNG backup without downloading or writing database records.
|
|
1053
|
+
* JSON is deflated and framed with the existing little-endian payload
|
|
1054
|
+
* length, then encoded in RGB channels with opaque alpha for restoration.
|
|
1053
1055
|
*
|
|
1054
|
-
*
|
|
1055
|
-
*
|
|
1056
|
-
*
|
|
1057
|
-
* Each
|
|
1058
|
-
*
|
|
1059
|
-
*
|
|
1060
|
-
*
|
|
1061
|
-
*
|
|
1062
|
-
*
|
|
1063
|
-
*
|
|
1064
|
-
* @param {string} name
|
|
1065
|
-
* Base name used for the generated backup filename.
|
|
1066
|
-
*
|
|
1067
|
-
* @returns {Promise<void>}
|
|
1056
|
+
* @param {Object} options
|
|
1057
|
+
* @param {string[]} [options.tableNames] Saved tables; omitted selects all.
|
|
1058
|
+
* @param {Object<string,Object<string,*>>} [options.additionalTables]
|
|
1059
|
+
* Caller-owned tables. Each replaces the matching saved table in this
|
|
1060
|
+
* export only. Values must be JSON-serializable; no storage write occurs.
|
|
1061
|
+
* @param {AbortSignal} [options.signal]
|
|
1062
|
+
* Stops further preparation and prevents a subsequent download. Browser
|
|
1063
|
+
* file reads and canvas encoding already in progress cannot be interrupted.
|
|
1064
|
+
* @returns {Promise<Blob>} An image/png Blob in the existing backup format.
|
|
1068
1065
|
*/
|
|
1069
|
-
async
|
|
1070
|
-
|
|
1071
|
-
|
|
1066
|
+
async createCompressedPNG({tableNames, additionalTables = {}, signal} = {}) {
|
|
1067
|
+
signal?.throwIfAborted();
|
|
1068
|
+
if (!('CompressionStream' in window)) {
|
|
1069
|
+
throw new Error('CompressionStream not supported.');
|
|
1072
1070
|
}
|
|
1073
1071
|
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
const jsonStream=new ReadableStream({
|
|
1080
|
-
start:async controller=>{
|
|
1081
|
-
controller.enqueue(encoder.encode('{'))
|
|
1072
|
+
await this.readyPromise;
|
|
1073
|
+
signal?.throwIfAborted();
|
|
1074
|
+
await this.getTableNames(true);
|
|
1075
|
+
signal?.throwIfAborted();
|
|
1082
1076
|
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
if(t>0){
|
|
1088
|
-
controller.enqueue(encoder.encode(','))
|
|
1089
|
-
}
|
|
1090
|
-
|
|
1091
|
-
controller.enqueue(
|
|
1092
|
-
encoder.encode(`${JSON.stringify(tableName)}:{`)
|
|
1093
|
-
)
|
|
1094
|
-
|
|
1095
|
-
let first=true
|
|
1077
|
+
function logicalTableName(name) {
|
|
1078
|
+
return tableNameForDirectory(directoryNameForTable(name));
|
|
1079
|
+
}
|
|
1096
1080
|
|
|
1097
|
-
|
|
1098
|
-
|
|
1081
|
+
const selectedNames = new Set(
|
|
1082
|
+
(tableNames === undefined ? Object.keys(this.#tableHandles) : tableNames)
|
|
1083
|
+
.map(logicalTableName)
|
|
1084
|
+
);
|
|
1085
|
+
const suppliedTables = new Map();
|
|
1086
|
+
for (const [name, records] of Object.entries(additionalTables)) {
|
|
1087
|
+
const tableName = logicalTableName(name);
|
|
1088
|
+
selectedNames.add(tableName);
|
|
1089
|
+
suppliedTables.set(tableName, records);
|
|
1090
|
+
}
|
|
1099
1091
|
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
}
|
|
1092
|
+
const database = this;
|
|
1093
|
+
const encoder = new TextEncoder();
|
|
1103
1094
|
|
|
1104
|
-
|
|
1095
|
+
async function* tableRecords(tableName) {
|
|
1096
|
+
if (suppliedTables.has(tableName)) {
|
|
1097
|
+
yield* Object.entries(suppliedTables.get(tableName));
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1105
1100
|
|
|
1106
|
-
|
|
1101
|
+
const table = database.#tableHandles[tableName];
|
|
1102
|
+
if (!table) return;
|
|
1103
|
+
for await (const [fileName] of table.entries()) {
|
|
1104
|
+
signal?.throwIfAborted();
|
|
1105
|
+
const value = await database.get(tableName, fileName);
|
|
1106
|
+
signal?.throwIfAborted();
|
|
1107
|
+
yield [fileName, value];
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1107
1110
|
|
|
1108
|
-
|
|
1111
|
+
async function* encodeDatabase() {
|
|
1112
|
+
yield encoder.encode('{');
|
|
1113
|
+
let firstTable = true;
|
|
1114
|
+
for (const tableName of selectedNames) {
|
|
1115
|
+
signal?.throwIfAborted();
|
|
1116
|
+
if (!firstTable) yield encoder.encode(',');
|
|
1117
|
+
firstTable = false;
|
|
1118
|
+
yield encoder.encode(`${JSON.stringify(tableName)}:{`);
|
|
1119
|
+
|
|
1120
|
+
let firstRecord = true;
|
|
1121
|
+
for await (const [fileName, value] of tableRecords(tableName)) {
|
|
1122
|
+
signal?.throwIfAborted();
|
|
1123
|
+
if (!firstRecord) yield encoder.encode(',');
|
|
1124
|
+
firstRecord = false;
|
|
1125
|
+
const serialized = JSON.stringify(value);
|
|
1126
|
+
if (serialized === undefined) {
|
|
1127
|
+
throw new TypeError(`PNG record ${tableName}/${fileName} is not JSON-serializable.`);
|
|
1109
1128
|
}
|
|
1110
|
-
|
|
1111
|
-
controller.enqueue(encoder.encode('}'))
|
|
1129
|
+
yield encoder.encode(`${JSON.stringify(fileName)}:${serialized}`);
|
|
1112
1130
|
}
|
|
1113
|
-
|
|
1114
|
-
controller.enqueue(encoder.encode('}'))
|
|
1115
|
-
controller.close()
|
|
1116
|
-
}
|
|
1117
|
-
})
|
|
1118
|
-
|
|
1119
|
-
const gzipStream=jsonStream.pipeThrough(
|
|
1120
|
-
new CompressionStream('deflate')
|
|
1121
|
-
)
|
|
1122
|
-
|
|
1123
|
-
const reader=gzipStream.getReader()
|
|
1124
|
-
|
|
1125
|
-
const chunks=[]
|
|
1126
|
-
let totalLength=0
|
|
1127
|
-
|
|
1128
|
-
while(true){
|
|
1129
|
-
const {done,value}=await reader.read()
|
|
1130
|
-
|
|
1131
|
-
if(done){
|
|
1132
|
-
break
|
|
1131
|
+
yield encoder.encode('}');
|
|
1133
1132
|
}
|
|
1134
|
-
|
|
1135
|
-
chunks.push(value)
|
|
1136
|
-
totalLength+=value.length
|
|
1133
|
+
yield encoder.encode('}');
|
|
1137
1134
|
}
|
|
1138
1135
|
|
|
1139
|
-
const
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1136
|
+
const encoded = encodeDatabase();
|
|
1137
|
+
const jsonStream = new ReadableStream(
|
|
1138
|
+
{
|
|
1139
|
+
async pull(controller) {
|
|
1140
|
+
signal?.throwIfAborted();
|
|
1141
|
+
const {done, value} = await encoded.next();
|
|
1142
|
+
if (done) controller.close();
|
|
1143
|
+
else controller.enqueue(value);
|
|
1144
|
+
},
|
|
1145
|
+
async cancel() {
|
|
1146
|
+
await encoded.return();
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
);
|
|
1150
|
+
const compressedStream = jsonStream.pipeThrough(
|
|
1151
|
+
new CompressionStream('deflate'),
|
|
1152
|
+
{signal}
|
|
1153
|
+
);
|
|
1154
|
+
const reader = compressedStream.getReader();
|
|
1155
|
+
const chunks = [];
|
|
1156
|
+
let totalLength = 0;
|
|
1157
|
+
try {
|
|
1158
|
+
while (true) {
|
|
1159
|
+
const {done, value} = await reader.read();
|
|
1160
|
+
signal?.throwIfAborted();
|
|
1161
|
+
if (done) break;
|
|
1162
|
+
chunks.push(value);
|
|
1163
|
+
totalLength += value.length;
|
|
1164
|
+
}
|
|
1165
|
+
} finally {
|
|
1166
|
+
reader.releaseLock();
|
|
1145
1167
|
}
|
|
1146
1168
|
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
const view=new DataView(payload.buffer)
|
|
1150
|
-
view.setUint32(0,totalLength,true)
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
const totalPixels=size*size
|
|
1157
|
-
|
|
1158
|
-
if(totalPixels*3 < payload.length){
|
|
1159
|
-
throw new Error('PNG canvas too small for payload.')
|
|
1169
|
+
// Length and channel offsets belong only to the existing PNG format.
|
|
1170
|
+
const payload = new Uint8Array(totalLength + 4);
|
|
1171
|
+
const view = new DataView(payload.buffer);
|
|
1172
|
+
view.setUint32(0, totalLength, true);
|
|
1173
|
+
let offset = 4;
|
|
1174
|
+
for (const chunk of chunks) {
|
|
1175
|
+
payload.set(chunk, offset);
|
|
1176
|
+
offset += chunk.length;
|
|
1160
1177
|
}
|
|
1161
1178
|
|
|
1162
|
-
const
|
|
1163
|
-
canvas.
|
|
1164
|
-
canvas.
|
|
1165
|
-
|
|
1166
|
-
const ctx=canvas.getContext('2d')
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
throw new Error('Canvas 2D context unavailable.')
|
|
1179
|
+
const size = Math.ceil(Math.sqrt(payload.length / 3));
|
|
1180
|
+
const canvas = document.createElement('canvas');
|
|
1181
|
+
canvas.width = size;
|
|
1182
|
+
canvas.height = size;
|
|
1183
|
+
const ctx = canvas.getContext('2d');
|
|
1184
|
+
if (!ctx) {
|
|
1185
|
+
throw new Error('Canvas 2D context unavailable.');
|
|
1170
1186
|
}
|
|
1171
1187
|
|
|
1172
|
-
const imgData=ctx.createImageData(size,size)
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
imgData.data[i+1] = payload[p+1] ?? 0;
|
|
1181
|
-
imgData.data[i+2] = payload[p+2] ?? 0;
|
|
1182
|
-
imgData.data[i+3] = 255;
|
|
1183
|
-
|
|
1184
|
-
p+=3;
|
|
1188
|
+
const imgData = ctx.createImageData(size, size);
|
|
1189
|
+
let position = 0;
|
|
1190
|
+
for (let index = 0; index < imgData.data.length; index += 4) {
|
|
1191
|
+
imgData.data[index] = payload[position] ?? 0;
|
|
1192
|
+
imgData.data[index + 1] = payload[position + 1] ?? 0;
|
|
1193
|
+
imgData.data[index + 2] = payload[position + 2] ?? 0;
|
|
1194
|
+
imgData.data[index + 3] = 255;
|
|
1195
|
+
position += 3;
|
|
1185
1196
|
}
|
|
1197
|
+
ctx.putImageData(imgData, 0, 0);
|
|
1186
1198
|
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1199
|
+
const blob = await new Promise(
|
|
1200
|
+
function encodeBackupPNG(resolve) {
|
|
1201
|
+
canvas.toBlob(resolve, 'image/png');
|
|
1202
|
+
}
|
|
1203
|
+
);
|
|
1204
|
+
signal?.throwIfAborted();
|
|
1205
|
+
if (!blob) {
|
|
1206
|
+
throw new Error('PNG export failed.');
|
|
1193
1207
|
}
|
|
1208
|
+
return blob;
|
|
1209
|
+
}
|
|
1194
1210
|
|
|
1195
|
-
|
|
1211
|
+
/**
|
|
1212
|
+
* Downloads one PNG backup using the same options as createCompressedPNG.
|
|
1213
|
+
* Existing one-argument callers still export the whole database.
|
|
1214
|
+
* @param {string} name Base filename; an ISO timestamp is appended.
|
|
1215
|
+
* @param {Object} options PNG table selection and optional signal.
|
|
1216
|
+
* @returns {Promise<void>}
|
|
1217
|
+
*/
|
|
1218
|
+
async downloadCompressedPNG(name = 'DBOPFS-backup', options = {}) {
|
|
1219
|
+
const blob = await this.createCompressedPNG(options);
|
|
1220
|
+
options.signal?.throwIfAborted();
|
|
1221
|
+
const stamp = new Date()
|
|
1196
1222
|
.toISOString()
|
|
1197
|
-
.slice(0,19)
|
|
1198
|
-
.replace(/[:T]/g,'-')
|
|
1199
|
-
|
|
1200
|
-
const url=URL.createObjectURL(blob)
|
|
1201
|
-
|
|
1202
|
-
const a=document.createElement('a')
|
|
1203
|
-
a.href=url
|
|
1204
|
-
a.download
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1223
|
+
.slice(0, 19)
|
|
1224
|
+
.replace(/[:T]/g, '-');
|
|
1225
|
+
|
|
1226
|
+
const url = URL.createObjectURL(blob);
|
|
1227
|
+
|
|
1228
|
+
const a = document.createElement('a');
|
|
1229
|
+
a.href = url;
|
|
1230
|
+
a.download = `${name}-${stamp}.png`;
|
|
1231
|
+
|
|
1232
|
+
try {
|
|
1233
|
+
document.body.appendChild(a);
|
|
1234
|
+
options.signal?.throwIfAborted();
|
|
1235
|
+
a.click();
|
|
1236
|
+
} finally {
|
|
1237
|
+
a.remove();
|
|
1238
|
+
URL.revokeObjectURL(url);
|
|
1239
|
+
}
|
|
1211
1240
|
}
|
|
1212
1241
|
|
|
1213
1242
|
/**
|
|
@@ -1229,30 +1258,39 @@ class DBOPFS {
|
|
|
1229
1258
|
*
|
|
1230
1259
|
* @param {File|Blob} file
|
|
1231
1260
|
* PNG backup file generated by DBOPFS.
|
|
1261
|
+
* @param {Object} options
|
|
1262
|
+
* @param {function(Object): Object|Promise<Object>} [options.selectTables]
|
|
1263
|
+
* Receives the complete decoded table/file map once, before writes. Only
|
|
1264
|
+
* its returned map is restored; the application owns selection/projection.
|
|
1232
1265
|
*
|
|
1233
1266
|
* @returns {Promise<void>}
|
|
1234
|
-
* Resolves
|
|
1267
|
+
* Resolves after every write succeeds. Rejected writes are collected after
|
|
1268
|
+
* all table batches settle and reported in an AggregateError with the
|
|
1269
|
+
* original reasons and table/file associations; successful writes remain.
|
|
1235
1270
|
*/
|
|
1236
|
-
async restoreFromPNG(file){
|
|
1271
|
+
async restoreFromPNG(file, {selectTables} = {}) {
|
|
1237
1272
|
const img=await createImageBitmap(
|
|
1238
1273
|
file,
|
|
1239
1274
|
{premultiplyAlpha:'none'}
|
|
1240
1275
|
)
|
|
1241
1276
|
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1277
|
+
let data;
|
|
1278
|
+
try {
|
|
1279
|
+
const canvas = document.createElement('canvas');
|
|
1280
|
+
canvas.width = img.width;
|
|
1281
|
+
canvas.height = img.height;
|
|
1245
1282
|
|
|
1246
|
-
|
|
1283
|
+
const ctx = canvas.getContext('2d');
|
|
1284
|
+
if (!ctx) {
|
|
1285
|
+
throw new Error('Canvas 2D context unavailable.');
|
|
1286
|
+
}
|
|
1247
1287
|
|
|
1248
|
-
|
|
1249
|
-
|
|
1288
|
+
ctx.drawImage(img, 0, 0);
|
|
1289
|
+
data = ctx.getImageData(0, 0, img.width, img.height).data;
|
|
1290
|
+
} finally {
|
|
1291
|
+
img.close();
|
|
1250
1292
|
}
|
|
1251
1293
|
|
|
1252
|
-
ctx.drawImage(img,0,0)
|
|
1253
|
-
|
|
1254
|
-
const data=ctx.getImageData(0,0,img.width,img.height).data
|
|
1255
|
-
|
|
1256
1294
|
if(data.length < 4){
|
|
1257
1295
|
throw new Error('Invalid PNG backup.')
|
|
1258
1296
|
}
|
|
@@ -1283,12 +1321,38 @@ class DBOPFS {
|
|
|
1283
1321
|
|
|
1284
1322
|
const json=await new Response(stream).text()
|
|
1285
1323
|
|
|
1286
|
-
const
|
|
1324
|
+
const decodedTables = JSON.parse(json);
|
|
1325
|
+
const db = selectTables === undefined
|
|
1326
|
+
? decodedTables
|
|
1327
|
+
: await selectTables(decodedTables);
|
|
1287
1328
|
|
|
1288
1329
|
const tables=Object.keys(db)
|
|
1289
1330
|
|
|
1290
|
-
|
|
1291
|
-
|
|
1331
|
+
const failures = [];
|
|
1332
|
+
for (const tableName of tables) {
|
|
1333
|
+
const fileNames = Object.keys(db[tableName]);
|
|
1334
|
+
const results = await this.setMany(tableName, db[tableName]);
|
|
1335
|
+
for (const [index, result] of results.entries()) {
|
|
1336
|
+
if (result.status === 'rejected') {
|
|
1337
|
+
failures.push(
|
|
1338
|
+
{tableName, fileName: fileNames[index], reason: result.reason}
|
|
1339
|
+
);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
if (failures.length > 0) {
|
|
1345
|
+
const error = new AggregateError(
|
|
1346
|
+
failures.map(
|
|
1347
|
+
function restoreWriteReason(failure) {
|
|
1348
|
+
return failure.reason;
|
|
1349
|
+
}
|
|
1350
|
+
),
|
|
1351
|
+
'PNG restore could not save every record.'
|
|
1352
|
+
);
|
|
1353
|
+
error.code = 'DBOPFS_RESTORE_WRITE_FAILED';
|
|
1354
|
+
error.failures = failures;
|
|
1355
|
+
throw error;
|
|
1292
1356
|
}
|
|
1293
1357
|
}
|
|
1294
1358
|
}
|
|
@@ -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);
|