@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,54 @@
1
+ package dev.pictor.video
2
+
3
+ import org.json.JSONObject
4
+
5
+ /**
6
+ * Whether gate-only behaviour exists in this build.
7
+ *
8
+ * ## Why this is not a runtime flag
9
+ *
10
+ * It was one. `ExportGate.diagnostics` was a `@Volatile var` that a host armed
11
+ * by calling a method — which meant a shipped build was one call away from
12
+ * being able to inject a cadence failure, wedge an export or spend a user's
13
+ * battery hashing every sample of every intermediate. "The production exporter
14
+ * never calls it" is a statement about one file, not about the binary, and the
15
+ * requirement is about the binary.
16
+ *
17
+ * So the decision moved to compile time. [ENABLED] is a `BuildConfig` constant:
18
+ * true in debug, true in a release built with `-PpictorDiagnostics=true`, and
19
+ * false in every other release. No sequence of calls from JavaScript or from
20
+ * native changes it, because there is nothing to change.
21
+ *
22
+ * ## What it guards
23
+ *
24
+ * Two things, and both are needed. Every **mutating** gate method refuses at its
25
+ * first line, so arming is impossible; and every site that would **act** on an
26
+ * injected fault is also guarded, so a value that somehow got set could still
27
+ * not do anything. The second is what makes this a boundary rather than a lock
28
+ * on one door.
29
+ *
30
+ * ## What it does not guard
31
+ *
32
+ * Read-only accounting — the run and resource ledgers, heartbeat counts, the
33
+ * live-temporary list — stays available in release. It is how cleanup is proven
34
+ * rather than asserted, it changes no execution path, and separating it from the
35
+ * fault controls is the point: a diagnostic that alters behaviour is not a
36
+ * diagnostic.
37
+ */
38
+ internal object Diagnostics {
39
+ /** Compile-time. Nothing at runtime can change it. */
40
+ val ENABLED: Boolean = BuildConfig.PICTOR_DIAGNOSTICS
41
+
42
+ /**
43
+ * The one refusal every mutating gate method returns in a release build.
44
+ *
45
+ * Stable and synchronous: a caller learns immediately that the control does
46
+ * not exist here, rather than believing it armed something.
47
+ */
48
+ fun unavailable(): String =
49
+ JSONObject()
50
+ .put("ok", false)
51
+ .put("diagnostics", false)
52
+ .put("error", "diagnostics unavailable in this build")
53
+ .toString()
54
+ }
@@ -0,0 +1,476 @@
1
+ package dev.pictor.video
2
+
3
+ import android.content.Context
4
+ import android.media.MediaCodec
5
+ import android.media.MediaExtractor
6
+ import android.media.MediaFormat
7
+ import java.io.File
8
+ import java.nio.ByteBuffer
9
+ import java.util.zip.CRC32
10
+ import kotlin.math.abs
11
+ import kotlin.math.min
12
+ import kotlin.math.sin
13
+ import org.json.JSONArray
14
+ import org.json.JSONObject
15
+
16
+ /**
17
+ * Freezing what the AAC encoder does, before anything changes it.
18
+ *
19
+ * The encode is 89% of a preview preparation and the loop that drives it is
20
+ * about to be rewritten. A rewrite of a codec loop can go wrong in ways nothing
21
+ * downstream notices — a dropped access unit, a timestamp that restarts, a
22
+ * sample shifted by a frame — so the shape of the output is recorded first, in
23
+ * enough detail that "the same" can be checked rather than assumed.
24
+ *
25
+ * ## Inputs are generated, not read
26
+ *
27
+ * Every fixture is synthesised here from a formula. A file could be replaced,
28
+ * re-encoded or reclaimed between the two halves of the comparison; an
29
+ * arithmetic sequence cannot. That is what makes the baseline immutable.
30
+ *
31
+ * ## What is recorded
32
+ *
33
+ * Both the compressed shape — access-unit count, timestamps, CSD, per-unit
34
+ * payload hashes, bytes — and the decoded result — length, RMS windows, and the
35
+ * positions of transients. Payload equality is the strong claim; if scheduling
36
+ * changes how samples are packaged into access units, the decoded comparison is
37
+ * what still has to hold.
38
+ */
39
+ object EncoderGate {
40
+
41
+ private const val RATE = AudioMixer.SAMPLE_RATE
42
+ private const val CHANNELS = AudioMixer.CHANNELS
43
+
44
+ /** Marker positions, in seconds, for the transient fixture. */
45
+ private val MARKERS_S = doubleArrayOf(0.0, 1.0, 2.5, 4.9)
46
+
47
+ /**
48
+ * The fixtures, by name.
49
+ *
50
+ * Deliberately spanning what the encoder has to survive: a trivial length,
51
+ * the acceptance length, pure silence, content that saturates, and sharp
52
+ * transients whose positions can be found again after a round trip.
53
+ */
54
+ fun pcmFor(name: String): FloatArray = when (name) {
55
+ "short" -> tone(1.0, 440.0, 0.5)
56
+ "acceptance" -> tone(33.0, 220.0, 0.4)
57
+ "silence" -> FloatArray((2.0 * RATE).toInt() * CHANNELS)
58
+ "saturated" -> saturated(2.0)
59
+ "markers" -> markers(5.0)
60
+ else -> FloatArray(0)
61
+ }
62
+
63
+ private fun tone(seconds: Double, hz: Double, amplitude: Double): FloatArray {
64
+ val frames = (seconds * RATE).toInt()
65
+ val out = FloatArray(frames * CHANNELS)
66
+ for (i in 0 until frames) {
67
+ val v = (amplitude * sin(2.0 * Math.PI * hz * i / RATE)).toFloat()
68
+ for (c in 0 until CHANNELS) out[i * CHANNELS + c] = v
69
+ }
70
+ return out
71
+ }
72
+
73
+ /** Two tones summed past full scale, so the saturation clamp is exercised. */
74
+ private fun saturated(seconds: Double): FloatArray {
75
+ val frames = (seconds * RATE).toInt()
76
+ val out = FloatArray(frames * CHANNELS)
77
+ for (i in 0 until frames) {
78
+ val a = 0.8 * sin(2.0 * Math.PI * 440.0 * i / RATE)
79
+ val b = 0.8 * sin(2.0 * Math.PI * 660.0 * i / RATE)
80
+ val v = maxOf(-1.0, min(1.0, a + b)).toFloat()
81
+ for (c in 0 until CHANNELS) out[i * CHANNELS + c] = v
82
+ }
83
+ return out
84
+ }
85
+
86
+ /** Silence with one-millisecond full-scale bursts at known times. */
87
+ private fun markers(seconds: Double): FloatArray {
88
+ val frames = (seconds * RATE).toInt()
89
+ val out = FloatArray(frames * CHANNELS)
90
+ val burst = (RATE / 1000)
91
+ for (at in MARKERS_S) {
92
+ val start = (at * RATE).toInt()
93
+ for (i in start until min(frames, start + burst)) {
94
+ // Alternating full scale: broadband, and unmistakable against
95
+ // silence after a lossy round trip.
96
+ val v = if ((i - start) % 2 == 0) 0.95f else -0.95f
97
+ for (c in 0 until CHANNELS) out[i * CHANNELS + c] = v
98
+ }
99
+ }
100
+ return out
101
+ }
102
+
103
+ /**
104
+ * Write a fixture out as a real audio file, so a mix can use it as a source.
105
+ *
106
+ * The alignment gate needs a source whose content is known to the
107
+ * microsecond. Generating it here rather than shipping one keeps the
108
+ * marker positions a property of the code that checks them.
109
+ */
110
+ fun writeFixture(context: Context, name: String, into: File): JSONObject {
111
+ if (!Diagnostics.ENABLED) return JSONObject(Diagnostics.unavailable())
112
+ val pcm = pcmFor(name)
113
+ if (pcm.isEmpty()) return JSONObject().put("ok", false).put("error", "unknown fixture")
114
+ val encoded = AudioMixer.encodeForGate(pcm, into, 65536)
115
+ if (!encoded.optBoolean("ok", false)) return encoded
116
+ return JSONObject()
117
+ .put("ok", true)
118
+ .put("uri", android.net.Uri.fromFile(into).toString())
119
+ .put("frames", pcm.size / CHANNELS)
120
+ .put("durationUs", (pcm.size / CHANNELS).toLong() * 1_000_000L / RATE)
121
+ .put("markersMs", JSONArray().also { a -> MARKERS_S.forEach { a.put((it * 1000).toInt()) } })
122
+ }
123
+
124
+ /**
125
+ * Write a fixture as uncompressed WAV.
126
+ *
127
+ * The alignment gate used an AAC source and measured +85 ms, which split
128
+ * into two equal halves: the mix's own priming, and the priming of the
129
+ * source the gate had itself encoded. The second half was an artifact of
130
+ * the measurement — so the source stops being compressed. WAV has no
131
+ * priming, no delay and no encoder, which makes the marker positions in the
132
+ * file exactly the marker positions in the formula.
133
+ */
134
+ fun writeWavFixture(context: Context, name: String, into: File): JSONObject {
135
+ if (!Diagnostics.ENABLED) return JSONObject(Diagnostics.unavailable())
136
+ val pcm = pcmFor(name)
137
+ if (pcm.isEmpty()) return JSONObject().put("ok", false).put("error", "unknown fixture")
138
+
139
+ val frames = pcm.size / CHANNELS
140
+ val dataBytes = pcm.size * 2
141
+ val header = java.nio.ByteBuffer.allocate(44).order(java.nio.ByteOrder.LITTLE_ENDIAN)
142
+ header.put("RIFF".toByteArray())
143
+ header.putInt(36 + dataBytes)
144
+ header.put("WAVE".toByteArray())
145
+ header.put("fmt ".toByteArray())
146
+ header.putInt(16)
147
+ header.putShort(1) // PCM
148
+ header.putShort(CHANNELS.toShort())
149
+ header.putInt(RATE)
150
+ header.putInt(RATE * CHANNELS * 2) // byte rate
151
+ header.putShort((CHANNELS * 2).toShort()) // block align
152
+ header.putShort(16) // bits per sample
153
+ header.put("data".toByteArray())
154
+ header.putInt(dataBytes)
155
+
156
+ java.io.FileOutputStream(into).use { file ->
157
+ file.write(header.array())
158
+ val body = java.nio.ByteBuffer.allocate(dataBytes).order(java.nio.ByteOrder.LITTLE_ENDIAN)
159
+ for (v in pcm) body.putShort((v * 32767f).toInt().toShort())
160
+ file.write(body.array())
161
+ file.flush()
162
+ file.fd.sync()
163
+ }
164
+
165
+ return JSONObject()
166
+ .put("ok", true)
167
+ .put("uri", android.net.Uri.fromFile(into).toString())
168
+ .put("frames", frames)
169
+ .put("durationUs", frames.toLong() * 1_000_000L / RATE)
170
+ .put("markersMs", JSONArray().also { a -> MARKERS_S.forEach { a.put((it * 1000).toInt()) } })
171
+ }
172
+
173
+ /**
174
+ * Where the markers ended up in a finished mix, against where they belong.
175
+ *
176
+ * This is the question the raw encoder baseline cannot answer. That baseline
177
+ * showed a constant +42 ms in the encoded file, which is AAC priming and is
178
+ * expected — the export removes it at the mux by dropping whole access
179
+ * units. What matters for preview is whether the file a player is handed is
180
+ * aligned, and the only way to know is to decode what was produced and look.
181
+ */
182
+ fun alignment(context: Context, mixFile: File, expectedMs: JSONArray): JSONObject {
183
+ val decoded = decode(mixFile)
184
+ val found = markerPositions(decoded)
185
+ val offsets = JSONArray()
186
+ var worst = 0
187
+ for (i in 0 until minOf(found.length(), expectedMs.length())) {
188
+ val d = found.getInt(i) - expectedMs.getInt(i)
189
+ offsets.put(d)
190
+ if (abs(d.toDouble()) > abs(worst.toDouble())) worst = d
191
+ }
192
+ /*
193
+ The head, measured rather than thresholded.
194
+
195
+ A transient at timeline zero has no silence in front of it once the
196
+ priming is removed, and AAC's overlapping transform spreads its energy
197
+ across the frames either side — including into the priming units that
198
+ get dropped. So "the onset detector did not fire" and "the sound is
199
+ gone" are different claims, and only the second one matters. This
200
+ reports what is actually in the first few milliseconds.
201
+ */
202
+ val head = JSONObject()
203
+ for (ms in intArrayOf(5, 20, 60)) {
204
+ var peak = 0.0
205
+ val upto = minOf(decoded.size, ms * RATE / 1000 * CHANNELS)
206
+ for (i in 0 until upto) peak = maxOf(peak, abs(decoded[i].toDouble()))
207
+ head.put("peakFirst${ms}ms", Math.round(peak * 1000.0) / 1000.0)
208
+ }
209
+
210
+ return JSONObject()
211
+ .put("ok", true)
212
+ .put("decodedFrames", decoded.size / CHANNELS)
213
+ .put("foundMs", found)
214
+ .put("expectedMs", expectedMs)
215
+ .put("offsetsMs", offsets)
216
+ .put("worstOffsetMs", worst)
217
+ .put("head", head)
218
+ }
219
+
220
+ /** Decode a file to interleaved float PCM. Gate use. */
221
+ fun decodeFile(file: File): FloatArray = decode(file)
222
+
223
+ /**
224
+ * The five places a sample can go missing, measured in one run.
225
+ *
226
+ * Mixing, encoder delay, decoder startup and mux trimming all look the same
227
+ * from the outside — a marker that is not where it should be — so each is
228
+ * measured separately against the same deterministic source.
229
+ */
230
+ fun describeStage(name: String, pcm: FloatArray, offsetMs: Int = 0): JSONObject {
231
+ val frames = pcm.size / CHANNELS
232
+ var peak60 = 0.0
233
+ val upto = minOf(pcm.size, 60 * RATE / 1000 * CHANNELS)
234
+ for (i in 0 until upto) peak60 = maxOf(peak60, abs(pcm[i].toDouble()))
235
+ val found = markerPositions(pcm)
236
+ val shifted = JSONArray()
237
+ for (i in 0 until found.length()) shifted.put(found.getInt(i) + offsetMs)
238
+ return JSONObject()
239
+ .put("stage", name)
240
+ .put("frames", frames)
241
+ .put("durationMs", frames.toLong() * 1000 / RATE)
242
+ .put("peakFirst60ms", Math.round(peak60 * 1000.0) / 1000.0)
243
+ .put("markersMs", shifted)
244
+ .put("rms", Math.round(rms(pcm) * 100000.0) / 100000.0)
245
+ }
246
+
247
+ /** The fixture's own marker positions, for comparison. */
248
+ fun expectedMarkersMs(): JSONArray =
249
+ JSONArray().also { a -> MARKERS_S.forEach { a.put((it * 1000).toInt()) } }
250
+
251
+ /**
252
+ * Encode one fixture and describe the result completely.
253
+ *
254
+ * Gate-only: it writes a temporary file, measures it and removes it.
255
+ */
256
+ fun run(context: Context, name: String, maxInputSize: Int): JSONObject {
257
+ if (!Diagnostics.ENABLED) return JSONObject(Diagnostics.unavailable())
258
+ val pcm = pcmFor(name)
259
+ if (pcm.isEmpty()) {
260
+ return JSONObject().put("ok", false).put("error", "unknown fixture '$name'")
261
+ }
262
+
263
+ val file = File.createTempFile("pictor-encgate-", ".m4a", context.cacheDir)
264
+ return try {
265
+ val at = System.nanoTime()
266
+ val encoded = AudioMixer.encodeForGate(pcm, file, maxInputSize)
267
+ val encodeMs = (System.nanoTime() - at) / 1_000_000
268
+ if (!encoded.optBoolean("ok", false)) {
269
+ JSONObject().put("ok", false).put("error", encoded.optString("error"))
270
+ } else {
271
+ describe(context, file, pcm, name)
272
+ .put("ok", true)
273
+ .put("fixture", name)
274
+ .put("inputFrames", pcm.size / CHANNELS)
275
+ .put("inputSamples", pcm.size)
276
+ .put("encodeMs", encodeMs)
277
+ .put("maxInputSize", maxInputSize)
278
+ .put("fileBytes", file.length())
279
+ }
280
+ } catch (e: Throwable) {
281
+ JSONObject().put("ok", false).put("error", e.javaClass.simpleName + ": " + e.message)
282
+ } finally {
283
+ runCatching { file.delete() }
284
+ }
285
+ }
286
+
287
+ /** The compressed shape, and the decoded result. */
288
+ private fun describe(context: Context, file: File, source: FloatArray, name: String): JSONObject {
289
+ val out = JSONObject()
290
+ val extractor = MediaExtractor()
291
+ extractor.setDataSource(file.absolutePath)
292
+ var track = -1
293
+ for (i in 0 until extractor.trackCount) {
294
+ val mime = extractor.getTrackFormat(i).getString(MediaFormat.KEY_MIME) ?: continue
295
+ if (mime.startsWith("audio/")) { track = i; break }
296
+ }
297
+ if (track < 0) {
298
+ extractor.release()
299
+ return out.put("units", 0).put("error", "no audio track")
300
+ }
301
+
302
+ val format = extractor.getTrackFormat(track)
303
+ out.put("mime", format.getString(MediaFormat.KEY_MIME))
304
+ out.put("trackRate", format.getInteger(MediaFormat.KEY_SAMPLE_RATE))
305
+ out.put("trackChannels", format.getInteger(MediaFormat.KEY_CHANNEL_COUNT))
306
+ if (format.containsKey(MediaFormat.KEY_DURATION)) {
307
+ out.put("trackDurationUs", format.getLong(MediaFormat.KEY_DURATION))
308
+ }
309
+ // The codec-specific data, hashed. A rewrite that changed it would
310
+ // produce a file older readers could not open.
311
+ format.getByteBuffer("csd-0")?.let { out.put("csd0", hashOf(it.duplicate())) }
312
+
313
+ extractor.selectTrack(track)
314
+ val buffer = ByteBuffer.allocate(256 * 1024)
315
+ val timestamps = JSONArray()
316
+ val payloads = CRC32()
317
+ var units = 0
318
+ var bytes = 0L
319
+ var firstUs = -1L
320
+ var lastUs = -1L
321
+ while (true) {
322
+ val size = extractor.readSampleData(buffer, 0)
323
+ if (size < 0) break
324
+ val us = extractor.sampleTime
325
+ if (firstUs < 0) firstUs = us
326
+ lastUs = us
327
+ // Every timestamp for the first few units, then a rolling hash: the
328
+ // head is where a restart shows, and the hash catches the rest.
329
+ if (units < 8) timestamps.put(us)
330
+ val copy = ByteArray(size)
331
+ buffer.position(0)
332
+ buffer.get(copy, 0, size)
333
+ payloads.update(copy)
334
+ units += 1
335
+ bytes += size
336
+ buffer.clear()
337
+ if (!extractor.advance()) break
338
+ }
339
+ extractor.release()
340
+
341
+ out.put("units", units)
342
+ .put("payloadBytes", bytes)
343
+ .put("payloadHash", payloads.value)
344
+ .put("firstUs", firstUs)
345
+ .put("lastUs", lastUs)
346
+ .put("headTimestamps", timestamps)
347
+
348
+ /*
349
+ Decoded back, because payload equality is the strong claim and this is
350
+ the one that still has to hold if scheduling repackages the units.
351
+ */
352
+ val decoded = decode(file)
353
+ out.put("decodedFrames", decoded.size / CHANNELS)
354
+ out.put("decodedRms", rms(decoded))
355
+ out.put("sourceRms", rms(source))
356
+ out.put("peak", peak(decoded))
357
+ if (name == "markers") {
358
+ out.put("markerPositionsMs", markerPositions(decoded))
359
+ out.put("expectedMarkersMs", JSONArray().also { a -> MARKERS_S.forEach { a.put((it * 1000).toInt()) } })
360
+ }
361
+ return out
362
+ }
363
+
364
+ /** Decode the encoded file back to interleaved float PCM. */
365
+ private fun decode(file: File): FloatArray {
366
+ val extractor = MediaExtractor()
367
+ extractor.setDataSource(file.absolutePath)
368
+ var track = -1
369
+ for (i in 0 until extractor.trackCount) {
370
+ val mime = extractor.getTrackFormat(i).getString(MediaFormat.KEY_MIME) ?: continue
371
+ if (mime.startsWith("audio/")) { track = i; break }
372
+ }
373
+ if (track < 0) { extractor.release(); return FloatArray(0) }
374
+ extractor.selectTrack(track)
375
+ val format = extractor.getTrackFormat(track)
376
+ val codec = MediaCodec.createDecoderByType(format.getString(MediaFormat.KEY_MIME)!!)
377
+ codec.configure(format, null, null, 0)
378
+ codec.start()
379
+
380
+ val raw = ArrayList<Float>()
381
+ val info = MediaCodec.BufferInfo()
382
+ var inputDone = false
383
+ var outputDone = false
384
+ var guard = 0
385
+ try {
386
+ while (!outputDone && guard < 500_000) {
387
+ guard += 1
388
+ if (!inputDone) {
389
+ val index = codec.dequeueInputBuffer(5_000)
390
+ if (index >= 0) {
391
+ val buf = codec.getInputBuffer(index)!!
392
+ val size = extractor.readSampleData(buf, 0)
393
+ if (size < 0) {
394
+ codec.queueInputBuffer(index, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
395
+ inputDone = true
396
+ } else {
397
+ codec.queueInputBuffer(index, 0, size, extractor.sampleTime, 0)
398
+ extractor.advance()
399
+ }
400
+ }
401
+ }
402
+ val index = codec.dequeueOutputBuffer(info, 5_000)
403
+ if (index >= 0) {
404
+ if (info.size > 0) {
405
+ val buf = codec.getOutputBuffer(index)!!
406
+ val shorts = buf.order(java.nio.ByteOrder.nativeOrder()).asShortBuffer()
407
+ while (shorts.hasRemaining()) raw.add(shorts.get() / 32768f)
408
+ }
409
+ codec.releaseOutputBuffer(index, false)
410
+ if ((info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) outputDone = true
411
+ }
412
+ }
413
+ } finally {
414
+ runCatching { codec.stop() }
415
+ runCatching { codec.release() }
416
+ runCatching { extractor.release() }
417
+ }
418
+ return FloatArray(raw.size) { raw[it] }
419
+ }
420
+
421
+ private fun rms(pcm: FloatArray): Double {
422
+ if (pcm.isEmpty()) return 0.0
423
+ var sum = 0.0
424
+ for (v in pcm) sum += v.toDouble() * v
425
+ return Math.sqrt(sum / pcm.size)
426
+ }
427
+
428
+ private fun peak(pcm: FloatArray): Double {
429
+ var max = 0.0
430
+ for (v in pcm) { val a = abs(v.toDouble()); if (a > max) max = a }
431
+ return max
432
+ }
433
+
434
+ /**
435
+ * Where the transients ended up, in milliseconds.
436
+ *
437
+ * A lossy round trip smears a one-millisecond burst, so this looks for the
438
+ * onset — the first frame past a threshold after a quiet run — rather than
439
+ * a peak, which can move within the smear.
440
+ */
441
+ private fun markerPositions(pcm: FloatArray): JSONArray {
442
+ val out = JSONArray()
443
+ val frames = pcm.size / CHANNELS
444
+ /*
445
+ Starts "already quiet", so a transient at sample zero is found.
446
+
447
+ It started at zero and required a preceding quiet run, which meant a
448
+ marker at position zero was never reported — and that is precisely the
449
+ case the priming trim creates. The first corrected run looked like a
450
+ lost marker and was a blind detector: before the fix there were 42 ms
451
+ of priming silence in front of it to satisfy the condition.
452
+ */
453
+ var quiet = Int.MAX_VALUE / 2
454
+ var i = 0
455
+ while (i < frames) {
456
+ var level = 0.0
457
+ for (c in 0 until CHANNELS) level = maxOf(level, abs(pcm[i * CHANNELS + c].toDouble()))
458
+ if (level > 0.25) {
459
+ if (quiet > RATE / 50) out.put((i.toLong() * 1000 / RATE).toInt())
460
+ quiet = 0
461
+ } else {
462
+ quiet += 1
463
+ }
464
+ i += 1
465
+ }
466
+ return out
467
+ }
468
+
469
+ private fun hashOf(buffer: ByteBuffer): Long {
470
+ val crc = CRC32()
471
+ val copy = ByteArray(buffer.remaining())
472
+ buffer.get(copy)
473
+ crc.update(copy)
474
+ return crc.value
475
+ }
476
+ }