@pictor-sdk/video-android 0.1.0
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/LICENSE +21 -0
- package/README.md +40 -0
- package/android/CMakeLists.txt +30 -0
- package/android/build.gradle +119 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/cpp/pictor_overlaystage.cpp +372 -0
- package/android/src/main/cpp/pictor_publish.cpp +87 -0
- package/android/src/main/cpp/pictor_videosurface.cpp +735 -0
- package/android/src/main/java/dev/pictor/video/AacPriming.kt +261 -0
- package/android/src/main/java/dev/pictor/video/AudioMixer.kt +1112 -0
- package/android/src/main/java/dev/pictor/video/AudioProbe.kt +162 -0
- package/android/src/main/java/dev/pictor/video/CadenceShaderProgram.kt +337 -0
- package/android/src/main/java/dev/pictor/video/CodecLedger.kt +126 -0
- package/android/src/main/java/dev/pictor/video/CompositionSpike.kt +978 -0
- package/android/src/main/java/dev/pictor/video/Diagnostics.kt +54 -0
- package/android/src/main/java/dev/pictor/video/EncoderGate.kt +476 -0
- package/android/src/main/java/dev/pictor/video/ExportGate.kt +1974 -0
- package/android/src/main/java/dev/pictor/video/ExportHeartbeat.kt +72 -0
- package/android/src/main/java/dev/pictor/video/ExportProgress.kt +103 -0
- package/android/src/main/java/dev/pictor/video/FrameClock.kt +591 -0
- package/android/src/main/java/dev/pictor/video/FramePending.kt +181 -0
- package/android/src/main/java/dev/pictor/video/GateAudio.kt +456 -0
- package/android/src/main/java/dev/pictor/video/GateDestination.kt +445 -0
- package/android/src/main/java/dev/pictor/video/GateFrames.kt +662 -0
- package/android/src/main/java/dev/pictor/video/GateMux.kt +185 -0
- package/android/src/main/java/dev/pictor/video/ManagedSources.kt +333 -0
- package/android/src/main/java/dev/pictor/video/Media3Player.kt +934 -0
- package/android/src/main/java/dev/pictor/video/MediaStoreSave.kt +419 -0
- package/android/src/main/java/dev/pictor/video/OverlayResources.kt +169 -0
- package/android/src/main/java/dev/pictor/video/PictorVideoModule.kt +1612 -0
- package/android/src/main/java/dev/pictor/video/PictorVideoPackage.kt +26 -0
- package/android/src/main/java/dev/pictor/video/PlaybackAnchor.kt +161 -0
- package/android/src/main/java/dev/pictor/video/PreviewAudioPlayer.kt +291 -0
- package/android/src/main/java/dev/pictor/video/PreviewConfig.kt +96 -0
- package/android/src/main/java/dev/pictor/video/PreviewMix.kt +396 -0
- package/android/src/main/java/dev/pictor/video/PreviewSync.kt +374 -0
- package/android/src/main/java/dev/pictor/video/PreviewTrace.kt +478 -0
- package/android/src/main/java/dev/pictor/video/PrimingTrim.kt +337 -0
- package/android/src/main/java/dev/pictor/video/RepeatingEncoderFactory.kt +97 -0
- package/android/src/main/java/dev/pictor/video/SceneProgram.kt +169 -0
- package/android/src/main/java/dev/pictor/video/SceneProgramNative.kt +299 -0
- package/android/src/main/java/dev/pictor/video/SkiaPassThroughEffect.kt +695 -0
- package/android/src/main/java/dev/pictor/video/SurfaceAttachment.kt +110 -0
- package/android/src/main/java/dev/pictor/video/ThumbnailDecoder.kt +171 -0
- package/android/src/main/java/dev/pictor/video/VideoSurface.kt +394 -0
- package/app.plugin.js +43 -0
- package/dist/index.d.mts +3622 -0
- package/dist/index.d.ts +3622 -0
- package/dist/index.js +3737 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +3625 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +82 -0
- package/plugin/gradleProperties.d.ts +19 -0
- package/plugin/gradleProperties.js +64 -0
- package/react-native.config.js +13 -0
- package/src/NativePictorVideo.ts +574 -0
- package/src/audioProbe.ts +72 -0
- package/src/compositionSpike.ts +305 -0
- package/src/convergence.ts +112 -0
- package/src/encoderGate.ts +66 -0
- package/src/exportDestination.ts +149 -0
- package/src/exportJob.ts +232 -0
- package/src/exportProgress.ts +391 -0
- package/src/exportProgressPump.ts +190 -0
- package/src/exporter.ts +655 -0
- package/src/index.ts +276 -0
- package/src/managedSources.ts +252 -0
- package/src/media3.ts +412 -0
- package/src/mediaStore.ts +177 -0
- package/src/memoryGuard.ts +118 -0
- package/src/mixSources.ts +82 -0
- package/src/overlayStaging.ts +216 -0
- package/src/playbackLifecycle.ts +137 -0
- package/src/player.ts +39 -0
- package/src/playlistSession.ts +125 -0
- package/src/previewAudioDiagnostics.ts +67 -0
- package/src/previewAudioSession.ts +168 -0
- package/src/previewCadence.ts +950 -0
- package/src/previewMix.ts +212 -0
- package/src/previewTrace.ts +348 -0
- package/src/spikeExcursions.ts +405 -0
- package/src/submissionScoring.ts +680 -0
- package/src/surfaceOwner.ts +264 -0
- package/src/surfaceRecovery.ts +121 -0
- package/src/syncTrace.ts +189 -0
- package/src/thumbnailProvider.ts +163 -0
- package/src/tracePhases.ts +217 -0
- package/src/useCompositionPlayback.ts +327 -0
- package/src/useMedia3Playback.ts +492 -0
- package/src/usePreviewAudio.ts +305 -0
- package/src/useVideoSurface.ts +282 -0
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import type { SpikeSample } from './compositionSpike'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where a shared-clock run left the tolerance, and why.
|
|
5
|
+
*
|
|
6
|
+
* ## What is being compared
|
|
7
|
+
*
|
|
8
|
+
* The audio clock's position and the presented frame's timestamp, brought to
|
|
9
|
+
* one instant. The frame was latched at `frameConsumedNs`; the position was
|
|
10
|
+
* read at `atNs`. Rolling the position back to the latch is what puts them on
|
|
11
|
+
* the same monotonic instant — the composition runs at project rate 1x, so the
|
|
12
|
+
* roll-back is the elapsed time itself.
|
|
13
|
+
*
|
|
14
|
+
* ## Which boundary this observes
|
|
15
|
+
*
|
|
16
|
+
* The **latch**, not the display. `frameConsumedNs` is when the consumer called
|
|
17
|
+
* `updateTexImage`; `frameReleaseNs` is the time Media3 *asked* the surface to
|
|
18
|
+
* present at, which is an intention and not an acknowledgement. Actual scanout
|
|
19
|
+
* happens one compositor pass later and is not observed anywhere in this path.
|
|
20
|
+
* So an offset here is "audio against latched picture", and the display adds a
|
|
21
|
+
* further unmeasured delay common to every sample.
|
|
22
|
+
*
|
|
23
|
+
* ## Uncertainty is stated, never subtracted
|
|
24
|
+
*
|
|
25
|
+
* Comparing a 30 fps frame's timestamp against a continuous clock carries an
|
|
26
|
+
* irreducible ±16.7 ms, and the paired read adds a few more. That is reported
|
|
27
|
+
* beside the verdict. It is not deducted from an excursion to bring it under
|
|
28
|
+
* the line: a 95 ms excursion is not a 78 ms excursion because the instrument
|
|
29
|
+
* is imperfect.
|
|
30
|
+
*
|
|
31
|
+
* @experimental
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** @experimental */
|
|
35
|
+
export const SPIKE_TOLERANCE_US = 60_000
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Why one sample left the tolerance.
|
|
39
|
+
*
|
|
40
|
+
* Ordered by precedence, because more than one can be true and the first is the
|
|
41
|
+
* one that explains the sample: a frame from before a seek is cross-epoch
|
|
42
|
+
* whatever else is also true of it.
|
|
43
|
+
*
|
|
44
|
+
* @experimental
|
|
45
|
+
*/
|
|
46
|
+
export type ExcursionCause =
|
|
47
|
+
/**
|
|
48
|
+
* The latched frame predates the moment playback was released.
|
|
49
|
+
*
|
|
50
|
+
* Not a filter and not a drift. The readiness gate releases on a frame whose
|
|
51
|
+
* *position* matches the epoch, and that frame may have been latched long
|
|
52
|
+
* before — measured at 13.9 s, the whole interval between attaching the
|
|
53
|
+
* composition and the person pressing play. The picture was right; the
|
|
54
|
+
* frame's wall-clock age simply includes a pause, and charging that to
|
|
55
|
+
* synchronisation measures the pause.
|
|
56
|
+
*
|
|
57
|
+
* Reported rather than excluded, because the size of it is the size of the
|
|
58
|
+
* interval in which the first sample after a release is not yet a
|
|
59
|
+
* synchronisation reading.
|
|
60
|
+
*/
|
|
61
|
+
| 'pre-release-frame'
|
|
62
|
+
/** The frame belongs to a different transition epoch or frame generation. */
|
|
63
|
+
| 'cross-epoch'
|
|
64
|
+
/** The frame was latched long enough ago that it is not a current reading. */
|
|
65
|
+
| 'stale-pairing'
|
|
66
|
+
/** The first frames after a seek or after readiness, before the picture caught up. */
|
|
67
|
+
| 'first-frame-after-seek'
|
|
68
|
+
/** The frame was latched well after the time it was asked to be presented at. */
|
|
69
|
+
| 'delayed-presentation'
|
|
70
|
+
/** The audio clock moved discontinuously between adjacent samples. */
|
|
71
|
+
| 'audio-discontinuity'
|
|
72
|
+
/** The mapped project time disagrees with the frame's own window and PTS. */
|
|
73
|
+
| 'mapping'
|
|
74
|
+
/** None of the above fitted. Deliberately not folded into a neighbour. */
|
|
75
|
+
| 'unexplained'
|
|
76
|
+
|
|
77
|
+
/** @experimental */
|
|
78
|
+
export interface Excursion {
|
|
79
|
+
seq: number
|
|
80
|
+
/** Against the latch: observed, and earlier than display. */
|
|
81
|
+
offsetUs: number
|
|
82
|
+
/** Against the time the frame was asked to appear. An intention. */
|
|
83
|
+
offsetAtIntendedUs: number
|
|
84
|
+
cause: ExcursionCause
|
|
85
|
+
epoch: number
|
|
86
|
+
sinceEpochMs: number
|
|
87
|
+
frameWindow: number
|
|
88
|
+
framePtsUs: number
|
|
89
|
+
/** Latched minus asked-for. Large means the picture arrived late. */
|
|
90
|
+
latchLagUs: number
|
|
91
|
+
/** How old the latched frame was when the position was read. */
|
|
92
|
+
frameAgeUs: number
|
|
93
|
+
/** The audio clock's step from the previous sample, in microseconds. */
|
|
94
|
+
positionStepUs: number
|
|
95
|
+
/** The wall-clock step from the previous sample. */
|
|
96
|
+
wallStepUs: number
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** @experimental */
|
|
100
|
+
export interface ExcursionReport {
|
|
101
|
+
toleranceUs: number
|
|
102
|
+
/** Samples where playback was declared active. Only these are judged. */
|
|
103
|
+
activeSamples: number
|
|
104
|
+
/** Samples before readiness, exposed rather than dropped. */
|
|
105
|
+
preparingSamples: number
|
|
106
|
+
excursions: Excursion[]
|
|
107
|
+
byCause: Record<string, number>
|
|
108
|
+
/**
|
|
109
|
+
* Samples still showing a frame latched before the release.
|
|
110
|
+
*
|
|
111
|
+
* Reported apart, and *retained* in the acceptance set: they are real
|
|
112
|
+
* playback, and if the picture never catches up they are the only thing that
|
|
113
|
+
* says so.
|
|
114
|
+
*/
|
|
115
|
+
preReleaseSamples: number
|
|
116
|
+
preReleaseWorstUs: number
|
|
117
|
+
worstAbsUs: number
|
|
118
|
+
/** The same run judged at the intended-presentation boundary. */
|
|
119
|
+
worstAtIntendedUs: number
|
|
120
|
+
overAtIntended: number
|
|
121
|
+
passAtIntended: boolean
|
|
122
|
+
/** Mean latch-minus-intended over the excursions. Negative means early. */
|
|
123
|
+
meanLatchLagUs: number
|
|
124
|
+
pass: boolean
|
|
125
|
+
uncertainty: {
|
|
126
|
+
/** Half a frame interval at the measured rate. Irreducible. */
|
|
127
|
+
frameQuantumUs: number
|
|
128
|
+
/** The widest paired read in the run. */
|
|
129
|
+
readSpanUs: number
|
|
130
|
+
positionQuantumUs: number
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The offset at each observable boundary.
|
|
136
|
+
*
|
|
137
|
+
* They are not the same instant and the difference is not noise. Measured on a
|
|
138
|
+
* Pixel 7, the consumer latches a buffer about 34 ms *before* the time Media3
|
|
139
|
+
* asked the surface to present it — `releaseOutputBuffer(index, releaseTimeNs)`
|
|
140
|
+
* queues immediately with a desired present time in the future, and the
|
|
141
|
+
* SurfaceTexture consumer takes it as soon as it is queued.
|
|
142
|
+
*
|
|
143
|
+
* So `atLatch` compares the audio clock against a frame that is in the texture
|
|
144
|
+
* but has not been displayed, and is systematically early by that gap;
|
|
145
|
+
* `atIntended` compares it against when that frame was *meant* to appear, which
|
|
146
|
+
* is the best available estimate of what a viewer experiences and is still an
|
|
147
|
+
* intention rather than an acknowledgement. The true display instant lies at or
|
|
148
|
+
* after `atIntended`, and nothing in this process observes it.
|
|
149
|
+
*
|
|
150
|
+
* Both are returned because choosing one silently is how a boundary decision
|
|
151
|
+
* becomes a verdict.
|
|
152
|
+
*
|
|
153
|
+
* @experimental
|
|
154
|
+
*/
|
|
155
|
+
export function offsetAt(
|
|
156
|
+
sample: SpikeSample,
|
|
157
|
+
/**
|
|
158
|
+
* When the current playing interval was released, if known.
|
|
159
|
+
*
|
|
160
|
+
* A frame latched while paused carries the pause in its age. Charging that to
|
|
161
|
+
* synchronisation measured a 13.9 second "error" for a picture that was
|
|
162
|
+
* showing exactly the right position. Clamping the reference to the release
|
|
163
|
+
* instant charges only the time playback has actually been running — so at
|
|
164
|
+
* release the offset is (audio position - displayed PTS), and it grows from
|
|
165
|
+
* there if the audio advances while the picture stays put.
|
|
166
|
+
*
|
|
167
|
+
* That growth is the point: this corrects the baseline without removing
|
|
168
|
+
* coverage. A frame that is never replaced still accrues the offset it
|
|
169
|
+
* deserves.
|
|
170
|
+
*/
|
|
171
|
+
releaseAtNs = 0,
|
|
172
|
+
): { atLatch: number; atIntended: number } {
|
|
173
|
+
/*
|
|
174
|
+
A frame already on screen when playback began has no age to subtract.
|
|
175
|
+
|
|
176
|
+
For a frame presented during playback, the sound being ahead of it by the
|
|
177
|
+
time since it appeared is not error — the frame is a sample of the recent
|
|
178
|
+
past. Subtracting that age is what makes the measure a synchronisation
|
|
179
|
+
error rather than a restatement of the frame rate.
|
|
180
|
+
|
|
181
|
+
A pre-release frame is different in kind. It was displayed before the clock
|
|
182
|
+
started, so there is no elapsed-since-display to discount: at the moment of
|
|
183
|
+
release the comparison is simply "what is on screen" against "where the
|
|
184
|
+
sound is", which is near zero when the readiness gate did its job. And if
|
|
185
|
+
the picture then stays put while the sound runs on, the gap is exactly the
|
|
186
|
+
staleness a viewer sees — so it is counted, not cancelled. Subtracting the
|
|
187
|
+
age here would have the two grow together and report zero forever.
|
|
188
|
+
*/
|
|
189
|
+
if (releaseAtNs > 0 && sample.frameConsumedNs < releaseAtNs) {
|
|
190
|
+
const raw = sample.positionUs - sample.framePtsUs
|
|
191
|
+
return { atLatch: raw, atIntended: raw }
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
atLatch: sample.positionUs - (sample.atNs - sample.frameConsumedNs) / 1000 - sample.framePtsUs,
|
|
195
|
+
atIntended:
|
|
196
|
+
sample.positionUs - (sample.atNs - sample.frameReleaseNs) / 1000 - sample.framePtsUs,
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Classify a run's over-tolerance samples.
|
|
202
|
+
*
|
|
203
|
+
* `frameIntervalUs` is the source's nominal frame interval; half of it is the
|
|
204
|
+
* irreducible quantisation of comparing a discrete frame against a continuous
|
|
205
|
+
* clock.
|
|
206
|
+
*
|
|
207
|
+
* @experimental
|
|
208
|
+
*/
|
|
209
|
+
export function classifyExcursions(
|
|
210
|
+
samples: readonly SpikeSample[],
|
|
211
|
+
options: { toleranceUs?: number; frameIntervalUs?: number } = {},
|
|
212
|
+
): ExcursionReport {
|
|
213
|
+
const toleranceUs = options.toleranceUs ?? SPIKE_TOLERANCE_US
|
|
214
|
+
const frameIntervalUs = options.frameIntervalUs ?? 33_333
|
|
215
|
+
|
|
216
|
+
/*
|
|
217
|
+
Judged only once playback is active, and the rest is exposed rather than
|
|
218
|
+
dropped. A position that has not started moving is not synchronised or
|
|
219
|
+
unsynchronised; counting it either way is a statement about nothing.
|
|
220
|
+
*/
|
|
221
|
+
/*
|
|
222
|
+
A frame from before this epoch is not a reading about this epoch.
|
|
223
|
+
|
|
224
|
+
After a start or a seek the clock moves immediately while the first frame
|
|
225
|
+
for the new position is still being decoded, so the newest latched frame is
|
|
226
|
+
the one from *before* the transition. Pairing it against the moving clock
|
|
227
|
+
produces an excursion the size of the seek — measured here at up to 13.8 s
|
|
228
|
+
— and no classification of that number is about synchronisation.
|
|
229
|
+
|
|
230
|
+
So the interval is exposed as preparing rather than judged, which is also
|
|
231
|
+
what makes "include every sample once playback is active" mean something:
|
|
232
|
+
active begins when the picture for this epoch has actually arrived.
|
|
233
|
+
*/
|
|
234
|
+
/*
|
|
235
|
+
And the clock has to be moving.
|
|
236
|
+
|
|
237
|
+
`isPlaying` and `STATE_READY` both go true before the composition's clock
|
|
238
|
+
starts: measured, the player reported both for fourteen seconds with its
|
|
239
|
+
position at zero and exactly one frame ever latched. Judging that interval
|
|
240
|
+
produced a 13.9 second "excursion" that is not a synchronisation reading at
|
|
241
|
+
all — it is the preparation the brief asks to be exposed rather than scored.
|
|
242
|
+
|
|
243
|
+
Observed rather than assumed: the position must have changed somewhere in
|
|
244
|
+
the preceding window, so the test is "this clock is running" and not "this
|
|
245
|
+
player says it is".
|
|
246
|
+
*/
|
|
247
|
+
const ADVANCE_WINDOW_NS = 200_000_000
|
|
248
|
+
const advancing = samples.map((s, i) => {
|
|
249
|
+
for (let j = i - 1; j >= 0; j -= 1) {
|
|
250
|
+
if (s.atNs - samples[j]!.atNs > ADVANCE_WINDOW_NS) break
|
|
251
|
+
if (samples[j]!.positionUs !== s.positionUs) return true
|
|
252
|
+
}
|
|
253
|
+
return false
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
/*
|
|
257
|
+
When the current playing interval began.
|
|
258
|
+
|
|
259
|
+
A frame latched before it carries the pause in its age, so the first
|
|
260
|
+
samples after a release are not yet synchronisation readings. Tracked so
|
|
261
|
+
they can be *named* rather than dropped.
|
|
262
|
+
*/
|
|
263
|
+
const releaseAtNs: number[] = []
|
|
264
|
+
let intervalStart = 0
|
|
265
|
+
for (let i = 0; i < samples.length; i += 1) {
|
|
266
|
+
if (!advancing[i]) intervalStart = 0
|
|
267
|
+
else if (intervalStart === 0) intervalStart = samples[i]!.atNs
|
|
268
|
+
releaseAtNs.push(intervalStart)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const isActive = (s: SpikeSample, i: number) =>
|
|
272
|
+
s.playing &&
|
|
273
|
+
s.ready &&
|
|
274
|
+
s.framePtsUs >= 0 &&
|
|
275
|
+
s.frameConsumedNs > 0 &&
|
|
276
|
+
// The picture for this epoch has arrived.
|
|
277
|
+
s.frameConsumedNs >= s.atNs - s.sinceEpochNs &&
|
|
278
|
+
advancing[i] === true
|
|
279
|
+
const active = samples.filter(isActive)
|
|
280
|
+
const preparing = samples.length - active.length
|
|
281
|
+
|
|
282
|
+
const excursions: Excursion[] = []
|
|
283
|
+
const byCause: Record<string, number> = {}
|
|
284
|
+
let worstAbsUs = 0
|
|
285
|
+
let worstAtIntendedUs = 0
|
|
286
|
+
let overAtIntended = 0
|
|
287
|
+
let readSpanUs = 0
|
|
288
|
+
let latchLagSum = 0
|
|
289
|
+
let preReleaseSamples = 0
|
|
290
|
+
let preReleaseWorstUs = 0
|
|
291
|
+
|
|
292
|
+
// Indexed by the sample's own position in the full run, because the release
|
|
293
|
+
// boundary was computed there and `active` has holes.
|
|
294
|
+
const releaseFor = new Map<number, number>()
|
|
295
|
+
samples.forEach((s, i) => releaseFor.set(s.seq, releaseAtNs[i]!))
|
|
296
|
+
|
|
297
|
+
for (let i = 0; i < active.length; i += 1) {
|
|
298
|
+
const s = active[i]!
|
|
299
|
+
const previous = i > 0 ? active[i - 1]! : null
|
|
300
|
+
readSpanUs = Math.max(readSpanUs, s.readSpanNs / 1000)
|
|
301
|
+
const releasedAt = releaseFor.get(s.seq) ?? 0
|
|
302
|
+
const both = offsetAt(s, releasedAt)
|
|
303
|
+
const offsetUs = both.atLatch
|
|
304
|
+
const abs = Math.abs(offsetUs)
|
|
305
|
+
const absIntended = Math.abs(both.atIntended)
|
|
306
|
+
if (releasedAt > 0 && s.frameConsumedNs < releasedAt) {
|
|
307
|
+
preReleaseSamples += 1
|
|
308
|
+
if (abs > preReleaseWorstUs) preReleaseWorstUs = abs
|
|
309
|
+
}
|
|
310
|
+
if (abs > worstAbsUs) worstAbsUs = abs
|
|
311
|
+
if (absIntended > worstAtIntendedUs) worstAtIntendedUs = absIntended
|
|
312
|
+
if (absIntended > toleranceUs) overAtIntended += 1
|
|
313
|
+
if (abs <= toleranceUs) continue
|
|
314
|
+
|
|
315
|
+
const frameAgeUs = (s.atNs - s.frameConsumedNs) / 1000
|
|
316
|
+
const latchLagUs = (s.frameConsumedNs - s.frameReleaseNs) / 1000
|
|
317
|
+
latchLagSum += latchLagUs
|
|
318
|
+
const positionStepUs = previous ? s.positionUs - previous.positionUs : 0
|
|
319
|
+
const wallStepUs = previous ? (s.atNs - previous.atNs) / 1000 : 0
|
|
320
|
+
|
|
321
|
+
/*
|
|
322
|
+
First match wins, and the order is the point.
|
|
323
|
+
|
|
324
|
+
A frame carried across a seek is cross-epoch whether or not it is also
|
|
325
|
+
old and also late; calling it "delayed presentation" would send the fix
|
|
326
|
+
to the renderer instead of to the epoch check.
|
|
327
|
+
*/
|
|
328
|
+
let cause: ExcursionCause
|
|
329
|
+
if (releasedAt > 0 && s.frameConsumedNs < releasedAt) {
|
|
330
|
+
cause = 'pre-release-frame'
|
|
331
|
+
} else if (s.frameConsumedNs > 0 && s.frameConsumedNs < s.atNs - s.sinceEpochNs) {
|
|
332
|
+
cause = 'cross-epoch'
|
|
333
|
+
} else if (previous && s.frameGeneration !== previous.frameGeneration) {
|
|
334
|
+
cause = 'cross-epoch'
|
|
335
|
+
} else if (frameAgeUs > 2 * frameIntervalUs) {
|
|
336
|
+
cause = 'stale-pairing'
|
|
337
|
+
} else if (s.sinceEpochNs / 1000 < 500_000) {
|
|
338
|
+
cause = 'first-frame-after-seek'
|
|
339
|
+
} else if (latchLagUs > frameIntervalUs) {
|
|
340
|
+
cause = 'delayed-presentation'
|
|
341
|
+
} else if (previous && Math.abs(positionStepUs - wallStepUs) > toleranceUs) {
|
|
342
|
+
cause = 'audio-discontinuity'
|
|
343
|
+
} else if (previous && s.frameWindow !== previous.frameWindow) {
|
|
344
|
+
cause = 'mapping'
|
|
345
|
+
} else {
|
|
346
|
+
cause = 'unexplained'
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
byCause[cause] = (byCause[cause] ?? 0) + 1
|
|
350
|
+
excursions.push({
|
|
351
|
+
seq: s.seq,
|
|
352
|
+
offsetUs: Math.round(offsetUs),
|
|
353
|
+
offsetAtIntendedUs: Math.round(both.atIntended),
|
|
354
|
+
cause,
|
|
355
|
+
epoch: s.epoch,
|
|
356
|
+
sinceEpochMs: Math.round(s.sinceEpochNs / 1_000_000),
|
|
357
|
+
frameWindow: s.frameWindow,
|
|
358
|
+
framePtsUs: s.framePtsUs,
|
|
359
|
+
latchLagUs: Math.round(latchLagUs),
|
|
360
|
+
frameAgeUs: Math.round(frameAgeUs),
|
|
361
|
+
positionStepUs: Math.round(positionStepUs),
|
|
362
|
+
wallStepUs: Math.round(wallStepUs),
|
|
363
|
+
})
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
toleranceUs,
|
|
368
|
+
activeSamples: active.length,
|
|
369
|
+
preparingSamples: preparing,
|
|
370
|
+
excursions,
|
|
371
|
+
byCause,
|
|
372
|
+
preReleaseSamples,
|
|
373
|
+
preReleaseWorstUs: Math.round(preReleaseWorstUs),
|
|
374
|
+
worstAbsUs: Math.round(worstAbsUs),
|
|
375
|
+
worstAtIntendedUs: Math.round(worstAtIntendedUs),
|
|
376
|
+
overAtIntended,
|
|
377
|
+
passAtIntended: active.length > 0 && overAtIntended === 0,
|
|
378
|
+
meanLatchLagUs: excursions.length
|
|
379
|
+
? Math.round(latchLagSum / excursions.length)
|
|
380
|
+
: 0,
|
|
381
|
+
// No samples is not a pass. A run that never played has not been judged.
|
|
382
|
+
pass: active.length > 0 && excursions.length === 0,
|
|
383
|
+
uncertainty: {
|
|
384
|
+
frameQuantumUs: Math.round(frameIntervalUs / 2),
|
|
385
|
+
readSpanUs: Math.round(readSpanUs),
|
|
386
|
+
positionQuantumUs: 1000,
|
|
387
|
+
},
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* The samples around one excursion, for looking at rather than inferring from.
|
|
393
|
+
*
|
|
394
|
+
* @experimental
|
|
395
|
+
*/
|
|
396
|
+
export function windowAround(
|
|
397
|
+
samples: readonly SpikeSample[],
|
|
398
|
+
seq: number,
|
|
399
|
+
spanUs = 250_000,
|
|
400
|
+
): SpikeSample[] {
|
|
401
|
+
const centre = samples.find((s) => s.seq === seq)
|
|
402
|
+
if (!centre) return []
|
|
403
|
+
const spanNs = spanUs * 1000
|
|
404
|
+
return samples.filter((s) => Math.abs(s.atNs - centre.atNs) <= spanNs)
|
|
405
|
+
}
|