@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 +8 -0
- package/CHANGELOG.md +25 -0
- package/README.md +13 -0
- package/package.json +1 -1
- package/server/lib/adaptive-playback-audio.ts +384 -0
- package/server/lib/meeting-audio-archive.ts +41 -8
- package/server/routes/health.ts +5 -1
- package/server/routes/meeting.ts +29 -4
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
|
@@ -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): {
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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 }
|
package/server/routes/health.ts
CHANGED
|
@@ -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 =
|
|
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
|
package/server/routes/meeting.ts
CHANGED
|
@@ -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
|
|
1444
|
-
|
|
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
|
-
|
|
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
|
/**
|