@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.
Files changed (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +40 -0
  3. package/android/CMakeLists.txt +30 -0
  4. package/android/build.gradle +119 -0
  5. package/android/src/main/AndroidManifest.xml +1 -0
  6. package/android/src/main/cpp/pictor_overlaystage.cpp +372 -0
  7. package/android/src/main/cpp/pictor_publish.cpp +87 -0
  8. package/android/src/main/cpp/pictor_videosurface.cpp +735 -0
  9. package/android/src/main/java/dev/pictor/video/AacPriming.kt +261 -0
  10. package/android/src/main/java/dev/pictor/video/AudioMixer.kt +1112 -0
  11. package/android/src/main/java/dev/pictor/video/AudioProbe.kt +162 -0
  12. package/android/src/main/java/dev/pictor/video/CadenceShaderProgram.kt +337 -0
  13. package/android/src/main/java/dev/pictor/video/CodecLedger.kt +126 -0
  14. package/android/src/main/java/dev/pictor/video/CompositionSpike.kt +978 -0
  15. package/android/src/main/java/dev/pictor/video/Diagnostics.kt +54 -0
  16. package/android/src/main/java/dev/pictor/video/EncoderGate.kt +476 -0
  17. package/android/src/main/java/dev/pictor/video/ExportGate.kt +1974 -0
  18. package/android/src/main/java/dev/pictor/video/ExportHeartbeat.kt +72 -0
  19. package/android/src/main/java/dev/pictor/video/ExportProgress.kt +103 -0
  20. package/android/src/main/java/dev/pictor/video/FrameClock.kt +591 -0
  21. package/android/src/main/java/dev/pictor/video/FramePending.kt +181 -0
  22. package/android/src/main/java/dev/pictor/video/GateAudio.kt +456 -0
  23. package/android/src/main/java/dev/pictor/video/GateDestination.kt +445 -0
  24. package/android/src/main/java/dev/pictor/video/GateFrames.kt +662 -0
  25. package/android/src/main/java/dev/pictor/video/GateMux.kt +185 -0
  26. package/android/src/main/java/dev/pictor/video/ManagedSources.kt +333 -0
  27. package/android/src/main/java/dev/pictor/video/Media3Player.kt +934 -0
  28. package/android/src/main/java/dev/pictor/video/MediaStoreSave.kt +419 -0
  29. package/android/src/main/java/dev/pictor/video/OverlayResources.kt +169 -0
  30. package/android/src/main/java/dev/pictor/video/PictorVideoModule.kt +1612 -0
  31. package/android/src/main/java/dev/pictor/video/PictorVideoPackage.kt +26 -0
  32. package/android/src/main/java/dev/pictor/video/PlaybackAnchor.kt +161 -0
  33. package/android/src/main/java/dev/pictor/video/PreviewAudioPlayer.kt +291 -0
  34. package/android/src/main/java/dev/pictor/video/PreviewConfig.kt +96 -0
  35. package/android/src/main/java/dev/pictor/video/PreviewMix.kt +396 -0
  36. package/android/src/main/java/dev/pictor/video/PreviewSync.kt +374 -0
  37. package/android/src/main/java/dev/pictor/video/PreviewTrace.kt +478 -0
  38. package/android/src/main/java/dev/pictor/video/PrimingTrim.kt +337 -0
  39. package/android/src/main/java/dev/pictor/video/RepeatingEncoderFactory.kt +97 -0
  40. package/android/src/main/java/dev/pictor/video/SceneProgram.kt +169 -0
  41. package/android/src/main/java/dev/pictor/video/SceneProgramNative.kt +299 -0
  42. package/android/src/main/java/dev/pictor/video/SkiaPassThroughEffect.kt +695 -0
  43. package/android/src/main/java/dev/pictor/video/SurfaceAttachment.kt +110 -0
  44. package/android/src/main/java/dev/pictor/video/ThumbnailDecoder.kt +171 -0
  45. package/android/src/main/java/dev/pictor/video/VideoSurface.kt +394 -0
  46. package/app.plugin.js +43 -0
  47. package/dist/index.d.mts +3622 -0
  48. package/dist/index.d.ts +3622 -0
  49. package/dist/index.js +3737 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/index.mjs +3625 -0
  52. package/dist/index.mjs.map +1 -0
  53. package/package.json +82 -0
  54. package/plugin/gradleProperties.d.ts +19 -0
  55. package/plugin/gradleProperties.js +64 -0
  56. package/react-native.config.js +13 -0
  57. package/src/NativePictorVideo.ts +574 -0
  58. package/src/audioProbe.ts +72 -0
  59. package/src/compositionSpike.ts +305 -0
  60. package/src/convergence.ts +112 -0
  61. package/src/encoderGate.ts +66 -0
  62. package/src/exportDestination.ts +149 -0
  63. package/src/exportJob.ts +232 -0
  64. package/src/exportProgress.ts +391 -0
  65. package/src/exportProgressPump.ts +190 -0
  66. package/src/exporter.ts +655 -0
  67. package/src/index.ts +276 -0
  68. package/src/managedSources.ts +252 -0
  69. package/src/media3.ts +412 -0
  70. package/src/mediaStore.ts +177 -0
  71. package/src/memoryGuard.ts +118 -0
  72. package/src/mixSources.ts +82 -0
  73. package/src/overlayStaging.ts +216 -0
  74. package/src/playbackLifecycle.ts +137 -0
  75. package/src/player.ts +39 -0
  76. package/src/playlistSession.ts +125 -0
  77. package/src/previewAudioDiagnostics.ts +67 -0
  78. package/src/previewAudioSession.ts +168 -0
  79. package/src/previewCadence.ts +950 -0
  80. package/src/previewMix.ts +212 -0
  81. package/src/previewTrace.ts +348 -0
  82. package/src/spikeExcursions.ts +405 -0
  83. package/src/submissionScoring.ts +680 -0
  84. package/src/surfaceOwner.ts +264 -0
  85. package/src/surfaceRecovery.ts +121 -0
  86. package/src/syncTrace.ts +189 -0
  87. package/src/thumbnailProvider.ts +163 -0
  88. package/src/tracePhases.ts +217 -0
  89. package/src/useCompositionPlayback.ts +327 -0
  90. package/src/useMedia3Playback.ts +492 -0
  91. package/src/usePreviewAudio.ts +305 -0
  92. package/src/useVideoSurface.ts +282 -0
@@ -0,0 +1,264 @@
1
+ import { NO_SURFACE } from './playlistSession'
2
+
3
+ /**
4
+ * Who is allowed to create a presentation surface, and when.
5
+ *
6
+ * There is one external texture in the process, so there is one of these. Not
7
+ * one per hook instance: a hook is remounted by a navigation, a fast refresh or
8
+ * a rotation, and each remount would bring its own lock and its own idea of
9
+ * what is in flight. Two creates then overlap, each registers a generation, and
10
+ * the second overwrites the native handles the first is still holding — the GL
11
+ * texture and `SurfaceTexture` from the first are then unreachable and never
12
+ * released. The symptom is a hook reporting one generation while the adapter
13
+ * has moved on to another, with an abandoned surface in between.
14
+ *
15
+ * ## The generation is native's, and only native's
16
+ *
17
+ * `create()` returns the generation `VideoSurface.register` assigned. This
18
+ * module publishes that number unchanged and keeps no counter of its own — a
19
+ * second counter is a second answer to "which surface is live", and the whole
20
+ * class of bug here is two answers to one question.
21
+ *
22
+ * ## A superseded attempt is retired, not ignored
23
+ *
24
+ * An attempt whose owner went away while it was on the UI runtime still
25
+ * *succeeds*: the texture exists, the `Surface` is registered, Media3 could
26
+ * attach to it. Ignoring its report leaves exactly that — a live, registered
27
+ * surface nobody will ever draw or release. So it is released, on the runtime
28
+ * that owns the GL context, before anything else is started.
29
+ *
30
+ * ## Serialised, and retirement does not unlock
31
+ *
32
+ * A request that arrives while an attempt is unresolved is queued, and
33
+ * backgrounding or unmounting does **not** release the lock: the earlier
34
+ * attempt is still out there on the UI runtime and will still come back. The
35
+ * queue is one deep, because every waiting request wants the same thing.
36
+ *
37
+ * Effects are injected so the interleavings are deterministic in a test rather
38
+ * than a matter of timing on a device.
39
+ *
40
+ * @experimental
41
+ */
42
+
43
+ /** What native `create()` reports. @experimental */
44
+ export interface SurfaceCreateResult {
45
+ ok: boolean
46
+ /** The generation `register` assigned, or 0 on failure. */
47
+ generation: number
48
+ textureId: number
49
+ error: string
50
+ }
51
+
52
+ /** @experimental */
53
+ export interface SurfaceOwnerEffects {
54
+ /**
55
+ * Schedule a create on the UI runtime.
56
+ *
57
+ * Must eventually call {@link SurfaceOwner.complete}, whatever happens.
58
+ */
59
+ create(): void
60
+ /**
61
+ * Release a specific generation, on the runtime that owns the GL context.
62
+ *
63
+ * Takes a generation rather than "the current surface" so a late attempt
64
+ * cannot release the surface that replaced it.
65
+ */
66
+ retire(generation: number): void
67
+ }
68
+
69
+ /** @experimental */
70
+ export interface SurfaceReadiness {
71
+ /** The live generation, exactly as native reported it. 0 when there is none. */
72
+ generation: number
73
+ /** The last failure, or empty. */
74
+ error: string
75
+ }
76
+
77
+ /** @experimental */
78
+ export interface SurfaceOwner {
79
+ readonly readiness: SurfaceReadiness
80
+ /** Whether an attempt is on the UI runtime right now. */
81
+ readonly busy: boolean
82
+ /** Generations created by a superseded attempt and released. Diagnostics. */
83
+ readonly retired: number
84
+ /**
85
+ * Ask for a surface on behalf of `token`.
86
+ *
87
+ * Returns false when the request was queued or refused rather than started.
88
+ */
89
+ request(token: number): boolean
90
+ /** That owner is gone. Does not unlock: an unresolved attempt is still out there. */
91
+ retireOwner(token: number): void
92
+ /** An attempt finished. */
93
+ complete(result: SurfaceCreateResult): void
94
+ /** Adopt a surface that already exists, without creating one. */
95
+ adopt(generation: number, token: number): void
96
+ subscribe(listener: () => void): () => void
97
+ /** Point the owner at the newest caller's effects. */
98
+ useEffects(next: SurfaceOwnerEffects): void
99
+ }
100
+
101
+ /** @experimental */
102
+ export function createSurfaceOwner(initial: SurfaceOwnerEffects): SurfaceOwner {
103
+ // Mutable, because the singleton outlives every hook that uses it and each
104
+ // one brings its own closure over the published handle and the texture size.
105
+ // The newest is the right one: they all drive the same native object.
106
+ let effects = initial
107
+ let readiness: SurfaceReadiness = { generation: NO_SURFACE, error: '' }
108
+ let busy = false
109
+ let retired = 0
110
+ /** The owner the in-flight attempt was started for. */
111
+ let activeToken: number | null = null
112
+ /**
113
+ * Whether that owner went away while its attempt was on the UI runtime.
114
+ *
115
+ * A flag rather than a comparison against whoever is current now: a *second*
116
+ * hook asking for a surface does not invalidate the one being built, because
117
+ * they share it. Only the asking owner disappearing does. Comparing against
118
+ * the current token retired a perfectly good surface every time a remount
119
+ * overlapped a create, which is a leak dressed as caution.
120
+ */
121
+ let activeRetired = false
122
+ /** The owner currently entitled to a surface. */
123
+ let currentToken: number | null = null
124
+ /** One waiting request. Every waiter wants the same thing. */
125
+ let queuedToken: number | null = null
126
+ const listeners = new Set<() => void>()
127
+
128
+ const emit = () => {
129
+ for (const listener of listeners) listener()
130
+ }
131
+
132
+ const publish = (next: SurfaceReadiness) => {
133
+ if (next.generation === readiness.generation && next.error === readiness.error) return
134
+ readiness = next
135
+ emit()
136
+ }
137
+
138
+ const start = (token: number) => {
139
+ busy = true
140
+ activeToken = token
141
+ activeRetired = false
142
+ effects.create()
143
+ }
144
+
145
+ const owner: SurfaceOwner = {
146
+ get readiness() {
147
+ return readiness
148
+ },
149
+ get busy() {
150
+ return busy
151
+ },
152
+ get retired() {
153
+ return retired
154
+ },
155
+
156
+ request(token) {
157
+ currentToken = token
158
+ if (readiness.generation !== NO_SURFACE) return false
159
+ if (busy) {
160
+ queuedToken = token
161
+ return false
162
+ }
163
+ start(token)
164
+ return true
165
+ },
166
+
167
+ retireOwner(token) {
168
+ if (activeToken === token) activeRetired = true
169
+ if (currentToken === token) currentToken = null
170
+ if (queuedToken === token) queuedToken = null
171
+ // Deliberately not `busy = false`. The attempt is on the UI runtime and
172
+ // will come back; unlocking here is what lets a second create overlap it.
173
+ },
174
+
175
+ adopt(generation, token) {
176
+ currentToken = token
177
+ if (generation === NO_SURFACE) return
178
+ publish({ generation, error: '' })
179
+ },
180
+
181
+ complete(result) {
182
+ const wasRetired = activeRetired
183
+ const hasOwner = currentToken !== null
184
+ busy = false
185
+ activeToken = null
186
+ activeRetired = false
187
+
188
+ if (result.ok && result.generation > NO_SURFACE) {
189
+ // Superseded means nobody is left who asked for this: the owner that
190
+ // started it retired, or every owner has gone. Not "somebody else asked
191
+ // in the meantime" — they want the same surface.
192
+ const superseded = wasRetired || !hasOwner
193
+ const stale = result.generation < readiness.generation
194
+ if (superseded || stale) {
195
+ // It exists and is registered, so it has to be released rather than
196
+ // forgotten. Releasing by generation means a late attempt cannot take
197
+ // down the surface that replaced it.
198
+ retired += 1
199
+ effects.retire(result.generation)
200
+ } else {
201
+ publish({ generation: result.generation, error: '' })
202
+ }
203
+ } else {
204
+ publish({
205
+ generation: readiness.generation,
206
+ error: result.error || 'surface create failed',
207
+ })
208
+ }
209
+
210
+ const next = queuedToken
211
+ queuedToken = null
212
+ // Only if there is still no surface: a queued request that has since been
213
+ // satisfied by the attempt that just finished wants nothing more.
214
+ if (next !== null && readiness.generation === NO_SURFACE && next === currentToken) {
215
+ start(next)
216
+ }
217
+ },
218
+
219
+ useEffects(next) {
220
+ effects = next
221
+ },
222
+
223
+ subscribe(listener) {
224
+ listeners.add(listener)
225
+ return () => {
226
+ listeners.delete(listener)
227
+ }
228
+ },
229
+ }
230
+
231
+ return owner
232
+ }
233
+
234
+ /**
235
+ * The process-wide owner, created on first use.
236
+ *
237
+ * A module-level singleton because the thing it guards — the one external
238
+ * texture — is one per process. `resetSurfaceOwnerForTests` exists so a test can
239
+ * start from a known state; nothing in production calls it.
240
+ *
241
+ * @experimental
242
+ */
243
+ let singleton: SurfaceOwner | null = null
244
+
245
+ /** @experimental */
246
+ export function surfaceOwner(effects: SurfaceOwnerEffects): SurfaceOwner {
247
+ if (!singleton) singleton = createSurfaceOwner(effects)
248
+ else singleton.useEffects(effects)
249
+ return singleton
250
+ }
251
+
252
+ /** @experimental */
253
+ export function resetSurfaceOwnerForTests(): void {
254
+ singleton = null
255
+ }
256
+
257
+ /** Fresh token for a hook instance. Only ever increases. @experimental */
258
+ let nextToken = 0
259
+
260
+ /** @experimental */
261
+ export function nextOwnerToken(): number {
262
+ nextToken += 1
263
+ return nextToken
264
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Deciding that a restored surface is not actually showing anything.
3
+ *
4
+ * After a background, a picker, or any other trip away from the editor, the
5
+ * player and the surface have to find each other again. When that handshake
6
+ * half-succeeds the result is the worst kind of failure: audio plays, the clock
7
+ * advances, no error is reported, and the picture is black. Nothing in the
8
+ * player's own state says anything is wrong, because from its point of view
9
+ * nothing is.
10
+ *
11
+ * So it is decided from the counters instead, and the counters are already
12
+ * separated into the four things that can go wrong — see `Media3State`, where
13
+ * `framesOffered` is the decoder saying a frame is coming and `framesAvailable`
14
+ * is the surface saying one arrived.
15
+ *
16
+ * The point of naming the branch is that the repairs differ. Re-running the
17
+ * handshake fixes a stale generation and does nothing at all for a consumer
18
+ * that has stopped pulling, and "retry everything" would have made both look
19
+ * like the same intermittent bug.
20
+ *
21
+ * @experimental
22
+ */
23
+
24
+ /** How long a stall must persist before it counts. @experimental */
25
+ export const RECOVERY_GRACE_MS = 1500
26
+
27
+ /** What a sample of the pipeline looked like at one instant. @experimental */
28
+ export interface RecoverySample {
29
+ atMs: number
30
+ /** The player's own clock. Advancing means audio is being produced. */
31
+ positionUs: number
32
+ /** Media3 announcing frames. */
33
+ framesOffered: number
34
+ /** The SurfaceTexture receiving them. */
35
+ framesAvailable: number
36
+ /** Frames the consumer actually took. */
37
+ framesLatched: number
38
+ /** Announcements for a generation already retired. */
39
+ framesRejected: number
40
+ /** The generation the producer is filling. */
41
+ pendingGeneration: number
42
+ /** The generation the consumer is watching. */
43
+ consumerGeneration: number
44
+ }
45
+
46
+ /**
47
+ * Which half of the pipeline is broken, or `null` when it is healthy.
48
+ *
49
+ * @experimental
50
+ */
51
+ export type StallKind =
52
+ /** Frames arrive and nobody takes them: the consumer or its frame loop. */
53
+ | 'consumer'
54
+ /** Producer and consumer disagree about which surface: a stale handshake. */
55
+ | 'handshake'
56
+ /** The player is running but nothing reaches the surface: attachment. */
57
+ | 'attachment'
58
+ /** Frames are latched and the picture is still wrong: scene or image. */
59
+ | 'resource'
60
+
61
+ /**
62
+ * Name the branch from two samples.
63
+ *
64
+ * `null` means nothing is wrong *that this can see* — including the ordinary
65
+ * case of a player that is simply not advancing, which is what a paused editor
66
+ * looks like and is not a fault.
67
+ *
68
+ * @experimental
69
+ */
70
+ export function classifyStall(since: RecoverySample, now: RecoverySample): StallKind | null {
71
+ // A clock that is not moving is not a stall. Nothing is expected of a player
72
+ // that is paused, ended or buffering, and calling that a fault would make
73
+ // every pause look like a broken surface.
74
+ if (now.positionUs <= since.positionUs) return null
75
+
76
+ // Checked before anything else: when the two sides disagree about the
77
+ // generation, every other counter is describing a surface nobody is watching.
78
+ if (
79
+ now.pendingGeneration !== now.consumerGeneration ||
80
+ now.framesRejected > since.framesRejected
81
+ ) {
82
+ return 'handshake'
83
+ }
84
+
85
+ const latched = now.framesLatched > since.framesLatched
86
+ if (latched) {
87
+ // The pipeline is delivering. Whatever is wrong is further along — the
88
+ // scene, the image, the matrix — and re-running the handshake would only
89
+ // interrupt something that is working.
90
+ return 'resource'
91
+ }
92
+
93
+ const arrived = now.framesAvailable > since.framesAvailable
94
+ if (arrived) return 'consumer'
95
+
96
+ // The decoder may or may not be announcing; either way nothing reached the
97
+ // surface, so the player is not attached to the one being watched.
98
+ return 'attachment'
99
+ }
100
+
101
+ /**
102
+ * Whether the stall has lasted long enough to act on.
103
+ *
104
+ * A grace period because none of this is instantaneous: a surface is recreated,
105
+ * a playlist is reconciled and the first frame decoded, and a check that fired
106
+ * inside that window would recover a pipeline that was about to work.
107
+ *
108
+ * @experimental
109
+ */
110
+ export function shouldRecover(
111
+ since: RecoverySample,
112
+ now: RecoverySample,
113
+ graceMs: number = RECOVERY_GRACE_MS,
114
+ ): boolean {
115
+ if (now.atMs - since.atMs < graceMs) return false
116
+ const kind = classifyStall(since, now)
117
+ // `resource` is deliberately excluded: frames are being latched, so the
118
+ // handshake this recovers is not what is wrong, and retrying it would blank a
119
+ // working picture on a timer.
120
+ return kind === 'consumer' || kind === 'handshake' || kind === 'attachment'
121
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Per-tick synchronisation forensics.
3
+ *
4
+ * A single offset number cannot say *why* video is late, and optimising against
5
+ * it invites exactly the wrong fixes — presenting future frames, nudging the
6
+ * clock, delaying audio. Each of those improves the number and worsens the
7
+ * playback.
8
+ *
9
+ * So every tick is decomposed into causes that can be acted on separately:
10
+ *
11
+ * - **Frame quantisation** — video only changes every frame period, so the
12
+ * picture is always somewhat behind the audio instant. Bounded by one frame
13
+ * by definition, and not a defect.
14
+ * - **Decoder lateness** — the frame that *should* be showing had not been
15
+ * decoded yet. Fix by adjusting the bounded lead or queue depth.
16
+ * - **Scheduler lateness** — the right frame *was* available and was not
17
+ * selected. Fix the missed tick or the contention causing it.
18
+ * - **Clock correction** — how far each raw `AudioTrack` report moves the
19
+ * extrapolated position. Large jumps mean stale timestamps or a bad baseline.
20
+ *
21
+ * Pure, so the decomposition is tested rather than trusted.
22
+ *
23
+ * @experimental
24
+ */
25
+
26
+ export interface TickSample {
27
+ /** Authoritative extrapolated audio time. */
28
+ audioUs: number
29
+ /** Age of the newest raw `getTimestamp()` report at this tick. */
30
+ rawAgeUs: number
31
+ /** How far the newest raw report moved the extrapolated position. */
32
+ correctionUs: number
33
+ /** Newest decoded frame at or before `audioUs`, or null if none is eligible. */
34
+ newestEligiblePtsUs: number | null
35
+ /** What was actually presented, or null. */
36
+ presentedPtsUs: number | null
37
+ /** Interval since the previous tick. */
38
+ tickIntervalUs: number
39
+ queueDepth: number
40
+ decoderLeadUs: number
41
+ }
42
+
43
+ export interface SyncTrace {
44
+ readonly frameUs: number
45
+ quantisation: number[]
46
+ decoderLate: number[]
47
+ schedulerLate: number[]
48
+ correction: number[]
49
+ presentedOffset: number[]
50
+ tickInterval: number[]
51
+ ticks: number
52
+ decoderLateTicks: number
53
+ schedulerLateTicks: number
54
+ }
55
+
56
+ const MAX_SAMPLES = 20_000
57
+
58
+ export function createSyncTrace(fps: number): SyncTrace {
59
+ const rate = Number.isFinite(fps) && fps > 0 ? fps : 30
60
+ return {
61
+ frameUs: Math.round(1_000_000 / rate),
62
+ quantisation: [],
63
+ decoderLate: [],
64
+ schedulerLate: [],
65
+ correction: [],
66
+ presentedOffset: [],
67
+ tickInterval: [],
68
+ ticks: 0,
69
+ decoderLateTicks: 0,
70
+ schedulerLateTicks: 0,
71
+ }
72
+ }
73
+
74
+ /**
75
+ * The frame that ought to be on screen at `audioUs`.
76
+ *
77
+ * Assumes a regular frame grid anchored at zero, which the controlled fixture
78
+ * has. On a variable-rate source this is an approximation and the quantisation
79
+ * figure should be read as indicative — which is why it is stated here rather
80
+ * than left for a reader to infer from a clean-looking number.
81
+ */
82
+ export function expectedPtsAt(audioUs: number, frameUs: number): number {
83
+ if (audioUs <= 0 || frameUs <= 0) return 0
84
+ return Math.floor(audioUs / frameUs) * frameUs
85
+ }
86
+
87
+ export function record(trace: SyncTrace, sample: TickSample): SyncTrace {
88
+ if (trace.ticks >= MAX_SAMPLES) return { ...trace, ticks: trace.ticks + 1 }
89
+
90
+ const expected = expectedPtsAt(sample.audioUs, trace.frameUs)
91
+ const next: SyncTrace = {
92
+ ...trace,
93
+ ticks: trace.ticks + 1,
94
+ quantisation: [...trace.quantisation, sample.audioUs - expected],
95
+ correction: [...trace.correction, Math.abs(sample.correctionUs)],
96
+ tickInterval: [...trace.tickInterval, sample.tickIntervalUs],
97
+ decoderLate: trace.decoderLate,
98
+ schedulerLate: trace.schedulerLate,
99
+ presentedOffset: trace.presentedOffset,
100
+ decoderLateTicks: trace.decoderLateTicks,
101
+ schedulerLateTicks: trace.schedulerLateTicks,
102
+ }
103
+
104
+ // Decoder lateness: the frame that should be showing was not available.
105
+ // Measured as how far short of `expected` the newest eligible frame fell.
106
+ const eligible = sample.newestEligiblePtsUs
107
+ if (eligible === null || eligible < expected) {
108
+ const shortfall = eligible === null ? sample.audioUs : expected - eligible
109
+ next.decoderLate = [...trace.decoderLate, shortfall]
110
+ next.decoderLateTicks = trace.decoderLateTicks + 1
111
+ }
112
+
113
+ // Scheduler lateness: an eligible frame existed and something older — or
114
+ // nothing — was shown instead. This is the category that means a tick was
115
+ // missed rather than a frame being absent.
116
+ if (eligible !== null && (sample.presentedPtsUs === null || sample.presentedPtsUs < eligible)) {
117
+ const behind = eligible - (sample.presentedPtsUs ?? 0)
118
+ next.schedulerLate = [...trace.schedulerLate, behind]
119
+ next.schedulerLateTicks = trace.schedulerLateTicks + 1
120
+ }
121
+
122
+ if (sample.presentedPtsUs !== null) {
123
+ next.presentedOffset = [...trace.presentedOffset, sample.presentedPtsUs - sample.audioUs]
124
+ }
125
+ return next
126
+ }
127
+
128
+ export interface Distribution {
129
+ n: number
130
+ p50: number
131
+ p95: number
132
+ p99: number
133
+ max: number
134
+ }
135
+
136
+ export function distribution(values: readonly number[]): Distribution {
137
+ if (values.length === 0) return { n: 0, p50: 0, p95: 0, p99: 0, max: 0 }
138
+ const abs = values.map(Math.abs).sort((a, b) => a - b)
139
+ const at = (q: number) => abs[Math.min(abs.length - 1, Math.floor(q * abs.length))]!
140
+ return { n: abs.length, p50: at(0.5), p95: at(0.95), p99: at(0.99), max: abs[abs.length - 1]! }
141
+ }
142
+
143
+ export interface SyncReport {
144
+ quantisation: Distribution
145
+ decoderLate: Distribution
146
+ schedulerLate: Distribution
147
+ correction: Distribution
148
+ presentedOffset: Distribution
149
+ tickInterval: Distribution
150
+ ticks: number
151
+ decoderLateTicks: number
152
+ schedulerLateTicks: number
153
+ frameUs: number
154
+ /** Which cause dominates, so the fix is chosen by evidence. */
155
+ dominant: 'none' | 'decoder' | 'scheduler' | 'quantisation'
156
+ }
157
+
158
+ export function syncReport(trace: SyncTrace): SyncReport {
159
+ const decoderLate = distribution(trace.decoderLate)
160
+ const schedulerLate = distribution(trace.schedulerLate)
161
+ const quantisation = distribution(trace.quantisation)
162
+ const presentedOffset = distribution(trace.presentedOffset)
163
+
164
+ // Attribute by how often each fires and how badly, not by which p95 is
165
+ // numerically larger: a rare huge decoder stall and a constant one-frame
166
+ // scheduler lag need different fixes.
167
+ const decoderShare = trace.ticks === 0 ? 0 : trace.decoderLateTicks / trace.ticks
168
+ const schedulerShare = trace.ticks === 0 ? 0 : trace.schedulerLateTicks / trace.ticks
169
+ let dominant: SyncReport['dominant'] = 'none'
170
+ if (presentedOffset.p95 > trace.frameUs) {
171
+ if (decoderShare >= schedulerShare && decoderShare > 0.01) dominant = 'decoder'
172
+ else if (schedulerShare > 0.01) dominant = 'scheduler'
173
+ else dominant = 'quantisation'
174
+ }
175
+
176
+ return {
177
+ quantisation,
178
+ decoderLate,
179
+ schedulerLate,
180
+ correction: distribution(trace.correction),
181
+ presentedOffset,
182
+ tickInterval: distribution(trace.tickInterval),
183
+ ticks: trace.ticks,
184
+ decoderLateTicks: trace.decoderLateTicks,
185
+ schedulerLateTicks: trace.schedulerLateTicks,
186
+ frameUs: trace.frameUs,
187
+ dominant,
188
+ }
189
+ }