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.
Files changed (46) hide show
  1. package/AudioWaveform.podspec +29 -0
  2. package/LICENSE +20 -0
  3. package/README.md +296 -0
  4. package/android/build.gradle +67 -0
  5. package/android/src/main/AndroidManifest.xml +3 -0
  6. package/android/src/main/java/com/audiowaveform/AudioPlayerEngine.kt +353 -0
  7. package/android/src/main/java/com/audiowaveform/AudioWaveformEvent.kt +22 -0
  8. package/android/src/main/java/com/audiowaveform/AudioWaveformPackage.kt +17 -0
  9. package/android/src/main/java/com/audiowaveform/AudioWaveformView.kt +715 -0
  10. package/android/src/main/java/com/audiowaveform/AudioWaveformViewManager.kt +234 -0
  11. package/android/src/main/java/com/audiowaveform/PlayPauseButton.kt +106 -0
  12. package/android/src/main/java/com/audiowaveform/SpeedPillView.kt +70 -0
  13. package/android/src/main/java/com/audiowaveform/WaveformBarsView.kt +358 -0
  14. package/android/src/main/java/com/audiowaveform/WaveformDecoder.kt +240 -0
  15. package/android/src/main/res/drawable/pause_fill.xml +15 -0
  16. package/android/src/main/res/drawable/play_fill.xml +15 -0
  17. package/ios/AudioPlayerEngine.swift +281 -0
  18. package/ios/AudioWaveformView.h +14 -0
  19. package/ios/AudioWaveformView.mm +307 -0
  20. package/ios/AudioWaveformViewImpl.swift +835 -0
  21. package/ios/PlayPauseButton.swift +118 -0
  22. package/ios/SpeedPillView.swift +70 -0
  23. package/ios/WaveformBarsView.swift +327 -0
  24. package/ios/WaveformDecoder.swift +332 -0
  25. package/lib/module/AudioWaveformView.js +8 -0
  26. package/lib/module/AudioWaveformView.js.map +1 -0
  27. package/lib/module/AudioWaveformView.native.js +79 -0
  28. package/lib/module/AudioWaveformView.native.js.map +1 -0
  29. package/lib/module/AudioWaveformViewNativeComponent.ts +95 -0
  30. package/lib/module/index.js +4 -0
  31. package/lib/module/index.js.map +1 -0
  32. package/lib/module/package.json +1 -0
  33. package/lib/typescript/package.json +1 -0
  34. package/lib/typescript/src/AudioWaveformView.d.ts +233 -0
  35. package/lib/typescript/src/AudioWaveformView.d.ts.map +1 -0
  36. package/lib/typescript/src/AudioWaveformView.native.d.ts +335 -0
  37. package/lib/typescript/src/AudioWaveformView.native.d.ts.map +1 -0
  38. package/lib/typescript/src/AudioWaveformViewNativeComponent.d.ts +71 -0
  39. package/lib/typescript/src/AudioWaveformViewNativeComponent.d.ts.map +1 -0
  40. package/lib/typescript/src/index.d.ts +3 -0
  41. package/lib/typescript/src/index.d.ts.map +1 -0
  42. package/package.json +138 -7
  43. package/src/AudioWaveformView.native.tsx +281 -0
  44. package/src/AudioWaveformView.tsx +96 -0
  45. package/src/AudioWaveformViewNativeComponent.ts +95 -0
  46. 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>