@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 +16 -5
- package/CHANGELOG.md +40 -0
- package/README.md +22 -0
- package/package.json +1 -1
- package/server/lib/adaptive-playback-audio.ts +384 -0
- package/server/lib/cos-operations-meetings.ts +230 -12
- package/server/lib/meeting-audio-archive.ts +41 -8
- package/server/lib/meeting-store.ts +8 -1
- package/server/routes/health.ts +12 -1
- package/server/routes/meeting.ts +121 -17
- package/server/routes/meetings.ts +91 -4
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"
|
|
128
|
-
# {
|
|
129
|
-
#
|
|
130
|
-
#
|
|
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/
|
|
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
|
@@ -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
|
+
}
|