@gotcos/glasses-server 6.21.31 → 6.21.32

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 CHANGED
@@ -167,6 +167,14 @@ BIND_HOST=0.0.0.0
167
167
  # deliberately independent from the progressive-HQ compute switch.
168
168
  # COS_MEETING_EARLY_SYNC=1
169
169
 
170
+ # ── RETAINED MEETING-AUDIO PLAYBACK CLEANUP (6.21.32 canary) ────────────
171
+ # Default OFF. When enabled, the first authenticated Play of a retained raw
172
+ # meeting chunk creates a cached, adaptive cleanup copy. Raw WAV evidence stays
173
+ # byte-identical; capture, live preview, canonical transcription, HQ, and sync
174
+ # are untouched. Any FFmpeg/analyzer failure serves raw. Use `?raw=1` on the
175
+ # playback URL for a per-request A/B comparison.
176
+ # COS_MEETING_AUDIO_ADAPTIVE_PLAYBACK=1
177
+
170
178
  # ── UNSAVED-CAPTURE QUARANTINE (6.19.0) ─────────────────────────────────
171
179
  # Meeting audio whose save never landed is QUARANTINED, never deleted. It
172
180
  # surfaces on /api/health (unsaved_captures) and, with COS Control 0.3.1+,
package/CHANGELOG.md CHANGED
@@ -1,3 +1,28 @@
1
+ ## 6.21.32
2
+
3
+ - **Adaptive meeting-audio cleanup is a default-off, replay-only canary.** When
4
+ `COS_MEETING_AUDIO_ADAPTIVE_PLAYBACK=1`, the authenticated retained-audio
5
+ playback route profiles each PCM chunk as hot/clipped, hot, quiet,
6
+ wind/noisy, or clean indoor, then generates a bounded cleaned WAV on first
7
+ play. Later plays use the cached copy.
8
+ - **Raw evidence is immutable and remains the fallback.** Derived files live
9
+ beside retained raw chunks under a versioned name, count against the existing
10
+ 8 GB archive cap, and cannot extend the seven-day retention clock. Unsupported
11
+ WAVs, missing FFmpeg, timeouts, and invalid output all serve the original raw
12
+ file. Cache-orphan cleanup deletes only directories proven to contain derived
13
+ playback files alone; unknown entries or failed stats retain the directory.
14
+ `?raw=1` provides an authenticated per-request A/B escape hatch.
15
+ - **A live recording always wins.** Play requests made while any meeting is
16
+ actively recording bypass cleanup and serve raw. If recording starts after
17
+ cleanup was admitted, the one global cleanup worker is preempted within
18
+ 100 ms; requests for other chunks while it is busy immediately serve raw.
19
+ FFmpeg falls back inside eight seconds, ahead of Control's media deadline.
20
+ - **No live or canonical path changed.** Capture, Turbo preview, Large-v3
21
+ canonical transcription, speaker attribution, meeting save, HQ polish, and
22
+ meeting sync do not import or call the cleanup module. Health reports the
23
+ active policy, generated/cache/fallback counters, and raw-preservation
24
+ contract for COS Control and field diagnostics.
25
+
1
26
  ## 6.21.31
2
27
 
3
28
  Domains belong to the user. Four places in this codebase hardcoded ONE user's
package/README.md CHANGED
@@ -279,6 +279,19 @@ available CPUs. `COS_MEETING_EARLY_SYNC=1` separately gives the Operations sync
279
279
  pipeline a stable meeting identity before HQ completes. Either switch can be
280
280
  disabled without changing canonical live transcription or raw meeting audio.
281
281
 
282
+ Server 6.21.32 adds a separate default-off cleanup canary for retained review
283
+ audio. Set `COS_MEETING_AUDIO_ADAPTIVE_PLAYBACK=1` (or use COS Control 0.5.11+)
284
+ to profile a retained PCM chunk and create a cached playback-only copy when a
285
+ reviewer presses Play. The raw WAV remains byte-identical, still owns the
286
+ seven-day retention clock, and is served on every analyzer/FFmpeg failure.
287
+ Capture, live preview, canonical transcription, speaker attribution, save, HQ,
288
+ and meeting sync are unchanged. Append `?raw=1` to an authenticated playback
289
+ URL for an immediate raw-versus-cleaned A/B check.
290
+ While a meeting is actively recording, playback automatically stays raw so the
291
+ optional cleanup process cannot contend with live transcription. Cleanup uses
292
+ one global worker, serves raw while that worker is busy, and preempts within
293
+ 100 ms if a meeting starts after a replay request was admitted.
294
+
282
295
  The first server start downloads the real-time turbo model. True HQ additionally
