@gotcos/glasses-server 6.73.0 → 6.73.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/package.json +2 -1
- package/server/lib/agent-session-store.ts +7 -2
- package/server/lib/batch-transcript-quality.ts +2 -0
- package/server/lib/codex-model-catalog.ts +15 -4
- package/server/lib/meeting-batch-transcribe.ts +31 -5
- package/server/lib/meeting-speaker-policy.ts +45 -0
- package/server/lib/nemotron-final.ts +10 -1
- package/server/lib/speaker-embeddings.ts +8 -6
- package/server/lib/voiceprint-floor.ts +3 -0
- package/server/routes/meeting.ts +32 -0
- package/server/routes/transcribe-stream.ts +24 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
## 6.73.3 — Speaker identity policy and exact Codex child sessions
|
|
2
|
+
|
|
3
|
+
- Preserve the original child Codex session when reading completed job details. Control can open the actual result without substituting the parent conversation or starting another model turn.
|
|
4
|
+
- Support Standard, High-confidence only and Anonymous / video identity policies per recording. Capture the policy before asynchronous audio processing and retain it through live chunks, stream decoding and finalization. Anonymous audio remains transcribed without naming or training known voices; strict policies cannot inherit weaker cached identity results.
|
|
5
|
+
- Add authenticated, revision-guarded policy reads and updates for the matched Control 0.5.290 (343). Existing clients keep the standard policy; existing named segments require explicit correction. Global voice profiles remain intact.
|
|
6
|
+
- Fix release metadata after the installed private 6.73.2 candidate failed its changelog/version gate. Preserve that installed artifact; this corrected package uses a new version.
|
|
7
|
+
- No transcription model selections, second-opinion/resolver experiments or cue flags are enabled by this update. Companion 640 remains compatible; physical Control/phone/G2 acceptance remains separate from automated server gates.
|
|
8
|
+
|
|
9
|
+
## 6.73.1 — GPT tier selection correction
|
|
10
|
+
|
|
11
|
+
- Resolve GPT Max/Frontier to the newest provider-described frontier model and GPT Balanced to the newest workhorse/balanced model. A newer workhorse version no longer displaces a frontier model: GPT-6-Astra serves Max, GPT-6.1-Sol serves Balanced in the current catalog.
|
|
12
|
+
- Keep version-based selection when the provider does not describe both tiers; preserve saved tier preferences, explicit model overrides, discovery refresh, and last-known-good fallback.
|
|
13
|
+
- Server catalog and execution share the corrected selection. Existing companion builds can receive the correction without an EHPK update. No transcription settings or experimental flags change.
|
|
14
|
+
|
|
1
15
|
## [6.73.0] - 2026-10-13 - Prompts and dictation follow the speech-to-text settings
|
|
2
16
|
|
|
3
17
|
Paired clients: none required. Every phone, including the public 618, already accepts the new dictation text (it never checks a model on dictation). COS Control and the phone can read the new `dictation` block in `GET /api/stt-settings` to replace "Dictation is unchanged" in their copy.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.73.
|
|
3
|
+
"version": "6.73.3",
|
|
4
4
|
"description": "COS Glasses: self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"!server/scripts/hq-contention-canary.ts",
|
|
46
46
|
"!server/scripts/live-cos-ask-canary.ts",
|
|
47
47
|
"!server/scripts/work-review-candidate.ts",
|
|
48
|
+
"!server/scripts/work-session-identity-canary.ts",
|
|
48
49
|
"!server/scripts/work-hardening-fixture.ts",
|
|
49
50
|
"!server/scripts/work-review-mutation-gate.mjs",
|
|
50
51
|
"!server/scripts/work-intake-mutation-gate.mjs",
|
|
@@ -1671,7 +1671,12 @@ export async function parseAgentSession(
|
|
|
1671
1671
|
let title = provider === 'codex' ? (opts.codexThreadName ?? '').trim() : ''
|
|
1672
1672
|
let project = ''
|
|
1673
1673
|
let gitBranch = ''
|
|
1674
|
-
|
|
1674
|
+
const filename = path.split('/').pop() || ''
|
|
1675
|
+
// A forked Codex rollout can retain its parent's session_meta. Use the same
|
|
1676
|
+
// filename identity as the list so details, signals and associations target
|
|
1677
|
+
// the child we actually opened. Legacy filenames still use metadata below.
|
|
1678
|
+
const codexFileId = provider === 'codex' ? idFromCodexFilename(filename) : null
|
|
1679
|
+
let sessionId = codexFileId ?? filename.replace(/\.jsonl$/, '')
|
|
1675
1680
|
let firstPrompt = ''
|
|
1676
1681
|
let latestAssistant = ''
|
|
1677
1682
|
let latestReply = ''
|
|
@@ -1797,7 +1802,7 @@ export async function parseAgentSession(
|
|
|
1797
1802
|
if (obj.type === 'session_meta' && obj.payload && typeof obj.payload === 'object') {
|
|
1798
1803
|
const payload = obj.payload as Record<string, unknown>
|
|
1799
1804
|
if (typeof payload.cwd === 'string') project = workspaceLabel(payload.cwd)
|
|
1800
|
-
if (typeof payload.id === 'string' && payload.id) sessionId = payload.id
|
|
1805
|
+
if (!codexFileId && typeof payload.id === 'string' && payload.id) sessionId = payload.id
|
|
1801
1806
|
const git = payload.git && typeof payload.git === 'object' ? payload.git as Record<string, unknown> : null
|
|
1802
1807
|
if (git && typeof git.branch === 'string') gitBranch = git.branch
|
|
1803
1808
|
continue
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SpeakerMode } from './meeting-speaker-policy.js'
|
|
1
2
|
import type { WhisperWord } from './whisper-local.js'
|
|
2
3
|
import type { Disagreement } from './stt-resolver.js'
|
|
3
4
|
|
|
@@ -7,6 +8,7 @@ export interface BatchSegment {
|
|
|
7
8
|
startElapsed: number
|
|
8
9
|
endElapsed: number
|
|
9
10
|
speakers: string[]
|
|
11
|
+
speakerPolicy?: SpeakerMode
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
/** One hypothesis as an engine heard it, before any resolution (6.72.0). */
|
|
@@ -50,7 +50,7 @@ export interface CodexModelCatalog {
|
|
|
50
50
|
source: CodexCatalogSource
|
|
51
51
|
refreshedAt: string
|
|
52
52
|
autoUpdates: true
|
|
53
|
-
selectionPolicy: 'newest-generation
|
|
53
|
+
selectionPolicy: 'capability-tiers-then-newest-generation'
|
|
54
54
|
options: CodexModelOption[]
|
|
55
55
|
refreshError?: string
|
|
56
56
|
}
|
|
@@ -93,13 +93,24 @@ function isCapabilityCandidate(model: CodexCatalogModel): boolean {
|
|
|
93
93
|
return !/(?:fast and affordable|cost-efficient|small,? fast|ultra-fast)/i.test(model.description)
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
/**
|
|
96
|
+
/** Prefer the provider's frontier/workhorse tiers; use generation order when
|
|
97
|
+
* the catalog does not describe both tiers. A newer workhorse (6.1 Sol) must
|
|
98
|
+
* not replace the frontier (6 Astra) merely because its version is higher.
|
|
99
|
+
*/
|
|
97
100
|
export function selectTopCodexModels(models: CodexCatalogModel[]): CodexCatalogModel[] {
|
|
98
101
|
const visible = models.filter(model => !model.hidden && versionParts(model.id))
|
|
99
102
|
if (visible.length === 0) return []
|
|
100
103
|
|
|
101
104
|
const capable = visible.filter(isCapabilityCandidate)
|
|
102
105
|
const pool = capable.length > 0 ? capable : visible
|
|
106
|
+
const byNewest = (candidates: CodexCatalogModel[]): CodexCatalogModel | undefined =>
|
|
107
|
+
candidates.slice().sort((a, b) => compareVersionsDesc(versionParts(a.id) ?? [], versionParts(b.id) ?? []))[0]
|
|
108
|
+
const frontier = byNewest(pool.filter(model => /\bfrontier\b/i.test(model.description)))
|
|
109
|
+
const balanced = byNewest(pool.filter(model =>
|
|
110
|
+
!/\bfrontier\b/i.test(model.description) && /\b(workhorse|balanced)\b/i.test(model.description),
|
|
111
|
+
))
|
|
112
|
+
if (frontier && balanced) return [frontier, balanced]
|
|
113
|
+
|
|
103
114
|
const versions = pool
|
|
104
115
|
.map(model => versionParts(model.id))
|
|
105
116
|
.filter((parts): parts is number[] => !!parts)
|
|
@@ -144,7 +155,7 @@ export function buildCodexModelCatalog(
|
|
|
144
155
|
source,
|
|
145
156
|
refreshedAt,
|
|
146
157
|
autoUpdates: true,
|
|
147
|
-
selectionPolicy: 'newest-generation
|
|
158
|
+
selectionPolicy: 'capability-tiers-then-newest-generation',
|
|
148
159
|
options,
|
|
149
160
|
}
|
|
150
161
|
}
|
|
@@ -163,7 +174,7 @@ function cliDefaultCatalog(refreshError?: string): CodexModelCatalog {
|
|
|
163
174
|
source: 'cli-default',
|
|
164
175
|
refreshedAt: new Date().toISOString(),
|
|
165
176
|
autoUpdates: true,
|
|
166
|
-
selectionPolicy: 'newest-generation
|
|
177
|
+
selectionPolicy: 'capability-tiers-then-newest-generation',
|
|
167
178
|
options: [
|
|
168
179
|
{ ...common, preference: CODEX_FRONTIER_MODEL, displayName: 'GPT Frontier (auto)' },
|
|
169
180
|
{ ...common, preference: CODEX_BALANCED_MODEL, displayName: 'GPT Balanced (auto)', isDefault: false },
|
|
@@ -23,6 +23,7 @@ import { currentEffectiveStt } from './stt-runtime.js'
|
|
|
23
23
|
import { transcribeParakeet, type ParakeetResult, type ParakeetRunOptions } from './parakeet.js'
|
|
24
24
|
import { isDeletionProneEngine, resolveHypotheses, RESOLVER_VERSION, type Hypothesis } from './stt-resolver.js'
|
|
25
25
|
import { getVocabulary } from './profile.js'
|
|
26
|
+
import { captureSpeakerMode, policyLabel } from './meeting-speaker-policy.js'
|
|
26
27
|
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
27
28
|
import { isMetalBatchPreempted } from './whisper-metal-gate.js'
|
|
28
29
|
import {
|
|
@@ -116,6 +117,20 @@ export function segmentTranscriptChunks(
|
|
|
116
117
|
segmentStartPosition = position
|
|
117
118
|
continue
|
|
118
119
|
}
|
|
120
|
+
// Close before the first chunk captured under a different identity policy.
|
|
121
|
+
// A named Standard chunk must never lend its name to anonymous audio.
|
|
122
|
+
if (position > segmentStartPosition && captureSpeakerMode(chunk.speakerPolicy) !== captureSpeakerMode(first.chunk.speakerPolicy)) {
|
|
123
|
+
const window = entries.slice(segmentStartPosition, position)
|
|
124
|
+
const last = window.at(-1)!
|
|
125
|
+
segments.push({
|
|
126
|
+
startChunkIdx: first.chunkIndex, endChunkIdx: last.chunkIndex,
|
|
127
|
+
startElapsed: first.chunk.elapsed, endElapsed: last.chunk.elapsed,
|
|
128
|
+
speakers: [...new Set(window.map(item => policyLabel(item.chunk, captureSpeakerMode(item.chunk.speakerPolicy)).speaker).filter(speaker => speaker && speaker !== 'Ext'))],
|
|
129
|
+
speakerPolicy: captureSpeakerMode(first.chunk.speakerPolicy),
|
|
130
|
+
})
|
|
131
|
+
segmentStartPosition = position
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
119
134
|
if (chunk.elapsed - first.chunk.elapsed >= targetMs && position > segmentStartPosition) {
|
|
120
135
|
const window = entries.slice(segmentStartPosition, position + 1)
|
|
121
136
|
segments.push({
|
|
@@ -123,7 +138,8 @@ export function segmentTranscriptChunks(
|
|
|
123
138
|
endChunkIdx: entry.chunkIndex,
|
|
124
139
|
startElapsed: first.chunk.elapsed,
|
|
125
140
|
endElapsed: chunk.elapsed,
|
|
126
|
-
speakers: [...new Set(window.map(item => item.chunk.speaker).filter(speaker => speaker && speaker !== 'Ext'))],
|
|
141
|
+
speakers: [...new Set(window.map(item => policyLabel(item.chunk, captureSpeakerMode(item.chunk.speakerPolicy)).speaker).filter(speaker => speaker && speaker !== 'Ext'))],
|
|
142
|
+
speakerPolicy: captureSpeakerMode(first.chunk.speakerPolicy),
|
|
127
143
|
})
|
|
128
144
|
segmentStartPosition = position + 1
|
|
129
145
|
}
|
|
@@ -139,7 +155,8 @@ export function segmentTranscriptChunks(
|
|
|
139
155
|
endChunkIdx: last.chunkIndex,
|
|
140
156
|
startElapsed: first.chunk.elapsed,
|
|
141
157
|
endElapsed: last.chunk.elapsed,
|
|
142
|
-
speakers: [...new Set(window.map(item => item.chunk.speaker).filter(speaker => speaker && speaker !== 'Ext'))],
|
|
158
|
+
speakers: [...new Set(window.map(item => policyLabel(item.chunk, captureSpeakerMode(item.chunk.speakerPolicy)).speaker).filter(speaker => speaker && speaker !== 'Ext'))],
|
|
159
|
+
speakerPolicy: captureSpeakerMode(first.chunk.speakerPolicy),
|
|
143
160
|
})
|
|
144
161
|
}
|
|
145
162
|
}
|
|
@@ -173,6 +190,12 @@ export function mapWordsToSpeakers(
|
|
|
173
190
|
segment: BatchSegment,
|
|
174
191
|
entries: IndexedTranscriptChunk[],
|
|
175
192
|
): Array<{ word: string; start: number; end: number; speaker: string; similarity: number }> {
|
|
193
|
+
const window = entries.filter(entry => entry.chunkIndex >= segment.startChunkIdx && entry.chunkIndex <= segment.endChunkIdx)
|
|
194
|
+
const policies = new Set(window.map(entry => captureSpeakerMode(entry.chunk.speakerPolicy)))
|
|
195
|
+
const strict = captureSpeakerMode(segment.speakerPolicy) !== 'standard' || [...policies].some(mode => mode !== 'standard')
|
|
196
|
+
// Defensive gate for caller-provided/cached mixed windows, even though the
|
|
197
|
+
// segmenter now splits them. Without precise spans, retain words anonymously.
|
|
198
|
+
const mixedStrict = strict && policies.size > 1
|
|
176
199
|
return words.map(word => {
|
|
177
200
|
const absoluteElapsed = segment.startElapsed + word.start * 1000
|
|
178
201
|
let speaker = segment.speakers[0] || 'Unknown'
|
|
@@ -184,14 +207,16 @@ export function mapWordsToSpeakers(
|
|
|
184
207
|
const distance = Math.abs(chunk.elapsed - absoluteElapsed)
|
|
185
208
|
if (distance < bestDistance) {
|
|
186
209
|
bestDistance = distance
|
|
187
|
-
|
|
188
|
-
|
|
210
|
+
const label = policyLabel(chunk, captureSpeakerMode(chunk.speakerPolicy))
|
|
211
|
+
speaker = label.speaker
|
|
212
|
+
similarity = Number.isFinite(label.similarity) ? label.similarity : 0
|
|
189
213
|
}
|
|
190
214
|
}
|
|
191
215
|
if (bestDistance > 3_500) {
|
|
192
|
-
speaker = segment.speakers[0] || 'Unknown'
|
|
216
|
+
speaker = strict ? 'Ext' : segment.speakers[0] || 'Unknown'
|
|
193
217
|
similarity = 0
|
|
194
218
|
}
|
|
219
|
+
if (mixedStrict || captureSpeakerMode(segment.speakerPolicy) === 'anonymous') { speaker = 'Ext'; similarity = 0 }
|
|
195
220
|
return { word: word.word, start: word.start, end: word.end, speaker, similarity }
|
|
196
221
|
})
|
|
197
222
|
}
|
|
@@ -322,6 +347,7 @@ function readProgressiveCheckpointMetadata(
|
|
|
322
347
|
|| cached.segment.endChunkIdx !== segment.endChunkIdx
|
|
323
348
|
|| cached.segment.startElapsed !== segment.startElapsed
|
|
324
349
|
|| cached.segment.endElapsed !== segment.endElapsed
|
|
350
|
+
|| captureSpeakerMode(cached.segment.speakerPolicy) !== captureSpeakerMode(segment.speakerPolicy)
|
|
325
351
|
|| !Array.isArray(cached.segment.speakers)
|
|
326
352
|
|| cached.segment.speakers.some(speaker => typeof speaker !== 'string')
|
|
327
353
|
|| typeof cached.text !== 'string'
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Per-capture identity matching. Transcription continues when names are withheld.
|
|
2
|
+
import { readFileSync, mkdirSync } from 'node:fs'
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { dataPath } from './data-dir.js'
|
|
6
|
+
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
7
|
+
import { AUTO_ENROLL_THRESHOLD } from './voiceprint-floor.js'
|
|
8
|
+
|
|
9
|
+
export const SPEAKER_MODES = ['standard', 'high-confidence', 'anonymous'] as const
|
|
10
|
+
export type SpeakerMode = typeof SPEAKER_MODES[number]
|
|
11
|
+
export function isSpeakerMode(value: unknown): value is SpeakerMode {
|
|
12
|
+
return typeof value === 'string' && SPEAKER_MODES.includes(value as SpeakerMode)
|
|
13
|
+
}
|
|
14
|
+
function path(sessionId: string): string {
|
|
15
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) throw new Error('Invalid session ID')
|
|
16
|
+
return join(dataPath('speaker-policy'), createHash('sha256').update(sessionId).digest('hex') + '.json')
|
|
17
|
+
}
|
|
18
|
+
export function readSpeakerPolicy(sessionId: string): { mode: SpeakerMode; revision: string; error?: string } {
|
|
19
|
+
try {
|
|
20
|
+
const raw = readFileSync(path(sessionId), 'utf8')
|
|
21
|
+
const value = JSON.parse(raw)
|
|
22
|
+
if (value.sessionId !== sessionId || !isSpeakerMode(value.mode)) throw new Error('Invalid speaker policy')
|
|
23
|
+
return { mode: value.mode, revision: createHash('sha256').update(raw).digest('hex') }
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { mode: 'standard', revision: 'unset' }
|
|
26
|
+
// Corrupt policy must never silently restore identity matching.
|
|
27
|
+
return { mode: 'anonymous', revision: 'unreadable', error: String(error) }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function writeSpeakerPolicy(sessionId: string, mode: SpeakerMode, expectedRevision: string): void {
|
|
31
|
+
if (!isSpeakerMode(mode)) throw new Error('Invalid speaker mode')
|
|
32
|
+
if (readSpeakerPolicy(sessionId).revision !== expectedRevision) throw new Error('Speaker policy changed; refresh first')
|
|
33
|
+
mkdirSync(dataPath('speaker-policy'), { recursive: true, mode: 0o700 })
|
|
34
|
+
durableAtomicWriteFileSync(path(sessionId), JSON.stringify({ sessionId, mode }), { mode: 0o600 })
|
|
35
|
+
}
|
|
36
|
+
export function policyLabel<T extends { speaker: string; similarity?: number | null }>(value: T, mode: SpeakerMode): T {
|
|
37
|
+
if (mode === 'standard') return value
|
|
38
|
+
if (mode === 'high-confidence' && typeof value.similarity === 'number' && Number.isFinite(value.similarity) && value.similarity >= AUTO_ENROLL_THRESHOLD && value.similarity <= 1) return value
|
|
39
|
+
return { ...value, speaker: 'Ext', similarity: 0 }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Missing markers are legacy Standard. Malformed markers fail closed. */
|
|
43
|
+
export function captureSpeakerMode(value: unknown): SpeakerMode {
|
|
44
|
+
return value === undefined ? 'standard' : isSpeakerMode(value) ? value : 'anonymous'
|
|
45
|
+
}
|
|
@@ -67,6 +67,7 @@ import {
|
|
|
67
67
|
type TokenTime,
|
|
68
68
|
wavHeader,
|
|
69
69
|
} from './nemotron-segments.js'
|
|
70
|
+
import { captureSpeakerMode, type SpeakerMode } from './meeting-speaker-policy.js'
|
|
70
71
|
import { NEMOTRON_CHANNELS } from './nemotron-cli.js'
|
|
71
72
|
import type { WhisperWord } from './whisper-local.js'
|
|
72
73
|
|
|
@@ -92,6 +93,7 @@ export interface SidecarChunk {
|
|
|
92
93
|
speaker: string
|
|
93
94
|
elapsed: number
|
|
94
95
|
similarity: number
|
|
96
|
+
speakerPolicy?: SpeakerMode
|
|
95
97
|
words?: WhisperWord[]
|
|
96
98
|
segments?: ChunkSpeakerSegment[]
|
|
97
99
|
evenHubSpeakerRole?: EvenRole
|
|
@@ -631,7 +633,7 @@ export const FINAL_REASON_CODES = [
|
|
|
631
633
|
'no_transcript', 'already_applied', 'no_chunks', 'nemotron_timeout', 'nemotron_cli_failed',
|
|
632
634
|
'nemotron_bad_output', 'nemotron_spawn_failed', 'nemotron_error', 'no_speaker_words', 'chunk_map_ambiguous',
|
|
633
635
|
'word_mismatch', 'no_change', 'changed_during_pass', 'markdown_write', 'sidecar_restore_failed', 'too_long',
|
|
634
|
-
'metadata_not_persisted', 'error',
|
|
636
|
+
'metadata_not_persisted', 'speaker_policy', 'error',
|
|
635
637
|
] as const
|
|
636
638
|
export type FinalReasonCode = typeof FINAL_REASON_CODES[number]
|
|
637
639
|
const REASON_SET: ReadonlySet<string> = new Set(FINAL_REASON_CODES)
|
|
@@ -793,6 +795,13 @@ async function finalPassNow(options: FinalPassOptions): Promise<FinalPassOutcome
|
|
|
793
795
|
}
|
|
794
796
|
const entries = (Array.isArray(sidecar.chunkEntries) ? sidecar.chunkEntries : []) as SidecarEntry[]
|
|
795
797
|
if (!entries.length) return { status: 'skipped', reason: 'no_chunks' }
|
|
798
|
+
// Final diarization builds meeting-wide channel/name maps. Earlier Standard
|
|
799
|
+
// identities cannot safely authorize names for later strict-policy audio.
|
|
800
|
+
// Preserve the canonical transcript (including earlier names) byte-for-byte.
|
|
801
|
+
if (entries.some(entry => captureSpeakerMode(entry.chunk.speakerPolicy) !== 'standard')
|
|
802
|
+
|| (Array.isArray(sidecar.chunks) && sidecar.chunks.some((chunk: SidecarChunk) => captureSpeakerMode(chunk.speakerPolicy) !== 'standard'))) {
|
|
803
|
+
return { status: 'skipped', reason: 'speaker_policy' }
|
|
804
|
+
}
|
|
796
805
|
|
|
797
806
|
const timeline = await buildMeetingTimeline(entries, options.audioDir)
|
|
798
807
|
const excluded = timeline.excluded
|
|
@@ -42,7 +42,7 @@ const require = createRequire(import.meta.url)
|
|
|
42
42
|
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
|
43
43
|
|
|
44
44
|
import { DATA_DIR } from './data-dir.js'
|
|
45
|
-
import { OWNER_VERIFY_SIMILARITY } from './voiceprint-floor.js'
|
|
45
|
+
import { AUTO_ENROLL_THRESHOLD, OWNER_VERIFY_SIMILARITY } from './voiceprint-floor.js'
|
|
46
46
|
|
|
47
47
|
export const SPEAKER_MODEL_FILENAME = '3dspeaker_speech_eres2net_sv_en_voxceleb_16k.onnx'
|
|
48
48
|
|
|
@@ -92,7 +92,8 @@ const SEARCH_THRESHOLD = 0.55
|
|
|
92
92
|
/** The bar `autoEnroll` enrols at. From 6.45.4 also the bar at which a held
|
|
93
93
|
* group is offered as a person with one click ("high"): the identifier would
|
|
94
94
|
* have written that sample itself. */
|
|
95
|
-
export
|
|
95
|
+
export { AUTO_ENROLL_THRESHOLD } from './voiceprint-floor.js'
|
|
96
|
+
|
|
96
97
|
/** The live path only ASKS autoEnroll about a chunk above this; autoEnroll
|
|
97
98
|
* then applies AUTO_ENROLL_THRESHOLD. Named so transcribe-stream and the
|
|
98
99
|
* held-voice panel read the same number (6.45.4). */
|
|
@@ -375,9 +376,10 @@ export function identifySpeaker(
|
|
|
375
376
|
expectedSpeakers?: string[],
|
|
376
377
|
/** 6.61.0: `event` tags the calibration rows, e.g. 'nemotron-track' for one
|
|
377
378
|
* Nemotron track's audio, so they are never read as whole-chunk checks. */
|
|
378
|
-
options: { event?: string } = {},
|
|
379
|
+
options: { event?: string; minSimilarity?: number } = {},
|
|
379
380
|
): { speaker: string; similarity: number; embedding?: Float32Array } | null {
|
|
380
381
|
if (!extractor || !manager) return null
|
|
382
|
+
const minimum = Number.isFinite(options.minSimilarity) ? Math.max(0, Math.min(1, options.minSimilarity!)) : 0
|
|
381
383
|
|
|
382
384
|
try {
|
|
383
385
|
const embedding = extractEmbeddingFromWav(wavBuffer)
|
|
@@ -386,7 +388,7 @@ export function identifySpeaker(
|
|
|
386
388
|
// If the wearer is enrolled, verify against them first (they're wearing the glasses)
|
|
387
389
|
const owner = getOwnerSpeakerLabel()
|
|
388
390
|
if (manager.contains(owner)) {
|
|
389
|
-
const isOwner = manager.verify({ name: owner, v: embedding, threshold: VERIFY_THRESHOLD })
|
|
391
|
+
const isOwner = manager.verify({ name: owner, v: embedding, threshold: Math.max(VERIFY_THRESHOLD, minimum) })
|
|
390
392
|
if (isOwner) {
|
|
391
393
|
const similarity = computeCosineSimilarity(embedding, owner)
|
|
392
394
|
logCalibration(owner, similarity, true, options.event)
|
|
@@ -399,7 +401,7 @@ export function identifySpeaker(
|
|
|
399
401
|
for (const name of expectedSpeakers) {
|
|
400
402
|
if (name === owner) continue // wearer already checked
|
|
401
403
|
if (!manager.contains(name)) continue
|
|
402
|
-
const matches = manager.verify({ name, v: embedding, threshold: SEARCH_THRESHOLD })
|
|
404
|
+
const matches = manager.verify({ name, v: embedding, threshold: Math.max(SEARCH_THRESHOLD, minimum) })
|
|
403
405
|
if (matches) {
|
|
404
406
|
const similarity = computeCosineSimilarity(embedding, name)
|
|
405
407
|
logCalibration(name, similarity, true, options.event)
|
|
@@ -409,7 +411,7 @@ export function identifySpeaker(
|
|
|
409
411
|
}
|
|
410
412
|
|
|
411
413
|
// Full search across all speakers (fallback or no expected speakers)
|
|
412
|
-
const found = manager.search({ v: embedding, threshold: SEARCH_THRESHOLD })
|
|
414
|
+
const found = manager.search({ v: embedding, threshold: Math.max(SEARCH_THRESHOLD, minimum) })
|
|
413
415
|
if (found && found.length > 0) {
|
|
414
416
|
const similarity = computeCosineSimilarity(embedding, found)
|
|
415
417
|
logCalibration(found, similarity, true, options.event)
|
|
@@ -10,3 +10,6 @@ export const VOICEPRINT_MIN_AUDIO_SEC = 2.0
|
|
|
10
10
|
* back as the wearer between 0.55 and 0.65 without ever passing owner verify;
|
|
11
11
|
* 6.61.1 refuses that for a split track (it named other people as the wearer). */
|
|
12
12
|
export const OWNER_VERIFY_SIMILARITY = 0.65
|
|
13
|
+
|
|
14
|
+
/** Existing auto-enrollment floor; strict meeting recognition uses the same bar. */
|
|
15
|
+
export const AUTO_ENROLL_THRESHOLD = 0.88
|
package/server/routes/meeting.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readSpeakerPolicy, writeSpeakerPolicy, isSpeakerMode } from '../lib/meeting-speaker-policy.js'
|
|
1
2
|
// POST /api/meeting/save — finalize an existing transcribe-stream session into
|
|
2
3
|
// the standalone public meeting store. The live transcript and chunk metadata
|
|
3
4
|
// are durable before the session is closed; batch improvement runs afterward.
|
|
@@ -952,6 +953,36 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
952
953
|
|
|
953
954
|
// (safeDirectoryRealpath / safeReadFile) instead of reassembling a path from
|
|
954
955
|
// client-supplied domain and filename components.
|
|
956
|
+
router.get('/meeting/:sessionId/speaker-policy', (req, res) => {
|
|
957
|
+
const sessionId = String(req.params.sessionId ?? '')
|
|
958
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) { res.status(400).json({ error: 'Invalid session ID' }); return }
|
|
959
|
+
if (sessions.getStartTime(sessionId) === null && !findCosOperationsMeetingBySessionId(sessionId) && !store.findBySessionId(sessionId)) {
|
|
960
|
+
res.status(404).json({ error: 'Meeting not found' }); return
|
|
961
|
+
}
|
|
962
|
+
res.set('Cache-Control', 'private, no-store').json(readSpeakerPolicy(sessionId))
|
|
963
|
+
})
|
|
964
|
+
router.post('/meeting/:sessionId/speaker-policy', (req, res) => {
|
|
965
|
+
const sessionId = String(req.params.sessionId ?? '')
|
|
966
|
+
const refusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId)
|
|
967
|
+
if (refusal) { res.status(refusal.status).json(refusal.body); return }
|
|
968
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId) || !isSpeakerMode(req.body?.mode) || typeof req.body?.expectedPolicyRevision !== 'string') {
|
|
969
|
+
res.status(400).json({ error: 'A valid session, mode and policy revision are required' }); return
|
|
970
|
+
}
|
|
971
|
+
const operations = findCosOperationsMeetingBySessionId(sessionId)
|
|
972
|
+
const saved = operations ? null : store.findBySessionId(sessionId)
|
|
973
|
+
if (!operations && !saved && sessions.getStartTime(sessionId) === null) { res.status(404).json({ error: 'Meeting not found' }); return }
|
|
974
|
+
if (operations || saved) {
|
|
975
|
+
const selected = operations ? `ops:${operations.domain}:${operations.month}:${operations.filename}` : `standalone:${sessionId}`
|
|
976
|
+
if (req.body?.recordId !== selected) { res.status(409).json({ error: 'Reopen the selected meeting' }); return }
|
|
977
|
+
const sourceRefusal = sourceRevisionRefusal(sessionId, req.body) ?? finalizingRefusal(sessionId)
|
|
978
|
+
if (sourceRefusal) { res.status(sourceRefusal.status).json(sourceRefusal.body); return }
|
|
979
|
+
}
|
|
980
|
+
try {
|
|
981
|
+
writeSpeakerPolicy(sessionId, req.body.mode, req.body.expectedPolicyRevision)
|
|
982
|
+
res.json({ ...readSpeakerPolicy(sessionId), message: 'Matching changed for new audio. Existing names are unchanged.' })
|
|
983
|
+
} catch (error) { res.status(409).json({ error: String(error) }); }
|
|
984
|
+
})
|
|
985
|
+
|
|
955
986
|
router.get('/meeting/:sessionId/speakers', (req, res) => {
|
|
956
987
|
const sessionId = String(req.params.sessionId ?? '')
|
|
957
988
|
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
|
|
@@ -1041,6 +1072,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1041
1072
|
domain,
|
|
1042
1073
|
filename,
|
|
1043
1074
|
...identity,
|
|
1075
|
+
speakerPolicy: readSpeakerPolicy(sessionId),
|
|
1044
1076
|
reviewIdentityVersion: 1,
|
|
1045
1077
|
sourceRevision: reviewRevision(operations?.meetingPath ?? direct?.meetingPath ?? saved!.filepath, sidecarPath),
|
|
1046
1078
|
...(operations?.mergedScribeConflict?.length ? { mutable: false, identityConflict: operations.mergedScribeConflict } : {}),
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { AUTO_ENROLL_THRESHOLD } from '../lib/voiceprint-floor.js'
|
|
2
|
+
import { readSpeakerPolicy, policyLabel, type SpeakerMode } from '../lib/meeting-speaker-policy.js'
|
|
1
3
|
// POST /api/transcribe-stream — Streaming transcription for continuous meeting capture
|
|
2
4
|
// Uses local Whisper. OpenAI API fallback is disabled by default and requires
|
|
3
5
|
// both COS_OPENAI_WHISPER_FALLBACK=1 and a configured key.
|
|
@@ -272,6 +274,8 @@ export interface TranscriptChunk {
|
|
|
272
274
|
speaker: string
|
|
273
275
|
elapsed: number // ms since session start
|
|
274
276
|
similarity: number // speaker identification confidence (0-1)
|
|
277
|
+
/** Capture-time policy survives batch and final passes; absent means legacy Standard. */
|
|
278
|
+
speakerPolicy?: SpeakerMode
|
|
275
279
|
words?: WhisperWord[] // word-level timestamps from DTW alignment
|
|
276
280
|
asrProvider?: 'server-whisper' | 'iphone-whisperkit-beta'
|
|
277
281
|
backend?: string
|
|
@@ -2061,15 +2065,16 @@ function ownerLabelOrNull(): string | null {
|
|
|
2061
2065
|
try { return getOwnerSpeakerLabel() } catch { return null }
|
|
2062
2066
|
}
|
|
2063
2067
|
|
|
2064
|
-
function identifyChunkSpeaker(audioBuffer: Buffer, sessionId: string, chunkIndex: number, clientSpeaker: string): { speaker: string; similarity: number } {
|
|
2068
|
+
function identifyChunkSpeaker(audioBuffer: Buffer, sessionId: string, chunkIndex: number, clientSpeaker: string, policy: SpeakerMode): { speaker: string; similarity: number } {
|
|
2069
|
+
if (policy === 'anonymous') return { speaker: 'Ext', similarity: 0 }
|
|
2065
2070
|
const expectedSpeakers = undefined
|
|
2066
2071
|
const audioDurationSec = Math.max(0, (audioBuffer.length - 44)) / 32000
|
|
2067
|
-
if (!isEmbeddingAvailable() || audioDurationSec < VOICEPRINT_MIN_AUDIO_SEC) return { speaker: clientSpeaker, similarity: 0 }
|
|
2072
|
+
if (!isEmbeddingAvailable() || audioDurationSec < VOICEPRINT_MIN_AUDIO_SEC) return policyLabel({ speaker: clientSpeaker, similarity: 0 }, policy)
|
|
2068
2073
|
|
|
2069
2074
|
const tEmb = performance.now()
|
|
2070
|
-
const embeddingResult = identifySpeaker(audioBuffer, expectedSpeakers)
|
|
2075
|
+
const embeddingResult = identifySpeaker(audioBuffer, expectedSpeakers, { minSimilarity: policy === 'high-confidence' ? AUTO_ENROLL_THRESHOLD : undefined })
|
|
2071
2076
|
console.log(`[perf] identifySpeaker: ${(performance.now() - tEmb).toFixed(1)}ms`)
|
|
2072
|
-
if (!embeddingResult) return { speaker: clientSpeaker, similarity: 0 }
|
|
2077
|
+
if (!embeddingResult) return policyLabel({ speaker: clientSpeaker, similarity: 0 }, policy)
|
|
2073
2078
|
|
|
2074
2079
|
// Bank the embedding before anything else can return. This is the only moment
|
|
2075
2080
|
// it exists: identifySpeaker computes it per chunk and nothing else keeps it,
|
|
@@ -2079,25 +2084,25 @@ function identifyChunkSpeaker(audioBuffer: Buffer, sessionId: string, chunkIndex
|
|
|
2079
2084
|
if (embeddingResult.embedding) {
|
|
2080
2085
|
appendChunkEmbedding(sessionId, {
|
|
2081
2086
|
i: chunkIndex,
|
|
2082
|
-
speaker: embeddingResult.speaker,
|
|
2087
|
+
speaker: policyLabel(embeddingResult, policy).speaker,
|
|
2083
2088
|
similarity: embeddingResult.similarity,
|
|
2084
2089
|
embedding: embeddingResult.embedding,
|
|
2085
2090
|
})
|
|
2086
2091
|
}
|
|
2087
2092
|
|
|
2088
|
-
let speaker = embeddingResult.speaker
|
|
2093
|
+
let speaker = policyLabel(embeddingResult, policy).speaker
|
|
2089
2094
|
if (speaker !== clientSpeaker) {
|
|
2090
2095
|
console.log(`[speaker] Embedding: ${speaker} vs Amplitude: ${clientSpeaker} (sim: ${embeddingResult.similarity.toFixed(2)})`)
|
|
2091
2096
|
}
|
|
2092
2097
|
|
|
2093
|
-
if (embeddingResult.similarity >= AUTO_ENROLL_CANDIDATE_SIMILARITY && speaker !== 'Ext') {
|
|
2098
|
+
if (policy === 'standard' && embeddingResult.similarity >= AUTO_ENROLL_CANDIDATE_SIMILARITY && speaker !== 'Ext') {
|
|
2094
2099
|
const enrollResult = autoEnroll(speaker, audioBuffer, embeddingResult.similarity, sessionId)
|
|
2095
2100
|
if (enrollResult.enrolled) {
|
|
2096
2101
|
console.log(`[speaker] Auto-enrolled ${speaker} from G2 mic (sim: ${embeddingResult.similarity.toFixed(3)})`)
|
|
2097
2102
|
}
|
|
2098
2103
|
}
|
|
2099
2104
|
|
|
2100
|
-
if (speaker !== 'Ext' && embeddingResult.similarity > 0.50) {
|
|
2105
|
+
if (policy === 'standard' && speaker !== 'Ext' && embeddingResult.similarity > 0.50) {
|
|
2101
2106
|
// Audio has its own count and TTL. A full profile still needs examples
|
|
2102
2107
|
// from new meetings; deleting/expiring old audio immediately frees a slot.
|
|
2103
2108
|
try {
|
|
@@ -2208,6 +2213,9 @@ async function processStreamChunk(opts: {
|
|
|
2208
2213
|
return emptyChunkResponse(alreadyEmpty, sessionId, chunkIndex)
|
|
2209
2214
|
}
|
|
2210
2215
|
|
|
2216
|
+
// Pin identity policy at acceptance, before persistence, ASR or diarization
|
|
2217
|
+
// yields. A later loosening applies to the next chunk, never this audio.
|
|
2218
|
+
const capturePolicy = readSpeakerPolicy(sessionId).mode
|
|
2211
2219
|
await persistRawSessionAudioChunk(sessionId, chunkIndex, audioBuffer)
|
|
2212
2220
|
// Commit the received-index ledger only after the canonical raw WAV is
|
|
2213
2221
|
// durable. A failure is typed non-2xx and a retry remains safe.
|
|
@@ -2237,7 +2245,7 @@ async function processStreamChunk(opts: {
|
|
|
2237
2245
|
const whisperContext = recentWhisperContext(sessionId, session)
|
|
2238
2246
|
console.log(`[perf] whisperContext: ${whisperContext.length}b | session.chunks: ${session.chunks.length}`)
|
|
2239
2247
|
|
|
2240
|
-
const speakerPromise = Promise.resolve(identifyChunkSpeaker(audioBuffer, sessionId, chunkIndex, clientSpeaker))
|
|
2248
|
+
const speakerPromise = Promise.resolve(identifyChunkSpeaker(audioBuffer, sessionId, chunkIndex, clientSpeaker, capturePolicy))
|
|
2241
2249
|
|
|
2242
2250
|
let rawText = ''
|
|
2243
2251
|
let words: WhisperWord[] | undefined
|
|
@@ -2312,13 +2320,16 @@ async function processStreamChunk(opts: {
|
|
|
2312
2320
|
clientSpeaker,
|
|
2313
2321
|
owner: ownerLabelOrNull(),
|
|
2314
2322
|
identify: wav => {
|
|
2315
|
-
const
|
|
2316
|
-
|
|
2323
|
+
const policy = capturePolicy
|
|
2324
|
+
if (policy === 'anonymous') return { speaker: 'Ext', similarity: 0 }
|
|
2325
|
+
const named = identifySpeaker(wav, undefined, { event: 'nemotron-track', minSimilarity: policy === 'high-confidence' ? AUTO_ENROLL_THRESHOLD : undefined })
|
|
2326
|
+
return named ? policyLabel({ speaker: named.speaker, similarity: named.similarity }, policy) : null
|
|
2317
2327
|
},
|
|
2318
2328
|
})
|
|
2319
2329
|
: { speaker: voiceprint.speaker, similarity: voiceprint.similarity }
|
|
2320
2330
|
if (labels.record) nemotronLive.record({ sessionId, chunkIndex }, labels.record)
|
|
2321
|
-
const { speaker, similarity } = labels
|
|
2331
|
+
const { speaker, similarity } = policyLabel(labels, capturePolicy)
|
|
2332
|
+
if (labels.segments) labels.segments = labels.segments.map(segment => policyLabel(segment, capturePolicy))
|
|
2322
2333
|
|
|
2323
2334
|
if (!trimmedText) {
|
|
2324
2335
|
console.log(`[hallucination] Filtered (${sanitized.fallbackReason || fallbackReason || 'empty'}): "${rawText.slice(0, 60)}"`)
|
|
@@ -2346,6 +2357,7 @@ async function processStreamChunk(opts: {
|
|
|
2346
2357
|
|
|
2347
2358
|
const chunk: TranscriptChunk = {
|
|
2348
2359
|
text: trimmedText,
|
|
2360
|
+
speakerPolicy: capturePolicy,
|
|
2349
2361
|
speaker,
|
|
2350
2362
|
elapsed,
|
|
2351
2363
|
similarity,
|