@gotcos/glasses-server 6.91.1 → 6.93.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/.env.example +64 -2
- package/CHANGELOG.md +154 -29
- package/README.md +90 -1
- package/docs/active-query-jobs.md +1 -1
- package/docs/test-plan-6.71.7.md +1 -1
- package/managed-runtime-contract.json +10 -1
- package/package.json +2 -1
- package/server/lib/ask-session-id.ts +2 -1
- package/server/lib/claude-circuit.ts +8 -4
- package/server/lib/claude-text-runner.ts +113 -0
- package/server/lib/dictation-fillers-fixture-cases.ts +214 -0
- package/server/lib/dictation-fillers.ts +30 -0
- package/server/lib/dictation-meaning-guard.ts +47 -0
- package/server/lib/dictation-polish-pipeline.ts +90 -12
- package/server/lib/embedding-eviction.ts +60 -2
- package/server/lib/fuzzy-correct.ts +1 -1
- package/server/lib/hallucination-filter.ts +1 -1
- package/server/lib/imported-library-rows.ts +4 -2
- package/server/lib/live-cos-ask.ts +3 -2
- package/server/lib/mac-live-voices.ts +57 -0
- package/server/lib/mac-recording-asr.ts +16 -8
- package/server/lib/mac-speaker-projection.ts +211 -0
- package/server/lib/mac-stream-diarizer.ts +185 -0
- package/server/lib/mac-voice-assignment.ts +361 -0
- package/server/lib/mac-voice-labels.ts +70 -8
- package/server/lib/mac-window-replay.ts +271 -0
- package/server/lib/mac-window-voices.ts +390 -0
- package/server/lib/meeting-actions.ts +1 -1
- package/server/lib/meeting-ask-owner.ts +4 -4
- package/server/lib/meeting-ask-transcript-sources.ts +41 -3
- package/server/lib/meeting-audio-archive.ts +4 -0
- package/server/lib/meeting-batch-transcribe.ts +7 -1
- package/server/lib/meeting-context-keys.ts +6 -3
- package/server/lib/meeting-corrections.ts +4 -4
- package/server/lib/meeting-fact-check.ts +3 -3
- package/server/lib/meeting-parse.ts +1 -1
- package/server/lib/meeting-relabel-enrolment.ts +8 -8
- package/server/lib/meeting-relabel.ts +3 -3
- package/server/lib/meeting-scribe-content.ts +1 -1
- package/server/lib/meeting-speaker-review.ts +8 -8
- package/server/lib/meeting-store.ts +19 -9
- package/server/lib/nemotron-final.ts +114 -9
- package/server/lib/nemotron-live.ts +58 -6
- package/server/lib/phone-recording.ts +114 -0
- package/server/lib/prompt-edit.ts +84 -3
- package/server/lib/prompt-tail-guard.ts +1 -1
- package/server/lib/speaker-embeddings.ts +154 -28
- package/server/lib/speaker-name.ts +1 -1
- package/server/lib/stranded-session-actions.ts +7 -2
- package/server/lib/token-audit.ts +6 -1
- package/server/lib/training-audio-provenance.ts +3 -2
- package/server/lib/transcript-cleanup-context.ts +3 -3
- package/server/lib/transcript-cleanup-model.ts +1 -1
- package/server/lib/transcript-cleanup-spans.ts +10 -10
- package/server/lib/transcript-cleanup-text.ts +4 -4
- package/server/lib/transcript-cleanup.ts +1 -1
- package/server/lib/unsaved-audio-quarantine.ts +10 -1
- package/server/lib/voice-directory.ts +1 -1
- package/server/lib/voice-evidence-guard.ts +2 -1
- package/server/lib/voice-profile-store.ts +11 -5
- package/server/lib/whisper-local.ts +1 -1
- package/server/routes/dictation-polish-settings.ts +8 -1
- package/server/routes/health.ts +8 -0
- package/server/routes/meeting.ts +189 -19
- package/server/routes/meetings.ts +9 -5
- package/server/routes/prompt-drafts.ts +3 -2
- package/server/routes/prompt-edit.ts +47 -5
- package/server/routes/transcribe-stream.ts +140 -15
- package/server/routes/voice.ts +12 -4
- package/server/scripts/dictation-parity-6920-mutation-gate.mjs +229 -0
- package/server/scripts/generate-dictation-fillers-fixture.ts +18 -0
- package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +225 -0
- package/server/scripts/mac-window-voices-6930-mutation-gate.mjs +193 -0
|
@@ -65,8 +65,8 @@ function distance(a: string, b: string): number {
|
|
|
65
65
|
}
|
|
66
66
|
/**
|
|
67
67
|
* One heard word against one name word. Short words must match exactly. Otherwise one edit (two from
|
|
68
|
-
* seven letters), or one edit after a leading y the recognizer added before a vowel:
|
|
69
|
-
* both read as
|
|
68
|
+
* seven letters), or one edit after a leading y the recognizer added before a vowel: Ukmya and Yukmya
|
|
69
|
+
* both read as Ukamya.
|
|
70
70
|
*/
|
|
71
71
|
export function nameWordMatches(heard: string, name: string): boolean {
|
|
72
72
|
const a = fold(heard), b = fold(name)
|
|
@@ -82,8 +82,8 @@ const LEAD_IN = new Set(['is', 'was', 'about', 'with', 'for', 'from', 'to', 'of'
|
|
|
82
82
|
export interface OwnerMatch { heard: string; owner: string }
|
|
83
83
|
/**
|
|
84
84
|
* Whether the question names the owner, allowing ASR spellings. Full name: first and last words, in
|
|
85
|
-
* order and adjacent. Surname alone: only after a lead-in word ("who is
|
|
86
|
-
* first name ("
|
|
85
|
+
* order and adjacent. Surname alone: only after a lead-in word ("who is Ukmya"), never after another
|
|
86
|
+
* first name ("Felix Ukamya" is someone else). First name alone: only when it is the whole subject of
|
|
87
87
|
* a who question, compared exactly.
|
|
88
88
|
*/
|
|
89
89
|
export function matchOwnerName(question: string, ownerName: string | null | undefined): OwnerMatch | null {
|
|
@@ -3,11 +3,49 @@ import { readBoundedFile, type AskTranscriptSources } from './meeting-ask-transc
|
|
|
3
3
|
import { getMeetingStore } from './meeting-store.js';
|
|
4
4
|
import { dataPath } from './data-dir.js';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
+
import { requirePhoneRecording, PHONE_SOURCE_TYPE, type PhoneRecording } from './phone-recording.js';
|
|
7
|
+
import { AskError } from './live-cos-ask.js';
|
|
8
|
+
|
|
9
|
+
function phoneRecording(id: string): PhoneRecording | null {
|
|
10
|
+
if (!id.startsWith('phone_')) return null;
|
|
11
|
+
try { return requirePhoneRecording(id); }
|
|
12
|
+
catch (error) { const e = error as { reason?: string; status?: number }; throw new AskError(e.reason ?? 'phone_capture_metadata_unreadable', e.status ?? 409); }
|
|
13
|
+
}
|
|
14
|
+
function anonymousChunks(chunks: unknown): boolean {
|
|
15
|
+
return Array.isArray(chunks) && chunks.every(c => c && typeof c === 'object' && c.speaker === 'Ext' && c.speakerPolicy === 'anonymous' && (c.segments === undefined || (Array.isArray(c.segments) && c.segments.every((s: any) => s && s.speaker === 'Ext'))));
|
|
16
|
+
}
|
|
17
|
+
/** Ask may not recover a phone journal that audio/session recovery refused. */
|
|
18
|
+
function validatePhoneDocument(text: string | null, phone: PhoneRecording): void {
|
|
19
|
+
try {
|
|
20
|
+
const doc = JSON.parse(text ?? 'null'), capture = doc?.captureSource;
|
|
21
|
+
if (!doc || doc.sessionId !== phone.sessionId || doc.startTime !== phone.startTime || doc.sourceType !== PHONE_SOURCE_TYPE || doc.speakerPolicy !== 'anonymous' || capture?.version !== 1 || capture?.captureId !== phone.captureId || capture?.sessionId !== phone.sessionId || capture?.startTime !== phone.startTime || capture?.sourceType !== PHONE_SOURCE_TYPE || capture?.speakerPolicy !== 'anonymous') throw Error('provenance');
|
|
22
|
+
let found = false;
|
|
23
|
+
for (const [key, field] of [['chunks', null], ['chunksIndexed', 'c'], ['chunkEntries', 'chunk']] as const) {
|
|
24
|
+
if (doc[key] === undefined) continue;
|
|
25
|
+
if (!Array.isArray(doc[key]) || !anonymousChunks(field ? doc[key].map((entry: any) => entry?.[field]) : doc[key])) throw Error('anonymous transcript');
|
|
26
|
+
found = true;
|
|
27
|
+
}
|
|
28
|
+
if (!found || (doc.batchSegments !== undefined && (!Array.isArray(doc.batchSegments) || !doc.batchSegments.every((s: any) => s && s.speaker === 'Ext')))) throw Error('anonymous transcript');
|
|
29
|
+
} catch { throw new AskError('phone_transcript_provenance_unreadable', 409); }
|
|
30
|
+
}
|
|
6
31
|
export const transcriptSources: AskTranscriptSources = {
|
|
7
|
-
live: id => {
|
|
32
|
+
live: id => {
|
|
33
|
+
const phone = phoneRecording(id), entries = getSessionChunkEntries(id), startTime = getSessionStartTime(id);
|
|
34
|
+
if (phone && entries && (phone.closedAt || startTime !== phone.startTime || !anonymousChunks(entries.map(e => e.chunk)))) throw new AskError('phone_transcript_provenance_unreadable', 409);
|
|
35
|
+
return entries && startTime !== null ? { entries, startTime } : null;
|
|
36
|
+
},
|
|
8
37
|
status: id => getMeetingSessionStatus(id),
|
|
9
|
-
activeFile: id =>
|
|
10
|
-
|
|
38
|
+
activeFile: id => {
|
|
39
|
+
const phone = phoneRecording(id), text = readBoundedFile(join(dataPath('active-sessions'), `${id}.json`));
|
|
40
|
+
if (phone && text) validatePhoneDocument(text, phone);
|
|
41
|
+
return text;
|
|
42
|
+
},
|
|
43
|
+
saved: id => {
|
|
44
|
+
const phone = phoneRecording(id), saved = getMeetingStore().findBySessionId(id);
|
|
45
|
+
if (phone && saved) validatePhoneDocument(readBoundedFile(saved.sidecarPath), phone);
|
|
46
|
+
if (phone?.closedAt && !saved) throw new AskError('phone_saved_metadata_unreadable', 409);
|
|
47
|
+
return saved;
|
|
48
|
+
},
|
|
11
49
|
read: readBoundedFile,
|
|
12
50
|
now: Date.now,
|
|
13
51
|
};
|
|
@@ -111,6 +111,10 @@ export function archiveSessionAudio(sessionId: string, sourceDir: string): Archi
|
|
|
111
111
|
}
|
|
112
112
|
try { out.bytes += statSync(to).size } catch { /* counted as linked regardless */ }
|
|
113
113
|
}
|
|
114
|
+
// Source metadata follows the same retained audio, without counting it as a chunk.
|
|
115
|
+
if (sessionId.startsWith('phone_')) {
|
|
116
|
+
try { copyFileSync(join(sourceDir, '_capture.json'), join(dest, '_capture.json')) } catch { /* durable registry and saved sidecar retain the source too */ }
|
|
117
|
+
}
|
|
114
118
|
return out
|
|
115
119
|
}
|
|
116
120
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { PHONE_CAPTURE_ID, requirePhoneRecording } from './phone-recording.js'
|
|
1
2
|
// Generic post-meeting transcription. Raw WAV chunks are concatenated into
|
|
2
3
|
// larger windows so Whisper gets enough context to improve the live stream.
|
|
3
4
|
// The candidate is never canonical until batch-transcript-quality accepts it.
|
|
@@ -466,12 +467,16 @@ export interface HqJobPlan {
|
|
|
466
467
|
secondOpinion: SecondOpinionId
|
|
467
468
|
resolver: boolean
|
|
468
469
|
frontEnd: FrontEndId
|
|
469
|
-
/** Mac
|
|
470
|
+
/** Canonical Mac/phone Parakeet must never silently substitute Whisper. */
|
|
470
471
|
strictPrimary?: true
|
|
471
472
|
}
|
|
472
473
|
|
|
473
474
|
export function currentHqJobPlan(sessionId?: string): HqJobPlan {
|
|
474
475
|
const selected = readSttSettings()
|
|
476
|
+
if (sessionId && PHONE_CAPTURE_ID.test(sessionId)) {
|
|
477
|
+
requirePhoneRecording(sessionId)
|
|
478
|
+
return { engine: 'parakeet-ultra', secondOpinion: 'off', resolver: false, frontEnd: selected.frontEnd, strictPrimary: true }
|
|
479
|
+
}
|
|
475
480
|
if (sessionId && MAC_CAPTURE_ID.test(sessionId) && selected.hq === 'parakeet-ultra') {
|
|
476
481
|
return { engine: selected.hq, secondOpinion: selected.secondOpinion, resolver: selected.resolver, frontEnd: selected.frontEnd, strictPrimary: true }
|
|
477
482
|
}
|
|
@@ -789,6 +794,7 @@ export async function transcribeSegments(
|
|
|
789
794
|
plan: HqJobPlan,
|
|
790
795
|
deps: HqDecodeDeps = defaultHqDeps,
|
|
791
796
|
): Promise<BatchResult[]> {
|
|
797
|
+
if (expectedSessionId?.startsWith('phone_')) { requirePhoneRecording(expectedSessionId); plan = { ...plan, engine: 'parakeet-ultra', secondOpinion: 'off', resolver: false, strictPrimary: true } }
|
|
792
798
|
if (expectedSessionId && MAC_CAPTURE_ID.test(expectedSessionId) && plan.engine === 'parakeet-ultra') plan = { ...plan, strictPrimary: true }
|
|
793
799
|
const results: BatchResult[] = []
|
|
794
800
|
const meetingId = basename(audioDir)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { PHONE_CAPTURE_ID } from './phone-recording.js'
|
|
1
2
|
import { readMacRecording } from './mac-recording.js'
|
|
2
3
|
/**
|
|
3
4
|
* Meeting context keys (6.64.0): the ids COS Control files a meeting's dropped files under.
|
|
@@ -12,6 +13,7 @@ import { readMacRecording } from './mac-recording.js'
|
|
|
12
13
|
* - `g2:<sessionId>` from its `.g2-chunks.json` sidecar (the speaker system's key; moves with every G2 rename);
|
|
13
14
|
* - `ff:<id>` from its `.fireflies.json` sidecar;
|
|
14
15
|
* - `mac:<captureId>` from an admitted Mac source and its saved sourceType;
|
|
16
|
+
* - `phone:<captureId>` from an admitted iPhone microphone source;
|
|
15
17
|
* - `g2:<id>` for every `<!-- g2-session -->` the file declares, which is how a merged meeting also shows the files of
|
|
16
18
|
* the capture it absorbed.
|
|
17
19
|
* Measured 2026-10-06: 649 of 656 meetings from August to October carry at least one.
|
|
@@ -25,7 +27,7 @@ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpat
|
|
|
25
27
|
import { dirname, join } from 'node:path'
|
|
26
28
|
|
|
27
29
|
/** A key as Control stores it. The id part is the server's own marker grammar (`G2_SESSION_MARKER`). */
|
|
28
|
-
export const MEETING_CONTEXT_KEY = /^(?:
|
|
30
|
+
export const MEETING_CONTEXT_KEY = /^(?:(?:ff|mac|g2):(?!phone_)[A-Za-z0-9:_-]{3,96}|phone:phone_(?:[0-9]{13}_)?[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})$/
|
|
29
31
|
const SOURCE_ID = /^[A-Za-z0-9:_-]{3,96}$/
|
|
30
32
|
|
|
31
33
|
const SIDECAR_HEAD_BYTES = 4096
|
|
@@ -101,8 +103,9 @@ export function firefliesSidecarId(monthDir: string, meetingFilename: string): s
|
|
|
101
103
|
export function meetingContextKeys(sources: { g2SessionId?: string; firefliesId?: string; declared?: readonly string[]; sourceType?: string }): string[] {
|
|
102
104
|
const keys: string[] = []
|
|
103
105
|
const add = (prefix: 'g2' | 'ff' | 'mac', id: string | undefined) => {
|
|
104
|
-
if (!id || !SOURCE_ID.test(id)) return
|
|
105
|
-
|
|
106
|
+
if (!id || !SOURCE_ID.test(id) || (prefix !== 'g2' && id.startsWith('phone_'))) return
|
|
107
|
+
if (prefix === 'g2' && (id.startsWith('phone_') || sources.sourceType === 'iphone_microphone') && (!PHONE_CAPTURE_ID.test(id) || sources.sourceType !== 'iphone_microphone')) return
|
|
108
|
+
const actualPrefix = prefix === 'g2' && sources.sourceType === 'iphone_microphone' ? 'phone' : prefix === 'g2' && (sources.sourceType === 'mac_system_audio' || readMacRecording(id)) ? 'mac' : prefix
|
|
106
109
|
const key = `${actualPrefix}:${id}`
|
|
107
110
|
if (!keys.includes(key)) keys.push(key)
|
|
108
111
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// chunk sidecar, the attendee list, the transcript turn labels. If that is all
|
|
5
5
|
// that happens, three things become impossible:
|
|
6
6
|
//
|
|
7
|
-
// 1. UNDO. Once "
|
|
7
|
+
// 1. UNDO. Once "Theo M" has become "Theo Marsh" in the sidecar, nothing
|
|
8
8
|
// remembers it was ever anything else. A mistaken correction is permanent.
|
|
9
9
|
// 2. CRASH RECOVERY. A rewrite touches several files. Die between them and the
|
|
10
10
|
// meeting is half-corrected with no trace of what was intended.
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
//
|
|
19
19
|
// WHAT IS DELIBERATELY NOT CORRECTED. The meeting markdown's Summary, Topics,
|
|
20
20
|
// Decisions and Action Items are LLM prose that refers to people by BARE FIRST
|
|
21
|
-
// NAME ("
|
|
21
|
+
// NAME ("Jasper pushed back", "Chris raised Beamer sentiment"). Verified on a
|
|
22
22
|
// real scribe: 6 of 12 speakers appear that way. This org has two Alexes, two
|
|
23
23
|
// Jordans and two Chrises, so a find/replace on a first name in narrative text
|
|
24
24
|
// would silently rewrite a sentence about a different person. Prose is left
|
|
@@ -255,8 +255,8 @@ export function appliedCorrections(sessionId: string): CorrectionRow[] {
|
|
|
255
255
|
/**
|
|
256
256
|
* Follow a chain of applied corrections to the label a voice now carries.
|
|
257
257
|
*
|
|
258
|
-
* Chains happen: a voice labelled 'Ext' is corrected to '
|
|
259
|
-
* '
|
|
258
|
+
* Chains happen: a voice labelled 'Ext' is corrected to 'Theo M', then later to
|
|
259
|
+
* 'Theo Marsh'. Asking "what is Ext now?" must answer 'Theo Marsh', not stop at
|
|
260
260
|
* the first hop. Cycles are possible if a human corrects A→B then B→A, so the
|
|
261
261
|
* walk is bounded by the number of corrections and returns the last label
|
|
262
262
|
* reached rather than looping.
|
|
@@ -151,7 +151,7 @@ const LEAD_WORDS = new Set('i im am we were are it its is starting leaving going
|
|
|
151
151
|
export const US_STATE_CODES = new Set('AL AK AZ AR CA CO CT DE FL GA HI ID IL IN IA KS KY LA ME MD MA MI MN MS MO MT NE NV NH NJ NM NY NC ND OH OK OR PA RI SC SD TN TX UT VT VA WA WV WI WY DC PR GU VI AS MP'.split(' '))
|
|
152
152
|
/**
|
|
153
153
|
* Larger US cities and the owner's Austin metro: a typed "City, ST" origin must name one of these (static, no
|
|
154
|
-
* private data), so "Talia TX" or "
|
|
154
|
+
* private data), so "Talia TX" or "Elliot PM" are never places.
|
|
155
155
|
*/
|
|
156
156
|
const US_CITIES = new Set(`new york|los angeles|chicago|houston|phoenix|philadelphia|san antonio|san diego|dallas|san jose|austin|jacksonville|fort worth|columbus|charlotte|san francisco|indianapolis|seattle|denver|washington|boston|el paso|nashville|detroit|oklahoma city|portland|las vegas|memphis|louisville|baltimore|milwaukee|albuquerque|tucson|fresno|mesa|sacramento|atlanta|kansas city|colorado springs|omaha|raleigh|miami|long beach|virginia beach|oakland|minneapolis|tulsa|tampa|arlington|new orleans|wichita|cleveland|bakersfield|aurora|anaheim|honolulu|santa ana|riverside|corpus christi|lexington|henderson|stockton|saint paul|st paul|st louis|saint louis|cincinnati|pittsburgh|greensboro|anchorage|plano|lincoln|orlando|irvine|newark|durham|chula vista|toledo|fort wayne|st petersburg|laredo|jersey city|chandler|madison|lubbock|scottsdale|reno|buffalo|gilbert|glendale|north las vegas|winston salem|chesapeake|norfolk|fremont|garland|irving|hialeah|richmond|boise|spokane|baton rouge|tacoma|san bernardino|modesto|fontana|des moines|santa clarita|fayetteville|birmingham|oxnard|rochester|grand rapids|huntsville|salt lake city|frisco|yonkers|amarillo|huntington beach|mckinney|montgomery|augusta|akron|little rock|tempe|overland park|grand prairie|tallahassee|cape coral|mobile|knoxville|shreveport|worcester|sioux falls|chattanooga|brownsville|fort lauderdale|providence|santa rosa|peoria|salem|eugene|cary|fort collins|springfield|jackson|alexandria|lancaster|pasadena|naperville|bellevue|killeen|midland|odessa|savannah|syracuse|denton|waco|charleston|hartford|new haven|stamford|columbia|santa clara|ann arbor|cambridge|lansing|evansville|college station|beaumont|provo|boulder|tyler|las cruces|green bay|san mateo|burbank|south bend|davenport|tuscaloosa|san angelo|round rock|cedar park|georgetown|leander|pflugerville|san marcos|new braunfels|liberty hill|hutto|kyle|buda|lakeway|bee cave|temple|bryan|abilene|wichita falls|galveston|mcallen|edinburg|pearland|sugar land|the woodlands|conroe|katy|allen|richardson|carrollton|lewisville|flower mound|mesquite|harlingen|key west|palm springs|santa fe|flagstaff|billings|fargo|bismarck|cheyenne|casper|missoula|juneau|burlington|portsmouth|manchester|wilmington|dover|annapolis|trenton|albany|montpelier|concord|augusta|bangor|duluth|des plaines|evanston|topeka|lawrence|olathe|independence|st joseph|joplin|branson|springdale|bentonville|rogers|fayetteville|hot springs|jonesboro|norman|edmond|lafayette|lake charles|monroe|gulfport|biloxi|hattiesburg|pensacola|gainesville|daytona beach|sarasota|naples|fort myers|clearwater|lakeland|ocala|asheville|greenville|myrtle beach|columbia|athens|macon|dothan|chattanooga|knoxville|murfreesboro|clarksville|bowling green|owensboro|dayton|youngstown|canton|erie|scranton|allentown|reading|harrisburg|lancaster|altoona|morgantown|roanoke|lynchburg|charlottesville|norfolk|hampton|newport news`.split('|'))
|
|
157
157
|
/** A full city label the user typed: a listed US city, then a real state code ("Dallas, TX", "San Antonio TX"). */
|
|
@@ -225,8 +225,8 @@ function takePlace(raw: readonly string[], low: readonly string[], start: number
|
|
|
225
225
|
/**
|
|
226
226
|
* A question that only supplies the starting place of the pending route claim: "from <place>" or
|
|
227
227
|
* "<place> to <destination>", where the destination is a place the claim names and every other word is a
|
|
228
|
-
* request, travel or route word (or the owner's own name). "
|
|
229
|
-
* hear from
|
|
228
|
+
* request, travel or route word (or the owner's own name). "Mira to send the deck by Friday?" and "What did we
|
|
229
|
+
* hear from Elliot Sterling today?" are ordinary questions, never an origin.
|
|
230
230
|
*/
|
|
231
231
|
export function originClarification(question: string, ctx: OriginContext): OwnerOrigin | null {
|
|
232
232
|
const { raw, low } = tokensOf(question)
|
|
@@ -134,7 +134,7 @@ export function parseMeeting(content: string, filename: string, month: string):
|
|
|
134
134
|
// codebase, disagreeing with each other.
|
|
135
135
|
domainAbbr: domainAbbreviation(domain),
|
|
136
136
|
source: parseField(content, 'Source'),
|
|
137
|
-
...(parseField(content, 'Capture source')
|
|
137
|
+
...(['mac_system_audio', 'iphone_microphone'].includes(parseField(content, 'Capture source')) ? { sourceType: parseField(content, 'Capture source') as 'mac_system_audio' | 'iphone_microphone' } : {}),
|
|
138
138
|
duration,
|
|
139
139
|
...(parseDurationMinutes(duration) !== undefined ? { durationMinutes: parseDurationMinutes(duration) } : {}),
|
|
140
140
|
month,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Turning "that unidentified voice was
|
|
1
|
+
// Turning "that unidentified voice was Wren Hale" into a real voice profile.
|
|
2
2
|
//
|
|
3
3
|
// WHY THIS EXISTS. Relabelling was TEXT ONLY: it rewrote `speaker` strings in the
|
|
4
4
|
// meeting sidecar and never touched the voice store. Naming a voice over 109
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
// appeared in /api/voice/profiles, the review panel still offered her as `new
|
|
7
7
|
// name` inside the SAME meeting, no later meeting could match her, and there was
|
|
8
8
|
// no profile to accumulate further chunks against. Verified live 2026-08-13:
|
|
9
|
-
// `
|
|
10
|
-
// voice-profiles.json held 77 profiles and no
|
|
9
|
+
// `Wren Hale` appears 70x in one sidecar and 112x in another while
|
|
10
|
+
// voice-profiles.json held 77 profiles and no Wren.
|
|
11
11
|
//
|
|
12
12
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
13
13
|
// WHY THIS IS ITS OWN MODULE AND WHY IT IS THIS PARANOID
|
|
@@ -189,12 +189,12 @@ export interface EnrolNamedVoiceInput {
|
|
|
189
189
|
* Enrol the voice a human just named.
|
|
190
190
|
*
|
|
191
191
|
* SCOPED to a real target name. The reviewer is assigning THESE chunks to `to`.
|
|
192
|
-
* That is a first training run when `to` has no profile (the live case:
|
|
193
|
-
*
|
|
194
|
-
*
|
|
192
|
+
* That is a first training run when `to` has no profile (the live case: Cole
|
|
193
|
+
* Garner → Leo Ashby, 2026-08-20 — 19 chunks relabelled, 78 profiles, no
|
|
194
|
+
* Leo) and an append when the profile already exists. Global fold of two
|
|
195
195
|
* identities remains `merge-profiles`. Per-meeting chunk assignment is not
|
|
196
196
|
* that, and gating on `from` being a placeholder made a wrong existing label
|
|
197
|
-
* (the identifier's 0.55
|
|
197
|
+
* (the identifier's 0.55 Cole match) create no profile at all.
|
|
198
198
|
*
|
|
199
199
|
* Still skipped: a placeholder `to` (Ext → Unidentified 2), and an empty
|
|
200
200
|
* `changed` list. An EXISTING name is appended to without a prompt, and
|
|
@@ -217,7 +217,7 @@ export function enrolNamedVoice(input: EnrolNamedVoiceInput): EnrolmentReport {
|
|
|
217
217
|
// Not an enrolment at all. `attempted: 0` with `skipped: null` is how the
|
|
218
218
|
// caller tells this apart from an enrolment that found no candidates.
|
|
219
219
|
// `from` is deliberately NOT gated: a wrong existing label is how a new
|
|
220
|
-
// person first appears in review (
|
|
220
|
+
// person first appears in review (Cole Garner → Leo Ashby). Gating on
|
|
221
221
|
// placeholder-only `from` labelled the meeting and taught the store nothing.
|
|
222
222
|
if (isPlaceholderLabel(to) || changed.length === 0) return IDLE
|
|
223
223
|
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// index-parallel rendering of the chunk sidecar. Measured on a real meeting
|
|
8
8
|
// (2026-08-06 Health Score V2): 135 sidecar chunks collapse to 46 speaker runs,
|
|
9
9
|
// while the markdown carries 70 turns — and they disagree on who spoke, with the
|
|
10
|
-
// markdown's 4th turn attributed to
|
|
10
|
+
// markdown's 4th turn attributed to Jonas Karlin where the sidecar's 4th run
|
|
11
11
|
// says Riley Example. The transcript was rendered from a different
|
|
12
12
|
// segmentation pass. So:
|
|
13
13
|
//
|
|
@@ -213,8 +213,8 @@ export function relabelMeetingMarkdown(
|
|
|
213
213
|
if (att) {
|
|
214
214
|
const body = out.slice(att.start, att.end)
|
|
215
215
|
// Anchored at BOTH ends, and `[ \t]` rather than `\s` so a pattern can never
|
|
216
|
-
// run past its own line. Without the end anchor, `-
|
|
217
|
-
// PREFIX of `-
|
|
216
|
+
// run past its own line. Without the end anchor, `- Theo M` matches the
|
|
217
|
+
// PREFIX of `- Theo Marsh` and deleting it leaves the fragment `enry`.
|
|
218
218
|
const line = (label: string) => `^-[ \t]+${escapeRegExp(label)}[ \t]*$`
|
|
219
219
|
// A de-attribution must DELETE the attendee bullet, never rename it. Renaming
|
|
220
220
|
// writes `- Unidentified 2` into the attendee list as though it were a
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// whose verdict we would be applying — rewriting them is circular and
|
|
15
15
|
// destroys the raw record the reviewer needs to judge for themselves.
|
|
16
16
|
// 2. A name in the prose is frequently a person MENTIONED, not one who SPOKE.
|
|
17
|
-
// An action item naming
|
|
17
|
+
// An action item naming Joanna Thornley does not claim she was in the room.
|
|
18
18
|
// Stripping it would replace one wrong claim with a different one.
|
|
19
19
|
//
|
|
20
20
|
// So the output carries an ALLOWLIST: who voice matching confirmed, plus a plain
|
|
@@ -8,13 +8,13 @@
|
|
|
8
8
|
// It also carries the diagnostic that actually catches a broken profile.
|
|
9
9
|
// Similarity between profile centroids does NOT distinguish "two people who
|
|
10
10
|
// sound alike" from "one voice split across two labels" — measured on the live
|
|
11
|
-
// store,
|
|
11
|
+
// store, Alex Kellar and Theo Marsh sat at 0.85 while genuinely being
|
|
12
12
|
// separate rows. What discriminates is RUN LENGTH within a single meeting:
|
|
13
13
|
//
|
|
14
|
-
// MU +
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// Scott Taylor +
|
|
14
|
+
// MU + Alex Kellar (known two people) : flip rate 0.069, mean run 30.0
|
|
15
|
+
// Theo M + Theo Marsh : flip rate 0.361, mean run 2.79
|
|
16
|
+
// Alex Kellar + Theo Marsh : flip rate 0.381, mean run 3.47
|
|
17
|
+
// Scott Taylor + Dalton Jaynes : flip rate 0.344, mean run 2.80
|
|
18
18
|
//
|
|
19
19
|
// Real conversation holds the floor for ~30 consecutive segments. A pair that
|
|
20
20
|
// swaps every ~3 is the identifier oscillating mid-turn, which means those two
|
|
@@ -84,8 +84,8 @@ export const CONFIDENT_SIMILARITY = 0.65
|
|
|
84
84
|
* The identifier accepts a match at SEARCH_THRESHOLD = 0.55, so a single segment
|
|
85
85
|
* scoring 0.55 currently arrives in the panel wearing somebody's full name. On
|
|
86
86
|
* the user's 2026-08-06 Ditto meeting that produced Riley Example (1 segment,
|
|
87
|
-
* 0.60),
|
|
88
|
-
* Jamie
|
|
87
|
+
* 0.60), Theo Marsh (1 segment, 0.55), Dalton Jaynes (2 segments, 0.58) and
|
|
88
|
+
* Jamie Sharpe (3 segments, 0.58) — and he confirmed none of them were in the
|
|
89
89
|
* room. Presenting those as names is the defect; the reviewer then has to undo
|
|
90
90
|
* an assertion the system should never have made.
|
|
91
91
|
*
|
|
@@ -374,7 +374,7 @@ const FILLER = /^(?:yeah|yep|okay|ok|right|sure|mm+|uh+|um+|hmm+|got it|exactly|
|
|
|
374
374
|
*
|
|
375
375
|
* Length matters (a longer line carries more voice), but so does specificity:
|
|
376
376
|
* a proper noun, a number, or a domain term is what makes someone say "that's
|
|
377
|
-
*
|
|
377
|
+
* Gordon." A long line of pleasantries scores lower than a short line naming a
|
|
378
378
|
* brand and a figure.
|
|
379
379
|
*/
|
|
380
380
|
export function phraseScore(text: string): number {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { PHONE_CAPTURE_ID, PHONE_SOURCE_TYPE, requirePhoneRecording, closePhoneRecording } from './phone-recording.js'
|
|
1
2
|
import { isVoiceLabel } from './mac-voice-labels.js'
|
|
2
3
|
import { MAC_SOURCE_TYPE, readMacRecording, closeMacRecording } from './mac-recording.js'
|
|
3
4
|
import {
|
|
@@ -96,7 +97,7 @@ export interface MeetingMeta {
|
|
|
96
97
|
domain: string
|
|
97
98
|
domainAbbr: string
|
|
98
99
|
source: string
|
|
99
|
-
sourceType?: 'mac_system_audio'
|
|
100
|
+
sourceType?: 'mac_system_audio' | 'iphone_microphone'
|
|
100
101
|
duration: string
|
|
101
102
|
durationMinutes?: number
|
|
102
103
|
month: string
|
|
@@ -198,7 +199,7 @@ export interface SaveMeetingInput {
|
|
|
198
199
|
|
|
199
200
|
export interface SavedMeeting {
|
|
200
201
|
captureId?: string
|
|
201
|
-
sourceType?: typeof MAC_SOURCE_TYPE
|
|
202
|
+
sourceType?: typeof MAC_SOURCE_TYPE | typeof PHONE_SOURCE_TYPE
|
|
202
203
|
filepath: string
|
|
203
204
|
sidecarPath: string
|
|
204
205
|
filename: string
|
|
@@ -350,10 +351,13 @@ export class MeetingStore {
|
|
|
350
351
|
if (!transcript) throw new MeetingStoreError('Transcript is empty', 400, 'empty_transcript')
|
|
351
352
|
|
|
352
353
|
const mac = readMacRecording(sessionId)
|
|
354
|
+
const phone = sessionId.startsWith('phone_') ? requirePhoneRecording(sessionId) : null
|
|
355
|
+
if (phone?.closedAt) throw new MeetingStoreError('iPhone capture is already saved', 409, 'phone_saved_metadata_unreadable')
|
|
356
|
+
if (phone && input.chunks.some(c => c.speaker !== 'Ext' || c.speakerPolicy !== 'anonymous' || c.segments?.some(s => s.speaker !== 'Ext'))) throw new MeetingStoreError('iPhone microphone transcript must remain anonymous', 409, 'phone_speaker_policy_violation')
|
|
353
357
|
const parts = localParts(input.startTime)
|
|
354
|
-
const fallbackTitle = `${mac ? 'Mac' : 'G2'} Recording ${parts.date} ${parts.hourMinute}`
|
|
358
|
+
const fallbackTitle = `${mac ? 'Mac' : phone ? 'iPhone' : 'G2'} Recording ${parts.date} ${parts.hourMinute}`
|
|
355
359
|
const title = normalizeTitle(input.title, fallbackTitle)
|
|
356
|
-
const stem = filenameStem(title, `${mac ? 'Mac' : 'G2'}_Recording_${parts.hourMinute}`)
|
|
360
|
+
const stem = filenameStem(title, `${mac ? 'Mac' : phone ? 'iPhone' : 'G2'}_Recording_${parts.hourMinute}`)
|
|
357
361
|
const durationMs = Number.isFinite(input.durationMs) ? Math.max(0, input.durationMs) : 0
|
|
358
362
|
const durationMin = Math.round(durationMs / 60_000)
|
|
359
363
|
|
|
@@ -392,7 +396,8 @@ export class MeetingStore {
|
|
|
392
396
|
`| **Date** | ${parts.date} |`,
|
|
393
397
|
`| **Time** | ${parts.time} |`,
|
|
394
398
|
`| **Duration** | ${durationMin} minutes |`,
|
|
395
|
-
mac ? '| **Source** | Mac System Audio |' : '| **Source** | G2 Glasses |',
|
|
399
|
+
mac ? '| **Source** | Mac System Audio |' : phone ? '| **Source** | iPhone Microphone |' : '| **Source** | G2 Glasses |',
|
|
400
|
+
...(phone ? ['| **Capture source** | iphone_microphone |', '| **Speaker policy** | anonymous |'] : []),
|
|
396
401
|
...(mac ? ['| **Capture source** | mac_system_audio |', '| **Captured voices** | Other participants only |'] : []),
|
|
397
402
|
`| **Domain** | ${escapeTableValue(domain)} |`,
|
|
398
403
|
`| **Transfer integrity** | ${integrityValue} |`,
|
|
@@ -403,7 +408,7 @@ export class MeetingStore {
|
|
|
403
408
|
// When COS ops is configured, use the private-app pipeline markers so
|
|
404
409
|
// sync_meetings.py --g2-file will enrich (and reclassify domain). Plain
|
|
405
410
|
// "Standalone recording" summaries are treated as already-final and skipped.
|
|
406
|
-
...(process.env.COS_SCRIPTS_DIR && !mac
|
|
411
|
+
...(process.env.COS_SCRIPTS_DIR && !mac && !phone
|
|
407
412
|
? [
|
|
408
413
|
'<!-- g2-needs-domain-review -->',
|
|
409
414
|
'',
|
|
@@ -420,7 +425,7 @@ export class MeetingStore {
|
|
|
420
425
|
const providers = [...new Set(input.chunks.map(chunk => chunk.asrProvider || 'server-whisper'))]
|
|
421
426
|
const sidecar = {
|
|
422
427
|
schemaVersion: 2,
|
|
423
|
-
...(mac ? { sourceType: MAC_SOURCE_TYPE, captureSource: mac } : {}),
|
|
428
|
+
...(mac ? { sourceType: MAC_SOURCE_TYPE, captureSource: mac } : phone ? { sourceType: PHONE_SOURCE_TYPE, speakerPolicy: 'anonymous', captureSource: phone } : {}),
|
|
424
429
|
sessionId,
|
|
425
430
|
startTime: input.startTime,
|
|
426
431
|
durationMs,
|
|
@@ -458,8 +463,9 @@ export class MeetingStore {
|
|
|
458
463
|
}
|
|
459
464
|
|
|
460
465
|
if (mac) closeMacRecording(sessionId)
|
|
466
|
+
if (phone) closePhoneRecording(sessionId)
|
|
461
467
|
return {
|
|
462
|
-
...(mac ? { captureId: sessionId, sourceType: MAC_SOURCE_TYPE } : {}),
|
|
468
|
+
...(mac ? { captureId: sessionId, sourceType: MAC_SOURCE_TYPE } : phone ? { captureId: sessionId, sourceType: PHONE_SOURCE_TYPE } : {}),
|
|
463
469
|
filepath,
|
|
464
470
|
sidecarPath,
|
|
465
471
|
filename,
|
|
@@ -490,6 +496,10 @@ export class MeetingStore {
|
|
|
490
496
|
try {
|
|
491
497
|
const sidecar = JSON.parse(sidecarText) as Record<string, unknown>
|
|
492
498
|
if (sidecar.sessionId !== sessionId) continue
|
|
499
|
+
if (sessionId.startsWith('phone_')) {
|
|
500
|
+
const capture = sidecar.captureSource as Record<string, unknown> | undefined
|
|
501
|
+
if (sidecar.sourceType !== PHONE_SOURCE_TYPE || sidecar.speakerPolicy !== 'anonymous' || capture?.version !== 1 || capture?.captureId !== sessionId || capture?.sessionId !== sessionId || capture?.sourceType !== PHONE_SOURCE_TYPE || capture?.speakerPolicy !== 'anonymous') continue
|
|
502
|
+
}
|
|
493
503
|
const filename = sidecarName.replace(/\.g2-chunks\.json$/, '.md')
|
|
494
504
|
const markdown = safeReadFile(monthDir, monthReal, filename)
|
|
495
505
|
if (markdown === null) continue
|
|
@@ -498,7 +508,7 @@ export class MeetingStore {
|
|
|
498
508
|
? Math.max(0, sidecar.durationMs)
|
|
499
509
|
: 0
|
|
500
510
|
return {
|
|
501
|
-
...(sidecar.sourceType === MAC_SOURCE_TYPE ? { captureId: sessionId, sourceType: MAC_SOURCE_TYPE } : {}),
|
|
511
|
+
...(sidecar.sourceType === MAC_SOURCE_TYPE ? { captureId: sessionId, sourceType: MAC_SOURCE_TYPE } : sidecar.sourceType === PHONE_SOURCE_TYPE && PHONE_CAPTURE_ID.test(sessionId) && sidecar.speakerPolicy === 'anonymous' ? { captureId: sessionId, sourceType: PHONE_SOURCE_TYPE } : {}),
|
|
502
512
|
filepath: join(monthDir, filename),
|
|
503
513
|
sidecarPath: join(monthDir, sidecarName),
|
|
504
514
|
filename,
|
|
@@ -416,14 +416,18 @@ export function relabelBatchSegments(
|
|
|
416
416
|
const original = typeof word.originalSpeaker === 'string' ? word.originalSpeaker : word.speaker
|
|
417
417
|
const start = batchWordToTimeline(segment, Number(word.start), timeline)
|
|
418
418
|
const end = batchWordToTimeline(segment, Number(word.end), timeline)
|
|
419
|
-
|
|
420
|
-
|
|
419
|
+
// Existing live anonymous turns are established capture evidence. A
|
|
420
|
+
// meeting-wide channel may refine unknown speech, never overwrite them.
|
|
421
|
+
const live = start ? liveSpeakerAt(start.window.chunk, start.inChunk, owner) : null
|
|
422
|
+
let name: string | null = isVoiceLabel(live) ? live : null
|
|
423
|
+
if (name === null && start) {
|
|
421
424
|
const channel = channelForSpan(a, start.at, Math.max(start.at, end?.window === start.window ? end.at : start.at + 0.01))
|
|
422
425
|
if (channel !== null && map.has(channel)) { name = map.get(channel)!; stats.viaChannel++ }
|
|
423
426
|
}
|
|
427
|
+
if (isVoiceLabel(live)) stats.kept++
|
|
424
428
|
if (name === null) {
|
|
425
429
|
stats.kept++
|
|
426
|
-
name =
|
|
430
|
+
name = live
|
|
427
431
|
?? (start ? guardOwnerName(original, start.window.chunk.similarity, start.window.chunk, owner) : original)
|
|
428
432
|
}
|
|
429
433
|
if (name !== word.speaker) stats.relabeled++
|
|
@@ -477,15 +481,20 @@ export function relabelStreamingChunks(
|
|
|
477
481
|
const times = streamingTokenTimes(chunk, tokens, window, a)
|
|
478
482
|
const base = guardOwnerName(typeof chunk.originalSpeaker === 'string' ? chunk.originalSpeaker : chunk.speaker, chunk.similarity, chunk, owner)
|
|
479
483
|
const labels: string[] = []
|
|
484
|
+
const liveTokens = (chunk.segments ?? []).flatMap(segment => splitTokens(String(segment.text ?? '')).map(token => ({ token, speaker: segment.speaker })))
|
|
485
|
+
const exactLive = liveTokens.length === tokens.length && liveTokens.every((part, i) => part.token === tokens[i]) ? liveTokens : null
|
|
480
486
|
let carry: string | null = null
|
|
481
487
|
for (let i = 0; i < tokens.length; i++) {
|
|
482
|
-
|
|
488
|
+
const established = exactLive?.[i].speaker
|
|
489
|
+
if (!isWordToken(tokens[i]) || !times) { labels.push(isVoiceLabel(established) ? established! : carry ?? liveSpeakerAt(chunk, 0, owner) ?? base); continue }
|
|
483
490
|
stats.total++
|
|
484
491
|
const span = times[i]
|
|
485
492
|
const channel = channelForSpan(a, window.start + span.start, window.start + Math.max(span.end, span.start + 0.01))
|
|
486
493
|
let name: string
|
|
487
|
-
|
|
488
|
-
|
|
494
|
+
const live = isVoiceLabel(established) ? established! : liveSpeakerAt(chunk, span.start, owner)
|
|
495
|
+
if (isVoiceLabel(live)) { name = live!; stats.kept++ }
|
|
496
|
+
else if (channel !== null && map.has(channel)) { name = map.get(channel)!; stats.viaChannel++ }
|
|
497
|
+
else { name = live ?? base; stats.kept++ }
|
|
489
498
|
labels.push(name)
|
|
490
499
|
carry = name
|
|
491
500
|
}
|
|
@@ -1121,6 +1130,85 @@ export function mapChannelsToVoices(windows: TimelineWindow[], a: Activity, opti
|
|
|
1121
1130
|
return { channels: evidence.filter(row => row.accepted).map(row => row.channel), evidence, eligible }
|
|
1122
1131
|
}
|
|
1123
1132
|
|
|
1133
|
+
/**
|
|
1134
|
+
* The label a chunk had LIVE. A final pass rewrites `speaker` and keeps the live one in
|
|
1135
|
+
* `originalSpeaker`, so a re-run (resumed finalization, nemotron-replay) must read that,
|
|
1136
|
+
* or it takes its own earlier numbers for live evidence and numbers from max + 1.
|
|
1137
|
+
*/
|
|
1138
|
+
function liveLabelOf(chunk: { speaker?: unknown; originalSpeaker?: unknown } | null | undefined): unknown {
|
|
1139
|
+
return typeof chunk?.originalSpeaker === 'string' ? chunk.originalSpeaker : chunk?.speaker
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/** Which final channels each established live turn overlaps (a turn is a claim on every channel active inside it). */
|
|
1143
|
+
function liveClaims(windows: TimelineWindow[], a: Activity): Map<number, Set<string>> {
|
|
1144
|
+
const claims = new Map<number, Set<string>>()
|
|
1145
|
+
for (const window of windows) for (const segment of window.chunk.segments ?? []) {
|
|
1146
|
+
if (!isVoiceLabel(segment.speaker)) continue
|
|
1147
|
+
const timed = Number.isFinite(segment.startSec) && Number.isFinite(segment.endSec)
|
|
1148
|
+
&& Number(segment.startSec) >= 0 && Number(segment.endSec) <= window.end - window.start
|
|
1149
|
+
&& Number(segment.endSec) > Number(segment.startSec)
|
|
1150
|
+
const start = timed ? window.start + Math.max(0, Number(segment.startSec)) : window.start
|
|
1151
|
+
const end = timed ? Math.min(window.end, window.start + Number(segment.endSec)) : window.end
|
|
1152
|
+
const [f0,f1] = frameRange(a, start, end)
|
|
1153
|
+
for (let channel = 0; channel < NEMOTRON_CHANNELS; channel++) {
|
|
1154
|
+
let overlaps = false
|
|
1155
|
+
for (let f=f0;f<f1;f++) if (isActive(a,f,channel)) { overlaps = true; break }
|
|
1156
|
+
if (!overlaps) continue
|
|
1157
|
+
const labels = claims.get(channel) ?? new Set<string>()
|
|
1158
|
+
labels.add(segment.speaker); claims.set(channel, labels)
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
return claims
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/** Crosswalk final channels to EXISTING live placeholders. A pure live chunk
|
|
1165
|
+
* must belong overwhelmingly to one final channel. Contradictory channels or a
|
|
1166
|
+
* live label split across channels are withheld; no numbers are reassigned. */
|
|
1167
|
+
export function mapChannelsToLiveVoices(windows: TimelineWindow[], a: Activity): Map<number, string> {
|
|
1168
|
+
const votes = new Map<number, Set<string>>()
|
|
1169
|
+
for (const window of windows) {
|
|
1170
|
+
const chunk = window.chunk
|
|
1171
|
+
const liveLabel = liveLabelOf(chunk) as string
|
|
1172
|
+
if (!isVoiceLabel(liveLabel) || !chunk.segments?.length || chunk.segments.some(s => s.speaker !== liveLabel)) continue
|
|
1173
|
+
const [f0,f1] = frameRange(a, window.start, window.end)
|
|
1174
|
+
let speech = 0
|
|
1175
|
+
const exclusive = new Array<number>(NEMOTRON_CHANNELS).fill(0)
|
|
1176
|
+
for (let f=f0;f<f1;f++) {
|
|
1177
|
+
const active: number[]=[]
|
|
1178
|
+
for (let c=0;c<NEMOTRON_CHANNELS;c++) if(isActive(a,f,c)) active.push(c)
|
|
1179
|
+
if(active.length) speech++
|
|
1180
|
+
if(active.length===1) exclusive[active[0]]++
|
|
1181
|
+
}
|
|
1182
|
+
const channel = exclusive.indexOf(Math.max(...exclusive))
|
|
1183
|
+
if(!speech || exclusive[channel]/speech < 0.9) continue
|
|
1184
|
+
const labels = votes.get(channel) ?? new Set<string>()
|
|
1185
|
+
labels.add(liveLabel); votes.set(channel,labels)
|
|
1186
|
+
}
|
|
1187
|
+
// A mixed live chunk cannot establish a crosswalk, but every established
|
|
1188
|
+
// turn in it CAN contradict one. A final diarizer that merges two live
|
|
1189
|
+
// voices into one channel must not transfer one voice's label to the other.
|
|
1190
|
+
const claims = liveClaims(windows, a)
|
|
1191
|
+
const proposed = [...votes].filter(([,labels])=>labels.size===1).map(([channel,labels])=>[channel,[...labels][0]] as const)
|
|
1192
|
+
// Count claims on conflicting channels too. Dropping a contradictory channel
|
|
1193
|
+
// must not make its label appear exclusive to a different channel.
|
|
1194
|
+
return new Map(proposed.filter(([channel,label]) =>
|
|
1195
|
+
[...votes.values()].filter(labels=>labels.has(label)).length===1
|
|
1196
|
+
&& [...(claims.get(channel) ?? [])].every(claim => claim === label)
|
|
1197
|
+
&& [...claims.values()].filter(labels=>labels.has(label)).length===1))
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/**
|
|
1201
|
+
* 6.93.0 QA W3: confident final channels that NO established live turn touches.
|
|
1202
|
+
* A capture whose live labels missed a voice (a track the live path never proved,
|
|
1203
|
+
* or one it lost) still gets that voice numbered at save, above every live number,
|
|
1204
|
+
* as 6.92.0 would have numbered it. A channel any live `Voice N` turn overlaps is
|
|
1205
|
+
* never in this set: its words keep the live label, and the rest stay `Ext`.
|
|
1206
|
+
*/
|
|
1207
|
+
export function unclaimedFinalChannels(windows: TimelineWindow[], a: Activity, channels: number[]): number[] {
|
|
1208
|
+
const claims = liveClaims(windows, a)
|
|
1209
|
+
return channels.filter(channel => !claims.has(channel))
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1124
1212
|
/** `Voice 1` for the first channel, `Voice 2` for the next, and so on. */
|
|
1125
1213
|
export function numberVoices(channels: number[]): Map<number, string> {
|
|
1126
1214
|
return new Map(channels.map((channel, i) => [channel, voiceLabel(i + 1)]))
|
|
@@ -1226,11 +1314,28 @@ async function unnamedVoicePass(input: UnnamedInput): Promise<FinalPassOutcome>
|
|
|
1226
1314
|
const aligned = textNext.length === chunks.length && textNext.every((entry, i) => entry.chunk.text === chunks[i]?.text)
|
|
1227
1315
|
if (!aligned) return { status: 'skipped', reason: 'chunk_map_ambiguous', unnamed: true, mode, excluded }
|
|
1228
1316
|
|
|
1229
|
-
|
|
1230
|
-
|
|
1317
|
+
// With no live Voice label (COS_MAC_LIVE_VOICES=off, or a capture the live path never
|
|
1318
|
+
// numbered) nothing is restricted and every confident channel is numbered, exactly as 6.92.0.
|
|
1319
|
+
const liveNumbers = entries.flatMap(entry => [liveLabelOf(entry.chunk), ...(Array.isArray(entry.chunk?.segments) ? entry.chunk.segments : []).map(segment => segment?.speaker)])
|
|
1320
|
+
.filter(isVoiceLabel).map(label => Number(String(label).slice(6)))
|
|
1321
|
+
const liveMap = liveNumbers.length ? mapChannelsToLiveVoices(timeline.windows, activity) : null
|
|
1322
|
+
// W3: a channel the live path never proved is numbered above every live number
|
|
1323
|
+
// unless a live turn contradicts it. Words that already carry a live number keep it.
|
|
1324
|
+
const unproven = liveMap
|
|
1325
|
+
? unclaimedFinalChannels(timeline.windows, activity, found.channels.filter(channel => !liveMap.has(channel))).slice(0, Math.max(0, 99 - Math.max(...liveNumbers)))
|
|
1326
|
+
: []
|
|
1327
|
+
let channels = found.channels.filter(channel => !liveMap || liveMap.has(channel) || unproven.includes(channel))
|
|
1328
|
+
const labelsFor = (kept: number[]) => {
|
|
1329
|
+
if (!liveMap) return numberVoices(kept)
|
|
1330
|
+
const out = new Map<number, string>()
|
|
1331
|
+
let next = Math.max(...liveNumbers)
|
|
1332
|
+
for (const channel of kept) out.set(channel, liveMap.get(channel) ?? voiceLabel(++next))
|
|
1333
|
+
return out
|
|
1334
|
+
}
|
|
1335
|
+
let map = labelsFor(channels)
|
|
1231
1336
|
let labelled: UnnamedLabelling | null = null
|
|
1232
1337
|
for (let round = 0; round <= NEMOTRON_CHANNELS && channels.length; round++) {
|
|
1233
|
-
map =
|
|
1338
|
+
map = labelsFor(channels)
|
|
1234
1339
|
const attempt = labelUnnamed(input, mode, map)
|
|
1235
1340
|
if ('status' in attempt) return { ...attempt, unnamed: true, mode, excluded }
|
|
1236
1341
|
const inTurns = voicesIn(attempt.next)
|