283
296
  requires the full `ggml-large-v3.bin` model (about 3.1 GB):
284
297
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.21.31",
3
+ "version": "6.21.32",
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": {
@@ -0,0 +1,384 @@
1
+ // Adaptive cleanup for RETAINED meeting-audio playback only.
2
+ //
3
+ // This module deliberately does not sit on the live capture, preview, canonical
4
+ // transcription, speaker-attribution, save, HQ, or sync paths. A reviewer asks
5
+ // to hear a retained raw chunk; on first play we derive a bounded cleaned WAV,
6
+ // cache it beside the raw evidence, and serve that copy. Raw is never rewritten.
7
+ // Any unsupported input, missing ffmpeg, timeout, or invalid output falls back
8
+ // to the exact raw path the route would have served before this feature existed.
9
+
10
+ import { spawn } from 'node:child_process'
11
+ import {
12
+ chmodSync,
13
+ existsSync,
14
+ readFileSync,
15
+ renameSync,
16
+ statSync,
17
+ unlinkSync,
18
+ } from 'node:fs'
19
+ import { basename, dirname, join, resolve } from 'node:path'
20
+ import { randomUUID } from 'node:crypto'
21
+ import { invalidateMeetingAudioStats } from './meeting-audio-archive.js'
22
+
23
+ const FILTER_VERSION = 1
24
+ // Control's bounded media request is 12s. Cleanup must fail back to raw before
25
+ // that client deadline, with margin for the response body to transfer.
26
+ const FFMPEG_TIMEOUT_MS = 8_000
27
+ const LIVE_PREEMPT_POLL_MS = 100
28
+ // Capture chunks are seconds long (~200 KB). Four MiB still tolerates a wildly
29
+ // oversized chunk while bounding synchronous profiling on the server event loop.
30
+ const MAX_INPUT_BYTES = 4 * 1024 * 1024
31
+ const MAX_STDERR_BYTES = 4_096
32
+
33
+ export type AdaptivePlaybackProfile =
34
+ | 'hot_clipped'
35
+ | 'hot'
36
+ | 'quiet'
37
+ | 'wind_noisy'
38
+ | 'clean_indoor'
39
+
40
+ export interface AudioSignalProfile {
41
+ profile: AdaptivePlaybackProfile
42
+ sampleRate: number
43
+ channels: number
44
+ samples: number
45
+ peakDbfs: number
46
+ rmsDbfs: number
47
+ clippedSampleRatio: number
48
+ lowFrequencyEnergyRatio: number
49
+ }
50
+
51
+ export interface AdaptivePlaybackResult {
52
+ path: string
53
+ mode: 'raw' | 'adaptive'
54
+ profile: AdaptivePlaybackProfile | null
55
+ reason?: string
56
+ }
57
+
58
+ export interface AdaptivePlaybackOptions {
59
+ /** Fail-safe admission/preemption hook supplied by the meeting route. */
60
+ shouldAbort?: () => boolean
61
+ }
62
+
63
+ const FILTERS: Record<AdaptivePlaybackProfile, string> = {
64
+ hot_clipped: 'adeclip,highpass=f=100,afftdn=nt=w,volume=-8dB,alimiter=limit=0.794,loudnorm=I=-17:LRA=9:TP=-2',
65
+ hot: 'highpass=f=80,volume=-6dB,alimiter=limit=0.794,loudnorm=I=-17:LRA=9:TP=-2',
66
+ quiet: 'highpass=f=80,afftdn=nt=w,volume=6dB,alimiter=limit=0.891',
67
+ wind_noisy: 'highpass=f=140,afftdn=nt=w,loudnorm=I=-18:LRA=9:TP=-2',
68
+ clean_indoor: 'highpass=f=80,alimiter=limit=0.891',
69
+ }
70
+
71
+ type Pcm16 = {
72
+ sampleRate: number
73
+ channels: number
74
+ dataOffset: number
75
+ dataBytes: number
76
+ }
77
+
78
+ function pcm16Wav(buffer: Buffer): Pcm16 | null {
79
+ if (buffer.length < 44 || buffer.toString('ascii', 0, 4) !== 'RIFF' || buffer.toString('ascii', 8, 12) !== 'WAVE') return null
80
+ let offset = 12
81
+ let audioFormat = 0
82
+ let channels = 0
83
+ let sampleRate = 0
84
+ let bitsPerSample = 0
85
+ let dataOffset = -1
86
+ let dataBytes = 0
87
+ while (offset + 8 <= buffer.length) {
88
+ const id = buffer.toString('ascii', offset, offset + 4)
89
+ const size = buffer.readUInt32LE(offset + 4)
90
+ const start = offset + 8
91
+ const end = start + size
92
+ if (end > buffer.length) return null
93
+ if (id === 'fmt ' && size >= 16) {
94
+ audioFormat = buffer.readUInt16LE(start)
95
+ channels = buffer.readUInt16LE(start + 2)
96
+ sampleRate = buffer.readUInt32LE(start + 4)
97
+ bitsPerSample = buffer.readUInt16LE(start + 14)
98
+ } else if (id === 'data') {
99
+ dataOffset = start
100
+ dataBytes = size
101
+ }
102
+ offset = end + (size % 2)
103
+ }
104
+ if (audioFormat !== 1 || channels !== 1 || bitsPerSample !== 16 || sampleRate < 8_000 || sampleRate > 96_000) return null
105
+ if (dataOffset < 0 || dataBytes < 2 || dataOffset + dataBytes > buffer.length || dataBytes % 2 !== 0) return null
106
+ return { sampleRate, channels, dataOffset, dataBytes }
107
+ }
108
+
109
+ function dbfs(value: number): number {
110
+ if (!(value > 0)) return -120
111
+ return Math.round(20 * Math.log10(Math.min(1, value)) * 10) / 10
112
+ }
113
+
114
+ /** Pure, deterministic profiler. It never shells out and never mutates audio. */
115
+ export function analyzePlaybackWav(buffer: Buffer): AudioSignalProfile | null {
116
+ const wav = pcm16Wav(buffer)
117
+ if (!wav) return null
118
+ const count = wav.dataBytes / 2
119
+ let peak = 0
120
+ let energy = 0
121
+ let lowEnergy = 0
122
+ let clipped = 0
123
+ let low = 0
124
+ // One-pole 120 Hz low-pass. This is only a conservative wind/noise proxy;
125
+ // it selects a stronger high-pass profile and never changes canonical text.
126
+ const alpha = 1 - Math.exp((-2 * Math.PI * 120) / wav.sampleRate)
127
+ for (let i = 0; i < count; i++) {
128
+ const raw = wav.dataOffset + i * 2
129
+ const sample = buffer.readInt16LE(raw) / 32768
130
+ const magnitude = Math.abs(sample)
131
+ peak = Math.max(peak, magnitude)
132
+ energy += sample * sample
133
+ low += alpha * (sample - low)
134
+ lowEnergy += low * low
135
+ if (Math.abs(buffer.readInt16LE(raw)) >= 32_760) clipped++
136
+ }
137
+ const rms = Math.sqrt(energy / count)
138
+ const clippedSampleRatio = clipped / count
139
+ const lowFrequencyEnergyRatio = energy > 0 ? Math.min(1, lowEnergy / energy) : 0
140
+ const rmsDbfs = dbfs(rms)
141
+ const peakDbfs = dbfs(peak)
142
+ let profile: AdaptivePlaybackProfile
143
+ if (clippedSampleRatio >= 0.0005 || rmsDbfs >= -12 || peakDbfs >= -0.3) profile = 'hot_clipped'
144
+ else if (lowFrequencyEnergyRatio >= 0.38 && rmsDbfs > -35) profile = 'wind_noisy'
145
+ else if (rmsDbfs >= -18) profile = 'hot'
146
+ else if (rmsDbfs <= -32) profile = 'quiet'
147
+ else profile = 'clean_indoor'
148
+ return {
149
+ profile,
150
+ sampleRate: wav.sampleRate,
151
+ channels: wav.channels,
152
+ samples: count,
153
+ peakDbfs,
154
+ rmsDbfs,
155
+ clippedSampleRatio: Math.round(clippedSampleRatio * 1_000_000) / 1_000_000,
156
+ lowFrequencyEnergyRatio: Math.round(lowFrequencyEnergyRatio * 1_000) / 1_000,
157
+ }
158
+ }
159
+
160
+ export function adaptivePlaybackEnabled(): boolean {
161
+ // Private canary: explicit opt-in, explicit 0 rollback through COS Control.
162
+ return process.env.COS_MEETING_AUDIO_ADAPTIVE_PLAYBACK === '1'
163
+ }
164
+
165
+ function outputPathFor(rawPath: string): string | null {
166
+ const rawName = basename(rawPath)
167
+ const match = /^chunk_(\d+)\.wav$/.exec(rawName)
168
+ if (!match) return null
169
+ const dir = dirname(rawPath)
170
+ const output = resolve(dir, `playback_v${FILTER_VERSION}_${match[1]}.wav`)
171
+ return output.startsWith(resolve(dir) + '/') ? output : null
172
+ }
173
+
174
+ function cachedOutput(rawPath: string, outputPath: string): boolean {
175
+ try {
176
+ const raw = statSync(rawPath)
177
+ const output = statSync(outputPath)
178
+ return output.isFile()
179
+ && output.size > 44
180
+ && output.mtimeMs >= raw.mtimeMs
181
+ && analyzePlaybackWav(readFileSync(outputPath)) !== null
182
+ } catch {
183
+ return false
184
+ }
185
+ }
186
+
187
+ function abortRequested(check?: () => boolean): boolean {
188
+ if (!check) return false
189
+ try { return check() }
190
+ catch { return true }
191
+ }
192
+
193
+ async function runFfmpeg(
194
+ inputPath: string,
195
+ outputPath: string,
196
+ filter: string,
197
+ shouldAbort?: () => boolean,
198
+ ): Promise<void> {
199
+ await new Promise<void>((resolvePromise, rejectPromise) => {
200
+ const proc = spawn('ffmpeg', [
201
+ '-nostdin',
202
+ '-hide_banner',
203
+ '-loglevel', 'error',
204
+ '-i', inputPath,
205
+ '-af', filter,
206
+ '-ar', '16000',
207
+ '-ac', '1',
208
+ '-c:a', 'pcm_s16le',
209
+ '-f', 'wav',
210
+ '-y',
211
+ outputPath,
212
+ ], { stdio: ['ignore', 'ignore', 'pipe'] })
213
+ let settled = false
214
+ let stderr = ''
215
+ let terminalError: Error | null = null
216
+ let killSettle: NodeJS.Timeout | null = null
217
+ let livePoll: NodeJS.Timeout | null = null
218
+ const finish = (error?: Error) => {
219
+ if (settled) return
220
+ settled = true
221
+ clearTimeout(timeout)
222
+ if (killSettle) clearTimeout(killSettle)
223
+ if (livePoll) clearInterval(livePoll)
224
+ error ? rejectPromise(error) : resolvePromise()
225
+ }
226
+ const stop = (error: Error) => {
227
+ if (settled || terminalError) return
228
+ terminalError = error
229
+ if (!proc.kill('SIGKILL')) { finish(error); return }
230
+ // SIGKILL should close the direct ffmpeg child immediately. Keep a bounded
231
+ // settle fallback so a broken process handle cannot strand the request.
232
+ killSettle = setTimeout(() => finish(error), 1_000)
233
+ killSettle.unref()
234
+ }
235
+ proc.stderr?.on('data', (chunk: Buffer) => {
236
+ stderr = (stderr + chunk.toString()).slice(-MAX_STDERR_BYTES)
237
+ })
238
+ const timeout = setTimeout(() => {
239
+ stop(new Error(`ffmpeg timeout (${FFMPEG_TIMEOUT_MS / 1000}s)`))
240
+ }, FFMPEG_TIMEOUT_MS)
241
+ timeout.unref()
242
+ if (shouldAbort) {
243
+ livePoll = setInterval(() => {
244
+ if (abortRequested(shouldAbort)) stop(new Error('live_recording_started'))
245
+ }, LIVE_PREEMPT_POLL_MS)
246
+ livePoll.unref()
247
+ }
248
+ proc.on('error', error => finish(error))
249
+ proc.on('close', code => {
250
+ if (terminalError) finish(terminalError)
251
+ else if (code !== 0) finish(new Error(`ffmpeg exit ${code}: ${stderr.trim().slice(-300)}`))
252
+ else finish()
253
+ })
254
+ })
255
+ }
256
+
257
+ const inFlight = new Map<string, Promise<AdaptivePlaybackResult>>()
258
+ // One cleanup worker for the whole server. Different retained chunks never fan
259
+ // out into competing ffmpeg children; busy requests immediately hear raw.
260
+ let activeGenerationPath: string | null = null
261
+ const counters = {
262
+ generated: 0,
263
+ cacheHits: 0,
264
+ fallbacks: 0,
265
+ busyBypasses: 0,
266
+ liveBypasses: 0,
267
+ profiles: {} as Record<AdaptivePlaybackProfile, number>,
268
+ }
269
+
270
+ async function prepare(rawPath: string, options: AdaptivePlaybackOptions): Promise<AdaptivePlaybackResult> {
271
+ if (!adaptivePlaybackEnabled()) return { path: rawPath, mode: 'raw', profile: null, reason: 'disabled' }
272
+ if (abortRequested(options.shouldAbort)) {
273
+ counters.liveBypasses++
274
+ return { path: rawPath, mode: 'raw', profile: null, reason: 'live_recording' }
275
+ }
276
+ if (activeGenerationPath && activeGenerationPath !== rawPath) {
277
+ counters.busyBypasses++
278
+ return { path: rawPath, mode: 'raw', profile: null, reason: 'cleanup_busy' }
279
+ }
280
+ const outputPath = outputPathFor(rawPath)
281
+ if (!outputPath) {
282
+ counters.fallbacks++
283
+ return { path: rawPath, mode: 'raw', profile: null, reason: 'unsupported_source' }
284
+ }
285
+ let input: Buffer
286
+ try {
287
+ const stat = statSync(rawPath)
288
+ if (!stat.isFile() || stat.size > MAX_INPUT_BYTES) throw new Error('input is not a bounded regular file')
289
+ input = readFileSync(rawPath)
290
+ } catch (error: unknown) {
291
+ counters.fallbacks++
292
+ return { path: rawPath, mode: 'raw', profile: null, reason: error instanceof Error ? error.message : String(error) }
293
+ }
294
+ const signal = analyzePlaybackWav(input)
295
+ if (!signal) {
296
+ counters.fallbacks++
297
+ return { path: rawPath, mode: 'raw', profile: null, reason: 'unsupported_wav' }
298
+ }
299
+ counters.profiles[signal.profile] = (counters.profiles[signal.profile] ?? 0) + 1
300
+ if (cachedOutput(rawPath, outputPath)) {
301
+ counters.cacheHits++
302
+ return { path: outputPath, mode: 'adaptive', profile: signal.profile }
303
+ }
304
+
305
+ // Re-check after bounded synchronous profiling. A recording that started
306
+ // during that work must win before any child process is launched.
307
+ if (abortRequested(options.shouldAbort)) {
308
+ counters.liveBypasses++
309
+ return { path: rawPath, mode: 'raw', profile: signal.profile, reason: 'live_recording' }
310
+ }
311
+ if (activeGenerationPath && activeGenerationPath !== rawPath) {
312
+ counters.busyBypasses++
313
+ return { path: rawPath, mode: 'raw', profile: signal.profile, reason: 'cleanup_busy' }
314
+ }
315
+
316
+ const tempPath = join(dirname(outputPath), `.${basename(outputPath)}.${randomUUID()}.tmp.wav`)
317
+ activeGenerationPath = rawPath
318
+ try {
319
+ await runFfmpeg(rawPath, tempPath, FILTERS[signal.profile], options.shouldAbort)
320
+ if (abortRequested(options.shouldAbort)) throw new Error('live_recording_started')
321
+ if (!existsSync(tempPath) || !analyzePlaybackWav(readFileSync(tempPath))) {
322
+ throw new Error('ffmpeg produced an invalid PCM WAV')
323
+ }
324
+ chmodSync(tempPath, 0o600)
325
+ renameSync(tempPath, outputPath)
326
+ counters.generated++
327
+ invalidateMeetingAudioStats()
328
+ return { path: outputPath, mode: 'adaptive', profile: signal.profile }
329
+ } catch (error: unknown) {
330
+ counters.fallbacks++
331
+ const message = error instanceof Error ? error.message : String(error)
332
+ const reason = message === 'live_recording_started' ? 'live_recording' : message
333
+ if (reason === 'live_recording') counters.liveBypasses++
334
+ console.warn(`[adaptive-playback] cleanup failed (${signal.profile}); serving raw: ${reason}`)
335
+ return { path: rawPath, mode: 'raw', profile: signal.profile, reason }
336
+ } finally {
337
+ if (activeGenerationPath === rawPath) activeGenerationPath = null
338
+ try { unlinkSync(tempPath) } catch { /* already renamed or never created */ }
339
+ }
340
+ }
341
+
342
+ /** Single-flight per raw chunk so simultaneous Play requests run ffmpeg once. */
343
+ export async function adaptivePlaybackAudio(
344
+ rawPath: string,
345
+ options: AdaptivePlaybackOptions = {},
346
+ ): Promise<AdaptivePlaybackResult> {
347
+ const current = inFlight.get(rawPath)
348
+ if (current) return current
349
+ const pending = prepare(rawPath, options).finally(() => inFlight.delete(rawPath))
350
+ inFlight.set(rawPath, pending)
351
+ return pending
352
+ }
353
+
354
+ export function adaptivePlaybackStatus(): {
355
+ supported: true
356
+ enabled: boolean
357
+ mode: 'retained_replay_only'
358
+ rawPreserved: true
359
+ liveRecordingProtected: true
360
+ generatedThisBoot: number
361
+ cacheHitsThisBoot: number
362
+ fallbacksThisBoot: number
363
+ busyBypassesThisBoot: number
364
+ liveBypassesThisBoot: number
365
+ inFlight: number
366
+ globalWorkerBusy: boolean
367
+ profilesThisBoot: Partial<Record<AdaptivePlaybackProfile, number>>
368
+ } {
369
+ return {
370
+ supported: true,
371
+ enabled: adaptivePlaybackEnabled(),
372
+ mode: 'retained_replay_only',
373
+ rawPreserved: true,
374
+ liveRecordingProtected: true,
375
+ generatedThisBoot: counters.generated,
376
+ cacheHitsThisBoot: counters.cacheHits,
377
+ fallbacksThisBoot: counters.fallbacks,
378
+ busyBypassesThisBoot: counters.busyBypasses,
379
+ liveBypassesThisBoot: counters.liveBypasses,
380
+ inFlight: inFlight.size,
381
+ globalWorkerBusy: activeGenerationPath !== null,
382
+ profilesThisBoot: { ...counters.profiles },
383
+ }
384
+ }
@@ -60,6 +60,11 @@ function isChunkWav(name: string): boolean {
60
60
  return /^chunk_\d+\.wav$/.test(name)
61
61
  }
