@gotcos/glasses-server 6.21.31 → 6.21.33

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
@@ -124,12 +124,15 @@ BIND_HOST=0.0.0.0
124
124
  # pipeline to inherit live tasks/calendar/people context. Omit for standalone.
125
125
  # COS_SCRIPTS_DIR=/path/to/your/cos/operations/scripts
126
126
  #
127
- # G2 "Review Meetings" reads COS meeting markdown from an operations tree:
128
- # {COS_OPERATIONS_DIR}/{quilt|personal|…}/meetings/YYYY-MM/*.md
129
- # Prefer an explicit ops root (each COS layout can differ). If unset, the
130
- # server falls back to COS_SCRIPTS_DIR/.. then to local G2 recordings only.
127
+ # G2 "Review Meetings" accepts either layout:
128
+ # {COS_MEETINGS_ROOT}/YYYY-MM/*.md # one read-only library
129
+ # {COS_OPERATIONS_DIR}/{domain}/meetings/YYYY-MM/*.md # multi-domain pipeline
130
+ # Keep COS_OPERATIONS_DIR for enrichment and writes. COS_MEETINGS_ROOT is a
131
+ # browse override; legacy values that contain a multi-domain operations tree
132
+ # retain their old alias behavior. If both are unset, the server tries
133
+ # COS_SCRIPTS_DIR/.. and then local G2 recordings.
131
134
  # COS_OPERATIONS_DIR=/path/to/your/cos/operations
132
- # COS_MEETINGS_ROOT=/path/to/your/cos/operations # alias for COS_OPERATIONS_DIR
135
+ # COS_MEETINGS_ROOT=/path/to/your/existing/meetings
133
136
 
134
137
  # Telegram session/activity notifications remain OFF even if the COS scripts
135
138
  # directory contains .telegram_config.json. Enable export explicitly:
@@ -167,6 +170,14 @@ BIND_HOST=0.0.0.0
167
170
  # deliberately independent from the progressive-HQ compute switch.
168
171
  # COS_MEETING_EARLY_SYNC=1
169
172
 
173
+ # ── RETAINED MEETING-AUDIO PLAYBACK CLEANUP (6.21.32 canary) ────────────
174
+ # Default OFF. When enabled, the first authenticated Play of a retained raw
175
+ # meeting chunk creates a cached, adaptive cleanup copy. Raw WAV evidence stays
176
+ # byte-identical; capture, live preview, canonical transcription, HQ, and sync
177
+ # are untouched. Any FFmpeg/analyzer failure serves raw. Use `?raw=1` on the
178
+ # playback URL for a per-request A/B comparison.
179
+ # COS_MEETING_AUDIO_ADAPTIVE_PLAYBACK=1
180
+
170
181
  # ── UNSAVED-CAPTURE QUARANTINE (6.19.0) ─────────────────────────────────
171
182
  # Meeting audio whose save never landed is QUARANTINED, never deleted. It
172
183
  # surfaces on /api/health (unsaved_captures) and, with COS Control 0.3.1+,
package/CHANGELOG.md CHANGED
@@ -1,3 +1,43 @@
1
+ ## 6.21.33
2
+
3
+ - **Existing meeting libraries can be selected directly.** `COS_MEETINGS_ROOT`
4
+ now accepts `meetings/YYYY-MM/*.md` as a read-only library, while
5
+ `COS_OPERATIONS_DIR` continues to own multi-domain enrichment and writes.
6
+ - **Mixed libraries stay coherent.** Review Meetings merges direct, enriched
7
+ operations, and standalone G2 records, dedupes by session identity, and
8
+ prefers the writable enriched copy when one exists.
9
+ - **Upgrades remain compatible.** A legacy multi-domain
10
+ `COS_MEETINGS_ROOT` keeps its prior operations-root meaning. Invalid explicit
11
+ roots report a degraded state instead of silently switching libraries.
12
+ - **Read-only means read-only.** Direct-library speaker mutations return a
13
+ typed conflict, paths and symlinks are contained, scans are bounded, and
14
+ public health never exposes the selected filesystem path.
15
+
16
+ ## 6.21.32
17
+
18
+ - **Adaptive meeting-audio cleanup is a default-off, replay-only canary.** When
19
+ `COS_MEETING_AUDIO_ADAPTIVE_PLAYBACK=1`, the authenticated retained-audio
20
+ playback route profiles each PCM chunk as hot/clipped, hot, quiet,
21
+ wind/noisy, or clean indoor, then generates a bounded cleaned WAV on first
22
+ play. Later plays use the cached copy.
23
+ - **Raw evidence is immutable and remains the fallback.** Derived files live
24
+ beside retained raw chunks under a versioned name, count against the existing
25
+ 8 GB archive cap, and cannot extend the seven-day retention clock. Unsupported
26
+ WAVs, missing FFmpeg, timeouts, and invalid output all serve the original raw
27
+ file. Cache-orphan cleanup deletes only directories proven to contain derived
28
+ playback files alone; unknown entries or failed stats retain the directory.
29
+ `?raw=1` provides an authenticated per-request A/B escape hatch.
30
+ - **A live recording always wins.** Play requests made while any meeting is
31
+ actively recording bypass cleanup and serve raw. If recording starts after
32
+ cleanup was admitted, the one global cleanup worker is preempted within
33
+ 100 ms; requests for other chunks while it is busy immediately serve raw.
34
+ FFmpeg falls back inside eight seconds, ahead of Control's media deadline.
35
+ - **No live or canonical path changed.** Capture, Turbo preview, Large-v3
36
+ canonical transcription, speaker attribution, meeting save, HQ polish, and
37
+ meeting sync do not import or call the cleanup module. Health reports the
38
+ active policy, generated/cache/fallback counters, and raw-preservation
39
+ contract for COS Control and field diagnostics.
40
+
1
41
  ## 6.21.31
2
42
 
3
43
  Domains belong to the user. Four places in this codebase hardcoded ONE user's
package/README.md CHANGED
@@ -279,6 +279,28 @@ 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
+
295
+ Server 6.21.33 lets Review Meetings browse an existing single-library tree such
296
+ as `meetings/YYYY-MM/*.md`. Set `COS_MEETINGS_ROOT` to the folder that directly
297
+ contains the month folders. It is intentionally read-only. For the full COS
298
+ sync and enrichment pipeline, keep using `COS_OPERATIONS_DIR` with
299
+ `<domain>/meetings/YYYY-MM/*.md`; arbitrary domain names are supported. When a
300
+ direct library and an operations root are both configured, the server merges
301
+ them with standalone G2 recordings and prefers the enriched writable record
302
+ for the same session.
303
+
282
304
  The first server start downloads the real-time turbo model. True HQ additionally
283
305
  requires the full `ggml-large-v3.bin` model (about 3.1 GB):
284
306
 
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.33",
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
+ }