@pictor-sdk/video-android 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/android/CMakeLists.txt +30 -0
- package/android/build.gradle +119 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/cpp/pictor_overlaystage.cpp +372 -0
- package/android/src/main/cpp/pictor_publish.cpp +87 -0
- package/android/src/main/cpp/pictor_videosurface.cpp +735 -0
- package/android/src/main/java/dev/pictor/video/AacPriming.kt +261 -0
- package/android/src/main/java/dev/pictor/video/AudioMixer.kt +1112 -0
- package/android/src/main/java/dev/pictor/video/AudioProbe.kt +162 -0
- package/android/src/main/java/dev/pictor/video/CadenceShaderProgram.kt +337 -0
- package/android/src/main/java/dev/pictor/video/CodecLedger.kt +126 -0
- package/android/src/main/java/dev/pictor/video/CompositionSpike.kt +978 -0
- package/android/src/main/java/dev/pictor/video/Diagnostics.kt +54 -0
- package/android/src/main/java/dev/pictor/video/EncoderGate.kt +476 -0
- package/android/src/main/java/dev/pictor/video/ExportGate.kt +1974 -0
- package/android/src/main/java/dev/pictor/video/ExportHeartbeat.kt +72 -0
- package/android/src/main/java/dev/pictor/video/ExportProgress.kt +103 -0
- package/android/src/main/java/dev/pictor/video/FrameClock.kt +591 -0
- package/android/src/main/java/dev/pictor/video/FramePending.kt +181 -0
- package/android/src/main/java/dev/pictor/video/GateAudio.kt +456 -0
- package/android/src/main/java/dev/pictor/video/GateDestination.kt +445 -0
- package/android/src/main/java/dev/pictor/video/GateFrames.kt +662 -0
- package/android/src/main/java/dev/pictor/video/GateMux.kt +185 -0
- package/android/src/main/java/dev/pictor/video/ManagedSources.kt +333 -0
- package/android/src/main/java/dev/pictor/video/Media3Player.kt +934 -0
- package/android/src/main/java/dev/pictor/video/MediaStoreSave.kt +419 -0
- package/android/src/main/java/dev/pictor/video/OverlayResources.kt +169 -0
- package/android/src/main/java/dev/pictor/video/PictorVideoModule.kt +1612 -0
- package/android/src/main/java/dev/pictor/video/PictorVideoPackage.kt +26 -0
- package/android/src/main/java/dev/pictor/video/PlaybackAnchor.kt +161 -0
- package/android/src/main/java/dev/pictor/video/PreviewAudioPlayer.kt +291 -0
- package/android/src/main/java/dev/pictor/video/PreviewConfig.kt +96 -0
- package/android/src/main/java/dev/pictor/video/PreviewMix.kt +396 -0
- package/android/src/main/java/dev/pictor/video/PreviewSync.kt +374 -0
- package/android/src/main/java/dev/pictor/video/PreviewTrace.kt +478 -0
- package/android/src/main/java/dev/pictor/video/PrimingTrim.kt +337 -0
- package/android/src/main/java/dev/pictor/video/RepeatingEncoderFactory.kt +97 -0
- package/android/src/main/java/dev/pictor/video/SceneProgram.kt +169 -0
- package/android/src/main/java/dev/pictor/video/SceneProgramNative.kt +299 -0
- package/android/src/main/java/dev/pictor/video/SkiaPassThroughEffect.kt +695 -0
- package/android/src/main/java/dev/pictor/video/SurfaceAttachment.kt +110 -0
- package/android/src/main/java/dev/pictor/video/ThumbnailDecoder.kt +171 -0
- package/android/src/main/java/dev/pictor/video/VideoSurface.kt +394 -0
- package/app.plugin.js +43 -0
- package/dist/index.d.mts +3622 -0
- package/dist/index.d.ts +3622 -0
- package/dist/index.js +3737 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +3625 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +82 -0
- package/plugin/gradleProperties.d.ts +19 -0
- package/plugin/gradleProperties.js +64 -0
- package/react-native.config.js +13 -0
- package/src/NativePictorVideo.ts +574 -0
- package/src/audioProbe.ts +72 -0
- package/src/compositionSpike.ts +305 -0
- package/src/convergence.ts +112 -0
- package/src/encoderGate.ts +66 -0
- package/src/exportDestination.ts +149 -0
- package/src/exportJob.ts +232 -0
- package/src/exportProgress.ts +391 -0
- package/src/exportProgressPump.ts +190 -0
- package/src/exporter.ts +655 -0
- package/src/index.ts +276 -0
- package/src/managedSources.ts +252 -0
- package/src/media3.ts +412 -0
- package/src/mediaStore.ts +177 -0
- package/src/memoryGuard.ts +118 -0
- package/src/mixSources.ts +82 -0
- package/src/overlayStaging.ts +216 -0
- package/src/playbackLifecycle.ts +137 -0
- package/src/player.ts +39 -0
- package/src/playlistSession.ts +125 -0
- package/src/previewAudioDiagnostics.ts +67 -0
- package/src/previewAudioSession.ts +168 -0
- package/src/previewCadence.ts +950 -0
- package/src/previewMix.ts +212 -0
- package/src/previewTrace.ts +348 -0
- package/src/spikeExcursions.ts +405 -0
- package/src/submissionScoring.ts +680 -0
- package/src/surfaceOwner.ts +264 -0
- package/src/surfaceRecovery.ts +121 -0
- package/src/syncTrace.ts +189 -0
- package/src/thumbnailProvider.ts +163 -0
- package/src/tracePhases.ts +217 -0
- package/src/useCompositionPlayback.ts +327 -0
- package/src/useMedia3Playback.ts +492 -0
- package/src/usePreviewAudio.ts +305 -0
- package/src/useVideoSurface.ts +282 -0
|
@@ -0,0 +1,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
|
+
}
|