@remnic/capture-audio 9.54.1 → 9.54.3
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/dist/{chunk-BRVXKUZY.js → chunk-MFWH245M.js} +603 -87
- package/dist/chunk-MFWH245M.js.map +1 -0
- package/dist/cli-bin.js +1 -1
- package/dist/index.d.ts +136 -6
- package/dist/index.js +1 -1
- package/package.json +5 -5
- package/dist/chunk-BRVXKUZY.js.map +0 -1
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
var CAPTURE_AUDIO_VERSION = "9.14.0";
|
|
5
5
|
var DEFAULT_HOST = "127.0.0.1";
|
|
6
6
|
var DEFAULT_PORT = 4340;
|
|
7
|
-
var SPOOL_SCHEMA_VERSION =
|
|
7
|
+
var SPOOL_SCHEMA_VERSION = 2;
|
|
8
8
|
var MAX_CONVERSATIONS_LIMIT = 500;
|
|
9
9
|
var DEFAULT_CONVERSATIONS_LIMIT = 50;
|
|
10
10
|
|
|
@@ -128,6 +128,7 @@ function defaultDaemonConfig() {
|
|
|
128
128
|
threshold: 0.5,
|
|
129
129
|
threads: 1
|
|
130
130
|
},
|
|
131
|
+
reorderWindowSeconds: 60,
|
|
131
132
|
diarization: { similarityThreshold: 0.4 },
|
|
132
133
|
stt: { engine: "whisper-cpp", modelPath: null, threads: null },
|
|
133
134
|
denyApps: [],
|
|
@@ -140,6 +141,7 @@ var KNOWN_TOP_KEYS = {
|
|
|
140
141
|
chunkSeconds: true,
|
|
141
142
|
captureChannel: true,
|
|
142
143
|
conversationGapMinutes: true,
|
|
144
|
+
reorderWindowSeconds: true,
|
|
143
145
|
rawRetentionHours: true,
|
|
144
146
|
spoolRetentionDays: true,
|
|
145
147
|
vad: true,
|
|
@@ -195,6 +197,12 @@ function parseDaemonConfig(raw) {
|
|
|
195
197
|
if (obj.spoolRetentionDays !== void 0) {
|
|
196
198
|
cfg.spoolRetentionDays = coerceNumber(obj.spoolRetentionDays, "spoolRetentionDays", { integer: true, min: 1 });
|
|
197
199
|
}
|
|
200
|
+
if (obj.reorderWindowSeconds !== void 0) {
|
|
201
|
+
cfg.reorderWindowSeconds = coerceNumber(obj.reorderWindowSeconds, "reorderWindowSeconds", {
|
|
202
|
+
min: 0,
|
|
203
|
+
max: 3600
|
|
204
|
+
});
|
|
205
|
+
}
|
|
198
206
|
if (obj.vad !== void 0) {
|
|
199
207
|
const vad = asObject(obj.vad, "vad");
|
|
200
208
|
warnUnknownKeys(
|
|
@@ -947,7 +955,8 @@ CREATE TABLE IF NOT EXISTS segments (
|
|
|
947
955
|
text TEXT NOT NULL,
|
|
948
956
|
start_utc TEXT NOT NULL,
|
|
949
957
|
end_utc TEXT NOT NULL,
|
|
950
|
-
ordinal INTEGER NOT NULL DEFAULT 0
|
|
958
|
+
ordinal INTEGER NOT NULL DEFAULT 0,
|
|
959
|
+
embedding BLOB
|
|
951
960
|
);
|
|
952
961
|
CREATE TABLE IF NOT EXISTS speaker_clusters (
|
|
953
962
|
id TEXT PRIMARY KEY,
|
|
@@ -965,6 +974,20 @@ CREATE TABLE IF NOT EXISTS applied_chunks (
|
|
|
965
974
|
CREATE INDEX IF NOT EXISTS idx_conv_keyset ON conversations(started_at_utc, id);
|
|
966
975
|
CREATE INDEX IF NOT EXISTS idx_seg_conv ON segments(conversation_id, ordinal);
|
|
967
976
|
`;
|
|
977
|
+
function encodeEmbedding(embedding) {
|
|
978
|
+
if (!embedding || embedding.length === 0) return null;
|
|
979
|
+
return Buffer.from(JSON.stringify(embedding));
|
|
980
|
+
}
|
|
981
|
+
function decodeEmbedding(blob) {
|
|
982
|
+
if (!blob || blob.byteLength === 0) return null;
|
|
983
|
+
try {
|
|
984
|
+
const parsed = JSON.parse(Buffer.from(blob).toString("utf8"));
|
|
985
|
+
if (!Array.isArray(parsed)) return null;
|
|
986
|
+
return parsed.every((n) => typeof n === "number" && Number.isFinite(n)) ? parsed : null;
|
|
987
|
+
} catch {
|
|
988
|
+
return null;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
968
991
|
var ISO_INSTANT = /^(\d{4})-(\d{2})-(\d{2})T\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:\d{2})$/;
|
|
969
992
|
function assertIsoInstant(value, label) {
|
|
970
993
|
const match = typeof value === "string" ? ISO_INSTANT.exec(value) : null;
|
|
@@ -1008,7 +1031,25 @@ var Spool = class {
|
|
|
1008
1031
|
} catch {
|
|
1009
1032
|
}
|
|
1010
1033
|
}
|
|
1011
|
-
this.#db.prepare("
|
|
1034
|
+
const storedVersion = this.#db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
|
|
1035
|
+
if (storedVersion?.value !== void 0) {
|
|
1036
|
+
const parsed = Number(storedVersion.value);
|
|
1037
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
1038
|
+
throw new CaptureConfigError(
|
|
1039
|
+
`spool schema_version is malformed: ${JSON.stringify(storedVersion.value)}`
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1042
|
+
if (parsed > SPOOL_SCHEMA_VERSION) {
|
|
1043
|
+
throw new CaptureConfigError(
|
|
1044
|
+
`spool schema_version ${parsed} is newer than this build supports (${SPOOL_SCHEMA_VERSION})`
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
const segmentColumns = this.#db.prepare("PRAGMA table_info(segments)").all();
|
|
1049
|
+
if (!segmentColumns.some((column) => column.name === "embedding")) {
|
|
1050
|
+
this.#db.exec("ALTER TABLE segments ADD COLUMN embedding BLOB");
|
|
1051
|
+
}
|
|
1052
|
+
this.#db.prepare("INSERT INTO meta(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run("schema_version", String(SPOOL_SCHEMA_VERSION));
|
|
1012
1053
|
this.#db.prepare("INSERT OR IGNORE INTO meta(key, value) VALUES (?, ?)").run("instance_id", ulid());
|
|
1013
1054
|
}
|
|
1014
1055
|
close() {
|
|
@@ -1077,7 +1118,7 @@ var Spool = class {
|
|
|
1077
1118
|
"INSERT INTO conversations(id, started_at_utc, ended_at_utc, state, segment_count) VALUES (?,?,?,?,?)"
|
|
1078
1119
|
).run(convId, startedAtUtc, endedAtUtc, state, segments.length);
|
|
1079
1120
|
const segStmt = db.prepare(
|
|
1080
|
-
"INSERT INTO segments(id, chunk_id, conversation_id, speaker_cluster, is_wearer, channel, text, start_utc, end_utc, ordinal) VALUES (
|
|
1121
|
+
"INSERT INTO segments(id, chunk_id, conversation_id, speaker_cluster, is_wearer, channel, text, start_utc, end_utc, ordinal, embedding) VALUES (?,?,?,?,?,?,?,?,?,?,?)"
|
|
1081
1122
|
);
|
|
1082
1123
|
for (let i = 0; i < segments.length; i++) {
|
|
1083
1124
|
const seg = segments[i];
|
|
@@ -1091,7 +1132,8 @@ var Spool = class {
|
|
|
1091
1132
|
seg.text,
|
|
1092
1133
|
seg.startUtc,
|
|
1093
1134
|
seg.endUtc,
|
|
1094
|
-
i
|
|
1135
|
+
i,
|
|
1136
|
+
encodeEmbedding(seg.embedding)
|
|
1095
1137
|
);
|
|
1096
1138
|
}
|
|
1097
1139
|
db.exec("COMMIT");
|
|
@@ -1166,7 +1208,7 @@ var Spool = class {
|
|
|
1166
1208
|
const ordinalRow = db.prepare("SELECT COALESCE(MAX(ordinal), -1) + 1 AS n FROM segments WHERE conversation_id = ?").get(convId);
|
|
1167
1209
|
const nextOrdinal = Number(ordinalRow.n);
|
|
1168
1210
|
const segStmt = db.prepare(
|
|
1169
|
-
"INSERT INTO segments(id, chunk_id, conversation_id, speaker_cluster, is_wearer, channel, text, start_utc, end_utc, ordinal) VALUES (
|
|
1211
|
+
"INSERT INTO segments(id, chunk_id, conversation_id, speaker_cluster, is_wearer, channel, text, start_utc, end_utc, ordinal, embedding) VALUES (?,?,?,?,?,?,?,?,?,?,?)"
|
|
1170
1212
|
);
|
|
1171
1213
|
for (let i = 0; i < segments.length; i++) {
|
|
1172
1214
|
const seg = segments[i];
|
|
@@ -1180,7 +1222,8 @@ var Spool = class {
|
|
|
1180
1222
|
seg.text,
|
|
1181
1223
|
seg.startUtc,
|
|
1182
1224
|
seg.endUtc,
|
|
1183
|
-
nextOrdinal + i
|
|
1225
|
+
nextOrdinal + i,
|
|
1226
|
+
encodeEmbedding(seg.embedding)
|
|
1184
1227
|
);
|
|
1185
1228
|
}
|
|
1186
1229
|
db.prepare(
|
|
@@ -1251,11 +1294,129 @@ var Spool = class {
|
|
|
1251
1294
|
}
|
|
1252
1295
|
return removed;
|
|
1253
1296
|
}
|
|
1297
|
+
/**
|
|
1298
|
+
* Segments of one conversation that still need a speaker, chronological.
|
|
1299
|
+
*
|
|
1300
|
+
* Only rows with a stored embedding and no cluster yet: clustering runs at
|
|
1301
|
+
* finalize over the segments that SURVIVED dedup (issue #2145), and skipping
|
|
1302
|
+
* already-assigned rows keeps a repeated finalize from double-counting a
|
|
1303
|
+
* centroid.
|
|
1304
|
+
*/
|
|
1305
|
+
conversationSegmentsForDiarization(conversationId) {
|
|
1306
|
+
const rows = this.#db.prepare(
|
|
1307
|
+
"SELECT id, channel, embedding FROM segments WHERE conversation_id = ? AND embedding IS NOT NULL AND speaker_cluster IS NULL ORDER BY start_utc ASC, ordinal ASC, id ASC"
|
|
1308
|
+
).all(conversationId);
|
|
1309
|
+
const out = [];
|
|
1310
|
+
for (const row of rows) {
|
|
1311
|
+
const embedding = decodeEmbedding(row.embedding);
|
|
1312
|
+
if (embedding !== null) out.push({ id: row.id, channel: row.channel, embedding });
|
|
1313
|
+
}
|
|
1314
|
+
return out;
|
|
1315
|
+
}
|
|
1316
|
+
/**
|
|
1317
|
+
* Commit one conversation's diarization: cluster snapshots and the segment
|
|
1318
|
+
* assignments that produced them, in ONE transaction.
|
|
1319
|
+
*
|
|
1320
|
+
* Splitting the two lets a crash persist an updated `embedding_count` while
|
|
1321
|
+
* its segments stay unassigned; the next finalize would select the same rows
|
|
1322
|
+
* and count the same embeddings again (issue #2145). Atomicity is what makes
|
|
1323
|
+
* the repeated-finalize idempotency claim true.
|
|
1324
|
+
*/
|
|
1325
|
+
commitDiarization(input) {
|
|
1326
|
+
if (input.assignments.length === 0 && input.clusters.length === 0) return 0;
|
|
1327
|
+
const stmt = this.#db.prepare("UPDATE segments SET speaker_cluster = ?, is_wearer = ? WHERE id = ?");
|
|
1328
|
+
let updated = 0;
|
|
1329
|
+
this.#db.exec("BEGIN");
|
|
1330
|
+
try {
|
|
1331
|
+
for (const cluster of input.clusters) this.#upsertSpeakerUnlocked(cluster);
|
|
1332
|
+
for (const assignment of input.assignments) {
|
|
1333
|
+
updated += Number(stmt.run(assignment.speakerCluster, assignment.isWearer ? 1 : 0, assignment.id).changes);
|
|
1334
|
+
}
|
|
1335
|
+
this.#db.exec("COMMIT");
|
|
1336
|
+
} catch (err) {
|
|
1337
|
+
this.#db.exec("ROLLBACK");
|
|
1338
|
+
throw err;
|
|
1339
|
+
}
|
|
1340
|
+
return updated;
|
|
1341
|
+
}
|
|
1254
1342
|
/** Ids of every still-`capturing` conversation (dedup-before-finalize sweep). */
|
|
1255
1343
|
capturingConversationIds() {
|
|
1256
1344
|
const rows = this.#db.prepare("SELECT id FROM conversations WHERE state = 'capturing' ORDER BY id ASC").all();
|
|
1257
1345
|
return rows.map((r) => r.id);
|
|
1258
1346
|
}
|
|
1347
|
+
/**
|
|
1348
|
+
* Record a bare idempotency marker (no segments).
|
|
1349
|
+
*
|
|
1350
|
+
* Used to persist facts a later replay cannot re-derive — such as how many
|
|
1351
|
+
* segments a chunk's transcript produced, which is the only way to tell a
|
|
1352
|
+
* legitimately shorter retranscription from a missing tail (issue #2145).
|
|
1353
|
+
*/
|
|
1354
|
+
markApplied(idempotencyKey, conversationId) {
|
|
1355
|
+
this.#db.prepare("INSERT OR IGNORE INTO applied_chunks(idempotency_key, conversation_id, applied_at_utc) VALUES (?,?,?)").run(idempotencyKey, conversationId, (/* @__PURE__ */ new Date()).toISOString());
|
|
1356
|
+
}
|
|
1357
|
+
/**
|
|
1358
|
+
* Whether ANY idempotency key for this chunk was applied.
|
|
1359
|
+
*
|
|
1360
|
+
* Only a SILENT replay needs this — a chunk partially applied by a binary
|
|
1361
|
+
* predating the transcript manifest has no manifest to compare, and a
|
|
1362
|
+
* zero-segment replay has no per-segment key to look up exactly. Speech
|
|
1363
|
+
* chunks use the indexed manifest lookup below, so continuous capture never
|
|
1364
|
+
* pays for this scan (issue #2145).
|
|
1365
|
+
*/
|
|
1366
|
+
hasAppliedChunkPrefix(chunkIdPrefix) {
|
|
1367
|
+
const escaped = chunkIdPrefix.replace(/[\\%_]/g, "\\$&");
|
|
1368
|
+
const row = this.#db.prepare("SELECT 1 AS present FROM applied_chunks WHERE idempotency_key LIKE ? ESCAPE '\\' LIMIT 1").get(`${escaped}%`);
|
|
1369
|
+
return row?.present === 1;
|
|
1370
|
+
}
|
|
1371
|
+
/**
|
|
1372
|
+
* Conversations a chunk actually contributed stored segments to.
|
|
1373
|
+
*
|
|
1374
|
+
* Used to scope a rebuilt replay hold to the prefix that chunk belongs to,
|
|
1375
|
+
* rather than to every conversation that happens to be capturing (#2145).
|
|
1376
|
+
* Matches the bare chunk id and every per-segment or per-group derivative
|
|
1377
|
+
* (`<chunkId>:h<hash>`, and the pre-manifest `<chunkId>:<n>`).
|
|
1378
|
+
*/
|
|
1379
|
+
conversationIdsForChunk(chunkId) {
|
|
1380
|
+
const escaped = chunkId.replace(/[\\%_]/g, "\\$&");
|
|
1381
|
+
const rows = this.#db.prepare(
|
|
1382
|
+
`SELECT DISTINCT conversation_id AS conversationId
|
|
1383
|
+
FROM segments
|
|
1384
|
+
WHERE conversation_id IS NOT NULL
|
|
1385
|
+
AND (chunk_id = ? OR chunk_id LIKE ? ESCAPE '\\')
|
|
1386
|
+
ORDER BY conversation_id`
|
|
1387
|
+
).all(chunkId, `${escaped}:%`);
|
|
1388
|
+
return rows.map((row) => row.conversationId);
|
|
1389
|
+
}
|
|
1390
|
+
/**
|
|
1391
|
+
* Chunks whose transcript manifest is recorded but which never completed.
|
|
1392
|
+
*
|
|
1393
|
+
* A restart loses the in-memory record of which chunks are still awaiting a
|
|
1394
|
+
* replay, so it is re-derived from these two durable markers: the manifest is
|
|
1395
|
+
* written before any append, `:done` only after every segment is stored
|
|
1396
|
+
* (issue #2145).
|
|
1397
|
+
*/
|
|
1398
|
+
incompleteChunkIds() {
|
|
1399
|
+
const rows = this.#db.prepare(
|
|
1400
|
+
`SELECT substr(idempotency_key, 1, length(idempotency_key) - 9) AS chunkId
|
|
1401
|
+
FROM applied_chunks
|
|
1402
|
+
WHERE idempotency_key LIKE '%:manifest'
|
|
1403
|
+
AND substr(idempotency_key, 1, length(idempotency_key) - 9) || ':done' NOT IN (
|
|
1404
|
+
SELECT idempotency_key FROM applied_chunks
|
|
1405
|
+
)`
|
|
1406
|
+
).all();
|
|
1407
|
+
return rows.map((row) => row.chunkId);
|
|
1408
|
+
}
|
|
1409
|
+
/**
|
|
1410
|
+
* The value stored alongside an idempotency marker, or `undefined`.
|
|
1411
|
+
*
|
|
1412
|
+
* `markApplied` uses this column to carry a fact a replay cannot re-derive —
|
|
1413
|
+
* the chunk's transcript manifest hash — and this is the exact, primary-key
|
|
1414
|
+
* lookup that reads it back (issue #2145).
|
|
1415
|
+
*/
|
|
1416
|
+
appliedChunkValue(idempotencyKey) {
|
|
1417
|
+
const row = this.#db.prepare("SELECT conversation_id AS conversationId FROM applied_chunks WHERE idempotency_key = ?").get(idempotencyKey);
|
|
1418
|
+
return row?.conversationId;
|
|
1419
|
+
}
|
|
1259
1420
|
/** Whether a chunk with this idempotency key was already durably applied. */
|
|
1260
1421
|
isChunkApplied(idempotencyKey) {
|
|
1261
1422
|
return this.#db.prepare("SELECT 1 FROM applied_chunks WHERE idempotency_key = ? LIMIT 1").get(idempotencyKey) !== void 0;
|
|
@@ -1280,11 +1441,23 @@ var Spool = class {
|
|
|
1280
1441
|
if (!row) return null;
|
|
1281
1442
|
return { id: row.id, startedAtUtc: row.startedAtUtc, endedAtUtc: row.endedAtUtc ?? row.startedAtUtc };
|
|
1282
1443
|
}
|
|
1444
|
+
/** One capturing conversation by id, for resuming a specific prefix. */
|
|
1445
|
+
capturingConversationById(id) {
|
|
1446
|
+
const row = this.#db.prepare(
|
|
1447
|
+
"SELECT id, started_at_utc AS startedAtUtc, ended_at_utc AS endedAtUtc FROM conversations WHERE id = ? AND state = 'capturing'"
|
|
1448
|
+
).get(id);
|
|
1449
|
+
if (!row) return null;
|
|
1450
|
+
return { id: row.id, startedAtUtc: row.startedAtUtc, endedAtUtc: row.endedAtUtc ?? row.startedAtUtc };
|
|
1451
|
+
}
|
|
1283
1452
|
#segmentCount(conversationId) {
|
|
1284
1453
|
const row = this.#db.prepare("SELECT segment_count AS n FROM conversations WHERE id = ?").get(conversationId);
|
|
1285
1454
|
return row ? Number(row.n) : 0;
|
|
1286
1455
|
}
|
|
1287
1456
|
upsertSpeaker(input) {
|
|
1457
|
+
this.#upsertSpeakerUnlocked(input);
|
|
1458
|
+
}
|
|
1459
|
+
/** `upsertSpeaker` without its own transaction, for use inside one. */
|
|
1460
|
+
#upsertSpeakerUnlocked(input) {
|
|
1288
1461
|
const current = this.#db.prepare(
|
|
1289
1462
|
"SELECT label, embedding_count AS embeddingCount, is_self AS isSelf, centroid, example_embeddings AS examples FROM speaker_clusters WHERE id = ?"
|
|
1290
1463
|
).get(input.id);
|
|
@@ -1421,6 +1594,12 @@ function assembleConversations(segments, gapMinutes, state = "final") {
|
|
|
1421
1594
|
return conversations;
|
|
1422
1595
|
}
|
|
1423
1596
|
var DEFAULT_CONVERSATION_GAP_MINUTES = 10;
|
|
1597
|
+
function cloneSegment(segment) {
|
|
1598
|
+
return {
|
|
1599
|
+
...segment,
|
|
1600
|
+
...segment.embedding ? { embedding: segment.embedding.slice() } : {}
|
|
1601
|
+
};
|
|
1602
|
+
}
|
|
1424
1603
|
function epochMs(value, field) {
|
|
1425
1604
|
const ms = Date.parse(value);
|
|
1426
1605
|
if (!Number.isFinite(ms)) {
|
|
@@ -1489,6 +1668,40 @@ var ConversationAssembler = class {
|
|
|
1489
1668
|
segments: []
|
|
1490
1669
|
});
|
|
1491
1670
|
}
|
|
1671
|
+
/**
|
|
1672
|
+
* Drop finalized conversations the caller no longer needs.
|
|
1673
|
+
*
|
|
1674
|
+
* A long-running daemon would otherwise retain every conversation and every
|
|
1675
|
+
* segment forever, which makes the rollback snapshot below O(capture
|
|
1676
|
+
* history) and the daemon's per-chunk work quadratic (issue #2145). Only the
|
|
1677
|
+
* open conversation can still be mutated, so nothing else needs keeping.
|
|
1678
|
+
*/
|
|
1679
|
+
pruneFinalized() {
|
|
1680
|
+
const open = this.#open();
|
|
1681
|
+
const removed = this.#conversations.length - (open ? 1 : 0);
|
|
1682
|
+
this.#conversations.length = 0;
|
|
1683
|
+
if (open) this.#conversations.push(open);
|
|
1684
|
+
return removed;
|
|
1685
|
+
}
|
|
1686
|
+
/**
|
|
1687
|
+
* Deep snapshot for rollback (issue #2145).
|
|
1688
|
+
*
|
|
1689
|
+
* `add` mutates the open conversation in place. A caller that fails BEFORE
|
|
1690
|
+
* anything was persisted must be able to rewind, or the retry feeds earlier
|
|
1691
|
+
* timestamps into an advanced assembler and collapses conversations the
|
|
1692
|
+
* first attempt had split. A caller that already persisted something must
|
|
1693
|
+
* NOT rewind: the durable ids would then diverge from the in-memory ones.
|
|
1694
|
+
*/
|
|
1695
|
+
checkpoint() {
|
|
1696
|
+
return this.#conversations.map((conv) => ({ ...conv, segments: conv.segments.map(cloneSegment) }));
|
|
1697
|
+
}
|
|
1698
|
+
/** Rewind to a {@link checkpoint}. */
|
|
1699
|
+
rewind(snapshot) {
|
|
1700
|
+
this.#conversations.length = 0;
|
|
1701
|
+
for (const conv of snapshot) {
|
|
1702
|
+
this.#conversations.push({ ...conv, segments: conv.segments.map(cloneSegment) });
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1492
1705
|
/** Ordered snapshot; segments are cloned so callers cannot mutate internal state. */
|
|
1493
1706
|
conversations() {
|
|
1494
1707
|
return this.#conversations.map((conv) => ({ ...conv, segments: conv.segments.slice() }));
|
|
@@ -1634,6 +1847,27 @@ var SpeakerClusterer = class {
|
|
|
1634
1847
|
this.#clusters.push(cluster);
|
|
1635
1848
|
return cluster.id;
|
|
1636
1849
|
}
|
|
1850
|
+
/**
|
|
1851
|
+
* Replace every cluster with `snapshot` (issue #2145).
|
|
1852
|
+
*
|
|
1853
|
+
* `assign` mutates centroids and counts in place, so a diarization commit
|
|
1854
|
+
* that rolls back in SQLite must roll back here too — otherwise the retry
|
|
1855
|
+
* counts the same embeddings twice. Deep-copied, so the caller's snapshot
|
|
1856
|
+
* cannot alias internal state.
|
|
1857
|
+
*/
|
|
1858
|
+
restore(snapshot) {
|
|
1859
|
+
this.#clusters = snapshot.map((cluster) => ({
|
|
1860
|
+
...cluster,
|
|
1861
|
+
centroid: cluster.centroid.slice(),
|
|
1862
|
+
examples: cluster.examples.map((example) => example.slice())
|
|
1863
|
+
}));
|
|
1864
|
+
let highest = 0;
|
|
1865
|
+
for (const cluster of this.#clusters) {
|
|
1866
|
+
const parsed = /^spk_(\d+)$/.exec(cluster.id);
|
|
1867
|
+
if (parsed) highest = Math.max(highest, Number(parsed[1]));
|
|
1868
|
+
}
|
|
1869
|
+
this.#next = highest + 1;
|
|
1870
|
+
}
|
|
1637
1871
|
/** Snapshot for persistence. */
|
|
1638
1872
|
clusters() {
|
|
1639
1873
|
return this.#clusters.map((c) => ({
|
|
@@ -2042,14 +2276,74 @@ function dedupeCrossChannel(segments, options = {}) {
|
|
|
2042
2276
|
|
|
2043
2277
|
// src/processor.ts
|
|
2044
2278
|
import { createHash as createHash2 } from "crypto";
|
|
2279
|
+
import { log } from "@remnic/core/logger";
|
|
2280
|
+
function segmentStableKey(chunkId, segment) {
|
|
2281
|
+
const digest = createHash2("sha1").update(`${segment.startUtc}\0${segment.endUtc}\0${segment.text}`).digest("hex").slice(0, 16);
|
|
2282
|
+
return `${chunkId}:h${digest}`;
|
|
2283
|
+
}
|
|
2284
|
+
function transcriptManifestHash(chunkId, segments) {
|
|
2285
|
+
return createHash2("sha1").update(segments.map((segment) => segmentStableKey(chunkId, segment)).join("\n")).digest("hex").slice(0, 16);
|
|
2286
|
+
}
|
|
2287
|
+
function transcriptManifestKey(chunkId) {
|
|
2288
|
+
return `${chunkId}:manifest`;
|
|
2289
|
+
}
|
|
2045
2290
|
function chunkStableId(event) {
|
|
2046
2291
|
return `chk_${createHash2("sha1").update(event.path).digest("hex")}`;
|
|
2047
2292
|
}
|
|
2293
|
+
var MAX_BUFFERED_CHUNKS = 512;
|
|
2294
|
+
function instantMs(value, field) {
|
|
2295
|
+
const ms = Date.parse(value);
|
|
2296
|
+
if (Number.isNaN(ms)) {
|
|
2297
|
+
throw new CaptureInputError(`${field}: expected an ISO-8601 instant, got ${JSON.stringify(value)}`);
|
|
2298
|
+
}
|
|
2299
|
+
return ms;
|
|
2300
|
+
}
|
|
2301
|
+
function firstStartMs(event, raw) {
|
|
2302
|
+
let earliest = instantMs(event.startedAtUtc, "chunk.startedAtUtc");
|
|
2303
|
+
for (const segment of raw) {
|
|
2304
|
+
earliest = Math.min(earliest, instantMs(segment.startUtc, "segment.startUtc"));
|
|
2305
|
+
}
|
|
2306
|
+
return earliest;
|
|
2307
|
+
}
|
|
2308
|
+
function lastEndMs(event, raw) {
|
|
2309
|
+
let latest = instantMs(event.endedAtUtc, "chunk.endedAtUtc");
|
|
2310
|
+
for (const segment of raw) {
|
|
2311
|
+
latest = Math.max(latest, instantMs(segment.endUtc, "segment.endUtc"));
|
|
2312
|
+
}
|
|
2313
|
+
return latest;
|
|
2314
|
+
}
|
|
2048
2315
|
function createChunkProcessor(deps) {
|
|
2049
2316
|
let tail = Promise.resolve();
|
|
2050
2317
|
let recovered = false;
|
|
2051
2318
|
let openConversationId = null;
|
|
2319
|
+
const retainedChunks = /* @__PURE__ */ new Map();
|
|
2320
|
+
function isHeldForReplay(conversationId) {
|
|
2321
|
+
for (const held of retainedChunks.values()) {
|
|
2322
|
+
if (held.has(conversationId)) return true;
|
|
2323
|
+
}
|
|
2324
|
+
return false;
|
|
2325
|
+
}
|
|
2326
|
+
function trackRetention(chunkId, retained, resumable) {
|
|
2327
|
+
if (!retained) {
|
|
2328
|
+
retainedChunks.delete(chunkId);
|
|
2329
|
+
return;
|
|
2330
|
+
}
|
|
2331
|
+
retainedChunks.set(chunkId, heldFor(chunkId, resumable));
|
|
2332
|
+
}
|
|
2333
|
+
function heldFor(chunkId, resumable) {
|
|
2334
|
+
const own = deps.spool.conversationIdsForChunk(chunkId).filter((id) => resumable.has(id));
|
|
2335
|
+
return own.length > 0 ? new Set(own) : new Set(resumable);
|
|
2336
|
+
}
|
|
2337
|
+
function resumableConversations() {
|
|
2338
|
+
const ids = new Set(deps.spool.capturingConversationIds());
|
|
2339
|
+
if (openConversationId !== null) ids.add(openConversationId);
|
|
2340
|
+
return ids;
|
|
2341
|
+
}
|
|
2052
2342
|
const processedThisRun = /* @__PURE__ */ new Set();
|
|
2343
|
+
const reorderWindowMs = Math.max(0, deps.reorderWindowMs ?? 0);
|
|
2344
|
+
const buffer = [];
|
|
2345
|
+
const bufferedIds = /* @__PURE__ */ new Set();
|
|
2346
|
+
let watermarkSourceMs = Number.NEGATIVE_INFINITY;
|
|
2053
2347
|
const dedupeConversation = (id) => {
|
|
2054
2348
|
const segs = deps.spool.conversationSegmentsForDedup(id);
|
|
2055
2349
|
if (segs.length === 0) return;
|
|
@@ -2058,16 +2352,54 @@ function createChunkProcessor(deps) {
|
|
|
2058
2352
|
const drop = segs.filter((s) => !keep.has(s.id)).map((s) => s.id);
|
|
2059
2353
|
if (drop.length > 0) deps.spool.deleteSegments(drop);
|
|
2060
2354
|
};
|
|
2355
|
+
const diarizeConversation = (id) => {
|
|
2356
|
+
const diarizer = deps.diarizer;
|
|
2357
|
+
if (!diarizer) return;
|
|
2358
|
+
const pending = deps.spool.conversationSegmentsForDiarization(id);
|
|
2359
|
+
if (pending.length === 0) return;
|
|
2360
|
+
const before = diarizer.clusters();
|
|
2361
|
+
const selfVoiceEnrolled = diarizer.clusters().some((c) => c.isSelf && c.embeddingCount > 0);
|
|
2362
|
+
const assignments = [];
|
|
2363
|
+
const touched = /* @__PURE__ */ new Set();
|
|
2364
|
+
for (const segment of pending) {
|
|
2365
|
+
const clusterId = diarizer.assign(segment.embedding);
|
|
2366
|
+
const assigned = diarizer.clusters().find((c) => c.id === clusterId);
|
|
2367
|
+
const isWearer = (assigned?.isSelf ?? false) || segment.channel === "mic" && !selfVoiceEnrolled;
|
|
2368
|
+
assignments.push({ id: segment.id, speakerCluster: clusterId, isWearer });
|
|
2369
|
+
touched.add(clusterId);
|
|
2370
|
+
}
|
|
2371
|
+
const byId = new Map(diarizer.clusters().map((c) => [c.id, c]));
|
|
2372
|
+
const clusters = [...touched].map((clusterId) => byId.get(clusterId)).filter((cluster) => cluster !== void 0).map((cluster) => ({
|
|
2373
|
+
id: cluster.id,
|
|
2374
|
+
label: cluster.label,
|
|
2375
|
+
isSelf: cluster.isSelf,
|
|
2376
|
+
embeddingCount: cluster.embeddingCount,
|
|
2377
|
+
centroid: cluster.centroid,
|
|
2378
|
+
examples: cluster.examples
|
|
2379
|
+
}));
|
|
2380
|
+
try {
|
|
2381
|
+
deps.spool.commitDiarization({ clusters, assignments });
|
|
2382
|
+
} catch (err) {
|
|
2383
|
+
diarizer.restore(before);
|
|
2384
|
+
throw err;
|
|
2385
|
+
}
|
|
2386
|
+
};
|
|
2061
2387
|
const finalizeConv = (id) => {
|
|
2388
|
+
if (isHeldForReplay(id)) return false;
|
|
2062
2389
|
dedupeConversation(id);
|
|
2390
|
+
diarizeConversation(id);
|
|
2063
2391
|
deps.spool.finalizeConversation(id);
|
|
2392
|
+
return true;
|
|
2064
2393
|
};
|
|
2065
2394
|
const report = (error, event) => {
|
|
2066
|
-
|
|
2395
|
+
try {
|
|
2396
|
+
deps.onError?.(error instanceof Error ? error : new Error(String(error)), event);
|
|
2397
|
+
} catch {
|
|
2398
|
+
}
|
|
2067
2399
|
};
|
|
2068
2400
|
async function process2(event) {
|
|
2069
2401
|
const chunkId = chunkStableId(event);
|
|
2070
|
-
if (processedThisRun.has(chunkId)) return;
|
|
2402
|
+
if (processedThisRun.has(chunkId) || bufferedIds.has(chunkId)) return;
|
|
2071
2403
|
if (deps.spool.isChunkApplied(`${chunkId}:done`)) {
|
|
2072
2404
|
processedThisRun.add(chunkId);
|
|
2073
2405
|
try {
|
|
@@ -2077,107 +2409,258 @@ function createChunkProcessor(deps) {
|
|
|
2077
2409
|
}
|
|
2078
2410
|
return;
|
|
2079
2411
|
}
|
|
2412
|
+
if (deps.spool.isChunkApplied(chunkId) || deps.spool.isChunkApplied(`${chunkId}:0`)) {
|
|
2413
|
+
processedThisRun.add(chunkId);
|
|
2414
|
+
log.warn(
|
|
2415
|
+
`[capture-audio] chunk ${chunkId} was partially applied under the pre-#2145 key scheme; leaving it and its raw audio in place for replay`
|
|
2416
|
+
);
|
|
2417
|
+
return;
|
|
2418
|
+
}
|
|
2080
2419
|
const isSpeech = deps.detectSpeech ? await deps.detectSpeech(event) : true;
|
|
2081
2420
|
const raw = isSpeech ? await deps.transcribe({
|
|
2082
2421
|
wavPath: event.path,
|
|
2083
2422
|
modelPath: deps.resolveModel(),
|
|
2084
2423
|
chunkStartedAtUtc: event.startedAtUtc
|
|
2085
2424
|
}) : [];
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2425
|
+
const built = buildSegments(event, raw);
|
|
2426
|
+
buffer.push({
|
|
2427
|
+
event,
|
|
2428
|
+
chunkId,
|
|
2429
|
+
built,
|
|
2430
|
+
startMs: firstStartMs(event, raw),
|
|
2431
|
+
endMs: lastEndMs(event, raw),
|
|
2432
|
+
manifestRecorded: false
|
|
2433
|
+
});
|
|
2434
|
+
bufferedIds.add(chunkId);
|
|
2435
|
+
watermarkSourceMs = Math.max(watermarkSourceMs, lastEndMs(event, raw));
|
|
2436
|
+
if (buffer.length > MAX_BUFFERED_CHUNKS) {
|
|
2437
|
+
const evicted = buffer.shift();
|
|
2438
|
+
if (evicted !== void 0) {
|
|
2439
|
+
bufferedIds.delete(evicted.chunkId);
|
|
2440
|
+
report(
|
|
2441
|
+
new Error(
|
|
2442
|
+
`reorder buffer is full (${MAX_BUFFERED_CHUNKS}); chunk ${evicted.chunkId} was dropped with its raw audio retained`
|
|
2443
|
+
),
|
|
2444
|
+
evicted.event
|
|
2445
|
+
);
|
|
2092
2446
|
}
|
|
2093
2447
|
}
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
openConversationId = null;
|
|
2098
|
-
}
|
|
2099
|
-
const selfVoiceEnrolled = deps.diarizer !== void 0 && deps.diarizer.clusters().some((c) => c.isSelf && c.embeddingCount > 0);
|
|
2448
|
+
await releaseReady(false);
|
|
2449
|
+
}
|
|
2450
|
+
function buildSegments(event, raw) {
|
|
2100
2451
|
const built = [];
|
|
2101
2452
|
for (const s of raw) {
|
|
2102
2453
|
const text = s.text.trim();
|
|
2103
2454
|
if (text === "") continue;
|
|
2104
2455
|
built.push({
|
|
2105
|
-
seg: {
|
|
2456
|
+
seg: {
|
|
2457
|
+
channel: event.channel,
|
|
2458
|
+
text,
|
|
2459
|
+
startUtc: s.startUtc,
|
|
2460
|
+
endUtc: s.endUtc,
|
|
2461
|
+
isWearer: event.channel === "mic"
|
|
2462
|
+
},
|
|
2106
2463
|
raw: s
|
|
2107
2464
|
});
|
|
2108
2465
|
}
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2466
|
+
return built;
|
|
2467
|
+
}
|
|
2468
|
+
async function applyBatch(batch, progress) {
|
|
2469
|
+
if (!recovered) {
|
|
2470
|
+
recovered = true;
|
|
2471
|
+
const ownPrefix = batch.flatMap((entry) => deps.spool.conversationIdsForChunk(entry.chunkId)).map((id) => deps.spool.capturingConversationById(id)).find((conversation) => conversation !== null);
|
|
2472
|
+
const prior = ownPrefix ?? deps.spool.latestCapturingConversation();
|
|
2473
|
+
if (prior) {
|
|
2474
|
+
deps.assembler.resume(prior);
|
|
2475
|
+
openConversationId = prior.id;
|
|
2476
|
+
}
|
|
2477
|
+
const capturingAtStartup = new Set(deps.spool.capturingConversationIds());
|
|
2478
|
+
for (const chunkId of deps.spool.incompleteChunkIds()) {
|
|
2479
|
+
const held = heldFor(chunkId, capturingAtStartup);
|
|
2480
|
+
if (held.size > 0) retainedChunks.set(chunkId, held);
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2483
|
+
const resumable = resumableConversations();
|
|
2484
|
+
for (const entry of batch) trackRetention(entry.chunkId, !isFullyProcessed(entry), resumable);
|
|
2485
|
+
const earliestStart = batch.reduce(
|
|
2486
|
+
(earliest, entry) => entry.startMs < earliest.startMs ? entry : earliest,
|
|
2487
|
+
batch[0]
|
|
2488
|
+
);
|
|
2489
|
+
const closed = deps.assembler.closeIfIdle(earliestStart.event.startedAtUtc);
|
|
2490
|
+
if (closed !== null && closed === openConversationId) {
|
|
2491
|
+
if (finalizeConv(closed)) {
|
|
2492
|
+
progress.persisted = true;
|
|
2493
|
+
openConversationId = null;
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2496
|
+
const stream = batch.flatMap(
|
|
2497
|
+
(entry) => entry.built.map((item, index) => ({ entry, item, index }))
|
|
2498
|
+
).sort((left, right) => {
|
|
2499
|
+
if (left.item.seg.startUtc !== right.item.seg.startUtc) {
|
|
2500
|
+
return left.item.seg.startUtc < right.item.seg.startUtc ? -1 : 1;
|
|
2501
|
+
}
|
|
2502
|
+
if (left.item.seg.endUtc !== right.item.seg.endUtc) {
|
|
2503
|
+
return left.item.seg.endUtc < right.item.seg.endUtc ? -1 : 1;
|
|
2504
|
+
}
|
|
2505
|
+
if (left.entry.chunkId !== right.entry.chunkId) {
|
|
2506
|
+
return left.entry.chunkId < right.entry.chunkId ? -1 : 1;
|
|
2507
|
+
}
|
|
2508
|
+
return left.index - right.index;
|
|
2509
|
+
});
|
|
2510
|
+
const fresh = stream.filter(
|
|
2511
|
+
({ entry, item }) => !deps.spool.isChunkApplied(segmentStableKey(entry.chunkId, item.seg))
|
|
2512
|
+
);
|
|
2513
|
+
if (deps.embed) {
|
|
2514
|
+
for (const { entry, item } of fresh) {
|
|
2515
|
+
item.seg.embedding = await deps.embed(entry.event, item.raw);
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
const runs = [];
|
|
2519
|
+
for (const { entry, item, index } of fresh) {
|
|
2520
|
+
const conv = deps.assembler.add(item.seg);
|
|
2521
|
+
const carried = { ...item, index };
|
|
2522
|
+
const last = runs[runs.length - 1];
|
|
2523
|
+
if (last && last.id === conv.id && last.entry === entry) last.items.push(carried);
|
|
2524
|
+
else runs.push({ entry, id: conv.id, startedAtUtc: conv.startedAtUtc, items: [carried] });
|
|
2525
|
+
}
|
|
2526
|
+
for (const run of runs) {
|
|
2527
|
+
const { chunkId, event } = run.entry;
|
|
2528
|
+
for (const item of run.items) {
|
|
2529
|
+
const key = segmentStableKey(chunkId, item.seg);
|
|
2530
|
+
if (openConversationId !== null && openConversationId !== run.id) {
|
|
2531
|
+
if (finalizeConv(openConversationId)) progress.persisted = true;
|
|
2158
2532
|
}
|
|
2159
2533
|
deps.spool.appendAssembledSegments({
|
|
2160
2534
|
idempotencyKey: key,
|
|
2161
2535
|
chunkId: key,
|
|
2162
|
-
conversationId:
|
|
2163
|
-
startedAtUtc:
|
|
2536
|
+
conversationId: run.id,
|
|
2537
|
+
startedAtUtc: run.startedAtUtc,
|
|
2164
2538
|
state: "capturing",
|
|
2165
2539
|
device: event.device,
|
|
2166
2540
|
wavPath: event.path,
|
|
2167
|
-
segments:
|
|
2541
|
+
segments: [item.seg]
|
|
2168
2542
|
});
|
|
2169
|
-
|
|
2543
|
+
progress.persisted = true;
|
|
2544
|
+
openConversationId = run.id;
|
|
2170
2545
|
}
|
|
2171
2546
|
}
|
|
2172
|
-
const
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2547
|
+
for (const entry of batch) {
|
|
2548
|
+
const fullyProcessed = isFullyProcessed(entry);
|
|
2549
|
+
trackRetention(entry.chunkId, !fullyProcessed, resumable);
|
|
2550
|
+
if (entry.built.length > 0 && !fullyProcessed) {
|
|
2551
|
+
log.warn(
|
|
2552
|
+
`[capture-audio] chunk ${entry.chunkId} retranscribed to a different transcript than an earlier run; keeping its raw audio for replay`
|
|
2553
|
+
);
|
|
2554
|
+
}
|
|
2555
|
+
processedThisRun.add(entry.chunkId);
|
|
2556
|
+
if (!fullyProcessed) continue;
|
|
2557
|
+
deps.spool.markChunkComplete(entry.chunkId, openConversationId ?? "-");
|
|
2176
2558
|
try {
|
|
2177
|
-
await deps.cleanupRawAudio(event);
|
|
2559
|
+
await deps.cleanupRawAudio(entry.event);
|
|
2178
2560
|
} catch (err) {
|
|
2179
|
-
report(err, event);
|
|
2561
|
+
report(err, entry.event);
|
|
2562
|
+
}
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
2565
|
+
function isFullyProcessed(entry) {
|
|
2566
|
+
if (entry.built.length === 0) return !deps.spool.hasAppliedChunkPrefix(`${entry.chunkId}:`);
|
|
2567
|
+
return deps.spool.appliedChunkValue(transcriptManifestKey(entry.chunkId)) === transcriptManifestHash(entry.chunkId, entry.built.map((item) => item.seg));
|
|
2568
|
+
}
|
|
2569
|
+
function recordTranscriptManifest(entry) {
|
|
2570
|
+
if (entry.built.length === 0) {
|
|
2571
|
+
entry.manifestRecorded = true;
|
|
2572
|
+
return true;
|
|
2573
|
+
}
|
|
2574
|
+
try {
|
|
2575
|
+
const key = transcriptManifestKey(entry.chunkId);
|
|
2576
|
+
const ours = transcriptManifestHash(entry.chunkId, entry.built.map((item) => item.seg));
|
|
2577
|
+
const recorded = deps.spool.appliedChunkValue(key);
|
|
2578
|
+
if (recorded === void 0) {
|
|
2579
|
+
deps.spool.markApplied(key, ours);
|
|
2580
|
+
} else if (recorded !== ours) {
|
|
2581
|
+
return "conflict";
|
|
2582
|
+
}
|
|
2583
|
+
entry.manifestRecorded = true;
|
|
2584
|
+
return true;
|
|
2585
|
+
} catch (err) {
|
|
2586
|
+
report(err, entry.event);
|
|
2587
|
+
return false;
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
async function releaseReady(flushAll) {
|
|
2591
|
+
let finalFailure;
|
|
2592
|
+
for (; ; ) {
|
|
2593
|
+
const threshold = flushAll ? Number.POSITIVE_INFINITY : watermarkSourceMs - reorderWindowMs;
|
|
2594
|
+
const batch = [];
|
|
2595
|
+
let manifestBlocked = false;
|
|
2596
|
+
for (let i = buffer.length - 1; i >= 0; i--) {
|
|
2597
|
+
const candidate = buffer[i];
|
|
2598
|
+
if (candidate.endMs > threshold) continue;
|
|
2599
|
+
const manifest = candidate.manifestRecorded ? true : recordTranscriptManifest(candidate);
|
|
2600
|
+
if (manifest === "conflict") {
|
|
2601
|
+
buffer.splice(i, 1);
|
|
2602
|
+
bufferedIds.delete(candidate.chunkId);
|
|
2603
|
+
trackRetention(candidate.chunkId, true, resumableConversations());
|
|
2604
|
+
report(
|
|
2605
|
+
new Error(
|
|
2606
|
+
`transcript for chunk ${candidate.chunkId} does not match the recorded manifest; raw audio retained`
|
|
2607
|
+
),
|
|
2608
|
+
candidate.event
|
|
2609
|
+
);
|
|
2610
|
+
continue;
|
|
2611
|
+
}
|
|
2612
|
+
if (!manifest) {
|
|
2613
|
+
manifestBlocked = true;
|
|
2614
|
+
break;
|
|
2615
|
+
}
|
|
2616
|
+
buffer.splice(i, 1);
|
|
2617
|
+
bufferedIds.delete(candidate.chunkId);
|
|
2618
|
+
batch.push(candidate);
|
|
2619
|
+
}
|
|
2620
|
+
if (manifestBlocked) {
|
|
2621
|
+
for (const entry of batch) {
|
|
2622
|
+
buffer.push(entry);
|
|
2623
|
+
bufferedIds.add(entry.chunkId);
|
|
2624
|
+
}
|
|
2625
|
+
if (flushAll) {
|
|
2626
|
+
throw new Error("flush-plan manifest could not be persisted; chunks are retained for replay");
|
|
2627
|
+
}
|
|
2628
|
+
return;
|
|
2629
|
+
}
|
|
2630
|
+
if (batch.length === 0) {
|
|
2631
|
+
if (flushAll && buffer.length > 0) {
|
|
2632
|
+
throw new Error("buffered chunks could not be released; they are retained for replay");
|
|
2633
|
+
}
|
|
2634
|
+
return;
|
|
2635
|
+
}
|
|
2636
|
+
batch.sort(
|
|
2637
|
+
(left, right) => left.startMs - right.startMs || left.endMs - right.endMs || (left.chunkId < right.chunkId ? -1 : left.chunkId > right.chunkId ? 1 : 0)
|
|
2638
|
+
);
|
|
2639
|
+
const checkpoint = {
|
|
2640
|
+
assembler: deps.assembler.checkpoint(),
|
|
2641
|
+
recovered,
|
|
2642
|
+
openConversationId
|
|
2643
|
+
};
|
|
2644
|
+
const progress = { persisted: false };
|
|
2645
|
+
try {
|
|
2646
|
+
await applyBatch(batch, progress);
|
|
2647
|
+
} catch (error) {
|
|
2648
|
+
if (!progress.persisted) {
|
|
2649
|
+
deps.assembler.rewind(checkpoint.assembler);
|
|
2650
|
+
recovered = checkpoint.recovered;
|
|
2651
|
+
openConversationId = checkpoint.openConversationId;
|
|
2652
|
+
}
|
|
2653
|
+
for (const entry of batch) {
|
|
2654
|
+
if (processedThisRun.has(entry.chunkId) || bufferedIds.has(entry.chunkId)) continue;
|
|
2655
|
+
buffer.push(entry);
|
|
2656
|
+
bufferedIds.add(entry.chunkId);
|
|
2657
|
+
}
|
|
2658
|
+
if (flushAll) finalFailure ??= error;
|
|
2659
|
+
report(error, batch[0].event);
|
|
2660
|
+
if (finalFailure !== void 0) throw finalFailure;
|
|
2661
|
+
return;
|
|
2180
2662
|
}
|
|
2663
|
+
deps.assembler.pruneFinalized();
|
|
2181
2664
|
}
|
|
2182
2665
|
}
|
|
2183
2666
|
function enqueue(event) {
|
|
@@ -2187,10 +2670,31 @@ function createChunkProcessor(deps) {
|
|
|
2187
2670
|
return tail.then(() => void 0);
|
|
2188
2671
|
}
|
|
2189
2672
|
async function finalize() {
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2673
|
+
const flush = tail.then(() => releaseReady(true));
|
|
2674
|
+
tail = flush.catch(() => void 0);
|
|
2675
|
+
let flushFailure;
|
|
2676
|
+
try {
|
|
2677
|
+
await flush;
|
|
2678
|
+
} catch (error) {
|
|
2679
|
+
flushFailure = error;
|
|
2680
|
+
}
|
|
2681
|
+
const holdOpen = flushFailure !== void 0;
|
|
2682
|
+
if (!holdOpen) deps.assembler.finalize();
|
|
2683
|
+
let sweepFailure;
|
|
2684
|
+
let closed = 0;
|
|
2685
|
+
for (const id of deps.spool.capturingConversationIds()) {
|
|
2686
|
+
try {
|
|
2687
|
+
dedupeConversation(id);
|
|
2688
|
+
if (!holdOpen && !isHeldForReplay(id)) diarizeConversation(id);
|
|
2689
|
+
if (!holdOpen && !isHeldForReplay(id) && deps.spool.finalizeConversation(id)) closed++;
|
|
2690
|
+
} catch (error) {
|
|
2691
|
+
sweepFailure ??= error;
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2694
|
+
const total = sweepFailure === void 0 && !holdOpen && retainedChunks.size === 0 ? closed + deps.spool.finalizeOpenConversations() : closed;
|
|
2695
|
+
if (flushFailure !== void 0) throw flushFailure;
|
|
2696
|
+
if (sweepFailure !== void 0) throw sweepFailure;
|
|
2697
|
+
return total;
|
|
2194
2698
|
}
|
|
2195
2699
|
return { enqueue, drain, finalize };
|
|
2196
2700
|
}
|
|
@@ -2350,6 +2854,13 @@ function createLiveCapture(options) {
|
|
|
2350
2854
|
resolveModel,
|
|
2351
2855
|
transcribe,
|
|
2352
2856
|
cleanupRawAudio,
|
|
2857
|
+
// Cross-channel arrival skew: hold each transcribed chunk until the
|
|
2858
|
+
// watermark passes it, so a delayed system chunk still assembles into the
|
|
2859
|
+
// conversation it belongs to (issue #2145).
|
|
2860
|
+
// Only "both" has two independent chunk streams to interleave. A single
|
|
2861
|
+
// channel is already ordered, so buffering would add latency and widen the
|
|
2862
|
+
// crash window for nothing (issue #2145).
|
|
2863
|
+
reorderWindowMs: config.captureChannel === "both" ? config.reorderWindowSeconds * 1e3 : 0,
|
|
2353
2864
|
...options.detectSpeech ? { detectSpeech: options.detectSpeech } : {},
|
|
2354
2865
|
...options.embed ? { embed: options.embed } : {},
|
|
2355
2866
|
...diarizer ? { diarizer } : {},
|
|
@@ -2869,7 +3380,12 @@ async function cmdStart(paths, flags, env, stdout, stderr, spawnArgvPrefix) {
|
|
|
2869
3380
|
if (closing) return;
|
|
2870
3381
|
closing = true;
|
|
2871
3382
|
replayAbort.abort();
|
|
2872
|
-
void replayTask.catch(() => void 0).then(() => live ? live.stop().then(() => void 0) : void 0).catch(() =>
|
|
3383
|
+
void replayTask.catch(() => void 0).then(() => live ? live.stop().then(() => void 0) : void 0).catch((error) => {
|
|
3384
|
+
process.stderr.write(
|
|
3385
|
+
`[capture-audio] shutdown flush failed; retained raw audio was NOT ingested: ${describeError(error)}
|
|
3386
|
+
`
|
|
3387
|
+
);
|
|
3388
|
+
}).then(() => {
|
|
2873
3389
|
spool.finalizeOpenConversations();
|
|
2874
3390
|
return handle.close().catch(() => void 0);
|
|
2875
3391
|
}).finally(() => {
|
|
@@ -3184,4 +3700,4 @@ export {
|
|
|
3184
3700
|
superviseReplay,
|
|
3185
3701
|
runCapture
|
|
3186
3702
|
};
|
|
3187
|
-
//# sourceMappingURL=chunk-
|
|
3703
|
+
//# sourceMappingURL=chunk-MFWH245M.js.map
|