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