@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,1112 @@
1
+ package dev.pictor.video
2
+
3
+ import android.content.Context
4
+ import android.media.MediaCodec
5
+ import android.media.MediaCodecInfo
6
+ import android.media.MediaExtractor
7
+ import android.media.MediaFormat
8
+ import android.media.MediaMuxer
9
+ import android.net.Uri
10
+ import androidx.media3.common.audio.AudioProcessor
11
+ import androidx.media3.common.audio.SonicAudioProcessor
12
+ import java.io.File
13
+ import java.nio.ByteBuffer
14
+ import java.nio.ByteOrder
15
+ import kotlin.math.max
16
+ import kotlin.math.min
17
+ import org.json.JSONObject
18
+
19
+ /**
20
+ * The whole timeline's audio, mixed once and encoded once.
21
+ *
22
+ * Driven **exclusively by `AudioMixProgram`**. There is no `AudioItem` here, no
23
+ * `kind`, no duration policy and no notion of what a muted clip is: the plan
24
+ * carries source windows, sample-accurate placements and gain envelopes, and
25
+ * this executes them. Anything else would be a second interpreter of the
26
+ * project.
27
+ *
28
+ * ## One output format, chosen rather than inherited
29
+ *
30
+ * **48 000 Hz, 2 channels, 32-bit float** while mixing. Sources arrive at
31
+ * whatever rate and channel count they were recorded at; every one is brought
32
+ * to this before it is summed, so the mix never has to ask what format it is
33
+ * working in. Float because summation must be able to exceed full scale before
34
+ * it is clipped — see below.
35
+ *
36
+ * ## Time is integer arithmetic, converted once
37
+ *
38
+ * [sampleAt] is the only conversion between timeline microseconds and sample
39
+ * indices, and it divides rather than accumulates. Advancing a running position
40
+ * by a rounded microsecond count per buffer loses a fraction of a sample each
41
+ * time, and over a few minutes that is audible drift — which is exactly the bug
42
+ * that never reproduces on a short test.
43
+ *
44
+ * Every plan boundary becomes a half-open sample range **once**, before mixing,
45
+ * and gain is evaluated from the absolute sample position. A buffer boundary
46
+ * therefore cannot change the envelope: the same mix computed with any buffer
47
+ * size is the same PCM.
48
+ *
49
+ * ## Sum, then saturate, once
50
+ *
51
+ * Contributions are added at full float precision and clipped to `[-1, 1]`
52
+ * **after all of them**. Saturating each contribution as it arrives would clip
53
+ * sounds that the sum never made loud, and would make the result depend on the
54
+ * order they were mixed in.
55
+ */
56
+ internal object AudioMixer {
57
+
58
+ /**
59
+ * Checked in every loop that can run for seconds.
60
+ *
61
+ * The mix was the last stage with no way to observe a cancellation.
62
+ * Measured: cancelling 1.5 s into a three-second export settled in 1610 ms,
63
+ * because the decode, the Sonic stretch and the AAC encode all ran to
64
+ * completion before the next checkpoint could see the request. At 0.1x that
65
+ * stage alone is most of the export.
66
+ */
67
+ @Volatile var keepGoing: () -> Boolean = { true }
68
+
69
+ /**
70
+ * One mix at a time, process-wide.
71
+ *
72
+ * [keepGoing] is a single mutable predicate on a singleton, which was safe
73
+ * while the exporter was the only caller: it set the flag, mixed, and put
74
+ * it back. A second caller — preview — makes that a race with two ways to
75
+ * lose. A preview starting during an export would replace the export's
76
+ * cancel predicate with its own, leaving the export uninterruptible; and
77
+ * restoring `{ true }` afterwards would silently un-cancel it.
78
+ *
79
+ * Serialising is the honest fix at this size. Mixing is CPU-bound work that
80
+ * saturates a core anyway, so two at once buys nothing, and a preview job
81
+ * is cancelled before it ever reaches this lock when it is superseded.
82
+ */
83
+ private val mixLock = Any()
84
+
85
+ /**
86
+ * Where a preparation's time went.
87
+ *
88
+ * One object rather than a parameter threaded through eight signatures,
89
+ * which is safe precisely because mixing is serialised under [mixLock] —
90
+ * there is never a second mix to confuse it.
91
+ *
92
+ * It exists because a 22-second preparation is a product problem and the
93
+ * first thing anyone will want to do about it is guess. Attribution first:
94
+ * a stage that is 3% of the total is not worth caching however slow it
95
+ * feels.
96
+ */
97
+ class Profile {
98
+ private val ns = LinkedHashMap<String, Long>()
99
+ private val counts = LinkedHashMap<String, Long>()
100
+ private val perSource = LinkedHashMap<String, JSONObject>()
101
+
102
+ fun <T> time(stage: String, block: () -> T): T {
103
+ val at = System.nanoTime()
104
+ try {
105
+ return block()
106
+ } finally {
107
+ ns[stage] = (ns[stage] ?: 0L) + (System.nanoTime() - at)
108
+ }
109
+ }
110
+
111
+ fun count(key: String, value: Long) {
112
+ counts[key] = (counts[key] ?: 0L) + value
113
+ }
114
+
115
+ fun source(id: String, detail: JSONObject) {
116
+ perSource[id] = detail
117
+ }
118
+
119
+ fun reset() {
120
+ ns.clear()
121
+ counts.clear()
122
+ perSource.clear()
123
+ }
124
+
125
+ fun json(): JSONObject {
126
+ val stages = JSONObject()
127
+ for ((k, v) in ns) stages.put(k, v / 1_000_000)
128
+ val totals = JSONObject()
129
+ for ((k, v) in counts) totals.put(k, v)
130
+ val sources = JSONObject()
131
+ for ((k, v) in perSource) sources.put(k, v)
132
+ return JSONObject()
133
+ .put("stageMs", stages)
134
+ .put("counts", totals)
135
+ .put("sources", sources)
136
+ }
137
+ }
138
+
139
+ /** The profile of the mix currently running. */
140
+ @Volatile var profile = Profile()
141
+ private set
142
+
143
+ /** Raised to unwind the loops without inventing a failure. */
144
+ internal class Cancelled : RuntimeException("cancelled")
145
+
146
+ private fun checkCancelled() {
147
+ if (!keepGoing()) throw Cancelled()
148
+ }
149
+
150
+ const val SAMPLE_RATE = 48_000
151
+ const val CHANNELS = 2
152
+
153
+ /**
154
+ * Timeline microseconds to an output sample index.
155
+ *
156
+ * Integer division from the absolute time, never an accumulated step.
157
+ */
158
+ fun sampleAt(timeUs: Long): Long = Math.floorDiv(timeUs * SAMPLE_RATE.toLong(), 1_000_000L)
159
+
160
+ /** The inverse, for evaluating an envelope at a known sample. */
161
+ fun timeAt(sample: Long): Long = Math.floorDiv(sample * 1_000_000L, SAMPLE_RATE.toLong())
162
+
163
+ private class Ramp(val fromUs: Long, val toUs: Long, val fromGain: Double, val toGain: Double)
164
+
165
+ private class Span(
166
+ val id: String,
167
+ val uri: String?,
168
+ val startSample: Long,
169
+ val endSample: Long,
170
+ val sourceInUs: Long,
171
+ val sourceOutUs: Long,
172
+ val speed: Float,
173
+ val gain: Double,
174
+ val ramps: List<Ramp>,
175
+ val silent: Boolean,
176
+ )
177
+
178
+ /**
179
+ * Mix and encode.
180
+ *
181
+ * `urisJson` maps span id to a host-resolved URI; a span whose id is absent
182
+ * and which is not silent fails the mix rather than being skipped.
183
+ */
184
+ fun mix(
185
+ context: Context,
186
+ programJson: String,
187
+ urisJson: String,
188
+ output: File,
189
+ /**
190
+ * Whether to keep going, asked in every loop that can run for seconds.
191
+ *
192
+ * Passed rather than assigned, so two callers cannot clobber each
193
+ * other's cancellation. Installed for the duration of this call under
194
+ * [mixLock] and restored afterwards.
195
+ */
196
+ keepGoing: () -> Boolean = { true },
197
+ /**
198
+ * Encoder input buffer size, for proving it does not matter.
199
+ *
200
+ * The mix itself cannot depend on it — contributions are accumulated by
201
+ * absolute sample position, not by walking buffers — so varying it and
202
+ * getting the same PCM is a check on that claim rather than a knob
203
+ * anyone should turn.
204
+ */
205
+ maxInputSize: Int = 64 * 1024,
206
+ ): String = synchronized(mixLock) {
207
+ val previous = this.keepGoing
208
+ this.keepGoing = keepGoing
209
+ try {
210
+ mixLocked(context, programJson, urisJson, output, maxInputSize)
211
+ } finally {
212
+ this.keepGoing = previous
213
+ }
214
+ }
215
+
216
+ private fun mixLocked(
217
+ context: Context,
218
+ programJson: String,
219
+ urisJson: String,
220
+ output: File,
221
+ maxInputSize: Int,
222
+ ): String {
223
+ val out = JSONObject()
224
+ profile = Profile()
225
+ try {
226
+ val program = JSONObject(programJson)
227
+ if (program.optInt("version", -1) != 1) {
228
+ return out.put("ok", false).put("error", "unsupported mix version").toString()
229
+ }
230
+ if (program.optString("rule", "") != "sum-then-saturate") {
231
+ return out.put("ok", false)
232
+ .put("error", "unknown mix rule '" + program.optString("rule") + "'").toString()
233
+ }
234
+ val durationUs = program.optLong("durationUs", -1)
235
+ if (durationUs <= 0) return out.put("ok", false).put("error", "no duration").toString()
236
+ val uris = JSONObject(urisJson)
237
+
238
+ val spans = ArrayList<Span>()
239
+ val array = program.optJSONArray("spans")
240
+ ?: return out.put("ok", false).put("error", "no spans").toString()
241
+ for (i in 0 until array.length()) {
242
+ val s = array.getJSONObject(i)
243
+ val id = s.getString("id")
244
+ val silent = s.optBoolean("silent", false)
245
+ val uri = if (uris.has(id)) uris.getString(id) else null
246
+ if (!silent && uri == null) {
247
+ return out.put("ok", false)
248
+ .put("error", "span '" + id + "' has no resolved source").toString()
249
+ }
250
+ val ramps = ArrayList<Ramp>()
251
+ s.optJSONArray("ramps")?.let { r ->
252
+ for (k in 0 until r.length()) {
253
+ val ramp = r.getJSONObject(k)
254
+ ramps.add(
255
+ Ramp(
256
+ ramp.getLong("fromUs"), ramp.getLong("toUs"),
257
+ ramp.getDouble("fromGain"), ramp.getDouble("toGain"),
258
+ ),
259
+ )
260
+ }
261
+ }
262
+ // Half-open, converted once. Nothing downstream re-derives it.
263
+ spans.add(
264
+ Span(
265
+ id, uri,
266
+ sampleAt(s.getLong("startUs")), sampleAt(s.getLong("endUs")),
267
+ s.getLong("sourceInUs"), s.getLong("sourceOutUs"),
268
+ s.optDouble("speed", 1.0).toFloat(), s.optDouble("gain", 1.0),
269
+ ramps, silent,
270
+ ),
271
+ )
272
+ }
273
+
274
+ val totalSamples = sampleAt(durationUs)
275
+ if (totalSamples <= 0) return out.put("ok", false).put("error", "empty timeline").toString()
276
+ val mixed = FloatArray((totalSamples * CHANNELS).toInt())
277
+
278
+ val contributed = JSONObject()
279
+ for (span in spans) {
280
+ if (span.silent) {
281
+ // An empty contribution. Nothing is generated and nothing is
282
+ // decoded: the accumulator is already zero here.
283
+ contributed.put(span.id, 0)
284
+ continue
285
+ }
286
+ val decoded = profile.time("decode.total") {
287
+ decodeToOutputFormat(
288
+ context, span.uri!!, span.sourceInUs, span.sourceOutUs, span.speed,
289
+ )
290
+ }
291
+ profile.source(
292
+ span.id,
293
+ JSONObject()
294
+ .put("decodedFrames", decoded.decodedFrames)
295
+ .put("croppedFrames", decoded.croppedFrames)
296
+ .put("stretchedFrames", decoded.stretchedFrames)
297
+ .put("outputFrames", decoded.outputFrames)
298
+ .put("sourceRate", decoded.sourceRate)
299
+ .put("sourceChannels", decoded.sourceChannels)
300
+ .put("speed", span.speed.toDouble())
301
+ .put("windowUs", span.sourceOutUs - span.sourceInUs),
302
+ )
303
+ profile.count("decodedFrames", decoded.decodedFrames.toLong())
304
+ profile.count("outputFrames", decoded.outputFrames.toLong())
305
+ val pcm = decoded.pcm
306
+ var written = 0L
307
+ val mixAt = System.nanoTime()
308
+ val wanted = span.endSample - span.startSample
309
+ var i = 0L
310
+ while (i < wanted) {
311
+ val outSample = span.startSample + i
312
+ if (outSample >= totalSamples) break
313
+ val srcIndex = (i * CHANNELS).toInt()
314
+ if (srcIndex + CHANNELS > pcm.size) break
315
+ // Absolute position, so a buffer boundary cannot move the
316
+ // envelope.
317
+ val g = gainAt(span, timeAt(outSample))
318
+ val dst = (outSample * CHANNELS).toInt()
319
+ for (c in 0 until CHANNELS) {
320
+ mixed[dst + c] += (pcm[srcIndex + c] * g).toFloat()
321
+ }
322
+ written += 1
323
+ i += 1
324
+ }
325
+ /*
326
+ A span that should sound and contributes nothing is an error.
327
+
328
+ Returning a mix that is quietly missing a clip produces a file
329
+ nothing downstream can tell from one that never had it — the
330
+ same failure mode this pipeline refuses everywhere else. It is
331
+ raised here rather than left to a listener to notice.
332
+ */
333
+ profile.count("mixNs", System.nanoTime() - mixAt)
334
+ if (written == 0L) {
335
+ return out.put("ok", false)
336
+ .put(
337
+ "error",
338
+ "span '" + span.id + "' contributed no samples " +
339
+ "(window " + span.sourceInUs + ".." + span.sourceOutUs +
340
+ " at speed " + span.speed + "; decoded " + decoded.decodedFrames +
341
+ ", cropped " + decoded.croppedFrames +
342
+ ", stretched " + decoded.stretchedFrames +
343
+ ", output " + decoded.outputFrames +
344
+ ", source " + decoded.sourceRate + "Hz x" + decoded.sourceChannels +
345
+ " " + decoded.encoding + ")",
346
+ )
347
+ .toString()
348
+ }
349
+ // Recorded against what was asked for: a decoder can only start
350
+ // on a frame boundary, so a window may be short by part of one.
351
+ contributed.put(
352
+ span.id,
353
+ written.toString() + "/" + wanted.toString() +
354
+ " preroll=" + decoded.prerollUs + " retries=" + decoded.retries +
355
+ (if (decoded.primingGapFrames > 0) " priming=" + decoded.primingGapFrames else ""),
356
+ )
357
+ }
358
+
359
+ // Saturated once, after every contribution.
360
+ profile.time("saturate") {
361
+ for (i in mixed.indices) {
362
+ mixed[i] = max(-1f, min(1f, mixed[i]))
363
+ }
364
+ }
365
+
366
+ /*
367
+ Retained only while an attribution run asks for it.
368
+
369
+ This kept a full copy of the mixed timeline — 12.7 MB for the
370
+ 33-second acceptance project, on top of the working array — for
371
+ the life of the process, in every diagnostics build. It exists for
372
+ one experiment and was costing every run that followed.
373
+ */
374
+ if (Diagnostics.ENABLED && PreviewConfig.retainMixedPcm) lastMixedPcm = mixed.copyOf()
375
+ val encoded = profile.time("encode") { encode(mixed, output, maxInputSize) }
376
+ if (!encoded.optBoolean("ok", false)) return encoded.toString()
377
+
378
+ out.put("ok", true)
379
+ .put("uri", output.absolutePath)
380
+ .put("sampleRate", SAMPLE_RATE)
381
+ .put("channels", CHANNELS)
382
+ .put("totalSamples", totalSamples)
383
+ .put("contributed", contributed)
384
+ .put("encodedSamples", encoded.optInt("samples"))
385
+ .put("profile", profile.json())
386
+ } catch (e: Exception) {
387
+ out.put("ok", false).put("error", e.javaClass.simpleName + ": " + e.message)
388
+ }
389
+ return out.toString()
390
+ }
391
+
392
+ /**
393
+ * Decode one window and report every stage, without mixing.
394
+ *
395
+ * The point is to run the identical window at 1x and at another rate: if
396
+ * the cropped frame counts match and only the stretched count differs, the
397
+ * stretch is responsible; if the cropped counts differ, the decode is.
398
+ */
399
+ /** Decode one window under a simulated decoder warm-up requirement. */
400
+ fun probePreroll(context: Context, uri: String, fromUs: Long, toUs: Long, warmupUs: Long): String {
401
+ warmupSimUs = warmupUs
402
+ try {
403
+ val out = JSONObject().put("warmupUs", warmupUs)
404
+ return try {
405
+ val d = decodeToOutputFormat(context, uri, fromUs, toUs, 1f)
406
+ out.put("ok", true).put("prerollUs", d.prerollUs).put("retries", d.retries)
407
+ .put("priming", d.primingGapFrames).put("croppedFrames", d.croppedFrames).put("outputFrames", d.outputFrames).toString()
408
+ } catch (e: DecodeFailure) {
409
+ out.put("ok", false).put("stage", e.stage).put("error", e.message).toString()
410
+ } catch (e: Exception) {
411
+ out.put("ok", false).put("stage", "unexpected")
412
+ .put("error", e.javaClass.simpleName + ": " + e.message).toString()
413
+ }
414
+ } finally {
415
+ warmupSimUs = 0
416
+ }
417
+ }
418
+
419
+ fun probeDecode(context: Context, uri: String, fromUs: Long, toUs: Long, speed: Float): String {
420
+ val out = JSONObject()
421
+ return try {
422
+ val d = decodeToOutputFormat(context, uri, fromUs, toUs, speed)
423
+ out.put("ok", true).put("stage", "complete")
424
+ .put("prerollUs", d.prerollUs).put("retries", d.retries)
425
+ .put("decodedFrames", d.decodedFrames)
426
+ .put("croppedFrames", d.croppedFrames)
427
+ .put("stretchedFrames", d.stretchedFrames)
428
+ .put("outputFrames", d.outputFrames)
429
+ .put("sourceRate", d.sourceRate)
430
+ .put("sourceChannels", d.sourceChannels)
431
+ .put("encoding", d.encoding)
432
+ .toString()
433
+ } catch (e: DecodeFailure) {
434
+ out.put("ok", false).put("stage", e.stage).put("error", e.message).toString()
435
+ } catch (e: Exception) {
436
+ out.put("ok", false).put("stage", "unexpected")
437
+ .put("error", e.javaClass.simpleName + ": " + e.message).toString()
438
+ }
439
+ }
440
+
441
+ /**
442
+ * Run [stretch] alone on synthetic PCM.
443
+ *
444
+ * No decoder, no file: a tone generated in memory, so a wrong answer here
445
+ * cannot be blamed on a source.
446
+ */
447
+ fun probeStretch(rate: Int, channels: Int, frames: Int, speed: Float): String {
448
+ val out = JSONObject()
449
+ return try {
450
+ val pcm = ShortArray(frames * channels)
451
+ for (f in 0 until frames) {
452
+ val v = (Math.sin(2.0 * Math.PI * 440.0 * f / rate) * 16000).toInt().toShort()
453
+ for (c in 0 until channels) pcm[f * channels + c] = v
454
+ }
455
+ val stretched = stretch(pcm, rate, channels, speed)
456
+ out.put("ok", true)
457
+ .put("inFrames", frames)
458
+ .put("outFrames", if (channels > 0) stretched.size / channels else 0)
459
+ .put("expected", (frames / speed).toInt())
460
+ .toString()
461
+ } catch (e: Exception) {
462
+ out.put("ok", false).put("error", e.javaClass.simpleName + ": " + e.message).toString()
463
+ }
464
+ }
465
+
466
+ /** The plan's envelope, evaluated at an absolute instant. */
467
+ private fun gainAt(span: Span, timeUs: Long): Double {
468
+ var factor = span.gain
469
+ for (ramp in span.ramps) {
470
+ factor *= when {
471
+ timeUs <= ramp.fromUs -> ramp.fromGain
472
+ timeUs >= ramp.toUs -> ramp.toGain
473
+ else -> {
474
+ val t = (timeUs - ramp.fromUs).toDouble() / (ramp.toUs - ramp.fromUs).toDouble()
475
+ ramp.fromGain + (ramp.toGain - ramp.fromGain) * t
476
+ }
477
+ }
478
+ }
479
+ return factor
480
+ }
481
+
482
+ /** What each stage produced, so a failure names the stage that caused it. */
483
+ class Decoded(
484
+ val pcm: FloatArray,
485
+ val decodedFrames: Int,
486
+ val croppedFrames: Int,
487
+ val stretchedFrames: Int,
488
+ val outputFrames: Int,
489
+ val sourceRate: Int,
490
+ val sourceChannels: Int,
491
+ val encoding: String,
492
+ /** How far before `sourceIn` the decode finally started. */
493
+ val prerollUs: Long,
494
+ /** How many attempts it took to cover the window. Zero means the first worked. */
495
+ val retries: Int,
496
+ /** Source frames at the head of the window the codec could not produce. */
497
+ val primingGapFrames: Long,
498
+ )
499
+
500
+ /** A stage failed. Never converted to silence. */
501
+ class DecodeFailure(val stage: String, message: String) : Exception(message)
502
+
503
+ /**
504
+ * Decode one source window into the fixed output format.
505
+ *
506
+ * **Nothing here turns an exception into empty PCM.** The previous version
507
+ * caught everything and returned a zero-length array, so a decode that never
508
+ * ran and a window that was genuinely silent looked identical — and a clip
509
+ * whose audio vanished reported success. Each stage now records what it
510
+ * produced and a failure names the stage.
511
+ *
512
+ * The window is intersected with `[sourceIn, sourceOut)` in **source frame
513
+ * indices**, computed rationally from each buffer's own timestamp. Both ends
514
+ * are cropped: the earlier code trimmed only the front, so the final buffer
515
+ * ran past `sourceOut` by up to one decoder frame.
516
+ *
517
+ * The decoder's **actual output format** is used, not the compressed input
518
+ * format. A decoder may resample, downmix, or emit float; assuming the
519
+ * compressed header describes the decoded buffers is how a correct decode
520
+ * gets read at the wrong stride.
521
+ */
522
+ private fun decodeToOutputFormat(
523
+ context: Context,
524
+ uri: String,
525
+ fromUs: Long,
526
+ toUs: Long,
527
+ speed: Float,
528
+ ): Decoded {
529
+ /*
530
+ Preroll is discovered, not assumed.
531
+
532
+ A decoder discards some number of output frames after a seek as
533
+ priming, and that number is the decoder's business: it varies by codec,
534
+ by device, and by whether the seek landed on a sync sample. A fixed
535
+ 250 ms run-up was a guess that happened to fit one AAC decoder on one
536
+ phone. Too small and the head of the window is silently missing -- the
537
+ failure this replaces, which read as clips ending early rather than as
538
+ an error. Too large and every span pays for the worst case.
539
+
540
+ So each attempt reports the first source frame it actually covered, and
541
+ the run-up expands backward until that frame is at or before the window
542
+ start. Attempts stop at the source's own beginning: if decoding from
543
+ zero still cannot reach `sourceIn`, that is a failure named by span,
544
+ because padding an audible span with silence is the class of quiet
545
+ wrongness this whole path exists to eliminate.
546
+ */
547
+ var preroll = 0L
548
+ var retries = 0
549
+ while (true) {
550
+ val attempt = decodeAttempt(context, uri, fromUs, toUs, preroll)
551
+ val covered = attempt.firstCoveredFrame <= attempt.windowStartFrame &&
552
+ attempt.firstCoveredFrame != Long.MAX_VALUE
553
+ if (covered) return finish(attempt, speed, preroll, retries, 0)
554
+ if (preroll >= fromUs) {
555
+ /*
556
+ At the very start of a file there is nowhere earlier to seek.
557
+
558
+ A lossy encoder prepends priming samples, so the first frames a
559
+ decoder emits are delay rather than content and the head of a
560
+ window at source zero is genuinely not in the file. That is a
561
+ property of the format, not a decode that went wrong -- but it
562
+ is only that when the missing part is priming-sized. So the
563
+ shortfall is checked against the encoder's declared delay
564
+ (falling back to two decoder frames when it declares none), and
565
+ represented as silence *at its own position* so nothing after
566
+ it shifts earlier. Anything larger is a failure named by span,
567
+ because a hole in the middle of a clip's audio and a codec's
568
+ priming are not the same thing and must not be treated alike.
569
+ */
570
+ if (attempt.croppedFrames == 0) {
571
+ throw DecodeFailure(
572
+ "preroll",
573
+ "decoding from source start produced nothing for [" + fromUs + ", " + toUs + ")",
574
+ )
575
+ }
576
+ val gap = attempt.firstCoveredFrame - attempt.windowStartFrame
577
+ if (gap > attempt.primingAllowanceFrames) {
578
+ throw DecodeFailure(
579
+ "preroll",
580
+ "window [" + fromUs + ", " + toUs + ") is missing " + gap +
581
+ " frames at its head, beyond the codec priming allowance of " +
582
+ attempt.primingAllowanceFrames,
583
+ )
584
+ }
585
+ return finish(attempt, speed, preroll, retries, gap)
586
+ }
587
+ retries += 1
588
+ // Geometric, from nothing: a decoder needing no warm-up pays no cost,
589
+ // and one needing a lot reaches it in a few attempts rather than many.
590
+ preroll = if (preroll == 0L) 40_000L else preroll * 4
591
+ if (preroll > fromUs) preroll = fromUs
592
+ }
593
+ }
594
+
595
+ /** One decode attempt at a chosen run-up. */
596
+ private class Attempt(
597
+ val raw: ShortArray,
598
+ val decodedFrames: Int,
599
+ val croppedFrames: Int,
600
+ val rate: Int,
601
+ val channels: Int,
602
+ val encoding: Int,
603
+ /** First source frame index actually captured, or MAX_VALUE if none. */
604
+ val firstCoveredFrame: Long,
605
+ val windowStartFrame: Long,
606
+ /** How much head loss this codec's declared priming can explain. */
607
+ val primingAllowanceFrames: Long,
608
+ )
609
+
610
+ /** Stretch and resample an attempt into the mixer's format. */
611
+ private fun finish(
612
+ attempt: Attempt,
613
+ speed: Float,
614
+ prerollUs: Long,
615
+ retries: Int,
616
+ primingGapFrames: Long,
617
+ ): Decoded {
618
+ /*
619
+ The gap is restored as silence before the stretch, not after.
620
+
621
+ Its position is what matters: dropping it would move every later sample
622
+ of the span earlier by the gap, which is an A/V sync error that grows
623
+ with nothing and is invisible in a sample count. Prepending it here
624
+ lets the stretch and resample carry it to the right place by the same
625
+ arithmetic as the real samples.
626
+ */
627
+ val raw = if (primingGapFrames > 0) {
628
+ val pad = (primingGapFrames * attempt.channels).toInt()
629
+ ShortArray(pad + attempt.raw.size).also { attempt.raw.copyInto(it, pad) }
630
+ } else attempt.raw
631
+ val stretched =
632
+ if (speed != 1f) {
633
+ profile.time("stretch") { stretch(raw, attempt.rate, attempt.channels, speed) }
634
+ } else raw
635
+ val stretchedFrames = if (attempt.channels > 0) stretched.size / attempt.channels else 0
636
+ val out = profile.time("resample") {
637
+ toOutputFormat(stretched, attempt.rate, attempt.channels)
638
+ }
639
+ return Decoded(
640
+ out, attempt.decodedFrames, if (attempt.channels > 0) raw.size / attempt.channels else 0,
641
+ stretchedFrames, out.size / CHANNELS,
642
+ attempt.rate, attempt.channels,
643
+ if (attempt.encoding == android.media.AudioFormat.ENCODING_PCM_FLOAT) "float" else "pcm16",
644
+ prerollUs, retries, primingGapFrames,
645
+ )
646
+ }
647
+
648
+ @Volatile private var warmupSimUs: Long = 0
649
+
650
+ private fun decodeAttempt(
651
+ context: Context,
652
+ uri: String,
653
+ fromUs: Long,
654
+ toUs: Long,
655
+ prerollUs: Long,
656
+ ): Attempt {
657
+ /*
658
+ `warmupSimUs` models a decoder that needs more run-up than this one.
659
+
660
+ Real decoders differ in how much output they discard after a seek, and
661
+ the adaptive search has to be correct for a decoder we do not have.
662
+ Setting this drops output covering the first N microseconds after the
663
+ seek point, which is exactly what a longer-priming decoder does. It is
664
+ zero everywhere except the probe.
665
+ */
666
+ val beatEpoch = ExportHeartbeat.currentEpoch()
667
+ val extractor = MediaExtractor()
668
+ var codec: MediaCodec? = null
669
+ // Declared out here because the `finally` below records the other end
670
+ // of this codec's life, and a value scoped to the `try` is not visible
671
+ // from the block that has to prove it was released.
672
+ var ledgerEntry: CodecLedger.Entry? = null
673
+ try {
674
+ profile.time("open") {
675
+ if (uri.startsWith("/")) extractor.setDataSource(uri)
676
+ else extractor.setDataSource(context, Uri.parse(uri), null)
677
+ }
678
+ var track = -1
679
+ var inputFormat: MediaFormat? = null
680
+ for (i in 0 until extractor.trackCount) {
681
+ val f = extractor.getTrackFormat(i)
682
+ if ((f.getString(MediaFormat.KEY_MIME) ?: "").startsWith("audio/")) {
683
+ track = i; inputFormat = f; break
684
+ }
685
+ }
686
+ if (track < 0 || inputFormat == null) {
687
+ throw DecodeFailure("find-track", "no audio track in $uri")
688
+ }
689
+ extractor.selectTrack(track)
690
+ val seekUs = maxOf(0L, fromUs - prerollUs)
691
+ extractor.seekTo(seekUs, MediaExtractor.SEEK_TO_PREVIOUS_SYNC)
692
+ val warmUntilUs = if (warmupSimUs > 0) seekUs + warmupSimUs else Long.MIN_VALUE
693
+
694
+ ledgerEntry = CodecLedger.open("mix.decoder")
695
+ codec = MediaCodec.createDecoderByType(inputFormat.getString(MediaFormat.KEY_MIME)!!)
696
+ codec.configure(inputFormat, null, null, 0)
697
+ codec.start()
698
+
699
+ // Replaced the moment the decoder announces its real output format.
700
+ var rate = inputFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)
701
+ var channels = inputFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
702
+ var encoding = android.media.AudioFormat.ENCODING_PCM_16BIT
703
+
704
+ val raw = ArrayList<Short>()
705
+ var decodedFrames = 0
706
+ var firstCovered = Long.MAX_VALUE
707
+ var observedWindowStart = 0L
708
+ val info = MediaCodec.BufferInfo()
709
+ var inputDone = false
710
+ var outputDone = false
711
+ var iterations = 0
712
+ while (!outputDone) {
713
+ iterations += 1
714
+ if (iterations > 1_000_000) {
715
+ // A guard that is hit is a failure, not a result.
716
+ throw DecodeFailure("decode-loop", "decoder did not finish within 1e6 iterations")
717
+ }
718
+ if (!inputDone) {
719
+ val index = codec.dequeueInputBuffer(10_000)
720
+ if (index >= 0) {
721
+ val buffer = codec.getInputBuffer(index)!!
722
+ val size = extractor.readSampleData(buffer, 0)
723
+ val time = extractor.sampleTime
724
+ // A sample *at* sourceOut is outside a half-open window.
725
+ if (size < 0 || time >= toUs) {
726
+ codec.queueInputBuffer(index, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
727
+ inputDone = true
728
+ } else {
729
+ codec.queueInputBuffer(index, 0, size, time, 0)
730
+ extractor.advance()
731
+ }
732
+ }
733
+ }
734
+ val index = codec.dequeueOutputBuffer(info, 10_000)
735
+ if (index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
736
+ val actual = codec.outputFormat
737
+ rate = actual.getInteger(MediaFormat.KEY_SAMPLE_RATE)
738
+ channels = actual.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
739
+ if (actual.containsKey(MediaFormat.KEY_PCM_ENCODING)) {
740
+ encoding = actual.getInteger(MediaFormat.KEY_PCM_ENCODING)
741
+ }
742
+ if (encoding != android.media.AudioFormat.ENCODING_PCM_16BIT &&
743
+ encoding != android.media.AudioFormat.ENCODING_PCM_FLOAT
744
+ ) {
745
+ throw DecodeFailure("pcm-encoding", "unsupported PCM encoding $encoding")
746
+ }
747
+ } else if (index >= 0) {
748
+ if (info.size > 0 && info.presentationTimeUs >= warmUntilUs) {
749
+ val buffer = codec.getOutputBuffer(index)!!
750
+ buffer.position(info.offset)
751
+ buffer.limit(info.offset + info.size)
752
+ val bytesPerSample = if (encoding == android.media.AudioFormat.ENCODING_PCM_FLOAT) 4 else 2
753
+ val frames = info.size / (bytesPerSample * channels)
754
+ decodedFrames += frames
755
+ // One decoded buffer is liveness, whatever it does to
756
+ // the visible percentage.
757
+ checkCancelled()
758
+ ExportHeartbeat.beat("audioBlock", beatEpoch)
759
+ /*
760
+ Intersected in source frame indices, from this buffer's
761
+ own timestamp. Rational, not accumulated, so a long
762
+ window cannot drift away from the plan.
763
+ */
764
+ val bufferStart = Math.floorDiv(info.presentationTimeUs * rate, 1_000_000L)
765
+ val windowStart = Math.floorDiv(fromUs * rate, 1_000_000L)
766
+ val windowEnd = Math.floorDiv(toUs * rate, 1_000_000L)
767
+ val take0 = max(bufferStart, windowStart)
768
+ val take1 = min(bufferStart + frames, windowEnd)
769
+ if (take1 > take0) {
770
+ if (firstCovered == Long.MAX_VALUE) firstCovered = take0
771
+ observedWindowStart = windowStart
772
+ val ordered = buffer.order(ByteOrder.nativeOrder())
773
+ if (encoding == android.media.AudioFormat.ENCODING_PCM_FLOAT) {
774
+ val floats = ordered.asFloatBuffer()
775
+ for (f in take0 until take1) {
776
+ val local = (f - bufferStart).toInt()
777
+ for (c in 0 until channels) {
778
+ val v = floats.get(local * channels + c)
779
+ raw.add((max(-1f, min(1f, v)) * 32767f).toInt().toShort())
780
+ }
781
+ }
782
+ } else {
783
+ val shorts = ordered.asShortBuffer()
784
+ for (f in take0 until take1) {
785
+ val local = (f - bufferStart).toInt()
786
+ for (c in 0 until channels) raw.add(shorts.get(local * channels + c))
787
+ }
788
+ }
789
+ }
790
+ }
791
+ codec.releaseOutputBuffer(index, false)
792
+ if ((info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) outputDone = true
793
+ }
794
+ }
795
+
796
+ val cropped = ShortArray(raw.size) { raw[it] }
797
+ val declaredDelay =
798
+ if (inputFormat.containsKey("encoder-delay")) inputFormat.getInteger("encoder-delay").toLong()
799
+ else 0L
800
+ return Attempt(
801
+ cropped, decodedFrames, if (channels > 0) cropped.size / channels else 0,
802
+ rate, channels, encoding, firstCovered,
803
+ if (firstCovered == Long.MAX_VALUE) Math.floorDiv(fromUs * rate, 1_000_000L)
804
+ else observedWindowStart,
805
+ /*
806
+ Never stricter for declaring than for saying nothing.
807
+
808
+ This was `declaredDelay` when one was present and 2048
809
+ otherwise, which made an honest file harder to decode than a
810
+ silent one. Measured: a LAME MP3 declares 576 frames of
811
+ encoder delay and its first covered frame arrives 622 in, so
812
+ the window failed — while the same file with no tag would
813
+ have been accepted comfortably.
814
+
815
+ A declared delay is a lower bound on what the decoder
816
+ withholds, not the whole of it: output is emitted in whole
817
+ frames and this path also resamples to 48 kHz, so the first
818
+ sample that actually covers the window can land later than
819
+ the tag alone predicts. Taking the larger of the two keeps
820
+ the tag useful for files that declare more than the fallback
821
+ and stops it being a penalty for the rest.
822
+ */
823
+ maxOf(declaredDelay, 2048L),
824
+ )
825
+ } finally {
826
+ /*
827
+ Both ends recorded, not just the call.
828
+
829
+ `release()` returning is what frees the hardware instance; a
830
+ `runCatching` that swallowed a throw would otherwise look
831
+ identical to a clean teardown, and the whole question here is
832
+ whether these give the resource back.
833
+ */
834
+ ledgerEntry?.stoppedNs = System.nanoTime()
835
+ runCatching { codec?.stop() }
836
+ .onFailure { ledgerEntry?.failure = "stop: " + it.javaClass.simpleName }
837
+ ledgerEntry?.releaseCalledNs = System.nanoTime()
838
+ runCatching { codec?.release() }
839
+ .onFailure { ledgerEntry?.failure = "release: " + it.javaClass.simpleName }
840
+ ledgerEntry?.releasedNs = System.nanoTime()
841
+ runCatching { extractor.release() }
842
+ }
843
+ }
844
+
845
+ /**
846
+ * Time-stretch without moving pitch, via Sonic on raw PCM.
847
+ *
848
+ * Sonic buffers: `queueInput` may consume only part of a buffer, and output
849
+ * keeps arriving after end-of-stream. Reading it once and stopping at the
850
+ * first empty buffer returns almost nothing — which showed up as a
851
+ * speed-changed clip contributing an rms of 0.042 while every clip at rate 1
852
+ * was fine.
853
+ */
854
+ private fun stretch(pcm: ShortArray, rate: Int, channels: Int, speed: Float): ShortArray {
855
+ val sonic = SonicAudioProcessor()
856
+ sonic.setSpeed(speed)
857
+ val format = AudioProcessor.AudioFormat(rate, channels, android.media.AudioFormat.ENCODING_PCM_16BIT)
858
+ sonic.configure(format)
859
+ /*
860
+ The overload that exists, not the one that reads better.
861
+
862
+ `AudioProcessor.flush()` is a `default` method that throws
863
+ "AudioProcessor must implement at least one #flush() overload" — so
864
+ every rate change failed here, at every sample rate, channel count and
865
+ input size. The old code caught that and returned empty PCM, which is
866
+ how a clip's audio disappeared while the export reported success.
867
+ */
868
+ sonic.flush(AudioProcessor.StreamMetadata.DEFAULT)
869
+ val input = ByteBuffer.allocateDirect(pcm.size * 2).order(ByteOrder.nativeOrder())
870
+ input.asShortBuffer().put(pcm)
871
+ input.limit(pcm.size * 2)
872
+ val collected = ArrayList<Short>(pcm.size)
873
+
874
+ val stretchEpoch = ExportHeartbeat.currentEpoch()
875
+ fun drain() {
876
+ while (true) {
877
+ // Sonic's drain is where a 0.1x export spends seconds in
878
+ // silence, stretching one second of source into ten.
879
+ checkCancelled()
880
+ ExportHeartbeat.beat("audioBlock", stretchEpoch)
881
+ val output = sonic.getOutput()
882
+ if (!output.hasRemaining()) return
883
+ val shorts = output.order(ByteOrder.nativeOrder()).asShortBuffer()
884
+ while (shorts.hasRemaining()) collected.add(shorts.get())
885
+ }
886
+ }
887
+
888
+ var guard = 0
889
+ while (input.hasRemaining() && guard < 100_000) {
890
+ guard += 1
891
+ sonic.queueInput(input)
892
+ drain()
893
+ }
894
+ sonic.queueEndOfStream()
895
+ guard = 0
896
+ while (!sonic.isEnded && guard < 100_000) {
897
+ guard += 1
898
+ drain()
899
+ }
900
+ drain()
901
+ runCatching { sonic.reset() }
902
+ return ShortArray(collected.size) { collected[it] }
903
+ }
904
+
905
+ /** Linear resample and channel map into 48 kHz stereo float. */
906
+ private fun toOutputFormat(pcm: ShortArray, rate: Int, channels: Int): FloatArray {
907
+ if (pcm.isEmpty() || channels <= 0) return FloatArray(0)
908
+ val frames = pcm.size / channels
909
+ val outFrames = ((frames.toLong() * SAMPLE_RATE) / rate).toInt()
910
+ val out = FloatArray(outFrames * CHANNELS)
911
+ for (i in 0 until outFrames) {
912
+ // Position in source frames, as a rational rather than an
913
+ // accumulated step, so a long span cannot drift.
914
+ val pos = (i.toDouble() * rate) / SAMPLE_RATE
915
+ val i0 = pos.toInt()
916
+ val i1 = min(frames - 1, i0 + 1)
917
+ val t = pos - i0
918
+ for (c in 0 until CHANNELS) {
919
+ // Mono duplicates into both channels; anything wider takes the
920
+ // matching channel.
921
+ val sc = if (channels == 1) 0 else min(channels - 1, c)
922
+ val a = pcm[i0 * channels + sc] / 32768f
923
+ val b = pcm[i1 * channels + sc] / 32768f
924
+ out[i * CHANNELS + c] = (a + (b - a) * t).toFloat()
925
+ }
926
+ }
927
+ return out
928
+ }
929
+
930
+ /**
931
+ * Gate-only: the mixed PCM, exactly as the encoder received it.
932
+ *
933
+ * The attribution experiment has to separate loss in the mix from loss in
934
+ * the encode, and the only way to do that is to look at the samples between
935
+ * them. Captured under [Diagnostics] and overwritten every mix, so it never
936
+ * accumulates and never exists in a release build.
937
+ */
938
+ @Volatile var lastMixedPcm: FloatArray? = null
939
+ private set
940
+
941
+ /** Gate-only door to [encode], so a baseline runs the production loop. */
942
+ internal fun encodeForGate(mixed: FloatArray, output: File, maxInputSize: Int): JSONObject =
943
+ encode(mixed, output, maxInputSize)
944
+
945
+ /** Encode the finished timeline to AAC, once. */
946
+ private fun encode(mixed: FloatArray, output: File, maxInputSize: Int): JSONObject {
947
+ val encodeEpoch = ExportHeartbeat.currentEpoch()
948
+ val result = JSONObject()
949
+ var codec: MediaCodec? = null
950
+ var muxer: MediaMuxer? = null
951
+ var encoderEntry: CodecLedger.Entry? = null
952
+ try {
953
+ val format = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC, SAMPLE_RATE, CHANNELS)
954
+ format.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
955
+ format.setInteger(MediaFormat.KEY_BIT_RATE, 128_000)
956
+ format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, maxInputSize)
957
+ encoderEntry = CodecLedger.open("mix.encoder")
958
+ codec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC)
959
+ codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
960
+ codec.start()
961
+ muxer = MediaMuxer(output.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
962
+
963
+ var track = -1
964
+ var started = false
965
+ val info = MediaCodec.BufferInfo()
966
+ var written = 0
967
+ var offset = 0
968
+ var inputDone = false
969
+ var outputDone = false
970
+ var guard = 0
971
+
972
+ /*
973
+ Feed and drain, rather than one of each per turn with a wait
974
+ between them.
975
+
976
+ The previous loop queued at most one input buffer and then called
977
+ `dequeueOutputBuffer` with a 10 ms timeout, every iteration. An AAC
978
+ encoder hands back input buffers of about a frame — 2048 samples,
979
+ not the 32768 `KEY_MAX_INPUT_SIZE` suggests — so a 33-second
980
+ timeline is around 1550 turns, each of them waiting on an encoder
981
+ that had nothing to say yet. Measured on a Pixel 7: encode time
982
+ tracked `units x 10 ms` almost exactly, 20.3 s of a 23.8 s
983
+ preparation.
984
+
985
+ So: take every input buffer that is ready, then take every output
986
+ buffer that is ready, both without waiting. Only when neither side
987
+ can move does this block, and then once, briefly — that is the
988
+ case where the encoder genuinely has work in progress.
989
+ */
990
+ var blockedNs = 0L
991
+ var iterations = 0L
992
+ var waits = 0L
993
+
994
+ while (!outputDone && guard < 5_000_000) {
995
+ guard += 1
996
+ iterations += 1
997
+ var progressed = false
998
+
999
+ // Feed: everything available, without waiting.
1000
+ while (!inputDone) {
1001
+ val index = codec.dequeueInputBuffer(0)
1002
+ if (index < 0) break
1003
+ val buffer = codec.getInputBuffer(index)!!
1004
+ buffer.clear()
1005
+ val capacity = buffer.remaining() / 2
1006
+ val count = min(capacity, mixed.size - offset)
1007
+ if (count <= 0) {
1008
+ codec.queueInputBuffer(
1009
+ index, 0, 0, presentationOf(offset), MediaCodec.BUFFER_FLAG_END_OF_STREAM,
1010
+ )
1011
+ inputDone = true
1012
+ } else {
1013
+ val shorts = buffer.order(ByteOrder.nativeOrder()).asShortBuffer()
1014
+ for (i in 0 until count) {
1015
+ shorts.put((mixed[offset + i] * 32767f).toInt().toShort())
1016
+ }
1017
+ codec.queueInputBuffer(index, 0, count * 2, presentationOf(offset), 0)
1018
+ offset += count
1019
+ // Interleaved samples handed to the encoder. The planned
1020
+ // total travels with it, because the mix's rate and
1021
+ // channel count are settled here and the JavaScript plan
1022
+ // must not restate them.
1023
+ ExportProgress.audio(offset.toLong(), mixed.size.toLong(), encodeEpoch)
1024
+ }
1025
+ progressed = true
1026
+ // Checked inside the feed loop too: this can now run for many
1027
+ // buffers in a row, and a cancel must not wait out all of them.
1028
+ checkCancelled()
1029
+ }
1030
+
1031
+ // Drain: everything available, without waiting.
1032
+ while (true) {
1033
+ val index = codec.dequeueOutputBuffer(info, 0)
1034
+ if (index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
1035
+ track = muxer.addTrack(codec.outputFormat)
1036
+ muxer.start()
1037
+ started = true
1038
+ progressed = true
1039
+ continue
1040
+ }
1041
+ if (index < 0) break
1042
+ if (info.size > 0 && started &&
1043
+ (info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG) == 0
1044
+ ) {
1045
+ val buffer = codec.getOutputBuffer(index)!!
1046
+ muxer.writeSampleData(track, buffer, info)
1047
+ written += 1
1048
+ }
1049
+ codec.releaseOutputBuffer(index, false)
1050
+ progressed = true
1051
+ if ((info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
1052
+ outputDone = true
1053
+ break
1054
+ }
1055
+ }
1056
+
1057
+ checkCancelled()
1058
+ ExportHeartbeat.beat("audioBlock", encodeEpoch)
1059
+ if (outputDone) break
1060
+
1061
+ if (!progressed) {
1062
+ /*
1063
+ Neither side could move, so the encoder is working. One
1064
+ bounded wait on output — not a spin, and not a wait taken
1065
+ after every buffer.
1066
+ */
1067
+ waits += 1
1068
+ val at = System.nanoTime()
1069
+ val index = codec.dequeueOutputBuffer(info, 5_000)
1070
+ blockedNs += System.nanoTime() - at
1071
+ if (index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
1072
+ track = muxer.addTrack(codec.outputFormat)
1073
+ muxer.start()
1074
+ started = true
1075
+ } else if (index >= 0) {
1076
+ if (info.size > 0 && started &&
1077
+ (info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG) == 0
1078
+ ) {
1079
+ val buffer = codec.getOutputBuffer(index)!!
1080
+ muxer.writeSampleData(track, buffer, info)
1081
+ written += 1
1082
+ }
1083
+ codec.releaseOutputBuffer(index, false)
1084
+ if ((info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) outputDone = true
1085
+ }
1086
+ }
1087
+ }
1088
+
1089
+ profile.count("encodeIterations", iterations)
1090
+ profile.count("encodeWaits", waits)
1091
+ profile.count("encodeBlockedNs", blockedNs)
1092
+
1093
+ result.put("ok", true).put("samples", written)
1094
+ } catch (e: Exception) {
1095
+ result.put("ok", false).put("error", "encode: " + e.javaClass.simpleName + ": " + e.message)
1096
+ } finally {
1097
+ encoderEntry?.stoppedNs = System.nanoTime()
1098
+ runCatching { codec?.stop() }
1099
+ .onFailure { encoderEntry?.failure = "stop: " + it.javaClass.simpleName }
1100
+ encoderEntry?.releaseCalledNs = System.nanoTime()
1101
+ runCatching { codec?.release() }
1102
+ .onFailure { encoderEntry?.failure = "release: " + it.javaClass.simpleName }
1103
+ encoderEntry?.releasedNs = System.nanoTime()
1104
+ runCatching { muxer?.stop() }
1105
+ runCatching { muxer?.release() }
1106
+ }
1107
+ return result
1108
+ }
1109
+
1110
+ private fun presentationOf(interleavedOffset: Int): Long =
1111
+ timeAt((interleavedOffset / CHANNELS).toLong())
1112
+ }