@gotcos/glasses-server 6.21.26 → 6.21.27

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 CHANGED
@@ -1,3 +1,30 @@
1
+ ## 6.21.27
2
+
3
+ - Per-speaker speaking time on the review. `speakingMs` per voice, plus
4
+ `voicedMs`, `attributedSpeakingMs`, `unattributedSpeakingMs` and
5
+ `notCapturedMs` on the meeting.
6
+
7
+ It reads the word timings the HQ batch pass ALREADY writes
8
+ (`batchSegments[].speakerWords`, present on 82 of 92 measured sidecars), so it
9
+ is real voiced time with silence excluded — no audio, no VAD, no embedding, and
10
+ it works past the 7-day audio retention. Without them it falls back to chunk
11
+ deltas capped at the capture ceiling, and `speakingTimeSource` says which ran.
12
+
13
+ Three things the arithmetic had to get right, each caught against real data:
14
+ word intervals OVERLAP, so a naive sum totals 1.2x-1.5x the meeting's own
15
+ duration — union counts overlap once and lands at 0.75x-0.97x. Speakers also
16
+ overlap EACH OTHER, so per-speaker figures legitimately exceed wall clock
17
+ (a real 5.2-minute capture summed to 6.0); the invariant is therefore
18
+ `voicedMs + notCapturedMs = durationMs`, never attributed + unattributed.
19
+ And the attributed/unattributed split is by `nameAsserted` PER VOICE, never
20
+ per segment: a per-segment floor cannot carry the owner or human-confirmed
21
+ waivers and contradicts the rows above it (measured on 2026-08-02 "G2 App
22
+ Fixes": the panel names MU for 47.3% of the meeting, a per-segment floor 14.9%).
23
+
24
+ - Uncapped chunk deltas credit dead air to whoever spoke last. On 2026-08-04
25
+ "Design Gaps" that turns 8.1 minutes of speech into 50.2, with a 36.6s MEDIAN
26
+ gap that no outlier rule would catch. Capped at the measured ceiling.
27
+
1
28
  ## 6.21.26
2
29
 
3
30
  - `assertedSegments` added to the speaker review: how many segments belong to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.21.26",
3
+ "version": "6.21.27",
4
4
  "description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -98,6 +98,17 @@ export const ASSERT_MIN_SEGMENTS = 3
98
98
 
99
99
  export type Reliability = 'confident' | 'weak' | 'unreliable' | 'unattributed'
100
100
 
101
+ /**
102
+ * Which measurement produced `speakingMs`.
103
+ *
104
+ * `words` is real voiced time from the HQ batch pass's word timings — silence is
105
+ * excluded by construction. `chunks` is wall-clock deltas with each chunk capped
106
+ * at the capture ceiling, which is coarser and still contains the sub-ceiling
107
+ * pauses. They are not comparable across meetings, so any UI showing a trend
108
+ * must not mix them silently.
109
+ */
110
+ export type SpeakingTimeSource = 'words' | 'chunks'
111
+
101
112
  export interface ThrashPair {
102
113
  speaker: string
103
114
  flipRate: number
@@ -122,6 +133,9 @@ export interface Phrase {
122
133
  export interface VoiceReview {
123
134
  label: string
124
135
  segments: number
136
+ /** Voiced milliseconds credited to this voice. See `SpeakingTimeSource` for
137
+ * how it was measured — the two methods are not comparable. */
138
+ speakingMs: number
125
139
  meanSimilarity: number | null
126
140
  /** Mean consecutive-run length across the WHOLE meeting. Not comparable to
127
141
  * the pair-scoped run in `thrashesWith`: in a 13-voice meeting every
@@ -244,6 +258,29 @@ export interface MeetingSpeakerReview {
244
258
  * unrelated word-overlap measure and the two must not read as siblings.
245
259
  */
246
260
  assertedSegments: number
261
+ /** How `speakingMs` was measured. */
262
+ speakingTimeSource: SpeakingTimeSource
263
+ /** Voiced ms belonging to voices shown WITH A NAME. The headline number. */
264
+ attributedSpeakingMs: number
265
+ /** Voiced ms belonging to voices the panel refuses to name. */
266
+ unattributedSpeakingMs: number
267
+ /**
268
+ * Voiced ms in the meeting, crosstalk counted ONCE.
269
+ *
270
+ * `attributed + unattributed` does NOT equal this, and must not be presented
271
+ * as though it does: when two people talk over each other both are credited,
272
+ * so per-speaker figures legitimately exceed wall clock. Measured on a real
273
+ * 5.2-minute capture the per-speaker times summed to 6.0 minutes.
274
+ */
275
+ voicedMs: number
276
+ /**
277
+ * Wall clock that produced no voice at all.
278
+ *
279
+ * Reported rather than distributed, because it is frequently large and is not
280
+ * all silence: a measured 2026-08-06 capture lost 93% of its chunks in
281
+ * transfer. THE invariant is `voicedMs + notCapturedMs = durationMs`.
282
+ */
283
+ notCapturedMs: number
247
284
  durationMs: number
248
285
  voices: VoiceReview[]
249
286
  /** Chronological spans, so a ribbon can be a timeline instead of a share bar. */
@@ -401,6 +438,135 @@ export function selectPhrases(
401
438
  .map(({ text, atMs, similarity, chunkIndex }) => ({ text, atMs, similarity, chunkIndex }))
402
439
  }
403
440
 
441
+ /**
442
+ * The capture ceiling. A chunk is VAD-flushed with a 2.5s floor and a hard
443
+ * ceiling here, so no single chunk can carry more voice than this no matter how
444
+ * long the wall-clock gap before it was.
445
+ *
446
+ * Measured on 1,000 retained chunk WAVs: median 6,050ms, max 7,100ms (the
447
+ * ceiling overshoots because RMS is sampled every ~200ms), and ZERO chunks under
448
+ * 2s. The ceiling is the normal flush, not an edge case.
449
+ */
450
+ export const CHUNK_CEILING_MS = 7_100
451
+
452
+ /**
453
+ * Voiced milliseconds credited to each chunk, index-aligned to `chunks`.
454
+ *
455
+ * WHY A CAP. `elapsed` is a wall-clock offset, so the delta between consecutive
456
+ * chunks is that chunk's audio PLUS all the dead air before it. Uncapped, that
457
+ * dead air gets credited to whoever happened to speak last: on the 2026-08-04
458
+ * "Design Gaps" meeting the deltas sum to 50.2 minutes against 8.1 minutes of
459
+ * actual speech — a 6.2x inflation, with a 36.6s MEDIAN gap that would sail
460
+ * under any outlier rule. One chunk elsewhere in the corpus credits 77.5
461
+ * continuous minutes to a single speaker.
462
+ *
463
+ * Capping at the ceiling makes each chunk contribute at most the audio it could
464
+ * physically hold. Everything the cap removes is real elapsed time that was
465
+ * never captured, and it is reported as `notCapturedMs` rather than distributed.
466
+ */
467
+ export function creditedChunkMs(chunks: ReviewChunk[]): number[] {
468
+ let prev = 0
469
+ return chunks.map(c => {
470
+ const at = typeof c.elapsed === 'number' && Number.isFinite(c.elapsed) ? c.elapsed : prev
471
+ // Monotonic: a sidecar with a backwards or missing elapsed contributes 0
472
+ // rather than a negative that would silently subtract from someone's total.
473
+ const delta = Math.max(0, at - prev)
474
+ prev = Math.max(prev, at)
475
+ return Math.min(delta, CHUNK_CEILING_MS)
476
+ })
477
+ }
478
+
479
+ /** One batch segment's word-level speaker timing, as the sidecar stores it. */
480
+ export interface SpeakerWordSegment {
481
+ /** Absolute ms offset of this segment; word times are RELATIVE to it. */
482
+ startElapsed?: number
483
+ speakerWords?: Array<{ start?: number; end?: number; speaker?: string }>
484
+ }
485
+
486
+ /** Total length of a set of intervals, counting overlap ONCE. */
487
+ export function unionMs(intervals: Array<[number, number]>): number {
488
+ if (intervals.length === 0) return 0
489
+ const sorted = [...intervals].sort((a, b) => a[0] - b[0])
490
+ let total = 0
491
+ let [start, end] = sorted[0]
492
+ for (const [a, b] of sorted.slice(1)) {
493
+ if (a > end) { total += end - start; start = a; end = b }
494
+ else if (b > end) { end = b }
495
+ }
496
+ return total + (end - start)
497
+ }
498
+
499
+ /**
500
+ * Voiced milliseconds per speaker, from the word timings the HQ batch pass
501
+ * already wrote into the sidecar.
502
+ *
503
+ * WHY UNION AND NOT A SUM. Word intervals overlap — both within a segment and
504
+ * across the overlapping batch windows. Measured over six real meetings, naively
505
+ * summing word durations totals 1.20x to 1.50x the meeting's own duration, which
506
+ * is impossible for voiced time. Union counts overlap once and lands at 0.75x to
507
+ * 0.97x, always under wall clock, which is the shape the answer must have.
508
+ *
509
+ * This is real voiced time: silence between words is excluded by construction,
510
+ * so it needs no ceiling, no gap heuristic, and no retained audio.
511
+ */
512
+ export function speakerWordIntervals(
513
+ segments: SpeakerWordSegment[],
514
+ ): Map<string, Array<[number, number]>> {
515
+ const byLabel = new Map<string, Array<[number, number]>>()
516
+ for (const seg of segments) {
517
+ const offset = typeof seg.startElapsed === 'number' && Number.isFinite(seg.startElapsed)
518
+ ? seg.startElapsed
519
+ : 0
520
+ for (const w of seg.speakerWords ?? []) {
521
+ const a = w.start
522
+ const b = w.end
523
+ if (typeof a !== 'number' || typeof b !== 'number') continue
524
+ if (!Number.isFinite(a) || !Number.isFinite(b) || b <= a) continue
525
+ const label = w.speaker ?? ''
526
+ if (!byLabel.has(label)) byLabel.set(label, [])
527
+ byLabel.get(label)!.push([offset + a * 1000, offset + b * 1000])
528
+ }
529
+ }
530
+ return byLabel
531
+ }
532
+
533
+ /** Voiced ms per speaker, overlap within a speaker counted once. */
534
+ export function speakerWordMs(segments: SpeakerWordSegment[]): Map<string, number> {
535
+ const out = new Map<string, number>()
536
+ for (const [label, ivs] of speakerWordIntervals(segments)) out.set(label, Math.round(unionMs(ivs)))
537
+ return out
538
+ }
539
+
540
+ /**
541
+ * The meeting-level decomposition.
542
+ *
543
+ * `voicedMs + notCapturedMs = durationMs` is the invariant that HOLDS.
544
+ * attributed + unattributed does NOT sum to voiced, because a named and an
545
+ * unnamed speaker can talk over each other and both are credited.
546
+ */
547
+ function speakingBuckets(
548
+ voices: VoiceReview[],
549
+ intervalsFor: (label: string) => Array<[number, number]>,
550
+ durationMs: number,
551
+ ): {
552
+ attributedSpeakingMs: number
553
+ unattributedSpeakingMs: number
554
+ voicedMs: number
555
+ notCapturedMs: number
556
+ } {
557
+ const gather = (pick: (v: VoiceReview) => boolean) =>
558
+ Math.round(unionMs(voices.filter(pick).flatMap(v => intervalsFor(v.label))))
559
+ const voiced = gather(() => true)
560
+ return {
561
+ attributedSpeakingMs: gather(v => v.nameAsserted),
562
+ unattributedSpeakingMs: gather(v => !v.nameAsserted),
563
+ voicedMs: voiced,
564
+ // Wall clock that produced no voice at all: silence, dropped chunks, and
565
+ // time the capture never saw. Reported, never distributed to a speaker.
566
+ notCapturedMs: Math.max(0, durationMs - voiced),
567
+ }
568
+ }
569
+
404
570
  /** Build the whole review for one meeting's chunks. */
405
571
  export function reviewMeetingSpeakers(
406
572
  chunks: ReviewChunk[],
@@ -415,6 +581,12 @@ export function reviewMeetingSpeakers(
415
581
  * the sidecar already carries it.
416
582
  */
417
583
  confirmed?: Set<string>
584
+ /**
585
+ * The sidecar's `batchSegments`, when the HQ pass has run. Their word
586
+ * timings give real voiced time; without them speaking time falls back to
587
+ * capped chunk deltas.
588
+ */
589
+ batchSegments?: SpeakerWordSegment[]
418
590
  } = {},
419
591
  ): MeetingSpeakerReview {
420
592
  const owner = options.owner ?? 'Me'
@@ -433,6 +605,33 @@ export function reviewMeetingSpeakers(
433
605
  const labels = [...new Set(sequence)].filter(s => s.length > 0)
434
606
  const named = labels.filter(l => !isUnattributed(l))
435
607
 
608
+ // Word timings when the HQ batch pass produced them (82 of 92 sidecars
609
+ // measured), capped chunk deltas otherwise. The two are NOT interchangeable —
610
+ // words are voiced time, deltas are wall clock with the silence capped off —
611
+ // so the answer carries which one produced it.
612
+ const wordIntervals = speakerWordIntervals(options.batchSegments ?? [])
613
+ const speakingTimeSource: SpeakingTimeSource = wordIntervals.size > 0 ? 'words' : 'chunks'
614
+
615
+ // The fallback expresses chunks as intervals too, so both paths decompose
616
+ // through the same union arithmetic. `elapsed` is the chunk END, so a chunk
617
+ // covers [end - credited, end] — non-overlapping by construction, because
618
+ // `credited` is already the capped gap to the previous chunk.
619
+ const chunkIntervals = new Map<string, Array<[number, number]>>()
620
+ if (speakingTimeSource === 'chunks') {
621
+ const credited = creditedChunkMs(chunks)
622
+ chunks.forEach((c, i) => {
623
+ if (credited[i] <= 0) return
624
+ const end = typeof c.elapsed === 'number' && Number.isFinite(c.elapsed) ? c.elapsed : 0
625
+ const label = c.speaker ?? ''
626
+ if (!chunkIntervals.has(label)) chunkIntervals.set(label, [])
627
+ chunkIntervals.get(label)!.push([end - credited[i], end])
628
+ })
629
+ }
630
+
631
+ const intervalsFor = (label: string): Array<[number, number]> =>
632
+ (speakingTimeSource === 'words' ? wordIntervals : chunkIntervals).get(label) ?? []
633
+ const speakingFor = (label: string): number => Math.round(unionMs(intervalsFor(label)))
634
+
436
635
  const voices: VoiceReview[] = labels.map(label => {
437
636
  const own = chunks.filter(c => (c.speaker ?? '') === label)
438
637
  const sims = own.map(c => c.similarity).filter((s): s is number => typeof s === 'number' && s > 0)
@@ -499,6 +698,7 @@ export function reviewMeetingSpeakers(
499
698
  return {
500
699
  label,
501
700
  segments: own.length,
701
+ speakingMs: speakingFor(label),
502
702
  meanSimilarity: meanSim,
503
703
  meanRun: Math.round(mean(runs) * 100) / 100,
504
704
  longestRun: runs.length ? Math.max(...runs) : 0,
@@ -525,6 +725,18 @@ export function reviewMeetingSpeakers(
525
725
  // list of rows reading "Unidentified voice", which is the confusion this
526
726
  // number exists to remove.
527
727
  assertedSegments: voices.reduce((n, v) => (v.nameAsserted ? n + v.segments : n), 0),
728
+ speakingTimeSource,
729
+ // UNION, not sum. Speakers overlap — crosstalk means two people are each
730
+ // correctly credited for the same wall-clock second, so per-speaker times
731
+ // legitimately add up to MORE than the meeting. Verified against a real
732
+ // 5.2-minute capture where the per-speaker figures summed to 6.0 minutes.
733
+ // Summing here produced a bucket total larger than the meeting itself.
734
+ //
735
+ // Split by nameAsserted PER VOICE, never per segment: a per-segment floor
736
+ // cannot express ASSERT_MIN_SEGMENTS nor carry the owner and confirmed
737
+ // waivers, so it contradicts the rows above it (measured on 2026-08-02 "G2
738
+ // App Fixes": panel names MU for 47.3%, per-segment floor 14.9%).
739
+ ...speakingBuckets(voices, intervalsFor, durationMs),
528
740
  durationMs,
529
741
  voices,
530
742
  timeline: speakerTimeline(chunks, durationMs),
@@ -118,6 +118,7 @@ import {
118
118
  attachRawChunkIndices,
119
119
  isUnattributed,
120
120
  reviewMeetingSpeakers,
121
+ type SpeakerWordSegment,
121
122
  type ReviewChunk,
122
123
  } from '../lib/meeting-speaker-review.js'
123
124
  import {
@@ -735,6 +736,13 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
735
736
  // timeline span end where it began — a 1.5pt sliver labelled "1s" for what
736
737
  // may be a long closing monologue.
737
738
  durationMs: typeof sidecar.durationMs === 'number' ? sidecar.durationMs : undefined,
739
+ // Word-level speaker timings from the HQ batch pass, present on 82 of 92
740
+ // measured sidecars. These give REAL voiced time per speaker; without them
741
+ // speaking time falls back to capped chunk deltas, which still carry the
742
+ // sub-ceiling pauses. `speakingTimeSource` on the response says which ran.
743
+ batchSegments: Array.isArray(sidecar.batchSegments)
744
+ ? (sidecar.batchSegments as SpeakerWordSegment[])
745
+ : undefined,
738
746
  })
739
747
  res.set('Cache-Control', 'private, no-store')
740
748
  res.json({