@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
|
@@ -49,11 +49,12 @@ import { dataPath } from './data-dir.js'
|
|
|
49
49
|
import { extAudioChunkPath, listExtAudioChunks } from './meeting-audio-archive.js'
|
|
50
50
|
import { EXPECTED_EMBEDDING_DIM, decodeEmbedding, encodeEmbedding, readChunkEmbeddings } from './chunk-embedding-store.js'
|
|
51
51
|
import {
|
|
52
|
-
AUTO_ENROLL_THRESHOLD, enrollEmbedding, extractEmbedding, isEmbeddingAvailable, rawCosineSimilarity, readVoiceProfiles,
|
|
52
|
+
AUTO_ENROLL_THRESHOLD, MAX_EMBEDDINGS_PER_SPEAKER, enrollEmbedding, extractEmbedding, isEmbeddingAvailable, rawCosineSimilarity, readVoiceProfiles,
|
|
53
53
|
type VoiceProfile,
|
|
54
54
|
} from './speaker-embeddings.js'
|
|
55
|
-
import { vouchesForIdentity } from './embedding-eviction.js'
|
|
55
|
+
import { chooseEviction, vouchesForIdentity } from './embedding-eviction.js'
|
|
56
56
|
import { getOwnerSpeakerLabel } from './profile.js'
|
|
57
|
+
import { alignedSources, appendEmbedding, dropEmbeddingAt, profileSimilarity } from './voice-profile-store.js'
|
|
57
58
|
import {
|
|
58
59
|
MAX_ENROL_PER_CORRECTION,
|
|
59
60
|
VOICE_COHERENCE_FLOOR,
|
|
@@ -112,6 +113,7 @@ export const HELD_GROUP_SOURCE = 'ext-group'
|
|
|
112
113
|
export interface HeldSampleRef {
|
|
113
114
|
sessionId: string
|
|
114
115
|
chunkIndex: number
|
|
116
|
+
suggestion?: HeldGroupSuggestion | null
|
|
115
117
|
}
|
|
116
118
|
|
|
117
119
|
export interface HeldSample extends HeldSampleRef {
|
|
@@ -131,6 +133,10 @@ export interface HeldGroupSuggestion {
|
|
|
131
133
|
of: number
|
|
132
134
|
/** The provenance of the strongest agreeing sample that may vouch. */
|
|
133
135
|
anchor: string
|
|
136
|
+
runnerUpSimilarity?: number
|
|
137
|
+
margin?: number
|
|
138
|
+
ownerSimilarity?: number
|
|
139
|
+
ownerCaution?: boolean
|
|
134
140
|
}
|
|
135
141
|
|
|
136
142
|
export interface HeldVoiceGroup {
|
|
@@ -365,6 +371,9 @@ export function foldDuplicates(sim: number[][], count: number): { reps: number[]
|
|
|
365
371
|
return { reps: [...copies.keys()], copies }
|
|
366
372
|
}
|
|
367
373
|
|
|
374
|
+
export const HELD_SUGGESTION_MIN_MARGIN = 0.05
|
|
375
|
+
export const HELD_NEAR_PROFILE_SIMILARITY = 0.95
|
|
376
|
+
|
|
368
377
|
export interface SuggestOptions {
|
|
369
378
|
/** The wearer's own label. Never offered: one click would write a stranger
|
|
370
379
|
* into the profile that drives owner detection. */
|
|
@@ -386,15 +395,22 @@ export interface SuggestOptions {
|
|
|
386
395
|
export function suggestProfile(embedding: Float32Array, profiles: VoiceProfile[], opts: SuggestOptions = {}): HeldGroupSuggestion | null {
|
|
387
396
|
type Candidate = { name: string; score: number; tier: HeldSuggestionTier; agreeing: number; of: number; anchor: string | null }
|
|
388
397
|
let best: Candidate | null = null
|
|
398
|
+
const candidates: Candidate[] = []
|
|
399
|
+
let ownerScore = 0
|
|
389
400
|
for (const profile of profiles) {
|
|
390
|
-
if (opts.ownerLabel && profile.name === opts.ownerLabel) continue
|
|
391
401
|
const rows: Array<{ sim: number; source: string }> = []
|
|
392
402
|
profile.embeddings.forEach((candidate, i) => {
|
|
393
|
-
if (candidate.length !== embedding.length) return
|
|
403
|
+
if (candidate.length !== embedding.length || !candidate.every(Number.isFinite)) return
|
|
394
404
|
rows.push({ sim: rawCosineSimilarity(embedding, new Float32Array(candidate)), source: profile.sources?.[i] ?? 'unknown' })
|
|
395
405
|
})
|
|
396
406
|
if (rows.length === 0) continue
|
|
397
407
|
rows.sort((a, b) => b.sim - a.sim)
|
|
408
|
+
// Caution is proximity evidence, not permission to suggest/enrol the owner.
|
|
409
|
+
// One very close owner sample warrants listening even without two anchors.
|
|
410
|
+
if (opts.ownerLabel && profile.name.normalize('NFKC').toLowerCase() === opts.ownerLabel.normalize('NFKC').toLowerCase()) {
|
|
411
|
+
ownerScore = Math.max(ownerScore, rows[0].sim)
|
|
412
|
+
continue
|
|
413
|
+
}
|
|
398
414
|
const agreeing = rows.filter(r => r.sim >= HELD_GROUP_SUGGESTION_FLOOR)
|
|
399
415
|
let score: number
|
|
400
416
|
let tier: HeldSuggestionTier
|
|
@@ -412,10 +428,26 @@ export function suggestProfile(embedding: Float32Array, profiles: VoiceProfile[]
|
|
|
412
428
|
name: profile.name, score, tier, agreeing: agreeing.length, of: profile.embeddings.length,
|
|
413
429
|
anchor: anchored ? anchored.source.split(':')[0] : null,
|
|
414
430
|
}
|
|
431
|
+
candidates.push(candidate)
|
|
415
432
|
if (!best || candidate.score > best.score) best = candidate
|
|
416
433
|
}
|
|
417
434
|
if (!best || best.anchor === null) return null
|
|
418
|
-
|
|
435
|
+
const winner = profiles.find(p => p.name === best!.name)!
|
|
436
|
+
// Near-duplicate profile pairs represent an existing identity ambiguity, not
|
|
437
|
+
// independent competing acoustic evidence. Do not let that pair erase the
|
|
438
|
+
// margin against genuinely different voices. Never merge or rename here.
|
|
439
|
+
const runnerUp = candidates.filter(c => c.name !== best!.name).filter(c => {
|
|
440
|
+
const other = profiles.find(p => p.name === c.name)!
|
|
441
|
+
return profileSimilarity(winner, other) < HELD_NEAR_PROFILE_SIMILARITY
|
|
442
|
+
}).reduce((score, c) => Math.max(score, c.score), 0)
|
|
443
|
+
const margin = best.score - runnerUp
|
|
444
|
+
if (margin < HELD_SUGGESTION_MIN_MARGIN) return null
|
|
445
|
+
return {
|
|
446
|
+
name: best.name, similarity: Number(best.score.toFixed(4)), tier: best.tier,
|
|
447
|
+
agreeing: best.agreeing, of: best.of, anchor: best.anchor,
|
|
448
|
+
runnerUpSimilarity: Number(runnerUp.toFixed(4)), margin: Number(margin.toFixed(4)),
|
|
449
|
+
ownerSimilarity: Number(ownerScore.toFixed(4)), ownerCaution: ownerScore >= 0.65 || ownerScore > best.score,
|
|
450
|
+
}
|
|
419
451
|
}
|
|
420
452
|
|
|
421
453
|
export interface BuildOptions extends SuggestOptions {
|
|
@@ -469,7 +501,12 @@ export function buildHeldVoiceGroups(samples: HeldSample[], profiles: VoiceProfi
|
|
|
469
501
|
active = active.filter(i => !taken.has(i))
|
|
470
502
|
}
|
|
471
503
|
groups.sort((a, b) => b.sampleCount - a.sampleCount || b.coherence - a.coherence || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
472
|
-
|
|
504
|
+
const byKey = new Map(samples.map(s => [sampleKey(s), s]))
|
|
505
|
+
const loose = refsOf(active).map(ref => {
|
|
506
|
+
const suggestion = suggestProfile(byKey.get(sampleKey(ref))!.embedding, profiles, opts)
|
|
507
|
+
return suggestion ? { ...ref, suggestion } : ref
|
|
508
|
+
})
|
|
509
|
+
return { groups, loose }
|
|
473
510
|
}
|
|
474
511
|
|
|
475
512
|
// ── Listing ────────────────────────────────────────────────────────────────
|
|
@@ -495,6 +532,7 @@ export function heldVoiceGroups(opts: { budgetMs?: number; now?: () => number }
|
|
|
495
532
|
if (listingMemo && listingMemo.fingerprint === fingerprint && now() - listingMemo.at < HELD_LISTING_MEMO_MS && listingMemo.result.pending === 0) {
|
|
496
533
|
return listingMemo.result
|
|
497
534
|
}
|
|
535
|
+
const started = performance.now()
|
|
498
536
|
const collected = collectHeldSamples({ budgetMs: opts.budgetMs, now })
|
|
499
537
|
const { groups, loose } = buildHeldVoiceGroups(collected.samples, readVoiceProfiles().profiles, { ownerLabel: getOwnerSpeakerLabel() })
|
|
500
538
|
if (collected.pending.length > 0) scheduleHeldEmbeddingSweep()
|
|
@@ -509,6 +547,11 @@ export function heldVoiceGroups(opts: { budgetMs?: number; now?: () => number }
|
|
|
509
547
|
speakerModel: isEmbeddingAvailable(),
|
|
510
548
|
generatedAt: new Date().toISOString(),
|
|
511
549
|
}
|
|
550
|
+
console.log(`[held-voice] scored listing: samples=${result.samples} suggestions=${loose.filter(r => r.suggestion).length} ms=${(performance.now() - started).toFixed(1)} floor=${HELD_GROUP_SUGGESTION_FLOOR} support=${HELD_SUGGESTION_MIN_SUPPORT} margin=${HELD_SUGGESTION_MIN_MARGIN}`)
|
|
551
|
+
for (const ref of loose) if (ref.suggestion) {
|
|
552
|
+
const p = ref.suggestion
|
|
553
|
+
console.log(`[held-voice] suggestion: key=${sampleKey(ref)} voice=${JSON.stringify(p.name)} secondBest=${p.similarity} agreeing=${p.agreeing}/${p.of} anchor=${p.anchor} runnerUp=${p.runnerUpSimilarity} margin=${p.margin} ownerScore=${p.ownerSimilarity} ownerCaution=${p.ownerCaution}`)
|
|
554
|
+
}
|
|
512
555
|
listingMemo = { fingerprint, at: now(), result }
|
|
513
556
|
return result
|
|
514
557
|
}
|
|
@@ -657,6 +700,10 @@ export interface EnrollHeldGroupPlan {
|
|
|
657
700
|
|
|
658
701
|
export interface EnrollHeldGroupResult extends EnrollHeldGroupPlan {
|
|
659
702
|
dryRun: boolean
|
|
703
|
+
coreMembers?: HeldSampleRef[]
|
|
704
|
+
/** Exact selected vectors accepted/refused by the store, not expanded duplicates. */
|
|
705
|
+
enrolledMembers?: HeldSampleRef[]
|
|
706
|
+
rejectedMembers?: HeldSampleRef[]
|
|
660
707
|
enrolled: number
|
|
661
708
|
/** Wavs removed: the whole core, selected or not, because all of it is now a known voice. */
|
|
662
709
|
deleted: number
|
|
@@ -676,7 +723,7 @@ export interface EnrollHeldGroupResult extends EnrollHeldGroupPlan {
|
|
|
676
723
|
* written. With the speaker model not loaded nothing can be written, so the
|
|
677
724
|
* request is refused before it touches anything.
|
|
678
725
|
*/
|
|
679
|
-
export function enrollHeldGroup(name: string, refs: HeldSampleRef[], opts: { dryRun?: boolean } = {}): EnrollHeldGroupResult {
|
|
726
|
+
export function enrollHeldGroup(name: string, refs: HeldSampleRef[], opts: { dryRun?: boolean; deleteAudio?: boolean } = {}): EnrollHeldGroupResult {
|
|
680
727
|
const dryRun = opts.dryRun === true
|
|
681
728
|
const collected = collectHeldSamples({ only: onlyMap(refs), budgetMs: HELD_ENROLL_DECODE_BUDGET_MS })
|
|
682
729
|
if (collected.pending.length > 0) scheduleHeldEmbeddingSweep()
|
|
@@ -732,21 +779,65 @@ export function enrollHeldGroup(name: string, refs: HeldSampleRef[], opts: { dry
|
|
|
732
779
|
}
|
|
733
780
|
// A dry run writes no profile and deletes no wav. It may still decode and
|
|
734
781
|
// cache vectors for the samples it was asked about, and arm the sweep.
|
|
735
|
-
if (dryRun) return { ...plan, dryRun: true, enrolled: 0, deleted: 0 }
|
|
782
|
+
if (dryRun) return { ...plan, coreMembers: core.map(({ sessionId, chunkIndex }) => ({ sessionId, chunkIndex })), dryRun: true, enrolled: 0, deleted: 0 }
|
|
736
783
|
if (!isEmbeddingAvailable()) {
|
|
737
784
|
throw new HeldGroupError(503, 'speaker_model_unavailable', 'The speaker model is not loaded on this Mac, so nothing can be enrolled. Nothing was changed.', { ...plan })
|
|
738
785
|
}
|
|
739
786
|
|
|
740
787
|
let enrolled = 0
|
|
788
|
+
const enrolledMembers: HeldSampleRef[] = [], rejectedMembers: HeldSampleRef[] = []
|
|
741
789
|
for (const emb of selected) {
|
|
742
790
|
const sample = bySample.get(emb)
|
|
743
791
|
const source = sample ? `${HELD_GROUP_SOURCE}:${sample.sessionId}` : HELD_GROUP_SOURCE
|
|
744
|
-
|
|
792
|
+
const accepted = enrollEmbedding(name, emb, source, true).success
|
|
793
|
+
if (accepted) enrolled++
|
|
794
|
+
if (sample) (accepted ? enrolledMembers : rejectedMembers).push({ sessionId: sample.sessionId, chunkIndex: sample.chunkIndex })
|
|
745
795
|
}
|
|
746
796
|
if (enrolled === 0) {
|
|
747
|
-
throw new HeldGroupError(409, 'nothing_enrolled', `The profile store refused every sample for ${name}; nothing was deleted.`, { ...plan })
|
|
797
|
+
throw new HeldGroupError(409, 'nothing_enrolled', `The profile store refused every sample for ${name}; nothing was deleted.`, { ...plan, enrolledMembers, rejectedMembers })
|
|
748
798
|
}
|
|
749
|
-
const
|
|
799
|
+
const coreMembers = core.map(s => ({ sessionId: s.sessionId, chunkIndex: s.chunkIndex }))
|
|
800
|
+
const { removed } = opts.deleteAudio === false ? { removed: [] } : discardHeldSamples(coreMembers)
|
|
750
801
|
console.log(`[held-voice] enroll "${name}" (${existing ? 'appended' : 'created'}): submitted=${refs.length} resolved=${resolved.length} distinct=${reps.length} coherent=${core.length} selected=${selected.length} enrolled=${enrolled} deleted=${removed.length} leftBehind=${leftBehind.length} notReady=${notReady.length} sessions=[${plan.sessions.join(',')}]`)
|
|
751
|
-
return { ...plan, dryRun: false, enrolled, deleted: removed.length, profileEmbeddings: existingCount + enrolled }
|
|
802
|
+
return { ...plan, coreMembers, enrolledMembers, rejectedMembers, dryRun: false, enrolled, deleted: removed.length, profileEmbeddings: existingCount + enrolled }
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
/** Preview the exact evidence enrollment will retain, without touching the store.
|
|
807
|
+
* Duplicate recordings count once; selection and cap eviction match enrollment.
|
|
808
|
+
* Wider matching must use this projection, never every submitted held vector. */
|
|
809
|
+
export function projectHeldEnrollment(name: string, refs: HeldSampleRef[]): {
|
|
810
|
+
plan: EnrollHeldGroupResult; profiles: VoiceProfile[]; selectedMembers: HeldSampleRef[]
|
|
811
|
+
} {
|
|
812
|
+
const plan = enrollHeldGroup(name, refs, { dryRun: true })
|
|
813
|
+
const coreRefs = plan.coreMembers ?? []
|
|
814
|
+
const collected = collectHeldSamples({ only: onlyMap(coreRefs), budgetMs: HELD_ENROLL_DECODE_BUDGET_MS })
|
|
815
|
+
const byKey = new Map(collected.samples.map(sample => [sampleKey(sample), sample]))
|
|
816
|
+
const core = coreRefs.map(ref => byKey.get(sampleKey(ref)))
|
|
817
|
+
if (core.some(sample => !sample)) throw new HeldGroupError(409, 'samples_changed', 'The held samples changed while preparing the preview. Preview again.')
|
|
818
|
+
const samples = core as HeldSample[]
|
|
819
|
+
const matrix = pairwiseSimilarityMatrix(samples.map(sample => sample.embedding))
|
|
820
|
+
const { reps } = foldDuplicates(matrix, samples.length)
|
|
821
|
+
const representatives = reps.map(i => samples[i])
|
|
822
|
+
const selected = greedyDiversitySelect(representatives.map(sample => sample.embedding), MAX_ENROL_PER_CORRECTION)
|
|
823
|
+
const byEmbedding = new Map(representatives.map(sample => [sample.embedding, sample]))
|
|
824
|
+
// readVoiceProfiles shares embedding arrays with the cache. Clone every row
|
|
825
|
+
// before simulating append/eviction so a preview cannot alter live evidence.
|
|
826
|
+
const profiles = readVoiceProfiles().profiles.map(profile => ({
|
|
827
|
+
...profile, embeddings: profile.embeddings.map(row => [...row]), sources: [...(profile.sources ?? [])],
|
|
828
|
+
}))
|
|
829
|
+
let target = profiles.find(profile => profile.name === name)
|
|
830
|
+
if (!target) { target = { name, embeddings: [], sources: [] }; profiles.push(target) }
|
|
831
|
+
const selectedMembers: HeldSampleRef[] = []
|
|
832
|
+
for (const embedding of selected) {
|
|
833
|
+
const sample = byEmbedding.get(embedding)!
|
|
834
|
+
const source = `${HELD_GROUP_SOURCE}:${sample.sessionId}`
|
|
835
|
+
if (target.embeddings.length >= MAX_EMBEDDINGS_PER_SPEAKER) {
|
|
836
|
+
const choice = chooseEviction(alignedSources(target), source, MAX_EMBEDDINGS_PER_SPEAKER)
|
|
837
|
+
dropEmbeddingAt(target, choice?.index ?? 0)
|
|
838
|
+
}
|
|
839
|
+
appendEmbedding(target, Array.from(embedding), source)
|
|
840
|
+
selectedMembers.push({ sessionId: sample.sessionId, chunkIndex: sample.chunkIndex })
|
|
841
|
+
}
|
|
842
|
+
return { plan, profiles, selectedMembers }
|
|
752
843
|
}
|
|
@@ -28,6 +28,7 @@ export function persistBatchDecisionSidecar(
|
|
|
28
28
|
if (batchResult.qualityReport) sidecar.batchQualityReport = batchResult.qualityReport
|
|
29
29
|
|
|
30
30
|
if (batchApplied) {
|
|
31
|
+
sidecar.lifecycleRevision = Number(sidecar.lifecycleRevision ?? 0) + 1
|
|
31
32
|
sidecar.batchTranscript = batchResult.batchTranscript
|
|
32
33
|
sidecar.batchSegments = batchResult.batchSegments?.map(result => ({
|
|
33
34
|
startChunkIdx: result.segment.startChunkIdx,
|
|
@@ -35,7 +35,7 @@ export const CORRECTIONS_DIR = 'meeting-corrections'
|
|
|
35
35
|
* `intent` goes down before any file is touched; `applied` or `failed` closes it.
|
|
36
36
|
* An unclosed intent is an incomplete correction, not a successful one.
|
|
37
37
|
*/
|
|
38
|
-
export type CorrectionPhase = 'intent' | 'applied' | 'failed' | 'confirmed'
|
|
38
|
+
export type CorrectionPhase = 'intent' | 'applied' | 'failed' | 'confirmed' | 'confirmed-chunks' | 'reverted'
|
|
39
39
|
/**
|
|
40
40
|
* A human confirming the identifier was RIGHT about a label the display floor
|
|
41
41
|
* demoted.
|
|
@@ -54,7 +54,7 @@ export type CorrectionPhase = 'intent' | 'applied' | 'failed' | 'confirmed'
|
|
|
54
54
|
export const CONFIRMATION_PHASE = 'confirmed' as const
|
|
55
55
|
|
|
56
56
|
/** Runtime counterpart of CorrectionPhase. Must stay in step with it. */
|
|
57
|
-
const VALID_PHASES = new Set<string>(['intent', 'applied', 'failed', 'confirmed'])
|
|
57
|
+
const VALID_PHASES = new Set<string>(['intent', 'applied', 'failed', 'confirmed', 'confirmed-chunks', 'reverted'])
|
|
58
58
|
|
|
59
59
|
/** Narrowing guard, so the reader keeps its type safety with one phase list. */
|
|
60
60
|
function isCorrectionPhase(value: unknown): value is CorrectionPhase {
|
|
@@ -91,6 +91,9 @@ export interface CorrectionRow {
|
|
|
91
91
|
* mistake becomes an unrecoverable one.
|
|
92
92
|
*/
|
|
93
93
|
scope: 'meeting'
|
|
94
|
+
source?: string
|
|
95
|
+
batchId?: string
|
|
96
|
+
priorLabels?: Record<string, string>
|
|
94
97
|
surfaces?: CorrectionSurfaces
|
|
95
98
|
/** True when narrative prose still carries the old label. See the header. */
|
|
96
99
|
proseStale?: boolean
|
|
@@ -160,6 +163,9 @@ export function readCorrections(sessionId: string): CorrectionReadResult {
|
|
|
160
163
|
to: o.to,
|
|
161
164
|
chunks: Array.isArray(o.chunks) ? o.chunks.filter((n): n is number => typeof n === 'number') : [],
|
|
162
165
|
scope: 'meeting',
|
|
166
|
+
source: typeof o.source === 'string' ? o.source : undefined,
|
|
167
|
+
batchId: typeof o.batchId === 'string' ? o.batchId : undefined,
|
|
168
|
+
priorLabels: o.priorLabels && typeof o.priorLabels === 'object' ? o.priorLabels as Record<string, string> : undefined,
|
|
163
169
|
surfaces: isSurfaces(o.surfaces) ? o.surfaces : undefined,
|
|
164
170
|
proseStale: typeof o.proseStale === 'boolean' ? o.proseStale : undefined,
|
|
165
171
|
error: typeof o.error === 'string' ? o.error : undefined,
|
|
@@ -194,8 +200,10 @@ function isSurfaces(v: unknown): v is CorrectionSurfaces {
|
|
|
194
200
|
*/
|
|
195
201
|
export function confirmedLabels(sessionId: string): Set<string> {
|
|
196
202
|
const confirmed = new Set<string>()
|
|
197
|
-
|
|
198
|
-
|
|
203
|
+
const rows = readCorrections(sessionId).rows
|
|
204
|
+
const revoked = new Set(rows.filter(r => r.phase === 'reverted').map(r => r.batchId ?? r.id))
|
|
205
|
+
for (const row of rows) {
|
|
206
|
+
if (row.phase === 'confirmed' && row.chunks.length === 0 && !revoked.has(row.batchId ?? row.id)) confirmed.add(row.to)
|
|
199
207
|
}
|
|
200
208
|
return confirmed
|
|
201
209
|
}
|
|
@@ -208,7 +216,9 @@ export function pendingCorrections(sessionId: string): CorrectionRow[] {
|
|
|
208
216
|
|
|
209
217
|
/** Only the corrections that actually landed — the ones piece 3 may train on. */
|
|
210
218
|
export function appliedCorrections(sessionId: string): CorrectionRow[] {
|
|
211
|
-
|
|
219
|
+
const rows = readCorrections(sessionId).rows
|
|
220
|
+
const reverted = new Set(rows.filter(r => r.phase === 'reverted').map(r => r.batchId ?? r.id))
|
|
221
|
+
return rows.filter(r => r.phase === 'applied' && !reverted.has(r.batchId ?? r.id))
|
|
212
222
|
}
|
|
213
223
|
|
|
214
224
|
/**
|
|
@@ -260,3 +270,31 @@ export function correctionStoreStats(): {
|
|
|
260
270
|
} catch { /* report what we have */ }
|
|
261
271
|
return { sessions, applied, pending, failed }
|
|
262
272
|
}
|
|
273
|
+
|
|
274
|
+
/** Chunk-scoped vouching never promotes other positions carrying the same label. */
|
|
275
|
+
export function confirmedChunks(sessionId: string): Map<number, string> {
|
|
276
|
+
const rows = readCorrections(sessionId).rows
|
|
277
|
+
const revoked = new Set(rows.filter(r => r.phase === 'reverted').map(r => r.batchId ?? r.id))
|
|
278
|
+
const out = new Map<number, string>()
|
|
279
|
+
for (const row of rows) {
|
|
280
|
+
if (revoked.has(row.batchId ?? row.id)) continue
|
|
281
|
+
if (row.phase === 'confirmed-chunks' || row.phase === 'confirmed') {
|
|
282
|
+
for (const position of row.chunks) out.set(position, row.to)
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return out
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Boot closeout records interrupted intent explicitly; no automatic retry. */
|
|
289
|
+
export function closeInterruptedCorrections(): number {
|
|
290
|
+
const dir = dataPath(CORRECTIONS_DIR)
|
|
291
|
+
if (!existsSync(dir)) return 0
|
|
292
|
+
let count = 0
|
|
293
|
+
for (const file of readdirSync(dir).filter(n => n.endsWith('.jsonl'))) {
|
|
294
|
+
const sessionId = file.slice(0, -6)
|
|
295
|
+
for (const row of pendingCorrections(sessionId)) {
|
|
296
|
+
if (appendCorrection(sessionId, { ...row, phase: 'failed', at: new Date().toISOString(), error: 'interrupted naming — review to resume or revert' })) count++
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return count
|
|
300
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/** Shared, position-scoped correction primitive. Transcript words are immutable. */
|
|
2
|
+
import { attachRawChunkIndices, type ReviewChunk } from './meeting-speaker-review.js'
|
|
3
|
+
import { invalidLabelReason } from './meeting-relabel.js'
|
|
4
|
+
|
|
5
|
+
export type SpeakerPatch = { path: Array<string | number>; before: unknown; after: unknown }
|
|
6
|
+
export type PrefixPatch = { line: number; before: string; after: string }
|
|
7
|
+
export interface LabelEdit {
|
|
8
|
+
doc: Record<string, any>
|
|
9
|
+
markdown: string
|
|
10
|
+
patches: SpeakerPatch[]
|
|
11
|
+
prefixes: PrefixPatch[]
|
|
12
|
+
changed: number[]
|
|
13
|
+
transcript: number
|
|
14
|
+
attendees: number
|
|
15
|
+
unresolvedTurns: number
|
|
16
|
+
}
|
|
17
|
+
const prefix = /^(\[([^\]\r\n]+)\]:|\*\*([^*\r\n]+)\*\*(?:[ \t]+_\[[^\]\r\n]*\]_)?:?)([ \t]*)/
|
|
18
|
+
const normalized = (value: string) => value.replace(/\s+/gu, ' ').trim()
|
|
19
|
+
export function strippedTranscript(markdown: string): string {
|
|
20
|
+
const section = /^## Transcript[^\n]*\n([\s\S]*?)(?=^## |$(?![\s\S]))/m.exec(markdown)
|
|
21
|
+
return (section?.[1] ?? '').split('\n').map(line => line.replace(prefix, '')).join('\n')
|
|
22
|
+
}
|
|
23
|
+
function setPatch(doc: Record<string, any>, path: Array<string | number>, value: unknown, patches: SpeakerPatch[]) {
|
|
24
|
+
let obj: any = doc
|
|
25
|
+
for (const key of path.slice(0, -1)) obj = obj[key]
|
|
26
|
+
const key = path[path.length - 1]
|
|
27
|
+
if (obj[key] === value) return
|
|
28
|
+
patches.push({ path, before: obj[key], after: value })
|
|
29
|
+
obj[key] = value
|
|
30
|
+
}
|
|
31
|
+
export function patchValue(doc: Record<string, any>, patch: SpeakerPatch, reverse = false): void {
|
|
32
|
+
let obj: any = doc
|
|
33
|
+
for (const key of patch.path.slice(0, -1)) {
|
|
34
|
+
if (obj?.[key] == null) throw new Error('Transcript shape changed; review before undo')
|
|
35
|
+
obj = obj[key]
|
|
36
|
+
}
|
|
37
|
+
const key = patch.path[patch.path.length - 1]
|
|
38
|
+
if (reverse && JSON.stringify(obj[key]) === JSON.stringify(patch.before)) return
|
|
39
|
+
if (JSON.stringify(obj[key]) !== JSON.stringify(reverse ? patch.after : patch.before)) throw new Error('A later correction touched this position')
|
|
40
|
+
const value = reverse ? patch.before : patch.after
|
|
41
|
+
if (value === undefined) delete obj[key]
|
|
42
|
+
else obj[key] = value
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Complete compacted-to-raw mapping; no trusting chunkIndex on compacted rows. */
|
|
46
|
+
export function validatedRawMap(doc: Record<string, any>): number[] {
|
|
47
|
+
if (!Array.isArray(doc.chunks)) throw new Error('Sidecar has no chunks')
|
|
48
|
+
const mapped = attachRawChunkIndices(doc.chunks as ReviewChunk[], doc.chunkEntries)
|
|
49
|
+
if (mapped === doc.chunks) throw new Error('Chunk counts disagree or no raw index map')
|
|
50
|
+
const raw = mapped.map(c => c.chunkIndex)
|
|
51
|
+
if (raw.some(i => !Number.isInteger(i) || Number(i) < 0) || new Set(raw).size !== raw.length) throw new Error('Invalid or ambiguous raw index map')
|
|
52
|
+
// Counts alone are insufficient: a damaged entry ordering can have the right length.
|
|
53
|
+
const entries = doc.chunkEntries.filter((e: any) => e?.chunk?.text)
|
|
54
|
+
if (entries.some((e: any, i: number) => e.chunk.text !== doc.chunks[i].text)) throw new Error('Chunk text and raw index map disagree')
|
|
55
|
+
return raw as number[]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function relabelSpeakerPositions(input: Record<string, any>, markdown: string, positions: number[], name: string, batchId?: string): LabelEdit {
|
|
59
|
+
if (invalidLabelReason(name)) throw new Error('Invalid speaker label')
|
|
60
|
+
const raw = validatedRawMap(input)
|
|
61
|
+
const wanted = new Set(positions)
|
|
62
|
+
if (positions.some(i => !Number.isInteger(i) || !input.chunks[i])) throw new Error('Unknown transcript position')
|
|
63
|
+
const doc = structuredClone(input)
|
|
64
|
+
const patches: SpeakerPatch[] = []
|
|
65
|
+
const changed = [...wanted].sort((a,b) => a-b)
|
|
66
|
+
for (const position of changed) {
|
|
67
|
+
setPatch(doc, ['chunks', position, 'speaker'], name, patches)
|
|
68
|
+
if (batchId) setPatch(doc, ['chunks', position, 'speakerCorrectionBatchId'], batchId, patches)
|
|
69
|
+
const entry = doc.chunkEntries.findIndex((e: any) => e.chunkIndex === raw[position])
|
|
70
|
+
setPatch(doc, ['chunkEntries', entry, 'chunk', 'speaker'], name, patches)
|
|
71
|
+
if (batchId) setPatch(doc, ['chunkEntries', entry, 'chunk', 'speakerCorrectionBatchId'], batchId, patches)
|
|
72
|
+
}
|
|
73
|
+
// Match capture HQ's exact nearest-chunk rule, raw segment bounds, 3.5s
|
|
74
|
+
// maximum. Never use compacted positions as raw capture indices (6.27.10).
|
|
75
|
+
for (const [si, segment] of (doc.batchSegments ?? []).entries()) {
|
|
76
|
+
for (const [wi, word] of (segment.speakerWords ?? []).entries()) {
|
|
77
|
+
if (!Number.isFinite(word.start)) continue
|
|
78
|
+
const absolute = Number(segment.startElapsed ?? 0) + word.start * 1000
|
|
79
|
+
let nearest = -1, distance = Infinity
|
|
80
|
+
for (let i = 0; i < raw.length; i++) {
|
|
81
|
+
if (raw[i] < segment.startChunkIdx || raw[i] > segment.endChunkIdx) continue
|
|
82
|
+
const d = Math.abs(Number(input.chunks[i].elapsed) - absolute)
|
|
83
|
+
if (d < distance) { distance = d; nearest = i }
|
|
84
|
+
}
|
|
85
|
+
if (distance <= 3500 && wanted.has(nearest)) {
|
|
86
|
+
setPatch(doc, ['batchSegments', si, 'speakerWords', wi, 'speaker'], name, patches)
|
|
87
|
+
if (batchId) setPatch(doc, ['batchSegments', si, 'speakerWords', wi, 'speakerCorrectionBatchId'], batchId, patches)
|
|
88
|
+
}
|
|
89
|
+
// similarity remains capture-time evidence; it is never refreshed here.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const speakers = [...new Set(doc.chunks.map((c: any) => c.speaker).filter((s: any) => typeof s === 'string' && s))]
|
|
93
|
+
doc.speakers = speakers
|
|
94
|
+
doc.correctionRevision = Number(input.correctionRevision ?? 0) + 1
|
|
95
|
+
doc.labelsNewerThanGraph = true
|
|
96
|
+
|
|
97
|
+
const lines = markdown.split('\n')
|
|
98
|
+
const prefixes: PrefixPatch[] = []
|
|
99
|
+
let section = '', transcript = 0, unresolvedTurns = 0, attendees = 0
|
|
100
|
+
const chunks: string[] = input.chunks.map((c: any) => normalized(String(c.text ?? '')))
|
|
101
|
+
for (let i = 0; i < lines.length; i++) {
|
|
102
|
+
if (/^## /.test(lines[i])) section = lines[i].slice(3).trim()
|
|
103
|
+
if (section !== 'Transcript') continue
|
|
104
|
+
const match = prefix.exec(lines[i])
|
|
105
|
+
if (!match) continue
|
|
106
|
+
let end = i + 1
|
|
107
|
+
while (end < lines.length && !prefix.test(lines[end]) && !/^## /.test(lines[end])) end++
|
|
108
|
+
const text = normalized([lines[i].slice(match[0].length), ...lines.slice(i+1,end)].join('\n').replace(/\n?<!-- speaker-labels-newer-than-graph -->\n?/g,''))
|
|
109
|
+
const runs: number[][] = []
|
|
110
|
+
for (let a = 0; a < chunks.length; a++) {
|
|
111
|
+
let joined = ''
|
|
112
|
+
for (let b = a; b < chunks.length; b++) {
|
|
113
|
+
joined = normalized(joined + ' ' + chunks[b])
|
|
114
|
+
if (joined === text) runs.push(Array.from({length:b-a+1},(_,k)=>a+k))
|
|
115
|
+
if (joined.length >= text.length) break
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (runs.length === 1 && runs[0].every(j => wanted.has(j))) {
|
|
119
|
+
const next = match[2] !== undefined ? `[${name}]:${match[4]}` : match[0].replace(`**${match[3]}**`, `**${name}**`)
|
|
120
|
+
if (next !== match[0]) { prefixes.push({line:i,before:match[0],after:next}); lines[i] = next + lines[i].slice(match[0].length); transcript++ }
|
|
121
|
+
} else if (runs.length !== 1 || runs[0].some(j => wanted.has(j))) unresolvedTurns++
|
|
122
|
+
}
|
|
123
|
+
// Attendees preserve every existing person and never add an unidentified label.
|
|
124
|
+
const att = lines.findIndex(line => /^## Attendees\s*$/.test(line))
|
|
125
|
+
if (att >= 0 && !/^(ext|unknown|unidentified|speaker\s*\d+)/i.test(name)) {
|
|
126
|
+
let end = att+1
|
|
127
|
+
while (end < lines.length && !/^## /.test(lines[end])) end++
|
|
128
|
+
if (!lines.slice(att+1,end).some(line => line.trim() === `- ${name}`)) {
|
|
129
|
+
const first = doc.chunks.findIndex((c: any) => c.speaker === name)
|
|
130
|
+
let insertion = end
|
|
131
|
+
for (let line = att + 1; line < end; line++) {
|
|
132
|
+
const listed = /^- (.+)$/.exec(lines[line])?.[1]
|
|
133
|
+
const appeared = listed ? doc.chunks.findIndex((c: any) => c.speaker === listed) : -1
|
|
134
|
+
if (appeared > first) { insertion = line; break }
|
|
135
|
+
}
|
|
136
|
+
lines.splice(insertion,0,`- ${name}`,'')
|
|
137
|
+
for (const p of prefixes) if (p.line >= insertion) p.line += 2
|
|
138
|
+
attendees = 1
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (att < 0 && !/^(ext|unknown|unidentified|speaker\s*\d+)/i.test(name) && changed.length > 0) {
|
|
142
|
+
const transcriptStart = lines.findIndex(line => /^## Transcript\s*$/.test(line))
|
|
143
|
+
if (transcriptStart >= 0) { lines.splice(transcriptStart,0,'## Attendees','',`- ${name}`,''); attendees = 1 }
|
|
144
|
+
}
|
|
145
|
+
let nextMarkdown = lines.join('\n')
|
|
146
|
+
if (!nextMarkdown.includes('<!-- speaker-labels-newer-than-graph -->')) nextMarkdown += '\n<!-- speaker-labels-newer-than-graph -->\n'
|
|
147
|
+
// Marker is metadata, outside the stripped-text comparison.
|
|
148
|
+
const clean = (s: string) => s.replace(/\n?<!-- speaker-labels-newer-than-graph -->\n?/g,'')
|
|
149
|
+
if (strippedTranscript(clean(markdown)) !== strippedTranscript(clean(nextMarkdown))) throw new Error('Transcript word preservation assertion failed')
|
|
150
|
+
return {doc,markdown:nextMarkdown,patches,prefixes,changed,transcript,attendees,unresolvedTurns}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Restore only this batch's label prefixes, using immutable turn text as the
|
|
154
|
+
* join key. Attendee insertions and disjoint later corrections do not shift it. */
|
|
155
|
+
export function undoSpeakerMarkdown(before:string,after:string,current:string,currentSpeakers:string[]):string {
|
|
156
|
+
const turns=(md:string)=>{
|
|
157
|
+
const lines=md.split('\n'),result:Array<{line:number;label:string;text:string}>=[]
|
|
158
|
+
let section=''
|
|
159
|
+
for(let i=0;i<lines.length;i++){
|
|
160
|
+
if(/^## /.test(lines[i]))section=lines[i].slice(3).trim()
|
|
161
|
+
if(section!=='Transcript')continue
|
|
162
|
+
const match=prefix.exec(lines[i]);if(!match)continue
|
|
163
|
+
let end=i+1
|
|
164
|
+
while(end<lines.length&&!prefix.test(lines[end])&&!/^## /.test(lines[end]))end++
|
|
165
|
+
const content=[lines[i].slice(match[0].length),...lines.slice(i+1,end)].join('\n').replace(/\n?<!-- speaker-labels-newer-than-graph -->\n?/g,'')
|
|
166
|
+
result.push({line:i,label:match[0],text:normalized(content)})
|
|
167
|
+
}
|
|
168
|
+
return result
|
|
169
|
+
}
|
|
170
|
+
const beforeTurns=turns(before),afterTurns=turns(after),currentTurns=turns(current)
|
|
171
|
+
if(beforeTurns.length!==afterTurns.length)throw new Error('Receipt transcript shape changed')
|
|
172
|
+
const lines=current.split('\n')
|
|
173
|
+
for(let i=0;i<beforeTurns.length;i++){
|
|
174
|
+
const a=afterTurns[i],b=beforeTurns[i]
|
|
175
|
+
if(a.label===b.label)continue
|
|
176
|
+
if(a.text!==b.text)throw new Error('Receipt transcript words changed')
|
|
177
|
+
const matches=currentTurns.filter(t=>t.text===a.text)
|
|
178
|
+
if(matches.length!==1)throw new Error('Transcript turn is missing or ambiguous; review before undo')
|
|
179
|
+
const match=matches[0]
|
|
180
|
+
if(match.label===b.label)continue // a prior interrupted Undo already restored it
|
|
181
|
+
if(match.label!==a.label)throw new Error('Later naming touched transcript prefix')
|
|
182
|
+
lines[match.line]=b.label+lines[match.line].slice(a.label.length)
|
|
183
|
+
}
|
|
184
|
+
const attendees=(md:string)=>{
|
|
185
|
+
const match=/^## Attendees\s*\n([\s\S]*?)(?=^## |$(?![\s\S]))/m.exec(md)
|
|
186
|
+
return new Set((match?.[1]??'').split('\n').flatMap(line=>/^- (.+)$/.exec(line)?.[1]?[line.slice(2)]:[]))
|
|
187
|
+
}
|
|
188
|
+
const priorAttendees=attendees(before),added=[...attendees(after)].filter(name=>!priorAttendees.has(name)&&!currentSpeakers.includes(name))
|
|
189
|
+
let section=''
|
|
190
|
+
const out=lines.filter(line=>{if(/^## /.test(line))section=line.slice(3).trim();return !(section==='Attendees'&&added.some(name=>line===`- ${name}`))}).join('\n')
|
|
191
|
+
return out.includes('<!-- speaker-labels-newer-than-graph -->')?out:out+'\n<!-- speaker-labels-newer-than-graph -->\n'
|
|
192
|
+
}
|
|
@@ -133,6 +133,9 @@ export interface Phrase {
|
|
|
133
133
|
export interface VoiceReview {
|
|
134
134
|
label: string
|
|
135
135
|
segments: number
|
|
136
|
+
/** Exact positions a human vouched for. A partial count does not waive the
|
|
137
|
+
* display floor for other positions carrying the same label. */
|
|
138
|
+
confirmedSegments: number
|
|
136
139
|
/** Voiced milliseconds credited to this voice. See `SpeakingTimeSource` for
|
|
137
140
|
* how it was measured — the two methods are not comparable. */
|
|
138
141
|
speakingMs: number
|
|
@@ -242,15 +245,15 @@ export interface MeetingSpeakerReview {
|
|
|
242
245
|
/** False when no chunk carries a real speaker — a recovered capture. */
|
|
243
246
|
attributed: boolean
|
|
244
247
|
/**
|
|
245
|
-
* Segments belonging to voices
|
|
248
|
+
* Segments belonging to asserted voices, plus individually confirmed
|
|
249
|
+
* positions in a voice whose remaining segments are still candidates.
|
|
246
250
|
*
|
|
247
251
|
* `attributed` is a boolean that only goes false at 100% unidentified, so a
|
|
248
252
|
* meeting where 295 of 299 chunks matched nobody still reports `true` and
|
|
249
253
|
* renders as though it were normally attributed. This is the graded version:
|
|
250
254
|
* measured Ext share across 14 retained sessions ran from 24% to 100%.
|
|
251
255
|
*
|
|
252
|
-
*
|
|
253
|
-
* label — see the derivation for why those differ. Denominator is `segments`
|
|
256
|
+
* A name-shaped label alone earns no credit. Denominator is `segments`
|
|
254
257
|
* (every chunk, including unlabelled ones), so `assertedSegments / segments`
|
|
255
258
|
* is the ratio every client should compute.
|
|
256
259
|
*
|
|
@@ -581,6 +584,7 @@ export function reviewMeetingSpeakers(
|
|
|
581
584
|
* the sidecar already carries it.
|
|
582
585
|
*/
|
|
583
586
|
confirmed?: Set<string>
|
|
587
|
+
confirmedChunks?: Map<number, string>
|
|
584
588
|
/**
|
|
585
589
|
* The sidecar's `batchSegments`, when the HQ pass has run. Their word
|
|
586
590
|
* timings give real voiced time; without them speaking time falls back to
|
|
@@ -590,7 +594,11 @@ export function reviewMeetingSpeakers(
|
|
|
590
594
|
} = {},
|
|
591
595
|
): MeetingSpeakerReview {
|
|
592
596
|
const owner = options.owner ?? 'Me'
|
|
593
|
-
const confirmed = options.confirmed ??
|
|
597
|
+
const confirmed = new Set(options.confirmed ?? [])
|
|
598
|
+
for (const label of new Set(chunks.map(c => c.speaker ?? ''))) {
|
|
599
|
+
const positions = chunks.flatMap((c, i) => c.speaker === label ? [i] : [])
|
|
600
|
+
if (positions.length > 0 && positions.every(i => options.confirmedChunks?.get(i) === label)) confirmed.add(label)
|
|
601
|
+
}
|
|
594
602
|
const limit = options.phrasesPerVoice ?? 3
|
|
595
603
|
const sequence = chunks.map(c => c.speaker ?? '')
|
|
596
604
|
// The caller's durationMs (the sidecar's own) is the meeting's true end.
|
|
@@ -637,6 +645,8 @@ export function reviewMeetingSpeakers(
|
|
|
637
645
|
const sims = own.map(c => c.similarity).filter((s): s is number => typeof s === 'number' && s > 0)
|
|
638
646
|
const runs = speakerRuns(sequence, label)
|
|
639
647
|
const unattributed = isUnattributed(label)
|
|
648
|
+
const confirmedSegments = unattributed ? 0 : confirmed.has(label) ? own.length
|
|
649
|
+
: chunks.reduce((n, c, i) => n + Number(c.speaker === label && options.confirmedChunks?.get(i) === label), 0)
|
|
640
650
|
|
|
641
651
|
const thrashesWith: ThrashPair[] = []
|
|
642
652
|
if (!unattributed) {
|
|
@@ -698,6 +708,7 @@ export function reviewMeetingSpeakers(
|
|
|
698
708
|
return {
|
|
699
709
|
label,
|
|
700
710
|
segments: own.length,
|
|
711
|
+
confirmedSegments,
|
|
701
712
|
speakingMs: speakingFor(label),
|
|
702
713
|
meanSimilarity: meanSim,
|
|
703
714
|
meanRun: Math.round(mean(runs) * 100) / 100,
|
|
@@ -724,7 +735,9 @@ export function reviewMeetingSpeakers(
|
|
|
724
735
|
// labels would claim three quarters of the meeting was identified above a
|
|
725
736
|
// list of rows reading "Unidentified voice", which is the confusion this
|
|
726
737
|
// number exists to remove.
|
|
727
|
-
|
|
738
|
+
// A scoped vouch adds only its exact positions, without promoting the
|
|
739
|
+
// candidate row or double-counting an already asserted voice.
|
|
740
|
+
assertedSegments: voices.reduce((n, v) => n + (v.nameAsserted ? v.segments : v.confirmedSegments), 0),
|
|
728
741
|
speakingTimeSource,
|
|
729
742
|
// UNION, not sum. Speakers overlap — crosstalk means two people are each
|
|
730
743
|
// correctly credited for the same wall-clock second, so per-speaker times
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { resolveCosOperationsDir } from './cos-operations-meetings.js'
|
|
5
|
+
|
|
6
|
+
/** Hold the SAME kernel flock as sync_meetings.py. Child stdin lifetime owns it. */
|
|
7
|
+
export async function acquireMeetingSyncLock(): Promise<{mode:'sync'|'standalone';release:()=>void}> {
|
|
8
|
+
const operations = resolveCosOperationsDir()
|
|
9
|
+
if (!operations) return {mode:'standalone',release:()=>{}}
|
|
10
|
+
const scripts = process.env.COS_SCRIPTS_DIR?.trim() || join(operations,'scripts')
|
|
11
|
+
const python = join(scripts,'cos_python')
|
|
12
|
+
if (!existsSync(python)) throw new Error('sync lock holder unavailable: cos_python missing')
|
|
13
|
+
const code = `import fcntl,sys,time\nf=open(sys.argv[1], 'a+')\nfor i in range(4):\n try:\n fcntl.flock(f, fcntl.LOCK_EX|fcntl.LOCK_NB)\n print('LOCKED',flush=True)\n sys.stdin.read()\n sys.exit(0)\n except BlockingIOError:\n time.sleep(0.15*(i+1))\nsys.exit(73)\n`
|
|
14
|
+
const child = spawn(python,['-c',code,join(scripts,'.sync_meetings.lock')],{stdio:['pipe','pipe','pipe']})
|
|
15
|
+
return await new Promise((resolve,reject) => {
|
|
16
|
+
let settled = false, output = '', error = ''
|
|
17
|
+
const timer = setTimeout(()=>{if(!settled){settled=true;child.kill();reject(new Error('sync running, retry'))}},5000)
|
|
18
|
+
child.stderr.on('data',d=>{error+=String(d)})
|
|
19
|
+
child.stdout.on('data',d=>{
|
|
20
|
+
output += String(d)
|
|
21
|
+
if (!settled && output.includes('LOCKED')) {settled=true;clearTimeout(timer);resolve({mode:'sync',release:()=>child.stdin.end()})}
|
|
22
|
+
})
|
|
23
|
+
child.on('error',err=>{if(!settled){settled=true;clearTimeout(timer);reject(err)}})
|
|
24
|
+
child.on('exit',()=>{if(!settled){settled=true;clearTimeout(timer);reject(new Error(error.trim() || 'sync running, retry'))}})
|
|
25
|
+
})
|
|
26
|
+
}
|
|
@@ -101,7 +101,7 @@ const AUTO_ENROLL_CONSENSUS = 2 // Must match N times in same session befo
|
|
|
101
101
|
// at 20, 40 AND 80 samples per speaker (77 speakers, sherpa SpeakerEmbeddingManager),
|
|
102
102
|
// so the old cap defended nothing — while 61 of 77 profiles sat AT it, meaning
|
|
103
103
|
// every correction cost a sample. 20 extra slots per speaker is ~1.2 MB.
|
|
104
|
-
const MAX_EMBEDDINGS_PER_SPEAKER = 40
|
|
104
|
+
export const MAX_EMBEDDINGS_PER_SPEAKER = 40
|
|
105
105
|
const SAMPLE_RATE = 16000
|
|
106
106
|
|
|
107
107
|
// Module-level state — sherpa-onnx-node is CJS with no TS types (SDK v0.0.7 interop)
|