@gotcos/glasses-server 6.45.5 → 6.46.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 +12 -0
- package/package.json +2 -2
- package/server/index.ts +2 -0
- package/server/lib/cos-operations-meetings.ts +1 -1
- package/server/lib/g2-ops-handoff.ts +27 -5
- package/server/lib/held-naming-batches.ts +527 -0
- package/server/lib/held-voice-groups.ts +103 -12
- package/server/lib/meeting-batch-persistence.ts +1 -0
- package/server/lib/meeting-corrections.ts +43 -5
- package/server/lib/meeting-speaker-labels.ts +192 -0
- package/server/lib/meeting-speaker-review.ts +18 -5
- package/server/lib/meeting-sync-lock.ts +26 -0
- package/server/lib/speaker-embeddings.ts +1 -1
- package/server/lib/speaker-rename-fanout.ts +91 -177
- package/server/lib/training-audio-save.ts +24 -0
- package/server/lib/voice-directory.ts +2 -1
- package/server/lib/voice-enrolment-selection.ts +25 -0
- package/server/lib/voice-profile-store.ts +8 -3
- package/server/routes/meeting.ts +5 -1
- package/server/routes/transcribe-stream.ts +13 -41
- package/server/routes/voice.ts +29 -43
|
@@ -1,57 +1,11 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// ---------------------------------------------------------------------------
|
|
6
|
-
// `merge-profiles` folds two voice profiles together and relabels the calibration log.
|
|
7
|
-
// That is ALL it has ever touched -- verified in the handler, not assumed. Meetings keep
|
|
8
|
-
// the speaker strings that were written at transcription time, and the review panel
|
|
9
|
-
// re-reads those strings from disk on every request, so a merge is invisible to every
|
|
10
|
-
// meeting that already exists.
|
|
11
|
-
//
|
|
12
|
-
// Measured on this machine when the Luke H / Luke Henry merge ran: the split had been
|
|
13
|
-
// live since 2026-03-25, and every affected meeting would have rendered two Lukes
|
|
14
|
-
// forever. The merge fixed identification going FORWARD and nothing behind it.
|
|
15
|
-
//
|
|
16
|
-
// ---------------------------------------------------------------------------
|
|
17
|
-
// WHY IT DOES NOT REUSE THE PER-MEETING RELABEL ROUTE
|
|
18
|
-
// ---------------------------------------------------------------------------
|
|
19
|
-
// `POST /api/meeting/:id/relabel` also calls `enrolNamedVoice`, which folds that
|
|
20
|
-
// meeting's audio into the target profile. That is correct when a human names a voice:
|
|
21
|
-
// the embedding genuinely should learn it.
|
|
22
|
-
//
|
|
23
|
-
// It is WRONG for a merge fan-out, and not by a little. The merge has already absorbed
|
|
24
|
-
// those embeddings; re-enrolling the same audio across every affected meeting would
|
|
25
|
-
// double-count it and drag the centroid further. That matters here specifically because
|
|
26
|
-
// the Luke merge already moved a neighbouring speaker's similarity from 0.818 to 0.842,
|
|
27
|
-
// and 0.842 is close enough to the identification threshold to start producing wrong
|
|
28
|
-
// attributions.
|
|
29
|
-
//
|
|
30
|
-
// So this is a PURE STRING REWRITE. Same two primitives the relabel route uses --
|
|
31
|
-
// `relabelSidecarJson` and `relabelMeetingMarkdown` -- minus the enrolment.
|
|
32
|
-
//
|
|
33
|
-
// ---------------------------------------------------------------------------
|
|
34
|
-
// SAFETY
|
|
35
|
-
// ---------------------------------------------------------------------------
|
|
36
|
-
// This rewrites production meeting records, so:
|
|
37
|
-
// - DRY RUN IS THE DEFAULT. Writing requires asking for it.
|
|
38
|
-
// - Atomic writes only, via the same helper the rest of the server uses.
|
|
39
|
-
// - iCloud conflict copies are skipped by construction. Desktop-and-Documents sync
|
|
40
|
-
// creates `2026-08 2/` and `meeting 2.md`; rewriting one of those would edit a file
|
|
41
|
-
// nothing reads while leaving the real one stale. Month directories must match
|
|
42
|
-
// `YYYY-MM` exactly.
|
|
43
|
-
// - Every skip is REPORTED rather than silently dropped, because a fan-out that
|
|
44
|
-
// quietly missed files is worse than one that refused.
|
|
1
|
+
// Merge fan-out carries exact speaker names through the durable correction core.
|
|
2
|
+
// Enrollment stays off: the profile merge already absorbed those voice samples.
|
|
3
|
+
// Missing copies, unavailable raw maps and legacy markdown-only records are
|
|
4
|
+
// explicit skips. A merge must never silently fall back to a chunks-only write.
|
|
45
5
|
|
|
46
|
-
import { readFileSync, readdirSync,
|
|
6
|
+
import { readFileSync, readdirSync, lstatSync, realpathSync } from 'node:fs'
|
|
47
7
|
import { join } from 'node:path'
|
|
48
|
-
import {
|
|
49
|
-
import {
|
|
50
|
-
relabelSidecarJson,
|
|
51
|
-
relabelMeetingMarkdown,
|
|
52
|
-
type SidecarRelabelResult,
|
|
53
|
-
type MarkdownRelabelResult,
|
|
54
|
-
} from './meeting-relabel.js'
|
|
8
|
+
import { renameMeetingSpeaker } from './held-naming-batches.js'
|
|
55
9
|
|
|
56
10
|
/** A month directory, and nothing that merely looks like one. */
|
|
57
11
|
const CANONICAL_MONTH = /^\d{4}-\d{2}$/
|
|
@@ -110,7 +64,7 @@ function safeReaddir(dir: string): string[] {
|
|
|
110
64
|
|
|
111
65
|
function isDir(path: string): boolean {
|
|
112
66
|
try {
|
|
113
|
-
return
|
|
67
|
+
return lstatSync(path).isDirectory() && !lstatSync(path).isSymbolicLink()
|
|
114
68
|
} catch {
|
|
115
69
|
return false
|
|
116
70
|
}
|
|
@@ -156,156 +110,116 @@ function monthsIn(meetingsDir: string): string[] {
|
|
|
156
110
|
}
|
|
157
111
|
|
|
158
112
|
/**
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
* one bad file: an unreadable or unparsable record is reported as a skip and the sweep
|
|
163
|
-
* continues, because stopping halfway would leave the library in a half-renamed state
|
|
164
|
-
* that is worse than either end.
|
|
113
|
+
* Preserve the existing report/error shape while executing each session through
|
|
114
|
+
* the shared two-copy, ledgered correction core. Dry-run remains the default.
|
|
115
|
+
* Sequential awaits are intentional: all correction entrypoints share one lock.
|
|
165
116
|
*/
|
|
166
|
-
export function fanOutSpeakerRename(
|
|
117
|
+
export async function fanOutSpeakerRename(
|
|
167
118
|
operationsDir: string,
|
|
168
119
|
from: string,
|
|
169
120
|
to: string,
|
|
170
121
|
options: FanOutOptions = {},
|
|
171
|
-
): SpeakerRenameFanOut {
|
|
122
|
+
): Promise<SpeakerRenameFanOut> {
|
|
172
123
|
const apply = options.apply === true
|
|
173
124
|
const result: SpeakerRenameFanOut = {
|
|
174
125
|
from, to, dryRun: !apply, sidecars: [], markdown: [], scanned: 0, skipped: [],
|
|
175
126
|
}
|
|
176
127
|
if (!from.trim() || !to.trim() || from === to) return result
|
|
177
|
-
|
|
128
|
+
const entries = new Map<string, string>()
|
|
129
|
+
const canonicalSources = new Map<string, string>()
|
|
178
130
|
for (const monthDir of meetingMonthDirs(operationsDir, options.includeArchive === true)) {
|
|
179
|
-
for (const name of safeReaddir(monthDir)) {
|
|
180
|
-
|
|
181
|
-
//
|
|
182
|
-
// The marker sits before the EXTENSION CHAIN, not just before the last dot.
|
|
183
|
-
// iCloud writes `sync.g2-chunks 2.json` for a sidecar and `sync 2.md` for a
|
|
184
|
-
// transcript, and a sidecar belonging to an already-conflicted meeting comes
|
|
185
|
-
// out as `sync 2.g2-chunks.json`. A first cut anchored the digit to the final
|
|
186
|
-
// extension and let that third form straight through -- it would have rewritten
|
|
187
|
-
// a duplicate nobody reads while the real file kept the old name.
|
|
131
|
+
for (const name of safeReaddir(monthDir).sort()) {
|
|
132
|
+
const path = join(monthDir, name)
|
|
188
133
|
if (/ \d+(\.[A-Za-z0-9-]+)*\.(md|json)$/.test(name)) {
|
|
189
|
-
result.skipped.push({ path
|
|
134
|
+
result.skipped.push({ path, reason: 'icloud conflict copy' })
|
|
190
135
|
continue
|
|
191
136
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
if (!isSidecar && !isMarkdown) continue
|
|
195
|
-
|
|
196
|
-
const path = join(monthDir, name)
|
|
197
|
-
result.scanned += 1
|
|
198
|
-
let raw: string
|
|
137
|
+
if (!name.endsWith(SIDECAR_SUFFIX) && !name.endsWith('.md')) continue
|
|
138
|
+
result.scanned++
|
|
199
139
|
try {
|
|
200
|
-
|
|
140
|
+
const stat = lstatSync(path)
|
|
141
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('not a canonical regular file')
|
|
142
|
+
const raw = readFileSync(path, 'utf8')
|
|
143
|
+
entries.set(path, raw)
|
|
144
|
+
canonicalSources.set(realpathSync(path), raw)
|
|
201
145
|
} catch (error) {
|
|
202
|
-
result.skipped.push({ path, reason: `unreadable: ${(error
|
|
203
|
-
continue
|
|
146
|
+
result.skipped.push({ path, reason: `unreadable: ${errorText(error)}` })
|
|
204
147
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
if (
|
|
219
|
-
|
|
220
|
-
if (!outcome.ok) {
|
|
221
|
-
if (!isNoMatch(outcome.error)) result.skipped.push({ path, reason: outcome.error })
|
|
222
|
-
continue
|
|
223
|
-
}
|
|
224
|
-
const value: SidecarRelabelResult = outcome.value
|
|
225
|
-
next = value.json
|
|
226
|
-
labels = value.changed.length
|
|
227
|
-
// The primitive's own invariant, carried rather than assumed: a partial relabel
|
|
228
|
-
// means chunks still hold the old name, and the markdown must NOT then be
|
|
229
|
-
// rewritten by label. Reported so the operator sees it.
|
|
230
|
-
if (value.remainingWithFrom > 0) {
|
|
231
|
-
note = `${value.remainingWithFrom} chunk(s) still carry "${from}"`
|
|
232
|
-
}
|
|
233
|
-
} else {
|
|
234
|
-
// A SECOND TRANSCRIPT FORMAT EXISTS AND THE PRIMITIVE DOES NOT HANDLE IT.
|
|
235
|
-
//
|
|
236
|
-
// `relabelMeetingMarkdown` rewrites `[Name]:` turn labels, anchored to line
|
|
237
|
-
// start. Measured across the real library for the Luke rename: 17 files and 97
|
|
238
|
-
// labels in that form -- and 20 files, 77 labels in a `**Name**` form it does
|
|
239
|
-
// not match, 13 of those files in LIVE quilt meetings, not the archive.
|
|
240
|
-
//
|
|
241
|
-
// That is a 39% silent miss. Detected and REPORTED rather than fixed here: the
|
|
242
|
-
// primitive is shared with the live per-meeting relabel route, and widening its
|
|
243
|
-
// matcher changes behaviour for a path nobody asked me to touch. The operator
|
|
244
|
-
// gets a number instead of a surprise.
|
|
245
|
-
const unhandled = countBoldLabels(raw, from)
|
|
246
|
-
const outcome = relabelMeetingMarkdown(raw, from, to)
|
|
247
|
-
if (!outcome.ok) {
|
|
248
|
-
if (!isNoMatch(outcome.error)) result.skipped.push({ path, reason: outcome.error })
|
|
249
|
-
continue
|
|
250
|
-
}
|
|
251
|
-
const value: MarkdownRelabelResult = outcome.value
|
|
252
|
-
next = value.markdown
|
|
253
|
-
labels = value.attendees + value.transcript
|
|
254
|
-
// Narrative prose is deliberately untouched by the primitive. Surfaced here so a
|
|
255
|
-
// stale summary is something the operator knows about rather than discovers.
|
|
256
|
-
const notes: string[] = []
|
|
257
|
-
if (unhandled > 0) notes.push(`${unhandled} label(s) in an unhandled **${from}** format`)
|
|
258
|
-
if (value.proseStale) {
|
|
259
|
-
notes.push(`prose still mentions "${from}"${value.proseHits.length ? `: ${value.proseHits.slice(0, 3).join(', ')}` : ''}`)
|
|
260
|
-
}
|
|
261
|
-
if (notes.length > 0) note = notes.join('; ')
|
|
262
|
-
|
|
263
|
-
// A file whose ONLY labels are in the unhandled form changes nothing, and would
|
|
264
|
-
// otherwise fall out of the report entirely -- the exact silent miss this guard
|
|
265
|
-
// exists to prevent. Recorded as a skip so it is visible.
|
|
266
|
-
if (labels === 0 && unhandled > 0) {
|
|
267
|
-
result.skipped.push({ path, reason: `${unhandled} label(s) in an unhandled **${from}** format` })
|
|
268
|
-
continue
|
|
269
|
-
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const sessions = new Set<string>()
|
|
151
|
+
const coveredMarkdown = new Set<string>()
|
|
152
|
+
for (const [path, raw] of entries) {
|
|
153
|
+
if (!path.endsWith(SIDECAR_SUFFIX)) continue
|
|
154
|
+
const markdownPath = path.slice(0, -SIDECAR_SUFFIX.length) + '.md'
|
|
155
|
+
coveredMarkdown.add(markdownPath)
|
|
156
|
+
if (!raw.includes(from)) continue
|
|
157
|
+
try {
|
|
158
|
+
const doc = JSON.parse(raw)
|
|
159
|
+
if (!doc || Array.isArray(doc) || typeof doc !== 'object') throw new Error('sidecar is not a JSON object')
|
|
160
|
+
if (!carriesSpeaker(doc, from)) continue
|
|
161
|
+
if (typeof doc.sessionId !== 'string' || !doc.sessionId.trim()) {
|
|
162
|
+
throw new Error('missing session identity; cannot verify both meeting copies')
|
|
270
163
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
164
|
+
if (sessions.has(doc.sessionId)) {
|
|
165
|
+
result.skipped.push({ path, reason: 'duplicate session sidecar; session already examined' })
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
sessions.add(doc.sessionId)
|
|
169
|
+
const outcome = await renameMeetingSpeaker(doc.sessionId, from, to, {
|
|
170
|
+
apply, operationsDir: realpathSync(operationsDir), expectedSidecarPath: path,
|
|
171
|
+
})
|
|
172
|
+
if (outcome.error) throw new Error(outcome.error)
|
|
173
|
+
for (const copy of outcome.copies) {
|
|
174
|
+
const note = copy.unresolvedTurns > 0
|
|
175
|
+
? `${copy.unresolvedTurns} transcript turn(s) could not be aligned; labels newer than graph`
|
|
176
|
+
: undefined
|
|
177
|
+
if (copy.labels > 0) result.sidecars.push({ path: copy.sidecarPath, labels: copy.labels, note })
|
|
178
|
+
if (copy.transcript + copy.attendees > 0) {
|
|
179
|
+
const prose = canonicalSources.get(copy.meetingPath) ?? entries.get(copy.meetingPath)
|
|
180
|
+
const stale = prose && proseMentions(prose, from) ? `prose still mentions "${from}"` : undefined
|
|
181
|
+
result.markdown.push({ path: copy.meetingPath, labels: copy.transcript + copy.attendees,
|
|
182
|
+
note: [note, stale].filter(Boolean).join('; ') || undefined })
|
|
279
183
|
}
|
|
184
|
+
coveredMarkdown.add(copy.meetingPath)
|
|
280
185
|
}
|
|
281
|
-
|
|
186
|
+
} catch (error) {
|
|
187
|
+
result.skipped.push({ path, reason: `${apply ? 'write failed' : 'preview refused'}: ${errorText(error)}` })
|
|
282
188
|
}
|
|
283
189
|
}
|
|
190
|
+
// Cloud-only and archived markdown has no raw G2 position authority. Keep it
|
|
191
|
+
// visible in the report rather than rewriting an attendee or quoted name.
|
|
192
|
+
for (const [path, raw] of entries) {
|
|
193
|
+
if (!path.endsWith('.md') || coveredMarkdown.has(path) || !raw.includes(from)) continue
|
|
194
|
+
if (hasNamedLabel(raw, from)) result.skipped.push({
|
|
195
|
+
path, reason: 'no G2 session sidecar; transcript labels cannot be safely aligned',
|
|
196
|
+
})
|
|
197
|
+
}
|
|
284
198
|
return result
|
|
285
199
|
}
|
|
286
200
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
*
|
|
290
|
-
* SKIPS ARE FOR THINGS A HUMAN SHOULD LOOK AT. The loose pre-filter above lets through
|
|
291
|
-
* every file holding the NEW name, because the old one is a substring of it -- measured
|
|
292
|
-
* on the real library, that padded the skip list with 15 files that were never affected.
|
|
293
|
-
* A report where most entries are non-issues is one nobody reads, and it hides the two
|
|
294
|
-
* that matter.
|
|
295
|
-
*/
|
|
296
|
-
function isNoMatch(error: string): boolean {
|
|
297
|
-
return /no chunk carries|not found|does not appear|no .* labelled/i.test(error)
|
|
201
|
+
function errorText(error: unknown): string {
|
|
202
|
+
return error instanceof Error ? error.message : String(error)
|
|
298
203
|
}
|
|
299
204
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
205
|
+
function carriesSpeaker(doc: Record<string, any>, name: string): boolean {
|
|
206
|
+
return (Array.isArray(doc.chunks) && doc.chunks.some((chunk: any) => chunk?.speaker === name))
|
|
207
|
+
|| (Array.isArray(doc.chunkEntries) && doc.chunkEntries.some((entry: any) => entry?.chunk?.speaker === name))
|
|
208
|
+
|| (Array.isArray(doc.batchSegments) && doc.batchSegments.some((segment: any) =>
|
|
209
|
+
Array.isArray(segment?.speakerWords) && segment.speakerWords.some((word: any) => word?.speaker === name)))
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function escapedName(name: string): string { return name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') }
|
|
213
|
+
function hasNamedLabel(markdown: string, name: string): boolean {
|
|
214
|
+
const escaped = escapedName(name)
|
|
215
|
+
return new RegExp(`^(?:\\[${escaped}\\]:|\\*\\*${escaped}(?::\\*\\*|\\*\\*)|[-*] ${escaped}$)`, 'm').test(markdown)
|
|
216
|
+
}
|
|
217
|
+
function proseMentions(markdown: string, name: string): boolean {
|
|
218
|
+
const prose = markdown.replace(/^## (?:Attendees|Transcript)[^\n]*\n[\s\S]*?(?=^## |$(?![\s\S]))/gm, '')
|
|
219
|
+
return new RegExp(`(?<![\\p{L}\\p{N}])${escapedName(name)}(?![\\p{L}\\p{N}])`, 'u').test(prose)
|
|
310
220
|
}
|
|
311
221
|
|
|
222
|
+
/** Kept for callers that count the older bold speaker-label format. */
|
|
223
|
+
export function countBoldLabels(markdown: string, name: string): number {
|
|
224
|
+
return (markdown.match(new RegExp(`^\\*\\*${escapedName(name)}\\*\\*`, 'gm')) ?? []).length
|
|
225
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs'
|
|
2
|
+
import { writeFile } from 'node:fs/promises'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
export const MAX_SAVED_TRAINING_CHUNKS = 30
|
|
6
|
+
const pending = new Map<string, Set<string>>()
|
|
7
|
+
/** Disk is authoritative after training or TTL deletion. Reservations cover the
|
|
8
|
+
* asynchronous write window so concurrent captures cannot exceed the cap. */
|
|
9
|
+
export async function saveTrainingAudioSample(directory: string, filename: string, audio: Buffer): Promise<boolean> {
|
|
10
|
+
const files = new Set(readdirSync(directory).filter(f => f.endsWith('.wav')))
|
|
11
|
+
const reservations = pending.get(directory) ?? new Set<string>()
|
|
12
|
+
if (files.has(filename) || reservations.has(filename)) return false
|
|
13
|
+
const count = new Set([...files, ...reservations]).size
|
|
14
|
+
if (count >= MAX_SAVED_TRAINING_CHUNKS) return false
|
|
15
|
+
reservations.add(filename)
|
|
16
|
+
pending.set(directory, reservations)
|
|
17
|
+
try {
|
|
18
|
+
await writeFile(join(directory, filename), audio, { mode: 0o600 })
|
|
19
|
+
return true
|
|
20
|
+
} finally {
|
|
21
|
+
reservations.delete(filename)
|
|
22
|
+
if (!reservations.size) pending.delete(directory)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -14,7 +14,7 @@ import { lstat, readFile, readdir, realpath } from 'node:fs/promises'
|
|
|
14
14
|
import { basename, dirname, join } from 'node:path'
|
|
15
15
|
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
|
|
16
16
|
import { dataPath } from './data-dir.js'
|
|
17
|
-
import { confirmedLabels } from './meeting-corrections.js'
|
|
17
|
+
import { confirmedLabels, confirmedChunks } from './meeting-corrections.js'
|
|
18
18
|
import { resolveCosOperationsDir, resolveMeetingLibrary } from './cos-operations-meetings.js'
|
|
19
19
|
import {
|
|
20
20
|
isUnattributed,
|
|
@@ -298,6 +298,7 @@ export async function buildVoiceDirectorySnapshot(): Promise<VoiceDirectorySnaps
|
|
|
298
298
|
owner,
|
|
299
299
|
phrasesPerVoice: 1,
|
|
300
300
|
confirmed: confirmedLabels(sessionId),
|
|
301
|
+
confirmedChunks: confirmedChunks(sessionId),
|
|
301
302
|
durationMs: typeof record.durationMs === 'number' ? record.durationMs : undefined,
|
|
302
303
|
batchSegments: Array.isArray(record.batchSegments)
|
|
303
304
|
? record.batchSegments as SpeakerWordSegment[]
|
|
@@ -226,3 +226,28 @@ export function greedyDiversitySelect(embeddings: Float32Array[], maxN: number):
|
|
|
226
226
|
|
|
227
227
|
return [...selected].map(i => embeddings[i])
|
|
228
228
|
}
|
|
229
|
+
|
|
230
|
+
/** A mutually coherent cluster anchored to EVERY explicitly named sample.
|
|
231
|
+
* Indices refer to [...seedMembers, ...candidates]. A larger unrelated voice
|
|
232
|
+
* can never win. Inconsistent seeds fail closed; a valid seed with no wider
|
|
233
|
+
* match remains represented so the caller may still label its exact position.
|
|
234
|
+
*/
|
|
235
|
+
export function coherentClusterContaining(
|
|
236
|
+
seedMembers: Float32Array[], candidates: Float32Array[], floor = VOICE_COHERENCE_FLOOR,
|
|
237
|
+
): CoherentCluster {
|
|
238
|
+
if (!seedMembers.length) return { members: [], seed: -1 }
|
|
239
|
+
const all = [...seedMembers, ...candidates]
|
|
240
|
+
const valid = (v: Float32Array) => v.length === seedMembers[0].length
|
|
241
|
+
&& v.length > 0 && Array.from(v).every(Number.isFinite) && v.some(n => n !== 0)
|
|
242
|
+
if (!Number.isFinite(floor) || seedMembers.some(v => !valid(v))) return { members: [], seed: -1 }
|
|
243
|
+
const sim = pairwiseSimilarityMatrix(all)
|
|
244
|
+
const members = seedMembers.map((_, i) => i)
|
|
245
|
+
for (const i of members) for (const j of members) {
|
|
246
|
+
if (i !== j && !(sim[i][j] >= floor)) return { members: [], seed: -1 }
|
|
247
|
+
}
|
|
248
|
+
const eligible = candidates.map((_, i) => i + seedMembers.length)
|
|
249
|
+
.filter(i => valid(all[i]))
|
|
250
|
+
.sort((a, b) => Math.min(...members.map(j => sim[b][j])) - Math.min(...members.map(j => sim[a][j])) || a - b)
|
|
251
|
+
for (const i of eligible) if (members.every(j => sim[i][j] >= floor)) members.push(i)
|
|
252
|
+
return { members: members.sort((a,b) => a-b), seed: 0 }
|
|
253
|
+
}
|
|
@@ -15,6 +15,7 @@ import { checkSpeakerName, type SpeakerNameRejection } from './speaker-name.js'
|
|
|
15
15
|
import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs'
|
|
16
16
|
import { basename, dirname, join, resolve } from 'node:path'
|
|
17
17
|
import { durableAtomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
|
|
18
|
+
import { EVICTION_ORDER, provenanceTier } from './embedding-eviction.js'
|
|
18
19
|
|
|
19
20
|
export interface VoiceProfile {
|
|
20
21
|
name: string
|
|
@@ -454,7 +455,7 @@ export function profileSimilarity(a: VoiceProfile, b: VoiceProfile): number {
|
|
|
454
455
|
/** Pick the N most acoustically diverse samples, carrying provenance along.
|
|
455
456
|
*
|
|
456
457
|
* A merge routinely produces more samples than the per-speaker cap (two
|
|
457
|
-
* capped profiles make
|
|
458
|
+
* capped profiles make 80 against a cap of 40), and which 40 survive matters:
|
|
458
459
|
* taking the first N would keep one profile's acoustic conditions and discard
|
|
459
460
|
* the other's, which is the opposite of what merging is for. Greedy
|
|
460
461
|
* max-min-distance keeps the spread.
|
|
@@ -517,7 +518,7 @@ export function mergeProfilesInStore(
|
|
|
517
518
|
from: string[],
|
|
518
519
|
options: { cap?: number } = {},
|
|
519
520
|
): MergeOutcome {
|
|
520
|
-
const cap = options.cap ??
|
|
521
|
+
const cap = options.cap ?? 40
|
|
521
522
|
const target = store.profiles.find(p => p.name === into)
|
|
522
523
|
const outcome: MergeOutcome = {
|
|
523
524
|
similarity: {},
|
|
@@ -552,7 +553,11 @@ export function mergeProfilesInStore(
|
|
|
552
553
|
|
|
553
554
|
if (outcome.mergedFrom.length === 0) return outcome
|
|
554
555
|
|
|
555
|
-
|
|
556
|
+
// Preserve stronger label provenance. Within one
|
|
557
|
+
// source tier retain newer evidence, matching weakest-first/oldest eviction.
|
|
558
|
+
const keep = embeddings.map((_, i) => i).sort((a, b) =>
|
|
559
|
+
EVICTION_ORDER.indexOf(provenanceTier(sources[b])) - EVICTION_ORDER.indexOf(provenanceTier(sources[a])) || b - a,
|
|
560
|
+
).slice(0, cap).sort((a, b) => a - b)
|
|
556
561
|
outcome.droppedToCap = embeddings.length - keep.length
|
|
557
562
|
target.embeddings = keep.map(i => embeddings[i])
|
|
558
563
|
target.sources = keep.map(i => sources[i])
|
package/server/routes/meeting.ts
CHANGED
|
@@ -15,7 +15,8 @@ import { sendAudioFile } from '../lib/send-audio.js'
|
|
|
15
15
|
import { adaptivePlaybackAudio } from '../lib/adaptive-playback-audio.js'
|
|
16
16
|
import { chunkDiagnostics } from '../lib/chunk-embedding-diagnostics.js'
|
|
17
17
|
import { errMsg } from '../lib/utils.js'
|
|
18
|
-
import {
|
|
18
|
+
import { setNamingSessionLiveness } from '../lib/held-naming-batches.js'
|
|
19
|
+
import { confirmedLabels, confirmedChunks } from '../lib/meeting-corrections.js'
|
|
19
20
|
import {
|
|
20
21
|
extAudioChunkPath,
|
|
21
22
|
listExtAudioChunks,
|
|
@@ -457,6 +458,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
457
458
|
const finalizationJobs = deps.finalizationJobs ?? new MeetingFinalizationJobStore()
|
|
458
459
|
const router = Router()
|
|
459
460
|
const savingSessions = new Set<string>()
|
|
461
|
+
setNamingSessionLiveness(id => sessions.getStartTime(id) !== null || savingSessions.has(id) || activeFinalizationJobs.has(id))
|
|
460
462
|
|
|
461
463
|
router.get('/meeting/sessions/:sessionId/status', (req, res) => {
|
|
462
464
|
const sessionId = String(req.params.sessionId ?? '')
|
|
@@ -773,6 +775,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
773
775
|
// floor re-demotes a confirmed name on every reload, and the reviewer
|
|
774
776
|
// confirms the same voice forever.
|
|
775
777
|
confirmed: confirmedLabels(sessionId),
|
|
778
|
+
confirmedChunks: confirmedChunks(sessionId),
|
|
776
779
|
phrasesPerVoice: Math.max(1, Math.min(6, Number(req.query.phrases) || 3)),
|
|
777
780
|
// The sidecar's own durationMs is the meeting's true end. Deriving it from
|
|
778
781
|
// max(elapsed) uses the START of the last chunk, which made the final
|
|
@@ -870,6 +873,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
870
873
|
const review = reviewMeetingSpeakers(attachRawChunkIndices(chunks, sidecar.chunkEntries), {
|
|
871
874
|
owner: getOwnerSpeakerLabel(),
|
|
872
875
|
confirmed: confirmedLabels(sessionId),
|
|
876
|
+
confirmedChunks: confirmedChunks(sessionId),
|
|
873
877
|
// This route renders no phrases; the default of 3 per voice computed and
|
|
874
878
|
// discarded 48 transcript excerpts on a 16-voice meeting.
|
|
875
879
|
phrasesPerVoice: 1,
|
|
@@ -39,7 +39,7 @@ import { errMsg } from '../lib/utils.js'
|
|
|
39
39
|
import { transcribeLocal, applyCorrections, type WhisperWord } from '../lib/whisper-local.js'
|
|
40
40
|
import { enhanceAudio } from '../lib/audio-enhance.js'
|
|
41
41
|
import { trimSilence, isSileroAvailable } from '../lib/vad-silero.js'
|
|
42
|
-
import { identifySpeaker, isEmbeddingAvailable, autoEnroll,
|
|
42
|
+
import { identifySpeaker, isEmbeddingAvailable, autoEnroll, AUTO_ENROLL_CANDIDATE_SIMILARITY } from '../lib/speaker-embeddings.js'
|
|
43
43
|
import {
|
|
44
44
|
assertOpenAIWhisperBudget,
|
|
45
45
|
recordOpenAIWhisperUsage,
|
|
@@ -78,6 +78,7 @@ import {
|
|
|
78
78
|
sweepOrphanedSessionAudio,
|
|
79
79
|
} from '../lib/unsaved-audio-quarantine.js'
|
|
80
80
|
import { durableAtomicWriteFileSync } from '../lib/atomic-fs.js'
|
|
81
|
+
import { saveTrainingAudioSample } from '../lib/training-audio-save.js'
|
|
81
82
|
import {
|
|
82
83
|
LOCAL_FIRST_MEETING_IDLE_RETENTION_MS,
|
|
83
84
|
compressIndexRanges,
|
|
@@ -157,7 +158,6 @@ function meetingTurboPreviewEnabled(): boolean {
|
|
|
157
158
|
// Audio persistence: save G2-mic chunks for speakers who need more training data
|
|
158
159
|
const AUDIO_SAVE_DIR = dataPath('training-audio')
|
|
159
160
|
ensurePrivateDirectory(AUDIO_SAVE_DIR)
|
|
160
|
-
const MAX_SAVED_CHUNKS_PER_SPEAKER = 30 // ~5 min of audio per speaker, cleaned after training
|
|
161
161
|
// Age bound. The count cap above is NOT a retention policy: a speaker who never
|
|
162
162
|
// gets trained keeps 30 WAVs of their voice indefinitely, and the only cleanup
|
|
163
163
|
// path was a manual /voice/train-g2 call. ext-audio has had a 72h sweep since it
|
|
@@ -218,25 +218,6 @@ function hasFreshPreservedAudioMarker(dirPath: string): boolean {
|
|
|
218
218
|
}
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
-
// In-memory training audio counts — lazy-initialized from disk on first access per speaker
|
|
222
|
-
const trainingAudioCounts = new Map<string, number>()
|
|
223
|
-
function getTrainingCount(speakerDir: string): number {
|
|
224
|
-
let count = trainingAudioCounts.get(speakerDir)
|
|
225
|
-
if (count === undefined) {
|
|
226
|
-
try {
|
|
227
|
-
if (existsSync(speakerDir)) {
|
|
228
|
-
count = readdirSync(speakerDir).filter((f: string) => f.endsWith('.wav')).length
|
|
229
|
-
} else {
|
|
230
|
-
count = 0
|
|
231
|
-
}
|
|
232
|
-
} catch {
|
|
233
|
-
count = 0
|
|
234
|
-
}
|
|
235
|
-
trainingAudioCounts.set(speakerDir, count)
|
|
236
|
-
}
|
|
237
|
-
return count
|
|
238
|
-
}
|
|
239
|
-
|
|
240
221
|
function sha256Hex(buffer: Buffer): string {
|
|
241
222
|
return createHash('sha256').update(buffer).digest('hex')
|
|
242
223
|
}
|
|
@@ -1907,26 +1888,17 @@ function identifyChunkSpeaker(audioBuffer: Buffer, sessionId: string, chunkIndex
|
|
|
1907
1888
|
}
|
|
1908
1889
|
|
|
1909
1890
|
if (speaker !== 'Ext' && embeddingResult.similarity > 0.50) {
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
)
|
|
1922
|
-
trainingAudioCounts.set(speakerDir, existing + 1)
|
|
1923
|
-
console.log(`[training-audio] Saved ${speaker} chunk (sim=${embeddingResult.similarity.toFixed(2)}, ${audioBuffer.length}b, total=${existing + 1})`)
|
|
1924
|
-
}
|
|
1925
|
-
} catch (audioSaveErr: unknown) {
|
|
1926
|
-
console.warn(`[training-audio] Save failed for ${speaker}: ${errMsg(audioSaveErr)}`)
|
|
1927
|
-
}
|
|
1928
|
-
} else if (chunkIndex % 20 === 0) {
|
|
1929
|
-
console.log(`[training-audio] ${speaker} at ${embCount} embeddings (>= 15), skipping save`)
|
|
1891
|
+
// Audio has its own count and TTL. A full profile still needs examples
|
|
1892
|
+
// from new meetings; deleting/expiring old audio immediately frees a slot.
|
|
1893
|
+
try {
|
|
1894
|
+
const speakerDir = resolve(AUDIO_SAVE_DIR, speaker.replace(/\s+/g, '_'))
|
|
1895
|
+
ensurePrivateDirectory(speakerDir)
|
|
1896
|
+
const filename = `${sessionId}_chunk${chunkIndex}_sim${embeddingResult.similarity.toFixed(2)}.wav`
|
|
1897
|
+
saveTrainingAudioSample(speakerDir, filename, audioBuffer).then(saved => {
|
|
1898
|
+
if (saved) console.log(`[training-audio] Saved ${speaker} chunk (sim=${embeddingResult.similarity.toFixed(2)}, ${audioBuffer.length}b)`)
|
|
1899
|
+
}).catch(err => console.warn(`[training-audio] Async save failed for ${speaker}: ${err.message}`))
|
|
1900
|
+
} catch (audioSaveErr: unknown) {
|
|
1901
|
+
console.warn(`[training-audio] Save failed for ${speaker}: ${errMsg(audioSaveErr)}`)
|
|
1930
1902
|
}
|
|
1931
1903
|
}
|
|
1932
1904
|
|