neoagent 2.0.0 → 2.0.2
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/.env.example +8 -0
- package/docs/configuration.md +3 -0
- package/package.json +1 -1
- package/server/db/database.js +75 -0
- package/server/http/middleware.js +29 -3
- package/server/http/routes.js +1 -0
- package/server/index.js +97 -6
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/NOTICES +24298 -26578
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/assets/shaders/ink_sparkle.frag +1 -0
- package/server/public/assets/shaders/stretch_effect.frag +64 -0
- package/server/public/assets/web/icons/Icon-192.png +0 -0
- package/server/public/canvaskit/canvaskit.js +91 -90
- package/server/public/canvaskit/canvaskit.js.symbols +11577 -11578
- package/server/public/canvaskit/canvaskit.wasm +0 -0
- package/server/public/canvaskit/chromium/canvaskit.js +92 -91
- package/server/public/canvaskit/chromium/canvaskit.js.symbols +10382 -10395
- package/server/public/canvaskit/chromium/canvaskit.wasm +0 -0
- package/server/public/canvaskit/skwasm.js +95 -89
- package/server/public/canvaskit/skwasm.js.symbols +12814 -12146
- package/server/public/canvaskit/skwasm.wasm +0 -0
- package/server/public/canvaskit/skwasm_heavy.js +95 -89
- package/server/public/canvaskit/skwasm_heavy.js.symbols +14435 -13747
- package/server/public/canvaskit/skwasm_heavy.wasm +0 -0
- package/server/public/canvaskit/wimp.js +136 -0
- package/server/public/canvaskit/wimp.js.symbols +11313 -0
- package/server/public/canvaskit/wimp.wasm +0 -0
- package/server/public/favicon.svg +12 -0
- package/server/public/flutter.js +3 -4
- package/server/public/flutter_bootstrap.js +5 -6
- package/server/public/flutter_service_worker.js +22 -199
- package/server/public/index.html +1 -0
- package/server/public/main.dart.js +73857 -65543
- package/server/routes/recordings.js +113 -0
- package/server/services/manager.js +9 -0
- package/server/services/recordings/deepgram.js +53 -0
- package/server/services/recordings/manager.js +715 -0
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { v4: uuidv4 } = require('uuid');
|
|
6
|
+
|
|
7
|
+
const db = require('../../db/database');
|
|
8
|
+
const { DATA_DIR } = require('../../../runtime/paths');
|
|
9
|
+
const { sanitizeError } = require('../../utils/security');
|
|
10
|
+
const {
|
|
11
|
+
DEFAULT_LANGUAGE,
|
|
12
|
+
DEFAULT_MODEL,
|
|
13
|
+
isDeepgramConfigured,
|
|
14
|
+
transcribeChunkWithDeepgram,
|
|
15
|
+
} = require('./deepgram');
|
|
16
|
+
|
|
17
|
+
const RECORDINGS_DIR = path.join(DATA_DIR, 'recordings');
|
|
18
|
+
const SESSION_STATUS = {
|
|
19
|
+
recording: 'recording',
|
|
20
|
+
processing: 'processing',
|
|
21
|
+
completed: 'completed',
|
|
22
|
+
failed: 'failed',
|
|
23
|
+
cancelled: 'cancelled',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function ensureRecordingDirs() {
|
|
27
|
+
fs.mkdirSync(RECORDINGS_DIR, { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
class RecordingManager {
|
|
31
|
+
constructor(io) {
|
|
32
|
+
this.io = io;
|
|
33
|
+
ensureRecordingDirs();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
listSessions(userId, { limit = 24 } = {}) {
|
|
37
|
+
const rows = db.prepare(`
|
|
38
|
+
SELECT *
|
|
39
|
+
FROM recording_sessions
|
|
40
|
+
WHERE user_id = ?
|
|
41
|
+
ORDER BY datetime(created_at) DESC
|
|
42
|
+
LIMIT ?
|
|
43
|
+
`).all(userId, Math.max(1, Math.min(Number(limit) || 24, 100)));
|
|
44
|
+
|
|
45
|
+
return rows.map((row) => this.getSession(userId, row.id));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
getSession(userId, sessionId) {
|
|
49
|
+
const session = db.prepare(`
|
|
50
|
+
SELECT *
|
|
51
|
+
FROM recording_sessions
|
|
52
|
+
WHERE user_id = ? AND id = ?
|
|
53
|
+
`).get(userId, sessionId);
|
|
54
|
+
if (!session) {
|
|
55
|
+
throw new Error('Recording session not found.');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const sources = db.prepare(`
|
|
59
|
+
SELECT *
|
|
60
|
+
FROM recording_sources
|
|
61
|
+
WHERE session_id = ?
|
|
62
|
+
ORDER BY created_at ASC
|
|
63
|
+
`).all(sessionId);
|
|
64
|
+
|
|
65
|
+
const segments = db.prepare(`
|
|
66
|
+
SELECT *
|
|
67
|
+
FROM recording_transcript_segments
|
|
68
|
+
WHERE session_id = ?
|
|
69
|
+
ORDER BY start_ms ASC, id ASC
|
|
70
|
+
`).all(sessionId);
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
...this.#mapSession(session),
|
|
74
|
+
sources: sources.map((source) => this.#mapSource(source)),
|
|
75
|
+
transcriptSegments: segments.map((segment) => this.#mapSegment(segment)),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
createSession(userId, payload = {}) {
|
|
80
|
+
const sessionId = uuidv4();
|
|
81
|
+
const now = new Date().toISOString();
|
|
82
|
+
const platform = typeof payload.platform === 'string' && payload.platform.trim()
|
|
83
|
+
? payload.platform.trim()
|
|
84
|
+
: 'unknown';
|
|
85
|
+
const sources = this.#normalizeSources(payload.sources, platform);
|
|
86
|
+
const metadata = {
|
|
87
|
+
capturePlan: payload.capturePlan || 'chunked-dual-source',
|
|
88
|
+
screenAnalysisReady: payload.screenAnalysisReady !== false,
|
|
89
|
+
notes: payload.notes || null,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const insertSession = db.prepare(`
|
|
93
|
+
INSERT INTO recording_sessions (
|
|
94
|
+
id, user_id, title, platform, status, metadata_json, started_at, created_at, updated_at
|
|
95
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
96
|
+
`);
|
|
97
|
+
const insertSource = db.prepare(`
|
|
98
|
+
INSERT INTO recording_sources (
|
|
99
|
+
id, session_id, source_key, source_kind, media_kind, mime_type, status, metadata_json, created_at, updated_at
|
|
100
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
101
|
+
`);
|
|
102
|
+
|
|
103
|
+
db.transaction(() => {
|
|
104
|
+
insertSession.run(
|
|
105
|
+
sessionId,
|
|
106
|
+
userId,
|
|
107
|
+
this.#resolveTitle(payload.title, platform, now),
|
|
108
|
+
platform,
|
|
109
|
+
SESSION_STATUS.recording,
|
|
110
|
+
JSON.stringify(metadata),
|
|
111
|
+
now,
|
|
112
|
+
now,
|
|
113
|
+
now,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
for (const source of sources) {
|
|
117
|
+
insertSource.run(
|
|
118
|
+
uuidv4(),
|
|
119
|
+
sessionId,
|
|
120
|
+
source.sourceKey,
|
|
121
|
+
source.sourceKind,
|
|
122
|
+
source.mediaKind,
|
|
123
|
+
source.mimeType,
|
|
124
|
+
SESSION_STATUS.recording,
|
|
125
|
+
JSON.stringify(source.metadata),
|
|
126
|
+
now,
|
|
127
|
+
now,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
})();
|
|
131
|
+
|
|
132
|
+
this.#emitUpdate(userId, sessionId);
|
|
133
|
+
return this.getSession(userId, sessionId);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
appendChunk(userId, sessionId, metadata = {}, audioBytes) {
|
|
137
|
+
if (!(audioBytes instanceof Buffer) || audioBytes.length === 0) {
|
|
138
|
+
throw new Error('Recording chunk is empty.');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const session = db.prepare(`
|
|
142
|
+
SELECT *
|
|
143
|
+
FROM recording_sessions
|
|
144
|
+
WHERE id = ? AND user_id = ?
|
|
145
|
+
`).get(sessionId, userId);
|
|
146
|
+
if (!session) {
|
|
147
|
+
throw new Error('Recording session not found.');
|
|
148
|
+
}
|
|
149
|
+
if (![SESSION_STATUS.recording, SESSION_STATUS.processing].includes(session.status)) {
|
|
150
|
+
throw new Error('Recording session is not accepting more chunks.');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const sourceKey = `${metadata.sourceKey || ''}`.trim();
|
|
154
|
+
if (!sourceKey) {
|
|
155
|
+
throw new Error('sourceKey is required.');
|
|
156
|
+
}
|
|
157
|
+
const source = db.prepare(`
|
|
158
|
+
SELECT *
|
|
159
|
+
FROM recording_sources
|
|
160
|
+
WHERE session_id = ? AND source_key = ?
|
|
161
|
+
`).get(sessionId, sourceKey);
|
|
162
|
+
if (!source) {
|
|
163
|
+
throw new Error(`Unknown recording source: ${sourceKey}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const sequenceIndex = Number(metadata.sequenceIndex);
|
|
167
|
+
if (!Number.isInteger(sequenceIndex) || sequenceIndex < 0) {
|
|
168
|
+
throw new Error('sequenceIndex must be a non-negative integer.');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const existing = db.prepare(`
|
|
172
|
+
SELECT id
|
|
173
|
+
FROM recording_chunks
|
|
174
|
+
WHERE source_id = ? AND sequence_index = ?
|
|
175
|
+
`).get(source.id, sequenceIndex);
|
|
176
|
+
if (existing) {
|
|
177
|
+
return {
|
|
178
|
+
duplicate: true,
|
|
179
|
+
accepted: false,
|
|
180
|
+
sessionId,
|
|
181
|
+
sourceKey,
|
|
182
|
+
sequenceIndex,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const mimeType = `${metadata.mimeType || source.mime_type || 'application/octet-stream'}`.trim();
|
|
187
|
+
const startMs = Math.max(0, Number(metadata.startMs) || 0);
|
|
188
|
+
const endMs = Math.max(startMs, Number(metadata.endMs) || startMs);
|
|
189
|
+
const extension = this.#extensionForMime(mimeType);
|
|
190
|
+
const fileDir = path.join(RECORDINGS_DIR, `user-${userId}`, sessionId, sourceKey);
|
|
191
|
+
fs.mkdirSync(fileDir, { recursive: true });
|
|
192
|
+
const filePath = path.join(fileDir, `${String(sequenceIndex).padStart(6, '0')}${extension}`);
|
|
193
|
+
const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
194
|
+
fs.writeFileSync(tempPath, audioBytes);
|
|
195
|
+
fs.renameSync(tempPath, filePath);
|
|
196
|
+
|
|
197
|
+
db.transaction(() => {
|
|
198
|
+
db.prepare(`
|
|
199
|
+
INSERT INTO recording_chunks (
|
|
200
|
+
source_id, sequence_index, start_ms, end_ms, byte_count, mime_type, file_path
|
|
201
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
202
|
+
`).run(
|
|
203
|
+
source.id,
|
|
204
|
+
sequenceIndex,
|
|
205
|
+
startMs,
|
|
206
|
+
endMs,
|
|
207
|
+
audioBytes.length,
|
|
208
|
+
mimeType,
|
|
209
|
+
filePath,
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
db.prepare(`
|
|
213
|
+
UPDATE recording_sources
|
|
214
|
+
SET
|
|
215
|
+
mime_type = COALESCE(?, mime_type),
|
|
216
|
+
chunk_count = chunk_count + 1,
|
|
217
|
+
bytes_received = bytes_received + ?,
|
|
218
|
+
duration_ms = MAX(duration_ms, ?),
|
|
219
|
+
updated_at = ?
|
|
220
|
+
WHERE id = ?
|
|
221
|
+
`).run(
|
|
222
|
+
mimeType,
|
|
223
|
+
audioBytes.length,
|
|
224
|
+
endMs,
|
|
225
|
+
new Date().toISOString(),
|
|
226
|
+
source.id,
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
db.prepare(`
|
|
230
|
+
UPDATE recording_sessions
|
|
231
|
+
SET updated_at = ?
|
|
232
|
+
WHERE id = ?
|
|
233
|
+
`).run(new Date().toISOString(), sessionId);
|
|
234
|
+
})();
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
duplicate: false,
|
|
238
|
+
accepted: true,
|
|
239
|
+
sessionId,
|
|
240
|
+
sourceKey,
|
|
241
|
+
sequenceIndex,
|
|
242
|
+
bytesReceived: audioBytes.length,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
finalizeSession(userId, sessionId, options = {}) {
|
|
247
|
+
const session = db.prepare(`
|
|
248
|
+
SELECT *
|
|
249
|
+
FROM recording_sessions
|
|
250
|
+
WHERE id = ? AND user_id = ?
|
|
251
|
+
`).get(sessionId, userId);
|
|
252
|
+
if (!session) {
|
|
253
|
+
throw new Error('Recording session not found.');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const stopReason = `${options.stopReason || 'stopped'}`.trim();
|
|
257
|
+
const mergedMetadata = {
|
|
258
|
+
...this.#parseJson(session.metadata_json, {}),
|
|
259
|
+
stopReason,
|
|
260
|
+
};
|
|
261
|
+
const chunkCount = db.prepare(`
|
|
262
|
+
SELECT COUNT(*) AS count
|
|
263
|
+
FROM recording_chunks c
|
|
264
|
+
INNER JOIN recording_sources s ON s.id = c.source_id
|
|
265
|
+
WHERE s.session_id = ?
|
|
266
|
+
`).get(sessionId).count;
|
|
267
|
+
const nextStatus = chunkCount > 0 ? SESSION_STATUS.processing : SESSION_STATUS.cancelled;
|
|
268
|
+
const now = new Date().toISOString();
|
|
269
|
+
|
|
270
|
+
db.transaction(() => {
|
|
271
|
+
db.prepare(`
|
|
272
|
+
UPDATE recording_sessions
|
|
273
|
+
SET
|
|
274
|
+
status = ?,
|
|
275
|
+
ended_at = COALESCE(ended_at, ?),
|
|
276
|
+
metadata_json = ?,
|
|
277
|
+
updated_at = ?
|
|
278
|
+
WHERE id = ?
|
|
279
|
+
`).run(nextStatus, now, JSON.stringify(mergedMetadata), now, sessionId);
|
|
280
|
+
|
|
281
|
+
db.prepare(`
|
|
282
|
+
UPDATE recording_sources
|
|
283
|
+
SET status = CASE WHEN chunk_count > 0 THEN ? ELSE ? END, updated_at = ?
|
|
284
|
+
WHERE session_id = ?
|
|
285
|
+
`).run(nextStatus, nextStatus === SESSION_STATUS.processing ? SESSION_STATUS.cancelled : nextStatus, now, sessionId);
|
|
286
|
+
})();
|
|
287
|
+
|
|
288
|
+
this.#emitUpdate(userId, sessionId);
|
|
289
|
+
|
|
290
|
+
if (nextStatus === SESSION_STATUS.processing) {
|
|
291
|
+
this.processSession(userId, sessionId).catch((error) => {
|
|
292
|
+
console.error('[Recordings] Processing failed:', sanitizeError(error));
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return this.getSession(userId, sessionId);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async retrySession(userId, sessionId) {
|
|
300
|
+
const session = db.prepare(`
|
|
301
|
+
SELECT *
|
|
302
|
+
FROM recording_sessions
|
|
303
|
+
WHERE id = ? AND user_id = ?
|
|
304
|
+
`).get(sessionId, userId);
|
|
305
|
+
if (!session) {
|
|
306
|
+
throw new Error('Recording session not found.');
|
|
307
|
+
}
|
|
308
|
+
if (!isDeepgramConfigured()) {
|
|
309
|
+
throw new Error('DEEPGRAM_API_KEY is not configured.');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
db.prepare(`
|
|
313
|
+
UPDATE recording_sessions
|
|
314
|
+
SET status = ?, last_error = NULL, updated_at = ?
|
|
315
|
+
WHERE id = ?
|
|
316
|
+
`).run(SESSION_STATUS.processing, new Date().toISOString(), sessionId);
|
|
317
|
+
this.#emitUpdate(userId, sessionId);
|
|
318
|
+
|
|
319
|
+
await this.processSession(userId, sessionId);
|
|
320
|
+
return this.getSession(userId, sessionId);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async resumePendingSessions() {
|
|
324
|
+
const rows = db.prepare(`
|
|
325
|
+
SELECT id, user_id
|
|
326
|
+
FROM recording_sessions
|
|
327
|
+
WHERE status = ?
|
|
328
|
+
ORDER BY created_at ASC
|
|
329
|
+
`).all(SESSION_STATUS.processing);
|
|
330
|
+
|
|
331
|
+
for (const row of rows) {
|
|
332
|
+
try {
|
|
333
|
+
await this.processSession(row.user_id, row.id);
|
|
334
|
+
} catch (error) {
|
|
335
|
+
console.error('[Recordings] Resume failed:', sanitizeError(error));
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async processSession(userId, sessionId) {
|
|
341
|
+
if (!isDeepgramConfigured()) {
|
|
342
|
+
throw new Error('DEEPGRAM_API_KEY is not configured.');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const session = db.prepare(`
|
|
346
|
+
SELECT *
|
|
347
|
+
FROM recording_sessions
|
|
348
|
+
WHERE id = ? AND user_id = ?
|
|
349
|
+
`).get(sessionId, userId);
|
|
350
|
+
if (!session) {
|
|
351
|
+
throw new Error('Recording session not found.');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const sources = db.prepare(`
|
|
355
|
+
SELECT *
|
|
356
|
+
FROM recording_sources
|
|
357
|
+
WHERE session_id = ?
|
|
358
|
+
ORDER BY created_at ASC
|
|
359
|
+
`).all(sessionId);
|
|
360
|
+
if (sources.length == 0) {
|
|
361
|
+
throw new Error('Recording session has no sources.');
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
db.transaction(() => {
|
|
365
|
+
db.prepare(`
|
|
366
|
+
DELETE FROM recording_transcript_segments
|
|
367
|
+
WHERE session_id = ?
|
|
368
|
+
`).run(sessionId);
|
|
369
|
+
db.prepare(`
|
|
370
|
+
UPDATE recording_sources
|
|
371
|
+
SET status = ?, updated_at = ?
|
|
372
|
+
WHERE session_id = ?
|
|
373
|
+
`).run(SESSION_STATUS.processing, new Date().toISOString(), sessionId);
|
|
374
|
+
})();
|
|
375
|
+
|
|
376
|
+
const collectedSegments = [];
|
|
377
|
+
let maxDuration = 0;
|
|
378
|
+
|
|
379
|
+
try {
|
|
380
|
+
for (const source of sources) {
|
|
381
|
+
const chunks = db.prepare(`
|
|
382
|
+
SELECT *
|
|
383
|
+
FROM recording_chunks
|
|
384
|
+
WHERE source_id = ?
|
|
385
|
+
ORDER BY sequence_index ASC
|
|
386
|
+
`).all(source.id);
|
|
387
|
+
|
|
388
|
+
if (chunks.length === 0) {
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
this.#assertSequentialChunks(source.source_key, chunks);
|
|
393
|
+
const sourceSegments = await this.#transcribeSourceChunks(source, chunks);
|
|
394
|
+
maxDuration = Math.max(
|
|
395
|
+
maxDuration,
|
|
396
|
+
...sourceSegments.map((segment) => Number(segment.endMs) || 0),
|
|
397
|
+
Number(source.duration_ms) || 0,
|
|
398
|
+
);
|
|
399
|
+
collectedSegments.push(...sourceSegments);
|
|
400
|
+
|
|
401
|
+
db.prepare(`
|
|
402
|
+
UPDATE recording_sources
|
|
403
|
+
SET status = ?, duration_ms = ?, updated_at = ?
|
|
404
|
+
WHERE id = ?
|
|
405
|
+
`).run(
|
|
406
|
+
SESSION_STATUS.completed,
|
|
407
|
+
Math.max(
|
|
408
|
+
Number(source.duration_ms) || 0,
|
|
409
|
+
...sourceSegments.map((segment) => Number(segment.endMs) || 0),
|
|
410
|
+
),
|
|
411
|
+
new Date().toISOString(),
|
|
412
|
+
source.id,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const insertSegment = db.prepare(`
|
|
417
|
+
INSERT INTO recording_transcript_segments (
|
|
418
|
+
session_id, source_id, source_key, speaker, text, start_ms, end_ms, confidence, words_json
|
|
419
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
420
|
+
`);
|
|
421
|
+
const ordered = collectedSegments.sort((a, b) => {
|
|
422
|
+
if (a.startMs !== b.startMs) {
|
|
423
|
+
return a.startMs - b.startMs;
|
|
424
|
+
}
|
|
425
|
+
return a.sourceKey.localeCompare(b.sourceKey);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
db.transaction(() => {
|
|
429
|
+
for (const segment of ordered) {
|
|
430
|
+
insertSegment.run(
|
|
431
|
+
sessionId,
|
|
432
|
+
segment.sourceId,
|
|
433
|
+
segment.sourceKey,
|
|
434
|
+
segment.speaker,
|
|
435
|
+
segment.text,
|
|
436
|
+
segment.startMs,
|
|
437
|
+
segment.endMs,
|
|
438
|
+
segment.confidence,
|
|
439
|
+
JSON.stringify(segment.words),
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
})();
|
|
443
|
+
|
|
444
|
+
const transcriptText = ordered
|
|
445
|
+
.map((segment) => `[${this.#formatTimestamp(segment.startMs)}] ${segment.text}`)
|
|
446
|
+
.join('\n');
|
|
447
|
+
db.prepare(`
|
|
448
|
+
UPDATE recording_sessions
|
|
449
|
+
SET
|
|
450
|
+
status = ?,
|
|
451
|
+
transcript_text = ?,
|
|
452
|
+
transcript_language = ?,
|
|
453
|
+
transcript_model = ?,
|
|
454
|
+
duration_ms = ?,
|
|
455
|
+
last_error = NULL,
|
|
456
|
+
updated_at = ?,
|
|
457
|
+
ended_at = COALESCE(ended_at, ?)
|
|
458
|
+
WHERE id = ?
|
|
459
|
+
`).run(
|
|
460
|
+
SESSION_STATUS.completed,
|
|
461
|
+
transcriptText,
|
|
462
|
+
DEFAULT_LANGUAGE,
|
|
463
|
+
DEFAULT_MODEL,
|
|
464
|
+
maxDuration,
|
|
465
|
+
new Date().toISOString(),
|
|
466
|
+
new Date().toISOString(),
|
|
467
|
+
sessionId,
|
|
468
|
+
);
|
|
469
|
+
} catch (error) {
|
|
470
|
+
db.prepare(`
|
|
471
|
+
UPDATE recording_sessions
|
|
472
|
+
SET status = ?, last_error = ?, updated_at = ?
|
|
473
|
+
WHERE id = ?
|
|
474
|
+
`).run(
|
|
475
|
+
SESSION_STATUS.failed,
|
|
476
|
+
sanitizeError(error),
|
|
477
|
+
new Date().toISOString(),
|
|
478
|
+
sessionId,
|
|
479
|
+
);
|
|
480
|
+
db.prepare(`
|
|
481
|
+
UPDATE recording_sources
|
|
482
|
+
SET status = ?, updated_at = ?
|
|
483
|
+
WHERE session_id = ?
|
|
484
|
+
`).run(SESSION_STATUS.failed, new Date().toISOString(), sessionId);
|
|
485
|
+
this.#emitUpdate(userId, sessionId);
|
|
486
|
+
throw error;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
this.#emitUpdate(userId, sessionId);
|
|
490
|
+
return this.getSession(userId, sessionId);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async #transcribeSourceChunks(source, chunks) {
|
|
494
|
+
const segments = [];
|
|
495
|
+
|
|
496
|
+
for (const chunk of chunks) {
|
|
497
|
+
const audioBytes = fs.readFileSync(chunk.file_path);
|
|
498
|
+
const payload = await transcribeChunkWithDeepgram({
|
|
499
|
+
audioBytes,
|
|
500
|
+
mimeType: chunk.mime_type || source.mime_type,
|
|
501
|
+
detectLanguage: DEFAULT_LANGUAGE,
|
|
502
|
+
});
|
|
503
|
+
segments.push(...this.#extractSegments(source, chunk, payload));
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return segments;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
#extractSegments(source, chunk, payload) {
|
|
510
|
+
const results = payload?.results || {};
|
|
511
|
+
const channels = Array.isArray(results.channels) ? results.channels : [];
|
|
512
|
+
const alternative = channels[0]?.alternatives?.[0] || {};
|
|
513
|
+
const utterances = Array.isArray(results.utterances) ? results.utterances : [];
|
|
514
|
+
const words = Array.isArray(alternative.words) ? alternative.words : [];
|
|
515
|
+
const chunkStartMs = Number(chunk.start_ms) || 0;
|
|
516
|
+
const chunkEndMs = Number(chunk.end_ms) || chunkStartMs;
|
|
517
|
+
|
|
518
|
+
if (utterances.length > 0) {
|
|
519
|
+
return utterances
|
|
520
|
+
.map((utterance, index) => {
|
|
521
|
+
const startMs = chunkStartMs + Math.max(0, Math.round((Number(utterance.start) || 0) * 1000));
|
|
522
|
+
const endMs = chunkStartMs + Math.max(0, Math.round((Number(utterance.end) || 0) * 1000));
|
|
523
|
+
return {
|
|
524
|
+
sourceId: source.id,
|
|
525
|
+
sourceKey: source.source_key,
|
|
526
|
+
speaker: source.source_kind,
|
|
527
|
+
text: `${utterance.transcript || ''}`.trim(),
|
|
528
|
+
startMs,
|
|
529
|
+
endMs: Math.max(startMs, endMs),
|
|
530
|
+
confidence: Number(utterance.confidence) || null,
|
|
531
|
+
words: Array.isArray(utterance.words) ? utterance.words : [],
|
|
532
|
+
index,
|
|
533
|
+
};
|
|
534
|
+
})
|
|
535
|
+
.filter((item) => item.text.length > 0);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const transcript = `${alternative.transcript || ''}`.trim();
|
|
539
|
+
if (!transcript) {
|
|
540
|
+
return [];
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return [
|
|
544
|
+
{
|
|
545
|
+
sourceId: source.id,
|
|
546
|
+
sourceKey: source.source_key,
|
|
547
|
+
speaker: source.source_kind,
|
|
548
|
+
text: transcript,
|
|
549
|
+
startMs: chunkStartMs,
|
|
550
|
+
endMs: Math.max(chunkStartMs, chunkEndMs),
|
|
551
|
+
confidence: Number(alternative.confidence) || null,
|
|
552
|
+
words,
|
|
553
|
+
},
|
|
554
|
+
];
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
#assertSequentialChunks(sourceKey, chunks) {
|
|
558
|
+
for (let index = 0; index < chunks.length; index += 1) {
|
|
559
|
+
if (Number(chunks[index].sequence_index) !== index) {
|
|
560
|
+
throw new Error(`Recording source "${sourceKey}" is missing chunk ${index}.`);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
#resolveTitle(title, platform, nowIso) {
|
|
566
|
+
if (typeof title === 'string' && title.trim()) {
|
|
567
|
+
return title.trim().slice(0, 160);
|
|
568
|
+
}
|
|
569
|
+
const stamp = new Date(nowIso).toISOString().replace('T', ' ').slice(0, 16);
|
|
570
|
+
if (platform === 'web') {
|
|
571
|
+
return `Screen + mic recording ${stamp}`;
|
|
572
|
+
}
|
|
573
|
+
if (platform === 'android') {
|
|
574
|
+
return `Background microphone recording ${stamp}`;
|
|
575
|
+
}
|
|
576
|
+
return `Recording ${stamp}`;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
#normalizeSources(rawSources, platform) {
|
|
580
|
+
const fallback = platform === 'android'
|
|
581
|
+
? [
|
|
582
|
+
{
|
|
583
|
+
sourceKey: 'microphone',
|
|
584
|
+
sourceKind: 'microphone',
|
|
585
|
+
mediaKind: 'audio',
|
|
586
|
+
mimeType: 'audio/wav',
|
|
587
|
+
metadata: { backgroundCapable: true },
|
|
588
|
+
},
|
|
589
|
+
]
|
|
590
|
+
: [
|
|
591
|
+
{
|
|
592
|
+
sourceKey: 'screen',
|
|
593
|
+
sourceKind: 'screen-share',
|
|
594
|
+
mediaKind: 'video',
|
|
595
|
+
mimeType: 'video/webm',
|
|
596
|
+
metadata: { analysisReady: true },
|
|
597
|
+
},
|
|
598
|
+
{
|
|
599
|
+
sourceKey: 'microphone',
|
|
600
|
+
sourceKind: 'microphone',
|
|
601
|
+
mediaKind: 'audio',
|
|
602
|
+
mimeType: 'audio/webm',
|
|
603
|
+
metadata: {},
|
|
604
|
+
},
|
|
605
|
+
];
|
|
606
|
+
|
|
607
|
+
const inputs = Array.isArray(rawSources) && rawSources.length > 0 ? rawSources : fallback;
|
|
608
|
+
const seen = new Set();
|
|
609
|
+
|
|
610
|
+
return inputs.map((item, index) => {
|
|
611
|
+
const sourceKey = `${item?.sourceKey || item?.id || `source-${index}`}`.trim().toLowerCase();
|
|
612
|
+
if (!sourceKey) {
|
|
613
|
+
throw new Error('Every recording source needs a sourceKey.');
|
|
614
|
+
}
|
|
615
|
+
if (seen.has(sourceKey)) {
|
|
616
|
+
throw new Error(`Duplicate recording source: ${sourceKey}`);
|
|
617
|
+
}
|
|
618
|
+
seen.add(sourceKey);
|
|
619
|
+
return {
|
|
620
|
+
sourceKey,
|
|
621
|
+
sourceKind: `${item?.sourceKind || sourceKey}`.trim().toLowerCase(),
|
|
622
|
+
mediaKind: `${item?.mediaKind || 'audio'}`.trim().toLowerCase(),
|
|
623
|
+
mimeType: `${item?.mimeType || 'application/octet-stream'}`.trim().toLowerCase(),
|
|
624
|
+
metadata: item?.metadata && typeof item.metadata === 'object'
|
|
625
|
+
? item.metadata
|
|
626
|
+
: {},
|
|
627
|
+
};
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
#mapSession(row) {
|
|
632
|
+
const metadata = this.#parseJson(row.metadata_json, {});
|
|
633
|
+
return {
|
|
634
|
+
id: row.id,
|
|
635
|
+
title: row.title || 'Recording',
|
|
636
|
+
platform: row.platform || 'unknown',
|
|
637
|
+
status: row.status || SESSION_STATUS.recording,
|
|
638
|
+
transcriptText: row.transcript_text || '',
|
|
639
|
+
transcriptLanguage: row.transcript_language || DEFAULT_LANGUAGE,
|
|
640
|
+
transcriptModel: row.transcript_model || DEFAULT_MODEL,
|
|
641
|
+
startedAt: row.started_at,
|
|
642
|
+
endedAt: row.ended_at,
|
|
643
|
+
durationMs: Number(row.duration_ms) || 0,
|
|
644
|
+
lastError: row.last_error,
|
|
645
|
+
metadata,
|
|
646
|
+
sourceCount: Number(
|
|
647
|
+
db.prepare('SELECT COUNT(*) AS count FROM recording_sources WHERE session_id = ?').get(row.id).count,
|
|
648
|
+
) || 0,
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
#mapSource(row) {
|
|
653
|
+
return {
|
|
654
|
+
id: row.id,
|
|
655
|
+
sourceKey: row.source_key,
|
|
656
|
+
sourceKind: row.source_kind,
|
|
657
|
+
mediaKind: row.media_kind,
|
|
658
|
+
mimeType: row.mime_type,
|
|
659
|
+
status: row.status,
|
|
660
|
+
chunkCount: Number(row.chunk_count) || 0,
|
|
661
|
+
bytesReceived: Number(row.bytes_received) || 0,
|
|
662
|
+
durationMs: Number(row.duration_ms) || 0,
|
|
663
|
+
metadata: this.#parseJson(row.metadata_json, {}),
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
#mapSegment(row) {
|
|
668
|
+
return {
|
|
669
|
+
id: Number(row.id),
|
|
670
|
+
sourceKey: row.source_key,
|
|
671
|
+
speaker: row.speaker || row.source_key || 'source',
|
|
672
|
+
text: row.text || '',
|
|
673
|
+
startMs: Number(row.start_ms) || 0,
|
|
674
|
+
endMs: Number(row.end_ms) || 0,
|
|
675
|
+
confidence: row.confidence == null ? null : Number(row.confidence),
|
|
676
|
+
words: this.#parseJson(row.words_json, []),
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
#emitUpdate(userId, sessionId) {
|
|
681
|
+
this.io?.to?.(`user:${userId}`)?.emit('recordings:updated', { sessionId });
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
#extensionForMime(mimeType) {
|
|
685
|
+
if (/wav/i.test(mimeType)) return '.wav';
|
|
686
|
+
if (/webm/i.test(mimeType)) return '.webm';
|
|
687
|
+
if (/mp4|m4a/i.test(mimeType)) return '.m4a';
|
|
688
|
+
if (/ogg|opus/i.test(mimeType)) return '.ogg';
|
|
689
|
+
return '.bin';
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
#formatTimestamp(ms) {
|
|
693
|
+
const totalSeconds = Math.max(0, Math.floor((Number(ms) || 0) / 1000));
|
|
694
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
695
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
696
|
+
const seconds = totalSeconds % 60;
|
|
697
|
+
if (hours > 0) {
|
|
698
|
+
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
|
699
|
+
}
|
|
700
|
+
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
#parseJson(value, fallback) {
|
|
704
|
+
try {
|
|
705
|
+
return value ? JSON.parse(value) : fallback;
|
|
706
|
+
} catch {
|
|
707
|
+
return fallback;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
module.exports = {
|
|
713
|
+
RecordingManager,
|
|
714
|
+
SESSION_STATUS,
|
|
715
|
+
};
|