@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,181 @@
1
+ package dev.pictor.video
2
+
3
+ import java.util.concurrent.atomic.AtomicLong
4
+
5
+ /**
6
+ * Whether a frame is waiting to be latched, as a level rather than an edge.
7
+ *
8
+ * `onFrameAvailable` is an edge: it fires once, and whoever was not listening at
9
+ * that instant never learns it happened. The consumer stops when the app
10
+ * backgrounds for the system picker, so every frame that arrives in that window
11
+ * is announced to nobody — and on return the consumer waits for the *next*
12
+ * edge. A paused player produces no next edge, so the preview stays black
13
+ * forever with no error anywhere and the producer's counters all healthy.
14
+ *
15
+ * Two monotonic counters instead. Availability is a level — `available >
16
+ * consumed` — which is true whenever there is something to take, however long
17
+ * ago it arrived and whoever was or was not listening at the time.
18
+ *
19
+ * ## Coalescing is the point, not a side effect
20
+ *
21
+ * A `SurfaceTexture` holds one buffer: `updateTexImage` latches the *newest*
22
+ * frame and drops whatever it skipped. So a successful latch sets `consumed` to
23
+ * whatever `available` had reached, which is the honest accounting — thirty
24
+ * frames arrived while backgrounded and exactly one of them can ever be shown.
25
+ * Incrementing `consumed` by one per latch would leave the difference positive
26
+ * forever and the consumer spinning on frames that no longer exist.
27
+ *
28
+ * ## A latch accounts for what it saw, never for what arrived meanwhile
29
+ *
30
+ * `updateTexImage` takes the newest buffer *as of the call*, so a latch may
31
+ * legitimately account for many announcements at once. It may not account for
32
+ * one that arrived while it was running: that frame was not taken, and setting
33
+ * `consumed` to the count as it stands afterwards would erase its only
34
+ * announcement — the edge has already been delivered and no second one is
35
+ * coming for the same buffer.
36
+ *
37
+ * So the mark is read *before* the acquisition and consumed *up to* that mark.
38
+ * Measured on the device, the erasing form left `announced` one ahead of every
39
+ * successful latch with nothing pending: a frame delivered, dropped, and no
40
+ * longer recorded as either.
41
+ *
42
+ * ## A failed latch restores nothing, because it took nothing
43
+ *
44
+ * `consumed` only moves on success. `updateTexImage` returns quietly when the
45
+ * GL context is not current, so a failure means the buffer is still there —
46
+ * leaving the counter alone *is* the restore, and there is no window in which a
47
+ * frame is neither pending nor latched.
48
+ *
49
+ * ## Generations
50
+ *
51
+ * Availability is tagged with the surface generation it arrived for. A frame
52
+ * pending against a retired surface is not a frame: the texture it belongs to is
53
+ * gone, and latching it would pull from a texture nobody is drawing. Retirement
54
+ * is the **only** thing that discards a pending frame; disarming the consumer
55
+ * must not, which is the whole reason this type exists.
56
+ *
57
+ * Free of Android types so the orderings are testable on the JVM without a
58
+ * device, a codec or a surface.
59
+ */
60
+ internal class FramePending {
61
+
62
+ /** Frames announced, ever. Only increases within a generation. */
63
+ private val available = AtomicLong(0)
64
+
65
+ /** Frames accounted for by a successful latch. */
66
+ private val consumed = AtomicLong(0)
67
+
68
+ /** The generation the counts above belong to. */
69
+ @Volatile
70
+ var generation: Int = SurfaceAttachment.NONE
71
+ private set
72
+
73
+ /** Availability signals that arrived for a generation already retired. */
74
+ @Volatile
75
+ var rejected: Long = 0
76
+ private set
77
+
78
+ /** Total announcements, for the overlay. Survives generation changes. */
79
+ @Volatile
80
+ var announced: Long = 0
81
+ private set
82
+
83
+ /** Begin counting for a live surface. Discards anything the old one had. */
84
+ fun adopt(generation: Int) {
85
+ if (this.generation == generation) return
86
+ this.generation = generation
87
+ available.set(0)
88
+ consumed.set(0)
89
+ }
90
+
91
+ /**
92
+ * A frame arrived.
93
+ *
94
+ * Takes the generation it arrived for: a listener detached late can fire
95
+ * after its surface is gone, and that frame belongs to a texture that no
96
+ * longer exists.
97
+ */
98
+ fun mark(generation: Int) {
99
+ if (generation != this.generation || generation == SurfaceAttachment.NONE) {
100
+ rejected += 1
101
+ return
102
+ }
103
+ announced += 1
104
+ available.incrementAndGet()
105
+ }
106
+
107
+ /** Whether a frame is waiting for [generation] right now. */
108
+ fun pending(generation: Int): Boolean =
109
+ generation == this.generation &&
110
+ generation != SurfaceAttachment.NONE &&
111
+ available.get() > consumed.get()
112
+
113
+ /**
114
+ * Pending-ness, depth and generation as **one** reading.
115
+ *
116
+ * Three separate calls are three separate instants. A consumer that asked
117
+ * "is anything pending" (no), then "how deep is it" (one, because a frame
118
+ * arrived in between), concluded the two halves disagreed — when in fact
119
+ * both answers were true when given. That is a sampling artefact, and
120
+ * suppressing the counter would have hidden a check that has real work to
121
+ * do.
122
+ *
123
+ * Packed rather than returned as an object so it crosses JNI as one value
124
+ * and cannot be torn: depth in the high bits, generation in the middle, and
125
+ * pending in the low bit.
126
+ */
127
+ fun snapshot(generation: Int): Long {
128
+ val available = this.available.get()
129
+ val consumed = this.consumed.get()
130
+ val current = this.generation
131
+ val matches = generation == current && generation != SurfaceAttachment.NONE
132
+ val depth = (available - consumed).coerceAtLeast(0).coerceAtMost(0xffff)
133
+ val pending = if (matches && available > consumed) 1L else 0L
134
+ return (depth shl 32) or ((current.toLong() and 0xffff) shl 1) or pending
135
+ }
136
+
137
+ /**
138
+ * The announcement count right now, for a latch that is about to begin.
139
+ *
140
+ * [SurfaceAttachment.NONE] generations and stale ones get -1, which
141
+ * [consumeUpTo] refuses — so a mark taken against a surface that is retired
142
+ * mid-latch cannot be applied to its replacement's counters.
143
+ */
144
+ fun observe(generation: Int): Long =
145
+ if (generation != this.generation || generation == SurfaceAttachment.NONE) -1
146
+ else available.get()
147
+
148
+ /**
149
+ * A latch succeeded: everything announced *as of [mark]* is accounted for.
150
+ *
151
+ * Coalesced to the newest the latch could have seen, deliberately — see the
152
+ * note above. Never past [mark], which is what keeps a frame announced
153
+ * during the acquisition pending for the next tick instead of vanishing.
154
+ */
155
+ fun consumeUpTo(generation: Int, mark: Long) {
156
+ if (generation != this.generation || mark < 0) return
157
+ while (true) {
158
+ val current = consumed.get()
159
+ // Monotonic: a late mark from an overlapping latch must not move
160
+ // `consumed` backwards and re-announce frames already shown.
161
+ if (current >= mark) return
162
+ if (consumed.compareAndSet(current, mark)) return
163
+ }
164
+ }
165
+
166
+ /**
167
+ * The surface is gone.
168
+ *
169
+ * The only thing that may discard a pending frame, because it is the only
170
+ * thing that makes one meaningless.
171
+ */
172
+ fun retire(generation: Int) {
173
+ if (generation != this.generation) return
174
+ this.generation = SurfaceAttachment.NONE
175
+ available.set(0)
176
+ consumed.set(0)
177
+ }
178
+
179
+ /** How many frames are outstanding. Diagnostics; usually 0 or 1. */
180
+ fun depth(): Long = (available.get() - consumed.get()).coerceAtLeast(0)
181
+ }
@@ -0,0 +1,456 @@
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 android.net.Uri
8
+ import java.nio.ByteBuffer
9
+ import kotlin.math.abs
10
+ import kotlin.math.cos
11
+ import kotlin.math.ln
12
+ import kotlin.math.sin
13
+ import kotlin.math.sqrt
14
+ import org.json.JSONArray
15
+ import org.json.JSONObject
16
+
17
+ /**
18
+ * Decoded audio, measured — the gate's ear.
19
+ *
20
+ * A speed change that shifts pitch and one that preserves it produce files whose
21
+ * duration, frame count and sample count are identical. Only the spectrum tells
22
+ * them apart, so the gate has to listen rather than count.
23
+ *
24
+ * Reports numbers and draws no conclusion. What counts as "near enough to 440"
25
+ * or "quiet enough to be silence" is the caller's predeclared business.
26
+ */
27
+ internal object GateAudio {
28
+
29
+ /**
30
+ * A dominant-frequency trajectory and harmonic band energies, per window.
31
+ *
32
+ * A single window over a whole section answers "what note is this" and
33
+ * nothing else: a steady 434 Hz and a 434 Hz wobbling by fifteen either way
34
+ * both report 434. Modulation only exists between windows, so this reports
35
+ * the sequence and lets the caller decide what its spread means.
36
+ *
37
+ * The peak is refined by parabolic interpolation, because the raw bin
38
+ * spacing at a window short enough to see modulation is wider than the
39
+ * modulation being looked for -- 11.7 Hz bins cannot resolve a few Hz of
40
+ * wander, and a trajectory quantised to the bin grid would report a flat
41
+ * line for any wobble smaller than one bin.
42
+ *
43
+ * Reports numbers only. Nothing here decides what is natural.
44
+ */
45
+ fun trajectory(context: Context, uri: String, specJson: String): String {
46
+ val out = JSONObject()
47
+ val extractor = MediaExtractor()
48
+ var codec: MediaCodec? = null
49
+ try {
50
+ if (uri.startsWith("/")) extractor.setDataSource(uri)
51
+ else extractor.setDataSource(context, Uri.parse(uri), null)
52
+ var track = -1
53
+ var format: MediaFormat? = null
54
+ for (i in 0 until extractor.trackCount) {
55
+ val f = extractor.getTrackFormat(i)
56
+ if ((f.getString(MediaFormat.KEY_MIME) ?: "").startsWith("audio/")) {
57
+ track = i; format = f; break
58
+ }
59
+ }
60
+ if (track < 0 || format == null) {
61
+ return out.put("ok", false).put("error", "no audio track").toString()
62
+ }
63
+ extractor.selectTrack(track)
64
+ val mime = format.getString(MediaFormat.KEY_MIME)!!
65
+ val rate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
66
+ val channels = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
67
+ codec = MediaCodec.createDecoderByType(mime)
68
+ codec.configure(format, null, null, 0)
69
+ codec.start()
70
+ val pcm = ArrayList<Float>(rate * 12)
71
+ val info = MediaCodec.BufferInfo()
72
+ var inputDone = false
73
+ var outputDone = false
74
+ var guard = 0
75
+ while (!outputDone && guard < 100_000) {
76
+ guard += 1
77
+ if (!inputDone) {
78
+ val index = codec.dequeueInputBuffer(10_000)
79
+ if (index >= 0) {
80
+ val buffer = codec.getInputBuffer(index)!!
81
+ val size = extractor.readSampleData(buffer, 0)
82
+ if (size < 0) {
83
+ codec.queueInputBuffer(index, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
84
+ inputDone = true
85
+ } else {
86
+ codec.queueInputBuffer(index, 0, size, extractor.sampleTime, 0)
87
+ extractor.advance()
88
+ }
89
+ }
90
+ }
91
+ val index = codec.dequeueOutputBuffer(info, 10_000)
92
+ if (index >= 0) {
93
+ if (info.size > 0) {
94
+ val buffer = codec.getOutputBuffer(index)!!
95
+ buffer.position(info.offset)
96
+ buffer.limit(info.offset + info.size)
97
+ val shorts = buffer.asShortBuffer()
98
+ var i = 0
99
+ while (i + channels <= shorts.limit()) {
100
+ var sum = 0f
101
+ for (c in 0 until channels) sum += shorts.get(i + c) / 32768f
102
+ pcm.add(sum / channels)
103
+ i += channels
104
+ }
105
+ }
106
+ codec.releaseOutputBuffer(index, false)
107
+ if ((info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) outputDone = true
108
+ }
109
+ }
110
+
111
+ val spec = JSONObject(specJson)
112
+ val n = spec.optInt("windowFrames", 4096)
113
+ val hop = spec.optInt("hopFrames", 1024)
114
+ val loHz = spec.optDouble("searchLoHz", 300.0)
115
+ val hiHz = spec.optDouble("searchHiHz", 600.0)
116
+ val bandHz = spec.optDouble("bandHz", 40.0)
117
+ val harmonics = spec.optJSONArray("harmonicsHz") ?: JSONArray()
118
+ val exclude = spec.optJSONArray("excludeUs") ?: JSONArray()
119
+ val from = ((spec.getLong("startUs") * rate) / 1_000_000L).toInt().coerceAtLeast(0)
120
+ val to = ((spec.getLong("endUs") * rate) / 1_000_000L).toInt().coerceAtMost(pcm.size)
121
+
122
+ val f0s = JSONArray()
123
+ val times = JSONArray()
124
+ val harmDb = Array(harmonics.length()) { ArrayList<Double>() }
125
+ var skipped = 0
126
+ var start = from
127
+ while (start + n <= to) {
128
+ val centreUs = (start + n / 2).toLong() * 1_000_000L / rate
129
+ var excluded = false
130
+ for (e in 0 until exclude.length()) {
131
+ val pair = exclude.getJSONArray(e)
132
+ if (centreUs >= pair.getLong(0) && centreUs <= pair.getLong(1)) { excluded = true; break }
133
+ }
134
+ if (excluded) { skipped += 1; start += hop; continue }
135
+
136
+ val w = DoubleArray(n)
137
+ for (i in 0 until n) {
138
+ val hann = 0.5 - 0.5 * cos(2.0 * Math.PI * i / (n - 1))
139
+ w[i] = pcm[start + i].toDouble() * hann
140
+ }
141
+ fun power(k: Int): Double {
142
+ var re = 0.0; var im = 0.0
143
+ val step = 2.0 * Math.PI * k / n
144
+ for (i in 0 until n) { re += w[i] * cos(step * i); im += w[i] * sin(step * i) }
145
+ return re * re + im * im
146
+ }
147
+ val kLo = (loHz * n / rate).toInt().coerceAtLeast(1)
148
+ val kHi = (hiHz * n / rate).toInt().coerceAtMost(n / 2 - 2)
149
+ var bestK = kLo; var bestP = -1.0
150
+ for (k in kLo..kHi) { val p = power(k); if (p > bestP) { bestP = p; bestK = k } }
151
+ // Parabolic on the log spectrum: sub-bin, which is the only way a
152
+ // window short enough to show modulation can measure it.
153
+ val pm = ln(power(bestK - 1) + 1e-30)
154
+ val p0 = ln(bestP + 1e-30)
155
+ val pp = ln(power(bestK + 1) + 1e-30)
156
+ val denom = pm - 2 * p0 + pp
157
+ val delta = if (denom != 0.0) 0.5 * (pm - pp) / denom else 0.0
158
+ val f0 = (bestK + delta.coerceIn(-1.0, 1.0)) * rate / n
159
+ f0s.put(Math.round(f0 * 100.0) / 100.0)
160
+ times.put(centreUs)
161
+
162
+ fun bandPower(centre: Double): Double {
163
+ val a = ((centre - bandHz) * n / rate).toInt().coerceAtLeast(1)
164
+ val b = ((centre + bandHz) * n / rate).toInt().coerceAtMost(n / 2 - 1)
165
+ var sum = 0.0
166
+ for (k in a..b) sum += power(k)
167
+ return sum
168
+ }
169
+ val fund = bandPower(f0)
170
+ for (h in 0 until harmonics.length()) {
171
+ val hz = harmonics.getDouble(h)
172
+ val p = bandPower(hz)
173
+ harmDb[h].add(10.0 * kotlin.math.log10((p + 1e-30) / (fund + 1e-30)))
174
+ }
175
+ start += hop
176
+ }
177
+
178
+ fun stats(v: List<Double>): JSONObject {
179
+ if (v.isEmpty()) return JSONObject().put("count", 0)
180
+ val sorted = v.sorted()
181
+ val median = sorted[sorted.size / 2]
182
+ val mean = v.sum() / v.size
183
+ var s2 = 0.0
184
+ for (x in v) s2 += (x - mean) * (x - mean)
185
+ val sd = sqrt(s2 / v.size)
186
+ var outliers = 0
187
+ for (x in v) if (sd > 0 && abs(x - median) > 3 * sd) outliers += 1
188
+ return JSONObject()
189
+ .put("count", v.size)
190
+ .put("median", Math.round(median * 100.0) / 100.0)
191
+ .put("sd", Math.round(sd * 1000.0) / 1000.0)
192
+ .put("peakToPeak", Math.round((sorted.last() - sorted.first()) * 100.0) / 100.0)
193
+ .put("outliers", outliers)
194
+ }
195
+ val f0List = ArrayList<Double>()
196
+ for (i in 0 until f0s.length()) f0List.add(f0s.getDouble(i))
197
+ val harmonicStats = JSONArray()
198
+ for (h in 0 until harmonics.length()) {
199
+ harmonicStats.put(
200
+ JSONObject().put("hz", harmonics.getDouble(h)).put("db", stats(harmDb[h])),
201
+ )
202
+ }
203
+ out.put("ok", true).put("sampleRate", rate).put("windows", f0s.length())
204
+ .put("skipped", skipped).put("f0", stats(f0List))
205
+ .put("harmonics", harmonicStats)
206
+ } catch (e: Exception) {
207
+ out.put("ok", false).put("error", e.javaClass.simpleName + ": " + e.message)
208
+ } finally {
209
+ runCatching { codec?.stop() }
210
+ runCatching { codec?.release() }
211
+ runCatching { extractor.release() }
212
+ }
213
+ return out.toString()
214
+ }
215
+
216
+ /** Bins are 44100/4096 ≈ 10.8 Hz apart, which resolves 440 from 220 and 880. */
217
+ private const val WINDOW = 4096
218
+
219
+ /**
220
+ * Per-window loudness and spectrum, plus the timing metadata a seam check
221
+ * needs: AAC carries encoder delay and trailing padding, and its first
222
+ * sample often has a negative timestamp. Aligning a click to a video frame
223
+ * without those is off by a couple of thousand samples for reasons that look
224
+ * like a sync bug.
225
+ */
226
+ fun analyse(context: Context, uri: String, windowsJson: String): String {
227
+ val out = JSONObject()
228
+ val extractor = MediaExtractor()
229
+ var codec: MediaCodec? = null
230
+ try {
231
+ if (uri.startsWith("/")) extractor.setDataSource(uri)
232
+ else extractor.setDataSource(context, Uri.parse(uri), null)
233
+
234
+ var track = -1
235
+ var format: MediaFormat? = null
236
+ for (i in 0 until extractor.trackCount) {
237
+ val f = extractor.getTrackFormat(i)
238
+ if ((f.getString(MediaFormat.KEY_MIME) ?: "").startsWith("audio/")) {
239
+ track = i; format = f; break
240
+ }
241
+ }
242
+ if (track < 0 || format == null) {
243
+ return out.put("ok", false).put("error", "no audio track").toString()
244
+ }
245
+ extractor.selectTrack(track)
246
+
247
+ val mime = format.getString(MediaFormat.KEY_MIME)!!
248
+ val sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
249
+ val channels = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
250
+ out.put("sampleRate", sampleRate).put("channels", channels).put("mime", mime)
251
+ out.put(
252
+ "encoderDelay",
253
+ if (format.containsKey("encoder-delay")) format.getInteger("encoder-delay") else -1,
254
+ )
255
+ out.put(
256
+ "encoderPadding",
257
+ if (format.containsKey("encoder-padding")) format.getInteger("encoder-padding") else -1,
258
+ )
259
+ out.put(
260
+ "formatDurationUs",
261
+ if (format.containsKey(MediaFormat.KEY_DURATION)) format.getLong(MediaFormat.KEY_DURATION) else -1L,
262
+ )
263
+
264
+ codec = MediaCodec.createDecoderByType(mime)
265
+ codec.configure(format, null, null, 0)
266
+ codec.start()
267
+
268
+ // Mono mixdown: the fixtures are mono and a stereo file's channels
269
+ // would otherwise interleave into the spectrum as noise.
270
+ val pcm = ArrayList<Float>(sampleRate * 12)
271
+ var firstPtsUs = Long.MIN_VALUE
272
+ val info = MediaCodec.BufferInfo()
273
+ var sawInputEos = false
274
+ var sawOutputEos = false
275
+ var guard = 0
276
+ while (!sawOutputEos && guard < 100_000) {
277
+ guard += 1
278
+ if (!sawInputEos) {
279
+ val index = codec.dequeueInputBuffer(10_000)
280
+ if (index >= 0) {
281
+ val buffer = codec.getInputBuffer(index)!!
282
+ val size = extractor.readSampleData(buffer, 0)
283
+ if (size < 0) {
284
+ codec.queueInputBuffer(index, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
285
+ sawInputEos = true
286
+ } else {
287
+ codec.queueInputBuffer(index, 0, size, extractor.sampleTime, 0)
288
+ extractor.advance()
289
+ }
290
+ }
291
+ }
292
+ val index = codec.dequeueOutputBuffer(info, 10_000)
293
+ if (index >= 0) {
294
+ if (info.size > 0) {
295
+ if (firstPtsUs == Long.MIN_VALUE) firstPtsUs = info.presentationTimeUs
296
+ val buffer: ByteBuffer = codec.getOutputBuffer(index)!!
297
+ buffer.position(info.offset)
298
+ buffer.limit(info.offset + info.size)
299
+ val shorts = buffer.asShortBuffer()
300
+ var i = 0
301
+ while (i + channels <= shorts.limit()) {
302
+ var sum = 0f
303
+ for (c in 0 until channels) sum += shorts.get(i + c) / 32768f
304
+ pcm.add(sum / channels)
305
+ i += channels
306
+ }
307
+ }
308
+ codec.releaseOutputBuffer(index, false)
309
+ if ((info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) sawOutputEos = true
310
+ }
311
+ }
312
+
313
+ out.put("firstPtsUs", if (firstPtsUs == Long.MIN_VALUE) 0 else firstPtsUs)
314
+ out.put("decodedSamples", pcm.size)
315
+ out.put("decodedDurationUs", pcm.size.toLong() * 1_000_000L / sampleRate)
316
+
317
+ val windows = JSONArray(windowsJson)
318
+ val results = JSONArray()
319
+ for (w in 0 until windows.length()) {
320
+ val spec = windows.getJSONObject(w)
321
+ val startUs = spec.getLong("startUs")
322
+ val endUs = spec.getLong("endUs")
323
+ val from = ((startUs * sampleRate) / 1_000_000L).toInt().coerceAtLeast(0)
324
+ val to = ((endUs * sampleRate) / 1_000_000L).toInt().coerceAtMost(pcm.size)
325
+ val entry = JSONObject().put("startUs", startUs).put("endUs", endUs)
326
+ if (to - from < 16) {
327
+ results.put(entry.put("error", "window has ${to - from} samples"))
328
+ continue
329
+ }
330
+ var peak = 0f
331
+ var energy = 0.0
332
+ for (i in from until to) {
333
+ val v = pcm[i]
334
+ if (abs(v) > peak) peak = abs(v)
335
+ energy += v.toDouble() * v.toDouble()
336
+ }
337
+ entry.put("samples", to - from)
338
+ entry.put("rms", sqrt(energy / (to - from)))
339
+ entry.put("peak", peak.toDouble())
340
+
341
+ /*
342
+ The largest sample-to-sample step in the window.
343
+
344
+ A seam click is a discontinuity, and a discontinuity is not
345
+ visible in rms or in a spectrum peak -- a window containing a
346
+ hard splice and one containing a clean tone can have the same
347
+ loudness and the same dominant frequency. A tone of amplitude a
348
+ at frequency f steps by at most 2*pi*f*a/rate between samples,
349
+ so a step far above that is a splice rather than a signal.
350
+ */
351
+ var maxStep = 0f
352
+ for (i in from + 1 until to) {
353
+ val step = abs(pcm[i] - pcm[i - 1])
354
+ if (step > maxStep) maxStep = step
355
+ }
356
+ entry.put("maxStep", maxStep.toDouble())
357
+ if (spec.optBoolean("stepAt")) {
358
+ // Where the largest step is, in microseconds: one splice and
359
+ // a rough passage look the same in a maximum alone.
360
+ var at = from
361
+ var best = 0f
362
+ for (i in from + 1 until to) {
363
+ val step = abs(pcm[i] - pcm[i - 1])
364
+ if (step > best) { best = step; at = i }
365
+ }
366
+ entry.put("stepAtUs", at.toLong() * 1_000_000L / sampleRate)
367
+ }
368
+
369
+ /*
370
+ Energy in a narrow band, when the caller names one.
371
+
372
+ Alignment is measured by asking which frame carries the marker
373
+ burst, and "which frame is loudest at 2 kHz" is a different
374
+ question from "what is the dominant frequency" -- under a music
375
+ bed the burst can be present and clearly located without ever
376
+ being dominant.
377
+ */
378
+ if (spec.has("bandHz")) {
379
+ val target = spec.getDouble("bandHz")
380
+ val n2 = minOf(WINDOW, to - from)
381
+ var band = 0.0
382
+ if (n2 >= 128) {
383
+ val half = (100.0 * n2 / sampleRate).toInt().coerceAtLeast(1)
384
+ val centre = (target * n2 / sampleRate).toInt()
385
+ for (k in (centre - half).coerceAtLeast(1)..(centre + half).coerceAtMost(n2 / 2 - 1)) {
386
+ var re = 0.0
387
+ var im = 0.0
388
+ val step = 2.0 * Math.PI * k / n2
389
+ for (i in 0 until n2) {
390
+ val hann = 0.5 - 0.5 * cos(2.0 * Math.PI * i / (n2 - 1))
391
+ re += pcm[from + i] * hann * cos(step * i)
392
+ im += pcm[from + i] * hann * sin(step * i)
393
+ }
394
+ band += re * re + im * im
395
+ }
396
+ }
397
+ entry.put("bandEnergy", band)
398
+ }
399
+
400
+ val n = minOf(WINDOW, to - from)
401
+ if (n >= 256) {
402
+ // Hann, so a tone that does not fit a whole number of cycles
403
+ // does not smear across every bin and drown its own peak.
404
+ val windowed = DoubleArray(n)
405
+ for (i in 0 until n) {
406
+ val hann = 0.5 - 0.5 * cos(2.0 * Math.PI * i / (n - 1))
407
+ windowed[i] = pcm[from + i].toDouble() * hann
408
+ }
409
+ var bestMag = 0.0
410
+ var bestBin = 0
411
+ val minBin = (50.0 * n / sampleRate).toInt().coerceAtLeast(1)
412
+ val maxBin = (5000.0 * n / sampleRate).toInt().coerceAtMost(n / 2 - 1)
413
+ val mags = DoubleArray(maxBin + 1)
414
+ for (k in minBin..maxBin) {
415
+ var re = 0.0
416
+ var im = 0.0
417
+ val step = 2.0 * Math.PI * k / n
418
+ for (i in 0 until n) {
419
+ re += windowed[i] * cos(step * i)
420
+ im += windowed[i] * sin(step * i)
421
+ }
422
+ val mag = re * re + im * im
423
+ mags[k] = mag
424
+ if (mag > bestMag) { bestMag = mag; bestBin = k }
425
+ }
426
+ var total = 0.0
427
+ for (k in minBin..maxBin) total += mags[k]
428
+ var nearPeak = 0.0
429
+ for (k in (bestBin - 2).coerceAtLeast(minBin)..(bestBin + 2).coerceAtMost(maxBin)) {
430
+ nearPeak += mags[k]
431
+ }
432
+ entry.put("dominantHz", bestBin.toDouble() * sampleRate / n)
433
+ entry.put("binHz", sampleRate.toDouble() / n)
434
+ /*
435
+ How much of the energy is the tone itself.
436
+
437
+ Reported beside the pitch, never folded into it: a
438
+ time-stretch can hold the fundamental exactly and still
439
+ sound wrong, so "the frequency is right" is not a claim
440
+ about quality.
441
+ */
442
+ entry.put("peakEnergyFraction", if (total > 0) nearPeak / total else 0.0)
443
+ }
444
+ results.put(entry)
445
+ }
446
+ out.put("windows", results).put("ok", true)
447
+ } catch (e: Exception) {
448
+ out.put("ok", false).put("error", e.javaClass.simpleName + ": " + e.message)
449
+ } finally {
450
+ runCatching { codec?.stop() }
451
+ runCatching { codec?.release() }
452
+ runCatching { extractor.release() }
453
+ }
454
+ return out.toString()
455
+ }
456
+ }