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