react-native-waveform-player 0.0.1 → 1.0.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/AudioWaveform.podspec +29 -0
- package/LICENSE +20 -0
- package/README.md +296 -0
- package/android/build.gradle +67 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/com/audiowaveform/AudioPlayerEngine.kt +353 -0
- package/android/src/main/java/com/audiowaveform/AudioWaveformEvent.kt +22 -0
- package/android/src/main/java/com/audiowaveform/AudioWaveformPackage.kt +17 -0
- package/android/src/main/java/com/audiowaveform/AudioWaveformView.kt +715 -0
- package/android/src/main/java/com/audiowaveform/AudioWaveformViewManager.kt +234 -0
- package/android/src/main/java/com/audiowaveform/PlayPauseButton.kt +106 -0
- package/android/src/main/java/com/audiowaveform/SpeedPillView.kt +70 -0
- package/android/src/main/java/com/audiowaveform/WaveformBarsView.kt +358 -0
- package/android/src/main/java/com/audiowaveform/WaveformDecoder.kt +240 -0
- package/android/src/main/res/drawable/pause_fill.xml +15 -0
- package/android/src/main/res/drawable/play_fill.xml +15 -0
- package/ios/AudioPlayerEngine.swift +281 -0
- package/ios/AudioWaveformView.h +14 -0
- package/ios/AudioWaveformView.mm +307 -0
- package/ios/AudioWaveformViewImpl.swift +835 -0
- package/ios/PlayPauseButton.swift +118 -0
- package/ios/SpeedPillView.swift +70 -0
- package/ios/WaveformBarsView.swift +327 -0
- package/ios/WaveformDecoder.swift +332 -0
- package/lib/module/AudioWaveformView.js +8 -0
- package/lib/module/AudioWaveformView.js.map +1 -0
- package/lib/module/AudioWaveformView.native.js +79 -0
- package/lib/module/AudioWaveformView.native.js.map +1 -0
- package/lib/module/AudioWaveformViewNativeComponent.ts +95 -0
- package/lib/module/index.js +4 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/AudioWaveformView.d.ts +233 -0
- package/lib/typescript/src/AudioWaveformView.d.ts.map +1 -0
- package/lib/typescript/src/AudioWaveformView.native.d.ts +335 -0
- package/lib/typescript/src/AudioWaveformView.native.d.ts.map +1 -0
- package/lib/typescript/src/AudioWaveformViewNativeComponent.d.ts +71 -0
- package/lib/typescript/src/AudioWaveformViewNativeComponent.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +3 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/package.json +138 -7
- package/src/AudioWaveformView.native.tsx +281 -0
- package/src/AudioWaveformView.tsx +96 -0
- package/src/AudioWaveformViewNativeComponent.ts +95 -0
- package/src/index.tsx +13 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
package com.audiowaveform
|
|
2
|
+
|
|
3
|
+
import android.animation.ValueAnimator
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.graphics.Canvas
|
|
6
|
+
import android.graphics.Color
|
|
7
|
+
import android.graphics.Paint
|
|
8
|
+
import android.graphics.Path
|
|
9
|
+
import android.graphics.RectF
|
|
10
|
+
import android.util.AttributeSet
|
|
11
|
+
import android.view.MotionEvent
|
|
12
|
+
import android.view.View
|
|
13
|
+
import android.view.animation.DecelerateInterpolator
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Renders the audio waveform as a row of vertical rounded-rect bars with a
|
|
17
|
+
* "played" / "unplayed" two-tone fill, and handles immediate touch-and-drag
|
|
18
|
+
* scrubbing.
|
|
19
|
+
*
|
|
20
|
+
* Drawing model:
|
|
21
|
+
* 1. Build a single cached `Path` containing every bar's rounded-rect when
|
|
22
|
+
* amplitudes / size / bar geometry change.
|
|
23
|
+
* 2. On every `onDraw`:
|
|
24
|
+
* a. `canvas.drawPath(cachedPath, unplayedPaint)`.
|
|
25
|
+
* b. `canvas.save()` -> `clipRect(0, 0, progressX, height)` ->
|
|
26
|
+
* `canvas.drawPath(cachedPath, playedPaint)` -> `canvas.restore()`.
|
|
27
|
+
*
|
|
28
|
+
* Touch handling is **immediate** — scrub starts at `ACTION_DOWN`, with no
|
|
29
|
+
* slop or long-press delay. The host view receives the proportional position
|
|
30
|
+
* via the `onScrubBegan/Moved/Ended` callbacks.
|
|
31
|
+
*/
|
|
32
|
+
class WaveformBarsView @JvmOverloads constructor(
|
|
33
|
+
context: Context,
|
|
34
|
+
attrs: AttributeSet? = null,
|
|
35
|
+
defStyleAttr: Int = 0
|
|
36
|
+
) : View(context, attrs, defStyleAttr) {
|
|
37
|
+
|
|
38
|
+
// region Visual configuration ------------------------------------------------
|
|
39
|
+
|
|
40
|
+
var amplitudes: FloatArray = FloatArray(0)
|
|
41
|
+
private set
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Update the bar amplitudes. Each call animates the existing displayed
|
|
45
|
+
* heights toward the new target over [AMPLITUDE_ANIMATION_DURATION_MS]
|
|
46
|
+
* (ease-out). Bars expand symmetrically from the centre because the bar
|
|
47
|
+
* geometry is centre-aligned in [buildBarPath].
|
|
48
|
+
*/
|
|
49
|
+
fun setAmplitudes(values: FloatArray) {
|
|
50
|
+
amplitudes = values
|
|
51
|
+
setTargetAmplitudes(values)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
var playedBarColor: Int
|
|
55
|
+
get() = playedPaint.color
|
|
56
|
+
set(value) {
|
|
57
|
+
playedPaint.color = value
|
|
58
|
+
invalidate()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
var unplayedBarColor: Int
|
|
62
|
+
get() = unplayedPaint.color
|
|
63
|
+
set(value) {
|
|
64
|
+
unplayedPaint.color = value
|
|
65
|
+
invalidate()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
var barWidthPx: Float = 3f * resources.displayMetrics.density
|
|
69
|
+
set(value) {
|
|
70
|
+
field = value
|
|
71
|
+
invalidatePath()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
var barGapPx: Float = 2f * resources.displayMetrics.density
|
|
75
|
+
set(value) {
|
|
76
|
+
field = value
|
|
77
|
+
invalidatePath()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** `< 0` means "auto" = barWidth / 2. */
|
|
81
|
+
var barRadiusPx: Float = -1f
|
|
82
|
+
set(value) {
|
|
83
|
+
field = value
|
|
84
|
+
invalidatePath()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** `<= 0` means "auto from view width". */
|
|
88
|
+
var barCountOverride: Int = 0
|
|
89
|
+
set(value) {
|
|
90
|
+
field = value
|
|
91
|
+
invalidatePath()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Fraction of the waveform that has been played, in [0, 1]. */
|
|
95
|
+
var progressFraction: Float = 0f
|
|
96
|
+
set(value) {
|
|
97
|
+
val clamped = value.coerceIn(0f, 1f)
|
|
98
|
+
if (clamped != field) {
|
|
99
|
+
field = clamped
|
|
100
|
+
invalidate()
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// endregion
|
|
105
|
+
|
|
106
|
+
// region Touch / scrub callbacks ---------------------------------------------
|
|
107
|
+
|
|
108
|
+
/** All callbacks pass a fraction in [0, 1] (touch x / view width). */
|
|
109
|
+
var onScrubBegan: ((Float) -> Unit)? = null
|
|
110
|
+
var onScrubMoved: ((Float) -> Unit)? = null
|
|
111
|
+
var onScrubEnded: ((Float, Boolean /* cancelled */) -> Unit)? = null
|
|
112
|
+
|
|
113
|
+
// endregion
|
|
114
|
+
|
|
115
|
+
// region Private state -------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
private val playedPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
118
|
+
color = Color.WHITE
|
|
119
|
+
style = Paint.Style.FILL
|
|
120
|
+
}
|
|
121
|
+
private val unplayedPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
122
|
+
color = Color.argb(128, 255, 255, 255)
|
|
123
|
+
style = Paint.Style.FILL
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private var cachedPath: Path? = null
|
|
127
|
+
private var cachedWidth: Int = 0
|
|
128
|
+
private var cachedHeight: Int = 0
|
|
129
|
+
private val tmpRect = RectF()
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Values actually drawn this frame. While an amplitudes update is
|
|
133
|
+
* animating, these are linearly interpolated between [startAmps] and
|
|
134
|
+
* [targetAmps] by [amplitudeAnimator].
|
|
135
|
+
*/
|
|
136
|
+
private var displayedAmps: FloatArray = FloatArray(0)
|
|
137
|
+
private var startAmps: FloatArray = FloatArray(0)
|
|
138
|
+
private var targetAmps: FloatArray = FloatArray(0)
|
|
139
|
+
|
|
140
|
+
private val amplitudeAnimator: ValueAnimator =
|
|
141
|
+
ValueAnimator.ofFloat(0f, 1f).apply {
|
|
142
|
+
duration = AMPLITUDE_ANIMATION_DURATION_MS
|
|
143
|
+
interpolator = DecelerateInterpolator(1.5f)
|
|
144
|
+
addUpdateListener { va ->
|
|
145
|
+
val t = va.animatedValue as Float
|
|
146
|
+
val count = minOf(displayedAmps.size, startAmps.size, targetAmps.size)
|
|
147
|
+
for (i in 0 until count) {
|
|
148
|
+
displayedAmps[i] = startAmps[i] + (targetAmps[i] - startAmps[i]) * t
|
|
149
|
+
}
|
|
150
|
+
invalidatePath()
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// endregion
|
|
155
|
+
|
|
156
|
+
init {
|
|
157
|
+
isClickable = true
|
|
158
|
+
isFocusable = true
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// region Layout / path caching -----------------------------------------------
|
|
162
|
+
|
|
163
|
+
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
|
164
|
+
super.onSizeChanged(w, h, oldw, oldh)
|
|
165
|
+
if (w != cachedWidth || h != cachedHeight) {
|
|
166
|
+
invalidatePath()
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private fun invalidatePath() {
|
|
171
|
+
cachedPath = null
|
|
172
|
+
cachedWidth = 0
|
|
173
|
+
cachedHeight = 0
|
|
174
|
+
invalidate()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private fun ensureCachedPath(): Path? {
|
|
178
|
+
val current = cachedPath
|
|
179
|
+
if (current != null && cachedWidth == width && cachedHeight == height) {
|
|
180
|
+
return current
|
|
181
|
+
}
|
|
182
|
+
val built = buildBarPath() ?: return null
|
|
183
|
+
cachedPath = built
|
|
184
|
+
cachedWidth = width
|
|
185
|
+
cachedHeight = height
|
|
186
|
+
return built
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Build the cached path. When [displayedAmps] is empty (decoding hasn't
|
|
191
|
+
* produced anything yet) we render a uniform low-amplitude skeleton so
|
|
192
|
+
* the user sees the bar pattern immediately instead of an empty card.
|
|
193
|
+
*
|
|
194
|
+
* `displayedAmps` already substitutes [PLACEHOLDER_AMPLITUDE] for any
|
|
195
|
+
* not-yet-decoded bar in [setTargetAmplitudes], so trailing bars stay at
|
|
196
|
+
* skeleton height instead of dropping to `minBarHeight` between partials.
|
|
197
|
+
*/
|
|
198
|
+
private fun buildBarPath(): Path? {
|
|
199
|
+
val totalWidth = width.toFloat()
|
|
200
|
+
val totalHeight = height.toFloat()
|
|
201
|
+
if (totalWidth <= 0 || totalHeight <= 0) return null
|
|
202
|
+
|
|
203
|
+
val step = barWidthPx + barGapPx
|
|
204
|
+
if (step <= 0f) return null
|
|
205
|
+
|
|
206
|
+
val autoCount = (totalWidth / step).toInt()
|
|
207
|
+
val barCount = if (barCountOverride > 0) {
|
|
208
|
+
kotlin.math.min(barCountOverride, autoCount)
|
|
209
|
+
} else {
|
|
210
|
+
autoCount
|
|
211
|
+
}
|
|
212
|
+
if (barCount <= 0) return null
|
|
213
|
+
|
|
214
|
+
val verticalPadding = barWidthPx * 1.5f
|
|
215
|
+
val drawableHeight = totalHeight - verticalPadding * 2f
|
|
216
|
+
if (drawableHeight <= 0f) return null
|
|
217
|
+
val minBarHeight = barWidthPx
|
|
218
|
+
val radius = if (barRadiusPx < 0f) barWidthPx / 2f else barRadiusPx
|
|
219
|
+
val usePlaceholder = displayedAmps.isEmpty()
|
|
220
|
+
|
|
221
|
+
val path = Path()
|
|
222
|
+
for (i in 0 until barCount) {
|
|
223
|
+
val amp = if (usePlaceholder) {
|
|
224
|
+
PLACEHOLDER_AMPLITUDE
|
|
225
|
+
} else {
|
|
226
|
+
val ampIndex = (i * displayedAmps.size / barCount)
|
|
227
|
+
.coerceIn(0, displayedAmps.size - 1)
|
|
228
|
+
displayedAmps[ampIndex].coerceIn(0f, 1f)
|
|
229
|
+
}
|
|
230
|
+
val barHeight = (amp * drawableHeight).coerceAtLeast(minBarHeight)
|
|
231
|
+
val x = i * step
|
|
232
|
+
val y = verticalPadding + (drawableHeight - barHeight) / 2f
|
|
233
|
+
tmpRect.set(x, y, x + barWidthPx, y + barHeight)
|
|
234
|
+
path.addRoundRect(tmpRect, radius, radius, Path.Direction.CW)
|
|
235
|
+
}
|
|
236
|
+
return path
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Update the animation targets when new amplitudes arrive. Each call
|
|
241
|
+
* captures the current [displayedAmps] as the start, sets the new
|
|
242
|
+
* `targetAmps`, and (re)starts the animator. Re-entrant — a new payload
|
|
243
|
+
* mid-animation cancels the previous run and animates from the
|
|
244
|
+
* mid-frame state to the new target.
|
|
245
|
+
*/
|
|
246
|
+
private fun setTargetAmplitudes(values: FloatArray) {
|
|
247
|
+
if (values.isEmpty()) {
|
|
248
|
+
amplitudeAnimator.cancel()
|
|
249
|
+
displayedAmps = FloatArray(0)
|
|
250
|
+
startAmps = FloatArray(0)
|
|
251
|
+
targetAmps = FloatArray(0)
|
|
252
|
+
invalidatePath()
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Treat zero amplitude as "not yet decoded" — keep those bars at the
|
|
257
|
+
// skeleton placeholder height instead of letting them snap to
|
|
258
|
+
// `minBarHeight` between partials.
|
|
259
|
+
val processed = FloatArray(values.size) { i ->
|
|
260
|
+
if (values[i] > 0f) values[i] else PLACEHOLDER_AMPLITUDE
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (displayedAmps.size != processed.size) {
|
|
264
|
+
// First non-empty payload (or barCount changed). Seed the
|
|
265
|
+
// current values with the placeholder skeleton so the animation
|
|
266
|
+
// grows from skeleton -> real shape.
|
|
267
|
+
displayedAmps = FloatArray(processed.size) { PLACEHOLDER_AMPLITUDE }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
startAmps = displayedAmps.copyOf()
|
|
271
|
+
targetAmps = processed
|
|
272
|
+
amplitudeAnimator.cancel()
|
|
273
|
+
amplitudeAnimator.start()
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
override fun onDetachedFromWindow() {
|
|
277
|
+
super.onDetachedFromWindow()
|
|
278
|
+
amplitudeAnimator.cancel()
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
companion object {
|
|
282
|
+
/**
|
|
283
|
+
* Uniform amplitude used to render the "loading" skeleton when no
|
|
284
|
+
* real amplitudes have been decoded yet.
|
|
285
|
+
*/
|
|
286
|
+
private const val PLACEHOLDER_AMPLITUDE = 0.2f
|
|
287
|
+
|
|
288
|
+
/** Duration (ms) of each amplitude-update animation. */
|
|
289
|
+
private const val AMPLITUDE_ANIMATION_DURATION_MS = 200L
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// endregion
|
|
293
|
+
|
|
294
|
+
override fun onDraw(canvas: Canvas) {
|
|
295
|
+
super.onDraw(canvas)
|
|
296
|
+
val path = ensureCachedPath() ?: return
|
|
297
|
+
|
|
298
|
+
// Pass 1: all bars in the unplayed color.
|
|
299
|
+
canvas.drawPath(path, unplayedPaint)
|
|
300
|
+
|
|
301
|
+
// Pass 2: clip to [0, progressX] and re-fill in the played color.
|
|
302
|
+
val progressX = (progressFraction.coerceIn(0f, 1f) * width.toFloat())
|
|
303
|
+
if (progressX <= 0f) return
|
|
304
|
+
val saveCount = canvas.save()
|
|
305
|
+
canvas.clipRect(0f, 0f, progressX, height.toFloat())
|
|
306
|
+
canvas.drawPath(path, playedPaint)
|
|
307
|
+
canvas.restoreToCount(saveCount)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// region Touch handling — immediate scrub ------------------------------------
|
|
311
|
+
|
|
312
|
+
@Suppress("ClickableViewAccessibility")
|
|
313
|
+
override fun onTouchEvent(event: MotionEvent): Boolean {
|
|
314
|
+
val w = width
|
|
315
|
+
if (w <= 0) return super.onTouchEvent(event)
|
|
316
|
+
|
|
317
|
+
when (event.actionMasked) {
|
|
318
|
+
MotionEvent.ACTION_DOWN -> {
|
|
319
|
+
// Don't let a parent ScrollView steal the drag.
|
|
320
|
+
parent?.requestDisallowInterceptTouchEvent(true)
|
|
321
|
+
val fraction = (event.x / w.toFloat()).coerceIn(0f, 1f)
|
|
322
|
+
progressFraction = fraction
|
|
323
|
+
invalidate()
|
|
324
|
+
onScrubBegan?.invoke(fraction)
|
|
325
|
+
return true
|
|
326
|
+
}
|
|
327
|
+
MotionEvent.ACTION_MOVE -> {
|
|
328
|
+
val fraction = (event.x / w.toFloat()).coerceIn(0f, 1f)
|
|
329
|
+
progressFraction = fraction
|
|
330
|
+
invalidate()
|
|
331
|
+
onScrubMoved?.invoke(fraction)
|
|
332
|
+
return true
|
|
333
|
+
}
|
|
334
|
+
MotionEvent.ACTION_UP -> {
|
|
335
|
+
val fraction = (event.x / w.toFloat()).coerceIn(0f, 1f)
|
|
336
|
+
progressFraction = fraction
|
|
337
|
+
invalidate()
|
|
338
|
+
onScrubEnded?.invoke(fraction, false)
|
|
339
|
+
parent?.requestDisallowInterceptTouchEvent(false)
|
|
340
|
+
performClick()
|
|
341
|
+
return true
|
|
342
|
+
}
|
|
343
|
+
MotionEvent.ACTION_CANCEL -> {
|
|
344
|
+
onScrubEnded?.invoke(progressFraction, true)
|
|
345
|
+
parent?.requestDisallowInterceptTouchEvent(false)
|
|
346
|
+
return true
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return super.onTouchEvent(event)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
override fun performClick(): Boolean {
|
|
353
|
+
super.performClick()
|
|
354
|
+
return true
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// endregion
|
|
358
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
package com.audiowaveform
|
|
2
|
+
|
|
3
|
+
import android.media.MediaCodec
|
|
4
|
+
import android.media.MediaExtractor
|
|
5
|
+
import android.media.MediaFormat
|
|
6
|
+
import android.os.Handler
|
|
7
|
+
import android.os.Looper
|
|
8
|
+
import android.util.Log
|
|
9
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
10
|
+
import kotlin.math.sqrt
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Decode an audio file into per-bar RMS amplitudes for waveform visualisation.
|
|
14
|
+
*
|
|
15
|
+
* Uses `MediaExtractor` + `MediaCodec` for hardware-accelerated PCM decode.
|
|
16
|
+
* `MediaExtractor` handles `http(s)://` URLs natively (it follows redirects),
|
|
17
|
+
* so no manual download is needed.
|
|
18
|
+
*
|
|
19
|
+
* Bucket-by-presentation-time (rather than sequential) for time-accurate bars
|
|
20
|
+
* even with VBR audio. Emits intermediate progress (5% then every 20%) so
|
|
21
|
+
* the waveform paints in as it decodes.
|
|
22
|
+
*
|
|
23
|
+
* All callbacks fire on the main thread.
|
|
24
|
+
*/
|
|
25
|
+
class WaveformDecoder {
|
|
26
|
+
|
|
27
|
+
interface Listener {
|
|
28
|
+
fun onProgress(amplitudes: FloatArray) {}
|
|
29
|
+
fun onComplete(amplitudes: FloatArray)
|
|
30
|
+
fun onFailure(message: String)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private val cancelFlag = AtomicBoolean(false)
|
|
34
|
+
private var workerThread: Thread? = null
|
|
35
|
+
private val mainHandler = Handler(Looper.getMainLooper())
|
|
36
|
+
|
|
37
|
+
fun decode(uri: String, barCount: Int, listener: Listener) {
|
|
38
|
+
cancel()
|
|
39
|
+
if (barCount <= 0) {
|
|
40
|
+
mainHandler.post { listener.onComplete(FloatArray(0)) }
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
cancelFlag.set(false)
|
|
44
|
+
val token = cancelFlag
|
|
45
|
+
val thread = Thread({
|
|
46
|
+
try {
|
|
47
|
+
runDecode(uri, barCount, token, listener)
|
|
48
|
+
} catch (e: Exception) {
|
|
49
|
+
Log.e(TAG, "decode crashed", e)
|
|
50
|
+
if (!token.get()) {
|
|
51
|
+
mainHandler.post { listener.onFailure(e.message ?: "Decode failed") }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}, "audiowaveform-decode")
|
|
55
|
+
workerThread = thread
|
|
56
|
+
thread.isDaemon = true
|
|
57
|
+
thread.start()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Cancel any in-flight decode. Safe to call repeatedly. */
|
|
61
|
+
fun cancel() {
|
|
62
|
+
cancelFlag.set(true)
|
|
63
|
+
workerThread = null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private fun runDecode(
|
|
67
|
+
uri: String,
|
|
68
|
+
barCount: Int,
|
|
69
|
+
cancelled: AtomicBoolean,
|
|
70
|
+
listener: Listener
|
|
71
|
+
) {
|
|
72
|
+
val extractor = MediaExtractor()
|
|
73
|
+
try {
|
|
74
|
+
try {
|
|
75
|
+
if (uri.startsWith("http://") || uri.startsWith("https://")) {
|
|
76
|
+
extractor.setDataSource(uri, HashMap())
|
|
77
|
+
} else {
|
|
78
|
+
extractor.setDataSource(uri)
|
|
79
|
+
}
|
|
80
|
+
} catch (e: Exception) {
|
|
81
|
+
if (!cancelled.get()) {
|
|
82
|
+
mainHandler.post {
|
|
83
|
+
listener.onFailure("setDataSource failed: ${e.message}")
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
var audioTrackIndex = -1
|
|
90
|
+
var audioFormat: MediaFormat? = null
|
|
91
|
+
for (i in 0 until extractor.trackCount) {
|
|
92
|
+
val format = extractor.getTrackFormat(i)
|
|
93
|
+
val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
|
|
94
|
+
if (mime.startsWith("audio/")) {
|
|
95
|
+
audioTrackIndex = i
|
|
96
|
+
audioFormat = format
|
|
97
|
+
break
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (audioTrackIndex < 0 || audioFormat == null) {
|
|
101
|
+
if (!cancelled.get()) {
|
|
102
|
+
mainHandler.post { listener.onFailure("No audio track found") }
|
|
103
|
+
}
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
extractor.selectTrack(audioTrackIndex)
|
|
108
|
+
extractor.seekTo(0L, MediaExtractor.SEEK_TO_CLOSEST_SYNC)
|
|
109
|
+
|
|
110
|
+
val mime = audioFormat.getString(MediaFormat.KEY_MIME) ?: run {
|
|
111
|
+
mainHandler.post { listener.onFailure("Audio mime type missing") }
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
val codec: MediaCodec = try {
|
|
115
|
+
MediaCodec.createDecoderByType(mime)
|
|
116
|
+
} catch (e: Exception) {
|
|
117
|
+
mainHandler.post { listener.onFailure("Codec creation failed: ${e.message}") }
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
codec.configure(audioFormat, null, null, 0)
|
|
123
|
+
codec.start()
|
|
124
|
+
|
|
125
|
+
val durationUs = if (audioFormat.containsKey(MediaFormat.KEY_DURATION)) {
|
|
126
|
+
audioFormat.getLong(MediaFormat.KEY_DURATION)
|
|
127
|
+
} else 0L
|
|
128
|
+
val totalDurationUs = if (durationUs > 0) durationUs else 60_000_000L
|
|
129
|
+
val barDurationUs = totalDurationUs.toDouble() / barCount
|
|
130
|
+
|
|
131
|
+
val sumSquares = DoubleArray(barCount)
|
|
132
|
+
val sampleCounts = IntArray(barCount)
|
|
133
|
+
val bufferInfo = MediaCodec.BufferInfo()
|
|
134
|
+
var inputDone = false
|
|
135
|
+
var outputDone = false
|
|
136
|
+
val timeoutUs = 10_000L
|
|
137
|
+
var highestFilledBar = -1
|
|
138
|
+
val firstUpdateThreshold = (barCount / 20).coerceAtLeast(1)
|
|
139
|
+
val regularUpdateInterval = (barCount / 5).coerceAtLeast(1)
|
|
140
|
+
var lastUpdateBar = -1
|
|
141
|
+
|
|
142
|
+
while (!outputDone) {
|
|
143
|
+
if (cancelled.get()) {
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
if (!inputDone) {
|
|
147
|
+
val inputIndex = codec.dequeueInputBuffer(timeoutUs)
|
|
148
|
+
if (inputIndex >= 0) {
|
|
149
|
+
val inputBuffer = codec.getInputBuffer(inputIndex) ?: continue
|
|
150
|
+
val sampleSize = extractor.readSampleData(inputBuffer, 0)
|
|
151
|
+
if (sampleSize < 0) {
|
|
152
|
+
codec.queueInputBuffer(
|
|
153
|
+
inputIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM
|
|
154
|
+
)
|
|
155
|
+
inputDone = true
|
|
156
|
+
} else {
|
|
157
|
+
codec.queueInputBuffer(
|
|
158
|
+
inputIndex, 0, sampleSize, extractor.sampleTime, 0
|
|
159
|
+
)
|
|
160
|
+
extractor.advance()
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
val outputIndex = codec.dequeueOutputBuffer(bufferInfo, timeoutUs)
|
|
166
|
+
if (outputIndex >= 0) {
|
|
167
|
+
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
|
|
168
|
+
outputDone = true
|
|
169
|
+
}
|
|
170
|
+
val outputBuffer = codec.getOutputBuffer(outputIndex)
|
|
171
|
+
if (outputBuffer != null && bufferInfo.size > 0) {
|
|
172
|
+
val barIndex = ((bufferInfo.presentationTimeUs / barDurationUs).toInt())
|
|
173
|
+
.coerceIn(0, barCount - 1)
|
|
174
|
+
|
|
175
|
+
outputBuffer.position(bufferInfo.offset)
|
|
176
|
+
outputBuffer.limit(bufferInfo.offset + bufferInfo.size)
|
|
177
|
+
val shortCount = bufferInfo.size / 2
|
|
178
|
+
for (i in 0 until shortCount) {
|
|
179
|
+
val sample = outputBuffer.short.toFloat() / Short.MAX_VALUE.toFloat()
|
|
180
|
+
sumSquares[barIndex] += (sample * sample).toDouble()
|
|
181
|
+
sampleCounts[barIndex]++
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (barIndex > highestFilledBar) highestFilledBar = barIndex
|
|
185
|
+
|
|
186
|
+
val interval = if (lastUpdateBar < 0) firstUpdateThreshold else regularUpdateInterval
|
|
187
|
+
if (highestFilledBar - kotlin.math.max(0, lastUpdateBar) >= interval) {
|
|
188
|
+
lastUpdateBar = highestFilledBar
|
|
189
|
+
val partial = normaliseAmplitudes(sumSquares, sampleCounts, barCount)
|
|
190
|
+
if (!cancelled.get()) {
|
|
191
|
+
mainHandler.post {
|
|
192
|
+
if (!cancelled.get()) listener.onProgress(partial)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
codec.releaseOutputBuffer(outputIndex, false)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
val finalAmps = normaliseAmplitudes(sumSquares, sampleCounts, barCount)
|
|
202
|
+
if (!cancelled.get()) {
|
|
203
|
+
mainHandler.post {
|
|
204
|
+
if (!cancelled.get()) listener.onComplete(finalAmps)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
} finally {
|
|
208
|
+
try { codec.stop() } catch (_: Exception) {}
|
|
209
|
+
codec.release()
|
|
210
|
+
}
|
|
211
|
+
} finally {
|
|
212
|
+
extractor.release()
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private fun normaliseAmplitudes(
|
|
217
|
+
sumSquares: DoubleArray,
|
|
218
|
+
sampleCounts: IntArray,
|
|
219
|
+
barCount: Int
|
|
220
|
+
): FloatArray {
|
|
221
|
+
val amplitudes = FloatArray(barCount)
|
|
222
|
+
var maxAmp = 0f
|
|
223
|
+
for (i in 0 until barCount) {
|
|
224
|
+
if (sampleCounts[i] > 0) {
|
|
225
|
+
amplitudes[i] = sqrt(sumSquares[i] / sampleCounts[i]).toFloat()
|
|
226
|
+
if (amplitudes[i] > maxAmp) maxAmp = amplitudes[i]
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (maxAmp > 0f) {
|
|
230
|
+
for (i in amplitudes.indices) {
|
|
231
|
+
amplitudes[i] = (amplitudes[i] / maxAmp).coerceIn(0f, 1f)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return amplitudes
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
companion object {
|
|
238
|
+
private const val TAG = "WaveformDecoder"
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
2
|
+
android:width="12.275dp"
|
|
3
|
+
android:height="16.162dp"
|
|
4
|
+
android:viewportWidth="12.275"
|
|
5
|
+
android:viewportHeight="16.162">
|
|
6
|
+
<path
|
|
7
|
+
android:fillColor="#FF000000"
|
|
8
|
+
android:pathData="M0,0h12.275v16.162h-12.275z"
|
|
9
|
+
android:strokeAlpha="0"
|
|
10
|
+
android:fillAlpha="0"/>
|
|
11
|
+
<path
|
|
12
|
+
android:pathData="M1.299,16.152L3.525,16.152C4.375,16.152 4.824,15.703 4.824,14.844L4.824,1.299C4.824,0.4 4.375,0 3.525,0L1.299,0C0.449,0 0,0.439 0,1.299L0,14.844C0,15.703 0.449,16.152 1.299,16.152ZM8.398,16.152L10.615,16.152C11.475,16.152 11.914,15.703 11.914,14.844L11.914,1.299C11.914,0.4 11.475,0 10.615,0L8.398,0C7.539,0 7.09,0.439 7.09,1.299L7.09,14.844C7.09,15.703 7.539,16.152 8.398,16.152Z"
|
|
13
|
+
android:fillColor="#ffffff"
|
|
14
|
+
android:fillAlpha="0.85"/>
|
|
15
|
+
</vector>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
2
|
+
android:width="16.289dp"
|
|
3
|
+
android:height="16.416dp"
|
|
4
|
+
android:viewportWidth="16.289"
|
|
5
|
+
android:viewportHeight="16.416">
|
|
6
|
+
<path
|
|
7
|
+
android:fillColor="#FF000000"
|
|
8
|
+
android:pathData="M0,0h16.289v16.416h-16.289z"
|
|
9
|
+
android:strokeAlpha="0"
|
|
10
|
+
android:fillAlpha="0"/>
|
|
11
|
+
<path
|
|
12
|
+
android:pathData="M1.709,14.981C1.709,15.947 2.266,16.406 2.93,16.406C3.223,16.406 3.525,16.309 3.828,16.152L15.205,9.502C16.016,9.033 16.289,8.711 16.289,8.203C16.289,7.686 16.016,7.373 15.205,6.904L3.828,0.254C3.525,0.088 3.223,0 2.93,0C2.266,0 1.709,0.459 1.709,1.426Z"
|
|
13
|
+
android:fillColor="#ffffff"
|
|
14
|
+
android:fillAlpha="0.85"/>
|
|
15
|
+
</vector>
|