62
62
 
63
+ /** Derived playback copies count against the cap but never define retention age. */
64
+ function isDerivedPlaybackWav(name: string): boolean {
65
+ return /^playback_v\d+_\d+\.wav$/.test(name)
66
+ }
67
+
63
68
  export interface ArchiveResult {
64
69
  linked: number
65
70
  /** Files that had to be copied because the link failed (e.g. cross-device). */
@@ -110,19 +115,40 @@ export function archiveSessionAudio(sessionId: string, sourceDir: string): Archi
110
115
  }
111
116
 
112
117
  /** Bytes and age for one archived session. */
113
- function sessionSize(dir: string): { bytes: number; mtimeMs: number; files: number } {
114
- let bytes = 0, mtimeMs = 0, files = 0
118
+ function sessionSize(dir: string): {
119
+ bytes: number
120
+ mtimeMs: number
121
+ files: number
122
+ derivedFiles: number
123
+ otherEntries: number
124
+ statFailures: number
125
+ readable: boolean
126
+ } {
127
+ let bytes = 0, mtimeMs = 0, files = 0, derivedFiles = 0
128
+ let otherEntries = 0, statFailures = 0, readable = false
115
129
  try {
116
- for (const name of readdirSync(dir).filter(isChunkWav)) {
130
+ const names = readdirSync(dir)
131
+ readable = true
132
+ for (const name of names) {
133
+ if (!isChunkWav(name) && !isDerivedPlaybackWav(name)) {
134
+ otherEntries++
135
+ continue
136
+ }
117
137
  try {
118
138
  const st = statSync(join(dir, name))
119
139
  bytes += st.size
120
- files++
121
- mtimeMs = Math.max(mtimeMs, st.mtimeMs)
122
- } catch { /* skip unreadable */ }
140
+ // A replay created six days after capture must not buy the raw evidence
141
+ // another seven days. Only immutable raw chunks determine session age.
142
+ if (isChunkWav(name)) {
143
+ files++
144
+ mtimeMs = Math.max(mtimeMs, st.mtimeMs)
145
+ } else {
146
+ derivedFiles++
147
+ }
148
+ } catch { statFailures++ }
123
149
  }
124
150
  } catch { /* unreadable dir reports zero */ }
125
- return { bytes, mtimeMs, files }
151
+ return { bytes, mtimeMs, files, derivedFiles, otherEntries, statFailures, readable }
126
152
  }
127
153
 
128
154
  export interface SweepResult {
@@ -145,7 +171,14 @@ export function sweepMeetingAudio(nowMs: number, ttlMs = meetingAudioTtlMs()): S
145
171
  try { names = readdirSync(root) } catch { return out }
146
172
  for (const name of names) {
147
173
  const dir = join(root, name)
148
- const { bytes, mtimeMs } = sessionSize(dir)
174
+ const { bytes, mtimeMs, files, derivedFiles, otherEntries, statFailures, readable } = sessionSize(dir)
175
+ // Delete only a provably cache-only directory. Any unknown entry or failed
176
+ // stat may be retained evidence, so ambiguity fails closed to preservation.
177
+ if (readable && files === 0 && derivedFiles > 0 && otherEntries === 0 && statFailures === 0) {
178
+ try { rmSync(dir, { recursive: true, force: true }); out.removed.push(name); out.bytesFreed += bytes }
179
+ catch { out.retained.push(name) }
180
+ continue
181
+ }
149
182
  if (mtimeMs <= 0) { out.retained.push(name); continue }
150
183
  if (nowMs - mtimeMs > ttlMs) {
151
184
  try { rmSync(dir, { recursive: true, force: true }); out.removed.push(name); out.bytesFreed += bytes }
@@ -10,6 +10,7 @@ import { profileProvenanceSummary, speakerModelState, speakerReadiness } from '.
10
10
  import { chunkEmbeddingStoreStats } from '../lib/chunk-embedding-store.js'
11
11
  import { correctionStoreStats } from '../lib/meeting-corrections.js'
12
12
  import { meetingAudioStats } from '../lib/meeting-audio-archive.js'
13
+ import { adaptivePlaybackStatus } from '../lib/adaptive-playback-audio.js'
13
14
  import { getAvailableCliSessionId } from '../lib/claude-bridge.js'
14
15
  import {
15
16
  isWhisperLocalAvailable,
@@ -125,7 +126,10 @@ healthRouter.get('/health', async (_req, res) => {
125
126
  // `pending` is the number that matters here: an intent that never closed means
126
127
  // some meeting's files may be half-rewritten.
127
128
  const speakerCorrections = correctionStoreStats()
128
- const reviewAudio = meetingAudioStats()
129
+ const reviewAudio = {
130
+ ...meetingAudioStats(),
131
+ adaptivePlayback: adaptivePlaybackStatus(),
132
+ }
129
133
  // `noHumanSample` is the one to read: a profile with no human-verified sample
130
134
  // is trained entirely on labels the system chose for itself.
131
135
  const voiceProvenance = speakerId.state === 'active' ? profileProvenanceSummary() : null
@@ -11,6 +11,7 @@ import { durableAtomicWriteFileSync } from '../lib/atomic-fs.js'
11
11
  import { appendCorrection, appliedCorrections, pendingCorrections } from '../lib/meeting-corrections.js'
12
12
  import { isSampleFromSession, untraceableSampleCount } from '../lib/training-audio-provenance.js'
13
13
  import { sendAudioFile } from '../lib/send-audio.js'
14
+ import { adaptivePlaybackAudio } from '../lib/adaptive-playback-audio.js'
14
15
  import { chunkDiagnostics } from '../lib/chunk-embedding-diagnostics.js'
15
16
  import { errMsg } from '../lib/utils.js'
16
17
  import { confirmedLabels } from '../lib/meeting-corrections.js'
@@ -100,6 +101,7 @@ import {
100
101
  getSessionStartTime,
101
102
  getSessionTranscript,
102
103
  getMeetingSessionStatus,
104
+ getTranscriptionSessionLiveness,
103
105
  hasSessionAudio,
104
106
  moveSessionAudioToPending,
105
107
  type IndexedTranscriptChunk,
@@ -1423,7 +1425,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1423
1425
  // voice. Retention is 7 days (see meeting-audio-archive), so this answers with
1424
1426
  // 404 + a reason once the window has passed rather than pretending the audio
1425
1427
  // was never there.
1426
- router.get('/meeting/:sessionId/audio/:chunkIndex', (req, res) => {
1428
+ router.get('/meeting/:sessionId/audio/:chunkIndex', async (req, res) => {
1427
1429
  res.set('Cache-Control', 'private, no-store')
1428
1430
  const sessionId = String(req.params.sessionId ?? '')
1429
1431
  if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
@@ -1440,8 +1442,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1440
1442
  // fallback there is nothing to play on any meeting predating 6.21.18 — while
1441
1443
  // 72 hours of unidentified-voice audio is sitting right there, and an
1442
1444
  // unidentified voice is exactly what a reviewer needs to hear.
1443
- const path = meetingAudioChunkPath(sessionId, chunkIndex)
1444
- ?? extAudioChunkPath(sessionId, chunkIndex)
1445
+ const archivedPath = meetingAudioChunkPath(sessionId, chunkIndex)
1446
+ const path = archivedPath ?? extAudioChunkPath(sessionId, chunkIndex)
1445
1447
  if (!path) {
1446
1448
  const retained = [...new Set([
1447
1449
  ...listMeetingAudioChunks(sessionId),
@@ -1460,7 +1462,30 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1460
1462
  })
1461
1463
  return
1462
1464
  }
1463
- sendAudioFile(res, path)
1465
+ // Canary scope is intentionally narrow: only the week-retained immutable
1466
+ // archive gets a derived playback copy. Legacy ext-audio remains byte-for-
1467
+ // byte raw. `?raw=1` is the authenticated A/B and emergency per-request
1468
+ // escape hatch; the COS Control toggle is the machine-wide rollback.
1469
+ const liveRecording = getTranscriptionSessionLiveness().live > 0
1470
+ const playback = archivedPath && req.query.raw !== '1' && !liveRecording
1471
+ ? await adaptivePlaybackAudio(archivedPath, {
1472
+ // Admission is not enough: if a meeting starts while FFmpeg is
1473
+ // working, the replay job is preempted and this request hears raw.
1474
+ shouldAbort: () => getTranscriptionSessionLiveness().live > 0,
1475
+ })
1476
+ : { path, mode: 'raw' as const, profile: null }
1477
+ res.set('X-COS-Audio-Playback', playback.mode)
1478
+ const bypassReason = 'reason' in playback ? playback.reason : undefined
1479
+ const bypass = liveRecording
1480
+ ? 'live_recording'
1481
+ : bypassReason === 'live_recording'
1482
+ ? 'live_recording'
1483
+ : bypassReason === 'cleanup_busy'
1484
+ ? 'cleanup_busy'
1485
+ : null
1486
+ if (bypass) res.set('X-COS-Audio-Bypass', bypass)
1487
+ if (playback.profile) res.set('X-COS-Audio-Profile', playback.profile)
1488
+ sendAudioFile(res, playback.path)
1464
1489
  })
1465
1490
 
1466
1491
  /**