From 8b07b9e0e91a0ef6814eef6e348150ba28b5a372 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:41:56 -0400 Subject: [PATCH] redo waveform and nomalization analysis --- .../AstraLibraryScannerModule.kt | 614 +++++++++++------- modules/astra-library-scanner/index.ts | 54 +- package.json | 1 + src/app/settings/troubleshooting.tsx | 56 ++ src/audio/trackAnalysis.ts | 282 ++++++-- src/audio/useNormalizationSync.ts | 51 +- src/components/WaveformSeekBar.tsx | 50 +- src/scope/waveform.ts | 161 ++--- src/scope/waveformMath.test.mts | 87 +++ src/scope/waveformMath.ts | 85 +++ vendor/kotlinaudio/kotlin-audio/build.gradle | 14 +- 11 files changed, 1059 insertions(+), 396 deletions(-) create mode 100644 src/scope/waveformMath.test.mts create mode 100644 src/scope/waveformMath.ts diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt index 6e9505b..0591f70 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt @@ -6,11 +6,15 @@ import android.graphics.Bitmap import android.graphics.BitmapFactory import android.media.AudioFormat import android.media.MediaCodec +import android.media.MediaCodecInfo +import android.media.MediaCodecList import android.media.MediaExtractor import android.media.MediaFormat import android.media.MediaMetadataRetriever import android.net.Uri import android.os.Build +import android.os.Handler +import android.os.HandlerThread import android.provider.DocumentsContract import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.MetadataRetriever @@ -18,8 +22,10 @@ import com.google.android.exoplayer2.metadata.id3.BinaryFrame import com.google.android.exoplayer2.metadata.id3.InternalFrame import com.google.android.exoplayer2.metadata.id3.TextInformationFrame import com.google.android.exoplayer2.metadata.flac.VorbisComment +import java.nio.ByteBuffer import java.nio.ByteOrder import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean import kotlin.math.PI import kotlin.math.abs import kotlin.math.log10 @@ -35,6 +41,7 @@ import expo.modules.kotlin.records.Record import expo.modules.astralibraryscanner.data.AstraLibraryRepository import expo.modules.astralibraryscanner.data.LocalAudioFile import expo.modules.astralibraryscanner.data.LocalAudioMetadata +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -42,6 +49,7 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import java.io.File import java.security.MessageDigest import java.util.concurrent.ConcurrentHashMap @@ -52,11 +60,29 @@ class FileRequest : Record { @Field val coverUri: String? = null } -/** Result of one scan-time decode: waveform peaks + integrated loudness + sample peak. */ +/** + * Result of ONE decode pass over a track: waveform peaks + integrated loudness + sample + * peak, plus timing so the JS side can report how fast the decode actually ran. Peaks and + * loudness share a pass because both need every sample; decoding twice was pure waste. + */ class AudioAnalysis : Record { @Field var peaks: FloatArray = FloatArray(0) @Field var lufs: Double? = null // integrated LUFS (negative dB); null if unmeasured @Field var peak: Double? = null // absolute sample peak, linear [0,1]; null if unmeasured + /** True when the decode was cancelled mid-flight; peaks/lufs are then meaningless. */ + @Field var cancelled: Boolean = false + /** Wall-clock decode time in ms — the number that decides whether we need a native decoder. */ + @Field var decodeMs: Double? = null + /** Track duration in ms, from the container. */ + @Field var durationMs: Double? = null + /** durationMs / decodeMs — "how many times faster than realtime". Higher is better. */ + @Field var realtimeFactor: Double? = null + /** Which MediaCodec actually ran (e.g. "c2.android.flac.decoder"). */ + @Field var decoderName: String? = null + /** Audio track mime (e.g. "audio/flac"). */ + @Field var mime: String? = null + /** Whether the loudness meter rode along on this pass. */ + @Field var withLoudness: Boolean = false } /** ReplayGain tags read from the container (no audio decode). Null = tag absent. */ @@ -75,13 +101,20 @@ class AstraLibraryScannerModule : Module() { // read and hashed once, not once per track. private val coverHashMemo = ConcurrentHashMap() - // Waveform decode is whole-file and CPU-heavy; throttle concurrent decodes. + // Analysis decode is whole-file and CPU-heavy; throttle concurrent decodes. Also keeps + // us from monopolising decoder instances while a track is actually playing. private val waveformSemaphore = Semaphore(2) + // Cancellation flags for in-flight analyses, keyed by track URI. Set by cancelAnalysis + // so a skipped-past track stops burning CPU instead of running to completion holding a + // semaphore permit. Registered before the permit is acquired, so a queued-but-unstarted + // decode can be cancelled too. + private val activeAnalyses = ConcurrentHashMap() + override fun definition() = ModuleDefinition { Name("AstraLibraryScanner") - Events("onScanProgress") + Events("onScanProgress", "onWaveformProgress") AsyncFunction("listAudioFiles") Coroutine { treeUri: String, extensions: List -> withContext(Dispatchers.IO) { listAudioFiles(treeUri, extensions) } @@ -144,33 +177,42 @@ class AstraLibraryScannerModule : Module() { } } - // Offline waveform peaks for the seek bar: full PCM decode -> RMS per bin, - // normalized to [0,1]. Heavy (whole-file decode), so cap concurrency and - // run lazily per track on the JS side; results are cached in SQLite there. - AsyncFunction("extractWaveform") Coroutine { uri: String, bins: Int -> - waveformSemaphore.withPermit { - withContext(Dispatchers.IO) { decodeAndAnalyze(uri, if (bins > 0) bins else 512).peaks } + // ONE whole-file PCM decode producing waveform peaks and (when withLoudness) gated + // integrated loudness + sample peak. Both need every sample, so they ride the same + // pass — running them separately meant decoding each track twice. Heavy, so cap + // concurrency; the JS side caches results in SQLite and prefetches the queue ahead. + // Emits onWaveformProgress as bins finalize so the seek bar can fill in left-to-right. + AsyncFunction("analyzeTrack") Coroutine { uri: String, bins: Int, withLoudness: Boolean -> + val flag = AtomicBoolean(false) + activeAnalyses[uri] = flag + try { + waveformSemaphore.withPermit { + // Cancelled while queued behind another decode — don't start at all. + if (flag.get()) AudioAnalysis().apply { cancelled = true } + else withContext(Dispatchers.IO) { + runAnalysis(uri, if (bins > 0) bins else 512, withLoudness, flag) + } + } + } finally { + activeAnalyses.remove(uri, flag) } } + // Stop an in-flight (or still-queued) analysis for this URI. Safe to call for a URI + // with no analysis running. The decode bails at the next buffer boundary. + AsyncFunction("cancelAnalysis") Coroutine { uri: String -> + activeAnalyses[uri]?.set(true) + } + // Fast waveform preview for first paint: sparse short-window decode across - // the file. The JS side shows this immediately but only persists the full - // extractWaveform result. + // the file. The JS side shows this immediately as the coarse full-width shape + // that the progressive accurate pass then fills over; only analyzeTrack persists. AsyncFunction("extractWaveformPreview") Coroutine { uri: String, bins: Int -> waveformSemaphore.withPermit { withContext(Dispatchers.IO) { decodeWaveformPreview(uri, if (bins > 0) bins else 96) } } } - // Fast loudness (M4): decodes only a few short windows spread across the track - // (not the whole file) + gated K-weighting -> integrated LUFS + sample peak. - // Waveform peaks stay lazy/full-decode (extractWaveform), decoupled from this. - AsyncFunction("measureLoudness") Coroutine { uri: String -> - waveformSemaphore.withPermit { - withContext(Dispatchers.IO) { measureLoudness(uri) } - } - } - // ReplayGain tags (M4): reads container metadata only (no PCM decode), so it is // cheap and lets us normalize a tagged library without the slow loudness decode. AsyncFunction("readReplayGain") Coroutine { uri: String -> @@ -644,18 +686,43 @@ class AstraLibraryScannerModule : Module() { ) // --------------------------------------------------------------------------- - // Waveform peaks (offline RMS bins) + // Track analysis: waveform peaks + loudness in ONE decode pass // --------------------------------------------------------------------------- - // One whole-file PCM decode -> per-bin RMS waveform peaks (normalized [0,1]) for the - // seek bar. Returns empty peaks on any failure (caller falls back to a flat seek - // bar). Loudness is measured separately by measureLoudness. - private fun decodeAndAnalyze(uriStr: String, bins: Int): AudioAnalysis { + /** Throttle for onWaveformProgress — ~12 emits/sec is plenty for a fill animation. */ + private val progressEmitNanos = 80L * 1_000_000L + + /** Hard ceiling so a corrupt file can't hang a decode forever holding a semaphore permit. */ + private val analysisTimeoutMs = 180_000L + + /** + * One whole-file PCM decode producing per-bin RMS waveform peaks (normalized to [0,1]) + * and, when `withLoudness`, gated integrated LUFS + absolute sample peak. Both analyses + * need every sample, so they share a pass. + * + * Uses MediaCodec in async (callback) mode: the old synchronous dequeue loop burned a + * 10ms timeout every time a buffer wasn't ready, thousands of times per track. All four + * callbacks land on one handler thread, so the extractor and accumulator are touched from + * exactly one thread and need no locking. + * + * Returns empty peaks on any failure and sets `cancelled` if it bailed early — callers + * must not persist a cancelled result. + */ + private suspend fun runAnalysis( + uriStr: String, + bins: Int, + withLoudness: Boolean, + cancelFlag: AtomicBoolean, + ): AudioAnalysis { val context = requireContext() val result = AudioAnalysis() + result.withLoudness = withLoudness val uri = Uri.parse(uriStr) val extractor = MediaExtractor() var codec: MediaCodec? = null + var handlerThread: HandlerThread? = null + val startNanos = System.nanoTime() + try { extractor.setDataSource(context, uri, null) @@ -670,63 +737,305 @@ class AstraLibraryScannerModule : Module() { val format = trackFormat ?: return result extractor.selectTrack(trackIndex) + val mime = format.getString(MediaFormat.KEY_MIME) ?: return result + result.mime = mime + val sampleRate = if (format.containsKey(MediaFormat.KEY_SAMPLE_RATE)) format.getInteger(MediaFormat.KEY_SAMPLE_RATE) else 44100 val durationUs = if (format.containsKey(MediaFormat.KEY_DURATION)) format.getLong(MediaFormat.KEY_DURATION) else 0L + result.durationMs = durationUs / 1000.0 val totalFrames = max(1L, (durationUs / 1_000_000.0 * sampleRate).toLong()) - var channelCount = + + val acc = AnalyzeAccumulator(bins, totalFrames, withLoudness) + acc.sampleRate = sampleRate + acc.channelCount = if (format.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) format.getInteger(MediaFormat.KEY_CHANNEL_COUNT) else 2 - var pcmFloat = false - val sumSquares = DoubleArray(bins) - val counts = LongArray(bins) + val decoder = createAnalysisDecoder(mime) + codec = decoder + result.decoderName = decoder.name + + handlerThread = HandlerThread("astra-analyze").also { it.start() } + val done = CompletableDeferred() + var sawInputEOS = false + var lastEmitNanos = 0L + var lastEmitBin = 0 + + decoder.setCallback( + object : MediaCodec.Callback() { + override fun onInputBufferAvailable(mc: MediaCodec, index: Int) { + if (done.isCompleted) return + try { + if (cancelFlag.get()) { + result.cancelled = true + done.complete(Unit) + return + } + if (sawInputEOS) return + val buf = mc.getInputBuffer(index) ?: return + val size = extractor.readSampleData(buf, 0) + if (size < 0) { + mc.queueInputBuffer(index, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM) + sawInputEOS = true + } else { + mc.queueInputBuffer(index, 0, size, extractor.sampleTime, 0) + extractor.advance() + } + } catch (_: Throwable) { + done.complete(Unit) + } + } + + override fun onOutputBufferAvailable( + mc: MediaCodec, + index: Int, + info: MediaCodec.BufferInfo, + ) { + if (done.isCompleted) return + try { + if (cancelFlag.get()) { + result.cancelled = true + done.complete(Unit) + return + } + if (info.size > 0) { + val out = mc.getOutputBuffer(index) + if (out != null) { + out.position(info.offset) + out.limit(info.offset + info.size) + out.order(ByteOrder.nativeOrder()) + acc.accumulate(out) + } + } + val eos = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0 + mc.releaseOutputBuffer(index, false) + + // Progressive emit so the seek bar fills left-to-right instead of snapping + // in at the end. Skipped on the EOS buffer — the promise carries the final, + // globally-normalized result a moment later. + val now = System.nanoTime() + if (!eos && acc.filledBins > lastEmitBin && now - lastEmitNanos >= progressEmitNanos) { + lastEmitNanos = now + lastEmitBin = acc.filledBins + emitWaveformProgress(uriStr, acc, lastEmitBin) + } + if (eos) done.complete(Unit) + } catch (_: Throwable) { + done.complete(Unit) + } + } + + override fun onOutputFormatChanged(mc: MediaCodec, fmt: MediaFormat) { + if (fmt.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) { + acc.channelCount = fmt.getInteger(MediaFormat.KEY_CHANNEL_COUNT) + } + if (fmt.containsKey(MediaFormat.KEY_SAMPLE_RATE)) { + acc.sampleRate = fmt.getInteger(MediaFormat.KEY_SAMPLE_RATE) + } + if (fmt.containsKey(MediaFormat.KEY_PCM_ENCODING)) { + acc.pcmFloat = + fmt.getInteger(MediaFormat.KEY_PCM_ENCODING) == AudioFormat.ENCODING_PCM_FLOAT + } + } + + override fun onError(mc: MediaCodec, e: MediaCodec.CodecException) { + done.complete(Unit) + } + }, + Handler(handlerThread.looper), + ) - codec = MediaCodec.createDecoderByType(format.getString(MediaFormat.KEY_MIME)!!) codec.configure(format, null, null, 0) codec.start() - val info = MediaCodec.BufferInfo() - var sawInputEOS = false - var sawOutputEOS = false - var frame = 0L + if (withTimeoutOrNull(analysisTimeoutMs) { done.await() } == null) { + // Timed out: peaks are partial, so treat it as a cancellation rather than caching + // a truncated waveform. + result.cancelled = true + } + if (result.cancelled) return result - while (!sawOutputEOS) { - if (!sawInputEOS) { - val inIndex = codec.dequeueInputBuffer(10_000) - if (inIndex >= 0) { - val inBuf = codec.getInputBuffer(inIndex)!! - val size = extractor.readSampleData(inBuf, 0) - if (size < 0) { - codec.queueInputBuffer(inIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM) - sawInputEOS = true - } else { - codec.queueInputBuffer(inIndex, 0, size, extractor.sampleTime, 0) - extractor.advance() + result.peaks = acc.finalPeaks() + if (withLoudness) { + result.lufs = acc.loudness + result.peak = acc.samplePeak + } + val decodeMs = (System.nanoTime() - startNanos) / 1_000_000.0 + result.decodeMs = decodeMs + val dur = result.durationMs + if (dur != null && dur > 0 && decodeMs > 0) result.realtimeFactor = dur / decodeMs + return result + } catch (_: Throwable) { + return result + } finally { + // Order matters: stopping the codec while a callback is mid-flight on the handler + // thread can crash. Quit the looper and wait for the in-flight callback to drain + // first, THEN tear the codec down. + try { + handlerThread?.quitSafely() + handlerThread?.join(1_000) + } catch (_: Throwable) {} + try { codec?.stop() } catch (_: Throwable) {} + try { codec?.release() } catch (_: Throwable) {} + try { extractor.release() } catch (_: Throwable) {} + } + } + + // Emits the raw (un-normalized) RMS prefix; JS normalizes against its own max, so the + // bars rescale slightly as louder material arrives rather than needing a global max we + // don't have yet. + private fun emitWaveformProgress(uri: String, acc: AnalyzeAccumulator, filledBins: Int) { + try { + sendEvent( + "onWaveformProgress", + mapOf( + "uri" to uri, + "filledBins" to filledBins, + "totalBins" to acc.bins, + "peaks" to acc.rmsPrefix(filledBins), + ), + ) + } catch (_: Throwable) { + // Best-effort: the final result still arrives via the promise. + } + } + + // Offline analysis wants raw throughput. Hardware audio decoders are tuned for low-power + // realtime playback, not bulk decode, and the instance is a scarce global resource shared + // with the track that is actually playing — so prefer a software decoder and fall back to + // the platform's default pick. + private fun createAnalysisDecoder(mime: String): MediaCodec { + try { + val info = MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos.firstOrNull { c -> + !c.isEncoder && + c.supportedTypes.any { it.equals(mime, ignoreCase = true) } && + isSoftwareDecoder(c) + } + if (info != null) return MediaCodec.createByCodecName(info.name) + } catch (_: Throwable) { + // Fall through to the platform default. + } + return MediaCodec.createDecoderByType(mime) + } + + private fun isSoftwareDecoder(info: MediaCodecInfo): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) return info.isSoftwareOnly + val name = info.name.lowercase() + return name.startsWith("omx.google.") || name.startsWith("c2.android.") + } + + /** + * Per-bin RMS accumulation over the decoded PCM stream, with the K-weighted loudness + * meter folded in when requested. + * + * Two things matter here because this runs ~20M times for a 4-minute stereo track: PCM is + * bulk-copied out of the codec buffer into a reused scratch array rather than read one + * sample at a time, and frames are consumed in runs (every frame landing in the same bin + * is summed in one tight loop) instead of recomputing the bin index per frame with a + * floating-point division. + */ + private class AnalyzeAccumulator( + val bins: Int, + private val totalFrames: Long, + private val withLoudness: Boolean, + ) { + private val sumSquares = DoubleArray(bins) + private val counts = LongArray(bins) + + var channelCount = 2 + var sampleRate = 44100 + var pcmFloat = false + + /** Index of the highest bin reached — every bin below it is fully accumulated. */ + var filledBins = 0 + private set + + private var frame = 0L + private var meter: LoudnessMeter? = null + private var floatScratch = FloatArray(0) + private var shortScratch = ShortArray(0) + + val loudness: Double? get() = meter?.lufs() + val samplePeak: Double? get() = meter?.peak + + fun accumulate(out: ByteBuffer) { + val ch = channelCount.coerceAtLeast(1) + // Created lazily: the true output channel count / rate only arrive with the first + // onOutputFormatChanged, which always precedes the first output buffer. + if (withLoudness && meter == null) meter = LoudnessMeter(ch, sampleRate) + if (pcmFloat) { + val fb = out.asFloatBuffer() + val n = fb.remaining() + if (floatScratch.size < n) floatScratch = FloatArray(n) + fb.get(floatScratch, 0, n) + consume(floatScratch, null, n, ch) + } else { + val sb = out.asShortBuffer() + val n = sb.remaining() + if (shortScratch.size < n) shortScratch = ShortArray(n) + sb.get(shortScratch, 0, n) + consume(null, shortScratch, n, ch) + } + } + + private fun consume(f: FloatArray?, s: ShortArray?, n: Int, ch: Int) { + val m = meter + var k = 0 + while (k < n) { + var bin = ((frame * bins) / totalFrames).toInt() + if (bin < 0) bin = 0 else if (bin >= bins) bin = bins - 1 + // First frame belonging to the next bin: ceil((bin + 1) * totalFrames / bins). + val boundary = ((bin + 1).toLong() * totalFrames + bins - 1L) / bins + val framesAvail = (n - k) / ch + if (framesAvail <= 0) break // trailing partial frame; drop it + var run = (boundary - frame).coerceAtLeast(1L) + if (run > framesAvail) run = framesAvail.toLong() + val end = k + run.toInt() * ch + + var acc = 0.0 + var j = k + if (m == null) { + if (f != null) { + while (j < end) { val v = f[j].toDouble(); acc += v * v; j++ } + } else if (s != null) { + while (j < end) { val v = s[j] / 32768.0; acc += v * v; j++ } + } + } else { + var c = 0 + if (f != null) { + while (j < end) { + val v = f[j].toDouble(); acc += v * v; m.process(v, c) + j++; c++; if (c == ch) c = 0 + } + } else if (s != null) { + while (j < end) { + val v = s[j] / 32768.0; acc += v * v; m.process(v, c) + j++; c++; if (c == ch) c = 0 } } } - val outIndex = codec.dequeueOutputBuffer(info, 10_000) - if (outIndex >= 0) { - if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) sawOutputEOS = true - if (info.size > 0) { - val out = codec.getOutputBuffer(outIndex)!! - out.position(info.offset) - out.limit(info.offset + info.size) - out.order(ByteOrder.nativeOrder()) - frame = accumulateAnalyze(out, pcmFloat, channelCount, bins, totalFrames, frame, sumSquares, counts) - } - codec.releaseOutputBuffer(outIndex, false) - } else if (outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { - val nf = codec.outputFormat - if (nf.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) channelCount = nf.getInteger(MediaFormat.KEY_CHANNEL_COUNT) - if (nf.containsKey(MediaFormat.KEY_PCM_ENCODING)) { - pcmFloat = nf.getInteger(MediaFormat.KEY_PCM_ENCODING) == AudioFormat.ENCODING_PCM_FLOAT - } - } + sumSquares[bin] += acc + counts[bin] += run * ch + frame += run + k = end + if (bin > filledBins) filledBins = bin } + } + /** Raw, un-normalized RMS for the first `count` bins (progressive emit). */ + fun rmsPrefix(count: Int): FloatArray { + val n = count.coerceIn(0, bins) + val out = FloatArray(n) + for (i in 0 until n) { + if (counts[i] > 0) out[i] = sqrt(sumSquares[i] / counts[i]).toFloat() + } + return out + } + + /** Final peaks, normalized against the global max across every bin. */ + fun finalPeaks(): FloatArray { val peaks = FloatArray(bins) var globalMax = 0.0 for (i in 0 until bins) { @@ -739,14 +1048,7 @@ class AstraLibraryScannerModule : Module() { if (globalMax > 0) { for (i in 0 until bins) peaks[i] = (peaks[i] / globalMax).toFloat() } - result.peaks = peaks - return result - } catch (_: Throwable) { - return result - } finally { - try { codec?.stop() } catch (_: Throwable) {} - try { codec?.release() } catch (_: Throwable) {} - try { extractor.release() } catch (_: Throwable) {} + return peaks } } @@ -758,7 +1060,7 @@ class AstraLibraryScannerModule : Module() { // Sparse preview waveform: seek to a bounded number of points, decode a very // short audio window at each point, and normalize those RMS samples. This is - // intentionally approximate; decodeAndAnalyze remains the accurate cache fill. + // intentionally approximate; runAnalysis remains the accurate cache fill. private fun decodeWaveformPreview(uriStr: String, bins: Int): FloatArray { val context = requireContext() val previewBins = bins.coerceIn(16, 128) @@ -916,172 +1218,6 @@ class AstraLibraryScannerModule : Module() { return PcmEnergy(sumSquares, sampleCount, frameCount) } - // Integrated gated loudness over the WHOLE file (accurate — subset sampling caused - // too much loudness inconsistency). Decodes the full track and feeds the gated - // K-weighting meter. Measured on the fly per track (current + queue lookahead) and - // cached, so the cost is paid once per track, never in a bulk background pass. - private fun measureLoudness(uriStr: String): AudioAnalysis { - val context = requireContext() - val result = AudioAnalysis() - val uri = Uri.parse(uriStr) - val extractor = MediaExtractor() - var codec: MediaCodec? = null - try { - extractor.setDataSource(context, uri, null) - - var trackFormat: MediaFormat? = null - var trackIndex = -1 - for (i in 0 until extractor.trackCount) { - val f = extractor.getTrackFormat(i) - if (f.getString(MediaFormat.KEY_MIME)?.startsWith("audio/") == true) { - trackFormat = f; trackIndex = i; break - } - } - val format = trackFormat ?: return result - extractor.selectTrack(trackIndex) - - val sampleRate = - if (format.containsKey(MediaFormat.KEY_SAMPLE_RATE)) format.getInteger(MediaFormat.KEY_SAMPLE_RATE) else 44100 - var channelCount = - if (format.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) format.getInteger(MediaFormat.KEY_CHANNEL_COUNT) else 2 - var pcmFloat = false - - codec = MediaCodec.createDecoderByType(format.getString(MediaFormat.KEY_MIME)!!) - codec.configure(format, null, null, 0) - codec.start() - val info = MediaCodec.BufferInfo() - - var meter: LoudnessMeter? = null - var sawInputEOS = false - var sawOutputEOS = false - while (!sawOutputEOS) { - if (!sawInputEOS) { - val inIndex = codec.dequeueInputBuffer(10_000) - if (inIndex >= 0) { - val inBuf = codec.getInputBuffer(inIndex)!! - val size = extractor.readSampleData(inBuf, 0) - if (size < 0) { - codec.queueInputBuffer(inIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM) - sawInputEOS = true - } else { - codec.queueInputBuffer(inIndex, 0, size, extractor.sampleTime, 0) - extractor.advance() - } - } - } - val outIndex = codec.dequeueOutputBuffer(info, 10_000) - if (outIndex >= 0) { - if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) sawOutputEOS = true - if (info.size > 0) { - val out = codec.getOutputBuffer(outIndex)!! - out.position(info.offset) - out.limit(info.offset + info.size) - out.order(ByteOrder.nativeOrder()) - val m = meter ?: LoudnessMeter(channelCount, sampleRate).also { meter = it } - feedMeter(out, pcmFloat, channelCount, m) - } - codec.releaseOutputBuffer(outIndex, false) - } else if (outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { - val nf = codec.outputFormat - if (nf.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) channelCount = nf.getInteger(MediaFormat.KEY_CHANNEL_COUNT) - if (nf.containsKey(MediaFormat.KEY_PCM_ENCODING)) { - pcmFloat = nf.getInteger(MediaFormat.KEY_PCM_ENCODING) == AudioFormat.ENCODING_PCM_FLOAT - } - } - } - - meter?.let { - result.lufs = it.lufs() - result.peak = it.peak - } - return result - } catch (_: Throwable) { - return result - } finally { - try { codec?.stop() } catch (_: Throwable) {} - try { codec?.release() } catch (_: Throwable) {} - try { extractor.release() } catch (_: Throwable) {} - } - } - - // Feeds one decoded PCM buffer (16-bit or float) to the loudness meter. - private fun feedMeter( - out: java.nio.ByteBuffer, - pcmFloat: Boolean, - channelCount: Int, - meter: LoudnessMeter - ) { - if (pcmFloat) { - val fb = out.asFloatBuffer() - val n = fb.remaining() - var k = 0 - while (k < n) { - var c = 0 - while (c < channelCount && k < n) { - meter.process(fb.get(k).toDouble(), c); k++; c++ - } - } - } else { - val sb = out.asShortBuffer() - val n = sb.remaining() - var k = 0 - while (k < n) { - var c = 0 - while (c < channelCount && k < n) { - meter.process(sb.get(k) / 32768.0, c); k++; c++ - } - } - } - } - - // Folds one decoded PCM buffer into the per-bin RMS accumulators and, when a - // loudness meter is provided, the K-weighted loudness + sample peak. Handles - // 16-bit (default) and float PCM. Returns the updated running frame index. - private fun accumulateAnalyze( - out: java.nio.ByteBuffer, - pcmFloat: Boolean, - channelCount: Int, - bins: Int, - totalFrames: Long, - startFrame: Long, - sumSquares: DoubleArray, - counts: LongArray - ): Long { - var frame = startFrame - if (pcmFloat) { - val fb = out.asFloatBuffer() - val n = fb.remaining() - var k = 0 - while (k < n) { - val bin = ((frame.toDouble() / totalFrames) * bins).toInt().coerceIn(0, bins - 1) - var c = 0 - while (c < channelCount && k < n) { - val s = fb.get(k).toDouble() - sumSquares[bin] += s * s - k++; c++ - } - counts[bin] += c.toLong() - frame++ - } - } else { - val sb = out.asShortBuffer() - val n = sb.remaining() - var k = 0 - while (k < n) { - val bin = ((frame.toDouble() / totalFrames) * bins).toInt().coerceIn(0, bins - 1) - var c = 0 - while (c < channelCount && k < n) { - val s = sb.get(k) / 32768.0 - sumSquares[bin] += s * s - k++; c++ - } - counts[bin] += c.toLong() - frame++ - } - } - return frame - } - // Gated integrated K-weighted loudness per ITU-R BS.1770 + absolute sample peak. // Two cascaded biquads (high-shelf pre-filter + RLB high-pass) per channel with // pyloudnorm-reference coefficients (so the -0.691 offset holds), accumulated into diff --git a/modules/astra-library-scanner/index.ts b/modules/astra-library-scanner/index.ts index 708e78f..6416a94 100644 --- a/modules/astra-library-scanner/index.ts +++ b/modules/astra-library-scanner/index.ts @@ -89,10 +89,44 @@ export interface NativeScanResult { catalogRevision: string; } +/** + * Partial waveform emitted while `analyzeTrack` decodes, so the seek bar can fill in + * left-to-right. `peaks` holds RAW (un-normalized) RMS for bins `[0, filledBins)` — the + * global max isn't known until the decode ends, so callers normalize against the max of + * what they've received so far and accept a slight rescale as louder material arrives. + */ +export interface WaveformProgressEvent { + /** Track URI this partial belongs to — callers must filter, decodes overlap. */ + uri: string; + filledBins: number; + totalBins: number; + peaks: number[]; +} + type AstraLibraryScannerEvents = { onScanProgress: (event: ScanProgressEvent) => void; + onWaveformProgress: (event: WaveformProgressEvent) => void; }; +/** One decode pass: waveform peaks + (optionally) loudness, plus timing. */ +export interface TrackAnalysis { + /** `bins` RMS peaks normalized to [0,1]; empty on failure. */ + peaks: number[]; + /** Integrated LUFS; null when unmeasured (withLoudness false) or unmeasurable. */ + lufs: number | null; + /** Absolute sample peak, linear [0,1]; null when unmeasured. */ + peak: number | null; + /** True if the decode bailed early — do NOT persist peaks or loudness. */ + cancelled: boolean; + decodeMs: number | null; + durationMs: number | null; + /** durationMs / decodeMs — how many times faster than realtime the decode ran. */ + realtimeFactor: number | null; + decoderName: string | null; + mime: string | null; + withLoudness: boolean; +} + declare class AstraLibraryScannerModuleType extends NativeModule { listAudioFiles(treeUri: string, extensions: string[]): Promise; extractMetadata(files: { uri: string; coverUri?: string | null }[]): Promise; @@ -102,21 +136,23 @@ declare class AstraLibraryScannerModuleType extends NativeModule; /** - * Decode the file's PCM and return `bins` RMS peaks normalized to [0,1] for - * the waveform seek bar. Whole-file decode (heavy); returns [] on failure. + * ONE whole-file PCM decode producing `bins` RMS waveform peaks and, when + * `withLoudness`, gated integrated LUFS + sample peak. Both analyses need every + * sample, so they share a pass — ask for loudness here whenever you'd otherwise + * measure it separately. Heavy; concurrency is capped natively at 2 and results + * should be cached. Emits `onWaveformProgress` as bins finalize. */ - extractWaveform(uri: string, bins: number): Promise; + analyzeTrack(uri: string, bins: number, withLoudness: boolean): Promise; + /** + * Stop an in-flight (or still-queued) `analyzeTrack` for this URI so a skipped-past + * track stops burning CPU. Safe to call when nothing is running. + */ + cancelAnalysis(uri: string): Promise; /** * Decode short windows across the file and return approximate RMS peaks for * immediate seek-bar paint. Cheap preview only; callers should not persist it. */ extractWaveformPreview(uri: string, bins: number): Promise; - /** - * Fast integrated loudness (M4): decodes only a few short windows across the - * track + gated K-weighting -> integrated LUFS + absolute sample peak. Null on - * failure / unmeasurable audio. Waveform peaks are separate (extractWaveform). - */ - measureLoudness(uri: string): Promise<{ lufs: number | null; peak: number | null }>; /** * Read ReplayGain track/album gain (dB) + peak (linear) from container tags * (ID3 TXXX / Vorbis comments / MP4 freeform) without decoding audio. All fields diff --git a/package.json b/package.json index 7416437..92ed59e 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "test:dynamic-playlists": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/playlists/dynamicPlaylist.test.mts", "test:album-grouping": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/library/albumGrouping.test.mts src/shared/library/albumEligibility.test.mts src/library/albumIdentity.test.mts src/library/albumSummary.test.mts", "test:artist-grouping": "node --experimental-strip-types --test src/library/artistGrouping.test.mts", + "test:waveform-math": "node --experimental-strip-types --test src/scope/waveformMath.test.mts", "test:desktop-sync": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/library/importMatching.test.mts src/services/desktopSyncPlaylistMerge.test.mts src/services/desktopSyncPolicy.test.mts src/shared/sync/conflictPreview.test.mts", "test:eq-share": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eqShare.test.mts", "test:signal": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/signalShare.test.mts src/audio/signalShareIntent.test.mts src/audio/signalScanGeometry.test.mts src/audio/signalLocalMatch.test.mts", diff --git a/src/app/settings/troubleshooting.tsx b/src/app/settings/troubleshooting.tsx index 157f443..58a8561 100644 --- a/src/app/settings/troubleshooting.tsx +++ b/src/app/settings/troubleshooting.tsx @@ -14,6 +14,7 @@ import { getLyricsCacheCount } from '@/db/lyricsQueries'; import { AstraLibraryData } from '../../../modules/astra-library-scanner'; import { clearAllLyricsCache } from '@/lyrics/lyrics'; import { clearAllWaveformCache } from '@/scope/waveform'; +import { getRecentAnalysisTimings, type AnalysisTiming } from '@/audio/trackAnalysis'; import { useLyricsStore } from '@/stores/lyricsStore'; import { useLibraryStore } from '@/stores/libraryStore'; import { useOnboardingStore } from '@/stores/onboardingStore'; @@ -183,10 +184,59 @@ export default function TroubleshootingSettingsScreen() { subtitle="Audition semantic feedback, device primitives, and signature candidates." onPress={() => router.push('/settings/haptics-lab' as never)} /> + ); } +/** + * How fast waveform/loudness decodes are actually running, per format and decoder. The + * realtime multiple is the number that decides whether MediaCodec is fast enough or whether + * the analysis path needs its own in-process decoder. + */ +function AnalysisTimingPanel() { + const styles = useStyles(); + const colors = useColors(); + const [timings, setTimings] = useState([]); + + useEffect(() => { + const read = () => setTimings(getRecentAnalysisTimings().slice(0, 6)); + read(); + const timer = setInterval(read, 2000); + return () => clearInterval(timer); + }, []); + + if (timings.length === 0) { + return ( + + Decode speed appears here after a track with no cached waveform plays. + + ); + } + + return ( + + {timings.map((timing) => ( + + + {timing.kind === 'preview' + ? `preview · ${Math.round(timing.decodeMs)}ms` + : `${(timing.mime ?? 'audio/?').replace('audio/', '')} · ${Math.round(timing.decodeMs)}ms` + + (timing.realtimeFactor ? ` · ${Math.round(timing.realtimeFactor)}× realtime` : '')} + + + {timing.kind === 'preview' + ? 'sparse first-paint pass' + : `${timing.decoderName ?? 'unknown decoder'}${ + timing.withLoudness ? ' · loudness folded in' : '' + }`} + + + ))} + + ); +} + function MaintenanceRow({ icon, title, @@ -247,4 +297,10 @@ const useStyles = createThemedStyles((colors) => ({ }, errorFeedback: { borderColor: colors.warning }, feedbackText: { flex: 1 }, + timingPanel: { + gap: spacing.sm, padding: spacing.md, borderRadius: radius.sm, + borderWidth: 1, borderColor: colors.glassBorder, backgroundColor: colors.glassBg, + }, + timingRow: { gap: 1 }, + timingEmpty: { paddingHorizontal: spacing.md, lineHeight: 16 }, })); diff --git a/src/audio/trackAnalysis.ts b/src/audio/trackAnalysis.ts index 441b995..bb57a04 100644 --- a/src/audio/trackAnalysis.ts +++ b/src/audio/trackAnalysis.ts @@ -1,18 +1,28 @@ -// Per-track normalization facts: ReplayGain tags (cheap, container-only) + measured -// integrated LUFS / sample peak (a decode, only when ReplayGain can't cover the track). +// Per-track analysis facts: waveform peaks + ReplayGain tags + measured integrated LUFS / +// sample peak. // -// ensureTrackLoudness is the single deduped entry point used by the normalization sync -// (current track + queue prefetch). It reads ReplayGain tags once per track, and only -// falls back to the expensive loudness decode when ReplayGain is off or absent — so a -// fully tagged library normalizes with no decoding at all. +// Peaks and loudness come from ONE native decode pass (analyzeTrack). Both need every +// sample, so running them as separate whole-file decodes meant decoding each track twice — +// and the two decodes then competed for the same native concurrency permits, which is why +// the waveform used to arrive so late. Peaks fall out of the pass regardless, so we persist +// them even when loudness was the only reason we decoded. +// +// ensureTrackAnalysis is the single deduped entry point. It reads what's already cached, +// decodes only what's missing, and stores both halves. ReplayGain tags are read first +// (container-only, no decode), so a fully tagged library still normalizes without decoding. import { AstraLibraryData, AstraLibraryScanner, type NativeTrackLoudness, + type TrackAnalysis, } from '../../modules/astra-library-scanner'; import { hasUsableReplayGain, type LoudnessFacts } from '@/audio/normalization'; import { useAudioSettingsStore } from '@/stores/audioSettingsStore'; +import { CacheInvalidationGate } from '@/lib/cacheInvalidation'; + +/** Stored waveform resolution. Downsampled to the bar count at render time. */ +export const WAVEFORM_BINS = 512; /** Map a loudness DB row (or a miss) to the resolver's facts shape. */ export function factsFromRow(row: NativeTrackLoudness | null): LoudnessFacts { @@ -26,46 +36,80 @@ export function factsFromRow(row: NativeTrackLoudness | null): LoudnessFacts { }; } -/** - * Measure + store integrated loudness + sample peak for one track (always - * re-measures). The decode is the expensive part; failures leave loudness NULL. - */ -export async function measureAndStoreLoudness( - path: string -): Promise<{ lufs: number | null; peak: number | null }> { - try { - const res = await AstraLibraryScanner.measureLoudness(path); - const lufs = res?.lufs ?? null; - const peak = res?.peak ?? null; - await AstraLibraryData.setTrackLoudness(path, lufs, peak).catch(() => {}); - return { lufs, peak }; - } catch { - return { lufs: null, peak: null }; - } +export interface TrackAnalysisResult { + /** Normalized [0,1] peaks, or null when unavailable / not requested and uncached. */ + peaks: Float32Array | null; + facts: LoudnessFacts; } -const inflight = new Map>(); +export interface EnsureAnalysisOptions { + /** + * Decode for waveform peaks when they're missing. Pass false from headless paths + * (Android Auto / Bluetooth with no UI) that only need loudness — peaks are still + * persisted if a loudness decode happens to run, since they come out free. + */ + peaks?: boolean; +} + +interface InflightRun { + promise: Promise; + wantPeaks: boolean; +} + +const inflight = new Map(); +// Paths cancelled while their run was still in its DB-read phase. The native cancel flag +// only exists once analyzeTrack has been called, so without this a cancel landing in that +// window would be silently lost and the decode would run to completion anyway. +const cancelledPaths = new Set(); +const cacheGate = new CacheInvalidationGate(); /** - * Loudness facts for a track, reading ReplayGain tags and decoding only as needed - * (deduped by path). Cheap when already analyzed (single DB read). The normalization - * sync uses this so tracks from a pre-M4 library still normalize before a full rescan. + * Analysis facts for a track, decoding at most once and only for what's actually missing + * (deduped by path). Cheap when already analyzed — two DB reads and no decode. */ -export function ensureTrackLoudness(path: string): Promise { +export function ensureTrackAnalysis( + path: string, + options: EnsureAnalysisOptions = {} +): Promise { + const wantPeaks = options.peaks !== false; const existing = inflight.get(path); - if (existing) return existing; - const task = run(path).finally(() => inflight.delete(path)); - inflight.set(path, task); - return task; + // A run that already covers what we need — join it. + if (existing && (existing.wantPeaks || !wantPeaks)) return existing.promise; + // A loudness-only run is going and we need peaks: let it finish (so we don't decode the + // same file twice concurrently), then fill in the peaks. + if (existing) return existing.promise.then(() => start(path, wantPeaks)); + return start(path, wantPeaks); } -async function run(path: string): Promise { - const row = (await AstraLibraryData.getTrackLoudness([path]))[0] ?? null; - let facts = factsFromRow(row); +function start(path: string, wantPeaks: boolean): Promise { + const existing = inflight.get(path); + if (existing?.wantPeaks) return existing.promise; + const promise = run(path, wantPeaks).finally(() => { + if (inflight.get(path)?.promise === promise) { + inflight.delete(path); + cancelledPaths.delete(path); + } + }); + inflight.set(path, { promise, wantPeaks }); + return promise; +} - // 1. Read ReplayGain tags once per track (container-only, no decode). Decoupled - // from loudness so a track measured before ReplayGain was enabled still picks - // up its tags; rg_scanned stays unset on failure so it retries next touch. +async function run(path: string, wantPeaks: boolean): Promise { + const generation = cacheGate.capture(); + + const [row, cachedPeaks] = await Promise.all([ + AstraLibraryData.getTrackLoudness([path]) + .then((rows) => rows[0] ?? null) + .catch(() => null), + AstraLibraryData.getWaveform(path).catch(() => null), + ]); + + let facts = factsFromRow(row); + let peaks = cachedPeaks && cachedPeaks.length > 0 ? Float32Array.from(cachedPeaks) : null; + + // ReplayGain tags: container-only, no decode. Decoupled from loudness so a track measured + // before ReplayGain was enabled still picks up its tags; rg_scanned stays unset on failure + // so it retries next touch. if (!row || row.rg_scanned !== 1) { try { const rg = await AstraLibraryScanner.readReplayGain(path); @@ -88,14 +132,162 @@ async function run(path: string): Promise { } } - // 2. Loudness already measured — nothing more to do. - if (facts.loudnessLufs != null) return facts; - - // 3. ReplayGain alone can normalize this track — skip the expensive decode. + // Loudness only needs measuring when it's unknown AND ReplayGain can't cover the track. const settings = useAudioSettingsStore.getState().asNormalizationSettings(); - if (hasUsableReplayGain(facts, settings)) return facts; + const needLoudness = facts.loudnessLufs == null && !hasUsableReplayGain(facts, settings); + const needPeaks = wantPeaks && !peaks; + if (!needLoudness && !needPeaks) return { peaks, facts }; + // Skipped past while we were reading the DB — don't start the decode at all. + if (cancelledPaths.has(path)) return { peaks, facts }; - // 4. Otherwise measure loudness now (decode) and merge it in. - const measured = await measureAndStoreLoudness(path); - return { ...facts, loudnessLufs: measured.lufs, samplePeak: measured.peak }; + let analysis: TrackAnalysis; + try { + analysis = await AstraLibraryScanner.analyzeTrack(path, WAVEFORM_BINS, needLoudness); + } catch { + return { peaks, facts }; + } + recordTiming(path, analysis); + // Skipped past / timed out: peaks are truncated and loudness is partial. Cache neither. + if (analysis.cancelled) return { peaks, facts }; + + if (analysis.peaks && analysis.peaks.length > 0) { + peaks = Float32Array.from(analysis.peaks); + await persistPeaks(path, peaks, generation); + } + if (needLoudness) { + await AstraLibraryData.setTrackLoudness(path, analysis.lufs, analysis.peak).catch(() => {}); + facts = { ...facts, loudnessLufs: analysis.lufs, samplePeak: analysis.peak }; + } + return { peaks, facts }; +} + +async function persistPeaks( + path: string, + peaks: Float32Array, + generation: number +): Promise { + await cacheGate + .enqueue(async () => { + if (!cacheGate.isCurrent(generation)) return; + await AstraLibraryData.putWaveform(path, Array.from(peaks)); + }) + .catch(() => { + /* cache write failure is non-fatal */ + }); +} + +/** + * Loudness facts only — the normalization path's entry point. Does not decode purely to + * fill in a missing waveform, but keeps the peaks if a loudness decode produces them. + */ +export async function ensureTrackLoudness(path: string): Promise { + const { facts } = await ensureTrackAnalysis(path, { peaks: false }); + return facts; +} + +/** + * Stop an in-flight analysis for a track we've skipped past, so it stops burning CPU and + * frees a native decode permit for the track the user is actually on. + */ +export function cancelTrackAnalysis(path: string): void { + if (!inflight.has(path)) return; + cancelledPaths.add(path); + void AstraLibraryScanner.cancelAnalysis(path).catch(() => {}); +} + +/** Paths with an analysis currently running or queued. */ +export function activeAnalysisPaths(): string[] { + return Array.from(inflight.keys()); +} + +/** + * Whether a decode for this path is already under way — i.e. progress events are about to + * start arriving, so a second "fast preview" decode would only land late and cause a + * visible rescale rather than buying a faster first paint. + */ +export function isAnalysisRunning(path: string): boolean { + return inflight.has(path); +} + +/** Drops cached waveform rows and stops in-flight decodes from writing them back. */ +export async function clearWaveformCache(): Promise { + inflight.clear(); + cancelledPaths.clear(); + await cacheGate.invalidate(async () => { + await AstraLibraryData.clearWaveforms(); + }); +} + +// --------------------------------------------------------------------------- +// Timing instrumentation +// --------------------------------------------------------------------------- + +export interface AnalysisTiming { + path: string; + /** 'preview' entries are the sparse first-paint decode, 'analysis' the real pass. */ + kind: 'analysis' | 'preview'; + decodeMs: number; + durationMs: number | null; + /** durationMs / decodeMs — how many times faster than realtime the decode ran. */ + realtimeFactor: number | null; + decoderName: string | null; + mime: string | null; + withLoudness: boolean; + at: number; +} + +const MAX_TIMINGS = 20; +const recentTimings: AnalysisTiming[] = []; + +function push(timing: AnalysisTiming): void { + recentTimings.unshift(timing); + if (recentTimings.length > MAX_TIMINGS) recentTimings.length = MAX_TIMINGS; +} + +/** + * Record how long the sparse preview decode took, measured end to end from JS (so it + * includes the wait for a native permit — which is the number that decides whether the + * preview can still beat the real decode's first progress event to the screen). + */ +export function recordPreviewTiming(path: string, elapsedMs: number): void { + push({ + path, + kind: 'preview', + decodeMs: elapsedMs, + durationMs: null, + realtimeFactor: null, + decoderName: null, + mime: null, + withLoudness: false, + at: Date.now(), + }); + if (__DEV__) console.log(`[analysis] preview ${elapsedMs.toFixed(0)}ms`); +} + +function recordTiming(path: string, analysis: TrackAnalysis): void { + if (analysis.cancelled || analysis.decodeMs == null) return; + push({ + path, + kind: 'analysis', + decodeMs: analysis.decodeMs, + durationMs: analysis.durationMs, + realtimeFactor: analysis.realtimeFactor, + decoderName: analysis.decoderName, + mime: analysis.mime, + withLoudness: analysis.withLoudness, + at: Date.now(), + }); + if (__DEV__) { + const rt = analysis.realtimeFactor; + console.log( + `[analysis] ${analysis.mime ?? '?'} ${analysis.decodeMs.toFixed(0)}ms` + + `${rt ? ` (${rt.toFixed(0)}x realtime)` : ''}` + + ` via ${analysis.decoderName ?? '?'}${analysis.withLoudness ? ' +loudness' : ''}` + ); + } +} + +/** Most recent decodes, newest first — surfaced in Settings → Troubleshooting. */ +export function getRecentAnalysisTimings(): readonly AnalysisTiming[] { + return recentTimings; } diff --git a/src/audio/useNormalizationSync.ts b/src/audio/useNormalizationSync.ts index eeb6ab6..bababff 100644 --- a/src/audio/useNormalizationSync.ts +++ b/src/audio/useNormalizationSync.ts @@ -13,7 +13,11 @@ import { usePlayerStore } from '@/stores/playerStore'; import { useQueueStore } from '@/stores/queueStore'; import { useAudioSettingsStore } from '@/stores/audioSettingsStore'; import { resolveNormalizationGain, type LoudnessFacts } from '@/audio/normalization'; -import { ensureTrackLoudness } from '@/audio/trackAnalysis'; +import { + activeAnalysisPaths, + cancelTrackAnalysis, + ensureTrackAnalysis, +} from '@/audio/trackAnalysis'; import { setNormalizationGainNative, setTrackGainNative, @@ -61,13 +65,13 @@ export function useNormalizationSync(): void { return; } - // ensureTrackLoudness is cheap when already analyzed (single DB read) and - // decodes+stores on a miss (lazy backfill for pre-scan tracks). During the + // ensureTrackAnalysis is cheap when already analyzed (two DB reads) and decodes on a + // miss — one pass covering both loudness and the seek bar's waveform. During the // await the track is already playing at the conservative fallback gain // (gainRegistry) — never at unity/full volume. let facts = EMPTY_FACTS; try { - facts = await ensureTrackLoudness(path); + ({ facts } = await ensureTrackAnalysis(path)); if (cancelled) return; // Track changed during the await — let the newer recompute win. if (usePlayerStore.getState().currentTrack?.path !== path) return; @@ -91,27 +95,38 @@ export function useNormalizationSync(): void { useScopeStore.getState().setOscGain(computeOscilloscopeGain(basePeak, resolved.linearGain)); } - // MEASURE the next several upcoming tracks' loudness while the current one plays - // (decode-ahead for tracks with no facts yet), and register each late-arriving - // result natively by URL — so when the player advances, the gain is in the map - // and applies at the transition with no JS in the loop. Already-analyzed tracks - // are bulk-registered by gainRegistry; re-registering them here is a harmless - // cheap DB hit with the same value. Looking a few ahead (not just the immediate - // next) means a song added several positions back is still measured with plenty - // of lead time. Derived from the queue mirror, so it re-runs on reorder / - // add-next / advance. Deduped + DB-cached + native-semaphore-capped. + // ANALYZE the next several upcoming tracks while the current one plays (decode-ahead + // for tracks with no facts yet), and register each late-arriving gain natively by URL — + // so when the player advances, the gain is in the map and applies at the transition with + // no JS in the loop. Already-analyzed tracks are bulk-registered by gainRegistry; + // re-registering them here is a harmless cheap DB hit with the same value. Looking a few + // ahead (not just the immediate next) means a song added several positions back is still + // analyzed with plenty of lead time. Derived from the queue mirror, so it re-runs on + // reorder / add-next / advance. Deduped + DB-cached + native-semaphore-capped. + // + // The same pass fills the seek bar's waveform, which is why the waveform is usually + // already cached by the time you open now-playing: it used to only start decoding when + // WaveformSeekBar mounted, from cold, queued behind these very decodes. function prefetchUpcoming(): void { const { tracks, activeIndex } = useQueueStore.getState(); if (activeIndex < 0) return; const settings = useAudioSettingsStore.getState().asNormalizationSettings(); + + // The set of tracks worth spending a decode on right now. Everything else that is + // still decoding has been skipped past. + const wanted = new Set(); + const currentPath = usePlayerStore.getState().currentTrack?.path; + if (currentPath) wanted.add(currentPath); + for (let i = 1; i <= PREFETCH_AHEAD; i++) { const queued = tracks[activeIndex + i]; const url = queued?.url; if (typeof url !== 'string' || url.length === 0) continue; // Remote tracks: unity gain, and decoding the stream URL would download it. if (queued?.sourceType && queued.sourceType !== 'local') continue; - void ensureTrackLoudness(url) - .then((facts) => { + wanted.add(url); + void ensureTrackAnalysis(url) + .then(({ facts }) => { if (cancelled) return; const resolved = resolveNormalizationGain(facts, settings); setTrackGainNative(url, resolved.linearGain); @@ -120,6 +135,12 @@ export function useNormalizationSync(): void { /* leave unregistered — defaults to unity at the transition */ }); } + + // Free the native decode permits: a decode for a track the user has skipped past is + // pure waste, and it would otherwise block the track they're actually on. + for (const path of activeAnalysisPaths()) { + if (!wanted.has(path)) cancelTrackAnalysis(path); + } } // The queue can change rapidly (drag-reorder); coalesce re-warms. diff --git a/src/components/WaveformSeekBar.tsx b/src/components/WaveformSeekBar.tsx index f6a5f1b..e26ef17 100644 --- a/src/components/WaveformSeekBar.tsx +++ b/src/components/WaveformSeekBar.tsx @@ -21,7 +21,12 @@ import { Text } from './Text'; import { spacing } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; import { formatDuration } from '@/lib/format'; -import { downsampleWaveform, getWaveform } from '@/scope/waveform'; +import { + downsampleWaveform, + getWaveform, + mergeProgressiveWaveform, + subscribeWaveformProgress, +} from '@/scope/waveform'; import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime'; import { usePlayerStore } from '@/stores/playerStore'; import { playHaptic } from '@/lib/haptics'; @@ -36,7 +41,8 @@ const BAR_WIDTH = 3; const BAR_GAP = 2; const MIN_BAR = 0.05; // floor so silent/idle sections still show a sliver const PLAYHEAD_WIDTH = 2; -type WaveformQuality = 'preview' | 'accurate'; +/** Ascending confidence — a lower quality never overwrites a higher one for the same track. */ +type WaveformQuality = 'preview' | 'partial' | 'accurate'; interface WaveformSeekBarProps { onSeek: (seconds: number) => void; @@ -90,16 +96,47 @@ export function WaveformSeekBar({ const grantRef = useRef({ fraction: 0, pageX: 0 }); const detentRef = useRef(null); const smoothTime = useSmoothPlaybackTime(currentTime, duration, isPlaying); + // The coarse preview is kept aside as well as rendered: it's the amplitude reference the + // partially-decoded prefix is scaled against, and it supplies the not-yet-decoded tail. + const previewRef = useRef<{ path: string; peaks: Float32Array } | null>(null); + // Whether progressive fill has already begun for the current track. A preview that shows + // up after that point is worse than useless: adopting it mid-fill rescales every bar at + // once (the prefix is scaled against the preview's amplitude), which reads as two + // different waveforms fighting. Once we're filling, the preview is dropped. + const progressStartedRef = useRef(false); - // Load (cache-first) the offline peaks whenever the track changes. + // Load (cache-first) the offline peaks whenever the track changes, and follow the decode + // as it runs so the bars resolve left-to-right rather than snapping in at the end. The + // progress subscription is independent of who started the decode — usually the queue + // prefetch got there first, in which case this only ever sees the cache hit. useEffect(() => { if (!trackPath) return; let cancelled = false; + previewRef.current = null; + progressStartedRef.current = false; + + const unsubscribe = subscribeWaveformProgress(trackPath, ({ peaks, totalBins }) => { + if (cancelled) return; + progressStartedRef.current = true; + const preview = previewRef.current?.path === trackPath ? previewRef.current.peaks : null; + const merged = mergeProgressiveWaveform(peaks, totalBins, preview); + setLoaded((current) => { + if (current?.path === trackPath && current.quality === 'accurate' && current.peaks) { + return current; + } + return { path: trackPath, peaks: merged, quality: 'partial' }; + }); + }); + void getWaveform(trackPath, { onPreview: (peaks) => { if (cancelled) return; + // Lost the race — the real decode is already painting. Adopting the preview now + // would rescale the whole bar in one frame. + if (progressStartedRef.current) return; + previewRef.current = { path: trackPath, peaks }; setLoaded((current) => { - if (current?.path === trackPath && current.quality === 'accurate' && current.peaks) { + if (current?.path === trackPath && current.quality !== 'preview' && current.peaks) { return current; } return { path: trackPath, peaks, quality: 'preview' }; @@ -108,12 +145,15 @@ export function WaveformSeekBar({ }).then((peaks) => { if (cancelled) return; setLoaded((current) => { - if (!peaks && current?.path === trackPath && current.quality === 'preview') return current; + // A failed decode must not wipe a good preview or partial fill. + if (!peaks && current?.path === trackPath && current.peaks) return current; return { path: trackPath, peaks, quality: 'accurate' }; }); }); + return () => { cancelled = true; + unsubscribe(); }; }, [trackPath]); diff --git a/src/scope/waveform.ts b/src/scope/waveform.ts index e654b8c..b9888e8 100644 --- a/src/scope/waveform.ts +++ b/src/scope/waveform.ts @@ -1,22 +1,28 @@ -// Waveform peaks for the seek bar: cache-first, preview-on-miss, accurate -// decode-on-miss, store. The heavy native decode (extractWaveform) still runs -// once per track and persists; extractWaveformPreview gives uncached local -// tracks a fast first paint. +// Waveform peaks for the seek bar: cache-first, preview-on-miss, then the accurate +// decode. The accurate pass lives in trackAnalysis (it shares one decode with loudness) +// and streams partial results back through onWaveformProgress, so the bar fills in +// left-to-right instead of snapping in when the whole file is done. import { AstraLibraryData, AstraLibraryScanner } from '../../modules/astra-library-scanner'; -import { CacheInvalidationGate } from '@/lib/cacheInvalidation'; +import { + WAVEFORM_BINS, + clearWaveformCache, + ensureTrackAnalysis, + isAnalysisRunning, + recordPreviewTiming, +} from '@/audio/trackAnalysis'; -export const WAVEFORM_BINS = 512; +export { WAVEFORM_BINS }; +export { downsampleWaveform, mergeProgressiveWaveform } from '@/scope/waveformMath'; export const WAVEFORM_PREVIEW_BINS = 96; export interface WaveformLoadOptions { onPreview?: (peaks: Float32Array) => void; } -// Dedupe concurrent requests for the same track (e.g. mini-player + now-playing). -const inflight = new Map>(); +// Dedupe concurrent preview requests for the same track (e.g. mini-player + now-playing). +// The accurate decode is deduped inside trackAnalysis. const previewInflight = new Map>(); -const cacheGate = new CacheInvalidationGate(); export function getWaveform( trackPath: string, @@ -30,52 +36,88 @@ async function loadWaveform( trackPath: string, options: WaveformLoadOptions ): Promise { - const cached = await AstraLibraryData.getWaveform(trackPath); + const cached = await AstraLibraryData.getWaveform(trackPath).catch(() => null); if (cached && cached.length > 0) return Float32Array.from(cached); - if (options.onPreview) { + // The preview is a SECOND native decode competing for the same two permits as the real + // pass. It only earns that cost when it can beat the real decode's first progress event + // to the screen. If a decode for this track is already running — the common case, since + // the queue prefetch starts one several tracks ahead — progress events are about to + // arrive immediately, and the preview would land late enough only to cause a visible + // rescale. Skip it entirely there. + if (options.onPreview && !isAnalysisRunning(trackPath)) { + const startedAt = Date.now(); void getWaveformPreview(trackPath).then((preview) => { + recordPreviewTiming(trackPath, Date.now() - startedAt); if (preview && preview.length > 0) options.onPreview?.(preview); }); } - const existing = inflight.get(trackPath); - if (existing) return existing; - const generation = cacheGate.capture(); - const task = decodeAccurateWaveform(trackPath, generation).finally(() => { - if (inflight.get(trackPath) === task) inflight.delete(trackPath); - }); - inflight.set(trackPath, task); - return task; -} - -async function decodeAccurateWaveform(trackPath: string, generation: number): Promise { - let raw: number[]; + // Shares one decode pass with loudness, and may already be running from the queue + // prefetch — in which case this just joins it. Failures fall back to flat bars. try { - raw = await AstraLibraryScanner.extractWaveform(trackPath, WAVEFORM_BINS); + const { peaks } = await ensureTrackAnalysis(trackPath); + return peaks; } catch { return null; } - if (!raw || raw.length === 0) return null; - - const peaks = Float32Array.from(raw); - await cacheGate.enqueue(async () => { - if (!cacheGate.isCurrent(generation)) return; - if (!cacheGate.isCurrent(generation)) return; - await AstraLibraryData.putWaveform(trackPath, Array.from(peaks)); - }).catch(() => { - /* cache write failure is non-fatal */ - }); - return peaks; } /** Deletes waveform rows and prevents decodes already in flight from writing them back. */ export async function clearAllWaveformCache(): Promise { - inflight.clear(); previewInflight.clear(); - await cacheGate.invalidate(async () => { - await AstraLibraryData.clearWaveforms(); - }); + await clearWaveformCache(); +} + +// --------------------------------------------------------------------------- +// Progressive decode updates +// --------------------------------------------------------------------------- + +export type WaveformProgressListener = (partial: { + /** Raw (un-normalized) RMS for the bins decoded so far. */ + peaks: Float32Array; + filledBins: number; + totalBins: number; +}) => void; + +const progressListeners = new Map>(); +let nativeProgressSub: { remove(): void } | null = null; + +/** + * Listen for partial waveforms while a track decodes. Independent of who started the + * decode, so the seek bar still fills progressively when the queue prefetch kicked it off. + * Returns an unsubscribe function. + */ +export function subscribeWaveformProgress( + trackPath: string, + listener: WaveformProgressListener +): () => void { + if (!nativeProgressSub) { + nativeProgressSub = AstraLibraryScanner.addListener('onWaveformProgress', (event) => { + const listeners = progressListeners.get(event.uri); + if (!listeners || listeners.size === 0) return; + const partial = { + peaks: Float32Array.from(event.peaks), + filledBins: event.filledBins, + totalBins: event.totalBins, + }; + for (const cb of listeners) cb(partial); + }); + } + + let listeners = progressListeners.get(trackPath); + if (!listeners) { + listeners = new Set(); + progressListeners.set(trackPath, listeners); + } + listeners.add(listener); + + return () => { + const current = progressListeners.get(trackPath); + if (!current) return; + current.delete(listener); + if (current.size === 0) progressListeners.delete(trackPath); + }; } function getWaveformPreview(trackPath: string): Promise { @@ -99,45 +141,6 @@ async function decodePreviewWaveform(trackPath: string): Promise max) max = peaks[i]; - if (max > 0) for (let i = 0; i < barCount; i++) peaks[i] /= max; - - // Power curve — exaggerate dynamic range. - for (let i = 0; i < barCount; i++) peaks[i] = peaks[i] ** 2; - - // Two smoothing passes. - let current = peaks; - for (let p = 0; p < 2; p++) { - const smoothed = new Float32Array(current.length); - smoothed[0] = current[0]; - smoothed[current.length - 1] = current[current.length - 1]; - for (let i = 1; i < current.length - 1; i++) { - smoothed[i] = current[i - 1] * 0.25 + current[i] * 0.5 + current[i + 1] * 0.25; - } - current = smoothed; - } - return current; -} diff --git a/src/scope/waveformMath.test.mts b/src/scope/waveformMath.test.mts new file mode 100644 index 0000000..6a4f49c --- /dev/null +++ b/src/scope/waveformMath.test.mts @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { downsampleWaveform, mergeProgressiveWaveform } from './waveformMath.ts'; + +const TOTAL = 512; + +/** Preview normalized to [0,1] across the whole track, as the native preview returns it. */ +function makePreview(values: number[]): Float32Array { + return Float32Array.from(values); +} + +test('merge with no prefix is just the stretched preview', () => { + const preview = makePreview([0.2, 0.8, 0.4, 1]); + const merged = mergeProgressiveWaveform(new Float32Array(0), TOTAL, preview); + assert.equal(merged.length, TOTAL); + assert.ok(Math.abs(merged[0] - 0.2) < 1e-6); + assert.ok(Math.abs(merged[TOTAL - 1] - 1) < 1e-6); + // Each preview value should occupy an equal quarter of the width. + assert.ok(Math.abs(merged[Math.floor(TOTAL * 0.3)] - 0.8) < 1e-6); +}); + +test('merge with no preview normalizes the prefix against its own max', () => { + const prefix = Float32Array.from([0.01, 0.02, 0.04]); // raw RMS, tiny absolute values + const merged = mergeProgressiveWaveform(prefix, TOTAL, null); + assert.ok(Math.abs(merged[2] - 1) < 1e-6, 'loudest decoded bin should reach full scale'); + assert.ok(Math.abs(merged[0] - 0.25) < 1e-6); + assert.equal(merged[3], 0, 'undecoded tail stays empty without a preview'); +}); + +test('prefix is rescaled to the preview, not to its own max', () => { + // Preview says the first half is quiet (0.25) and the second half is loud (1.0). + const preview = makePreview([0.25, 0.25, 1, 1]); + // We have decoded the quiet first half only. Raw RMS values are arbitrary in scale. + const prefix = new Float32Array(TOTAL / 2).fill(0.003); + const merged = mergeProgressiveWaveform(prefix, TOTAL, preview); + + // Naive self-normalization would put the decoded half at 1.0 — far louder than the + // preview says it is, and louder than the not-yet-decoded loud half. It must stay at + // the preview's amplitude for that region instead. + assert.ok(Math.abs(merged[0] - 0.25) < 1e-6, `decoded region should match preview scale, got ${merged[0]}`); + assert.ok(merged[0] < merged[TOTAL - 1], 'quiet decoded half must stay below the loud undecoded half'); + assert.ok(Math.abs(merged[TOTAL - 1] - 1) < 1e-6, 'undecoded tail keeps the preview value'); +}); + +test('merge never exceeds full scale', () => { + const preview = makePreview([1, 1, 1, 1]); + const prefix = Float32Array.from([5, 10, 2]); + const merged = mergeProgressiveWaveform(prefix, TOTAL, preview); + for (let i = 0; i < merged.length; i++) { + assert.ok(merged[i] <= 1, `bin ${i} exceeded 1: ${merged[i]}`); + assert.ok(merged[i] >= 0, `bin ${i} went negative: ${merged[i]}`); + } +}); + +test('an all-silent prefix falls back to the preview rather than blanking the bar', () => { + const preview = makePreview([0.5, 0.6, 0.7, 0.8]); + const merged = mergeProgressiveWaveform(new Float32Array(64), TOTAL, preview); + assert.ok(Math.abs(merged[0] - 0.5) < 1e-6); +}); + +test('downsampling a partially-filled merge keeps the decoded region proportionate', () => { + // Decoded half is quiet, undecoded half is loud — after downsampling the relationship + // must survive, i.e. the global normalize must not lift the quiet decoded half. + const preview = makePreview([0.25, 0.25, 1, 1]); + const prefix = new Float32Array(TOTAL / 2).fill(0.003); + const merged = mergeProgressiveWaveform(prefix, TOTAL, preview); + + const bars = downsampleWaveform(merged, 64); + assert.equal(bars.length, 64); + const firstQuarter = bars[8]; + const lastQuarter = bars[56]; + assert.ok( + lastQuarter > firstQuarter * 4, + `loud half (${lastQuarter}) should dominate the quiet decoded half (${firstQuarter})` + ); + for (let i = 0; i < bars.length; i++) { + assert.ok(bars[i] >= 0 && bars[i] <= 1, `bar ${i} out of range: ${bars[i]}`); + } +}); + +test('downsample is unchanged for a fully accurate waveform', () => { + const source = Float32Array.from({ length: TOTAL }, (_, i) => (i < TOTAL / 2 ? 0.2 : 1)); + const bars = downsampleWaveform(source, 32); + assert.equal(bars.length, 32); + assert.ok(Math.abs(bars[31] - 1) < 1e-6, 'loudest bar normalizes to full scale'); + assert.ok(bars[0] < 0.1, 'x^2 power curve should push the quiet region well down'); +}); diff --git a/src/scope/waveformMath.ts b/src/scope/waveformMath.ts new file mode 100644 index 0000000..45aa543 --- /dev/null +++ b/src/scope/waveformMath.ts @@ -0,0 +1,85 @@ +// Pure waveform shaping — no native imports, so it stays unit-testable under `node --test` +// (see waveformMath.test.mts). waveform.ts re-exports these. + +/** + * Splice a partially-decoded raw RMS prefix over the coarse preview, so the bar fills + * left-to-right with no visible seam. + * + * The prefix is raw — mid-decode the native side can't know the track's global max — while + * the preview is already normalized against the whole track. So the prefix is rescaled to + * the preview's amplitude over the region it covers rather than to its own max; normalizing + * it independently would make the decoded part read far louder than the rest until a loud + * section happened to arrive. + */ +export function mergeProgressiveWaveform( + prefix: Float32Array, + totalBins: number, + preview: Float32Array | null +): Float32Array { + const out = new Float32Array(Math.max(0, totalBins)); + if (totalBins <= 0) return out; + + // Stretch the (much coarser) preview across the full width first. + const hasPreview = !!preview && preview.length > 0; + if (preview && hasPreview) { + for (let i = 0; i < totalBins; i++) { + const p = Math.min(preview.length - 1, Math.floor((i / totalBins) * preview.length)); + out[i] = preview[p]; + } + } + + const filled = Math.min(prefix.length, totalBins); + if (filled === 0) return out; + + let prefixMax = 0; + for (let i = 0; i < filled; i++) if (prefix[i] > prefixMax) prefixMax = prefix[i]; + if (prefixMax <= 0) return out; + + // Match the preview's scale over the decoded region so the seam is continuous. + let reference = 0; + if (hasPreview) { + for (let i = 0; i < filled; i++) if (out[i] > reference) reference = out[i]; + } + const scale = (reference > 0 ? reference : 1) / prefixMax; + for (let i = 0; i < filled; i++) out[i] = Math.min(1, prefix[i] * scale); + return out; +} + +/** + * Downsample high-res peaks to `barCount` bars with a power curve and two + * smoothing passes. Ported verbatim from desktop waveformExtractor.ts so the + * mobile seek bar matches the desktop look. + */ +export function downsampleWaveform(source: Float32Array, barCount: number): Float32Array { + if (source.length === 0 || barCount <= 0) return new Float32Array(0); + const binsPerBar = source.length / barCount; + const peaks = new Float32Array(barCount); + + for (let i = 0; i < barCount; i++) { + const start = Math.floor(i * binsPerBar); + const end = Math.max(start + 1, Math.floor((i + 1) * binsPerBar)); + let sum = 0; + for (let j = start; j < end; j++) sum += source[j]; + peaks[i] = sum / (end - start); + } + + let max = 0; + for (let i = 0; i < barCount; i++) if (peaks[i] > max) max = peaks[i]; + if (max > 0) for (let i = 0; i < barCount; i++) peaks[i] /= max; + + // Power curve — exaggerate dynamic range. + for (let i = 0; i < barCount; i++) peaks[i] = peaks[i] ** 2; + + // Two smoothing passes. + let current = peaks; + for (let p = 0; p < 2; p++) { + const smoothed = new Float32Array(current.length); + smoothed[0] = current[0]; + smoothed[current.length - 1] = current[current.length - 1]; + for (let i = 1; i < current.length - 1; i++) { + smoothed[i] = current[i - 1] * 0.25 + current[i] * 0.5 + current[i + 1] * 0.25; + } + current = smoothed; + } + return current; +} diff --git a/vendor/kotlinaudio/kotlin-audio/build.gradle b/vendor/kotlinaudio/kotlin-audio/build.gradle index 13ace50..e701da1 100644 --- a/vendor/kotlinaudio/kotlin-audio/build.gradle +++ b/vendor/kotlinaudio/kotlin-audio/build.gradle @@ -1,9 +1,15 @@ // Vendored fork of com.github.doublesymmetry:kotlinaudio v2.1.0 (Apache-2.0). // Substituted in for the Jitpack binary so we can inject a PCM-tap AudioProcessor -// into the ExoPlayer it builds (see players/BaseAudioPlayer.kt + scope/). The -// ONLY source change vs upstream v2.1.0 is the single .setRenderersFactory(...) -// line in BaseAudioPlayer's init and the new scope/ package. Keep that diff -// minimal so re-vendoring on a kotlin-audio bump stays mechanical. +// into the ExoPlayer it builds (see players/BaseAudioPlayer.kt + scope/). +// +// Source changes vs upstream v2.1.0 — keep this list accurate, it is what makes +// re-vendoring on a kotlin-audio bump mechanical: +// 1. .setRenderersFactory(buildScopeRenderersFactory(context)) in BaseAudioPlayer's init +// 2. the new scope/ package (taps, EQ, normalization gain) +// 3. GainBridge.activateFor(url) in onMediaItemTransition +// 4. shared per-player media-source factories + the fast path in +// getMediaSourceFromAudioItem (perf: Util.getUserAgent was a PackageManager +// lookup per queue item, which stalled the main thread on large queues) plugins { id 'com.android.library'