mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-28 17:11:19 +02:00
m4
This commit is contained in:
@@ -16,3 +16,10 @@ android {
|
||||
abortOnError false
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// ExoPlayer 2.19.0 (same version the vendored kotlin-audio fork pulls in) for
|
||||
// MetadataRetriever — parses ID3/Vorbis/MP4 container tags (ReplayGain) without
|
||||
// decoding audio. Transitively brings exoplayer-extractor (the frame classes).
|
||||
implementation 'com.google.android.exoplayer:exoplayer-core:2.19.0'
|
||||
}
|
||||
|
||||
+371
-11
@@ -12,9 +12,19 @@ import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.DocumentsContract
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
import com.google.android.exoplayer2.MetadataRetriever
|
||||
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.ByteOrder
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.log10
|
||||
import kotlin.math.max
|
||||
import kotlin.math.sqrt
|
||||
import kotlin.math.tan
|
||||
import expo.modules.kotlin.exception.Exceptions
|
||||
import expo.modules.kotlin.functions.Coroutine
|
||||
import expo.modules.kotlin.modules.Module
|
||||
@@ -38,6 +48,21 @@ class FileRequest : Record {
|
||||
@Field val coverUri: String? = null
|
||||
}
|
||||
|
||||
/** Result of one scan-time decode: waveform peaks + integrated loudness + sample peak. */
|
||||
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
|
||||
}
|
||||
|
||||
/** ReplayGain tags read from the container (no audio decode). Null = tag absent. */
|
||||
class ReplayGainTags : Record {
|
||||
@Field var trackGainDb: Double? = null // REPLAYGAIN_TRACK_GAIN (dB)
|
||||
@Field var albumGainDb: Double? = null // REPLAYGAIN_ALBUM_GAIN (dB)
|
||||
@Field var trackPeak: Double? = null // REPLAYGAIN_TRACK_PEAK (linear, ~[0,1+])
|
||||
@Field var albumPeak: Double? = null // REPLAYGAIN_ALBUM_PEAK (linear, ~[0,1+])
|
||||
}
|
||||
|
||||
class AstraLibraryScannerModule : Module() {
|
||||
private val artworkThumbSize = 128
|
||||
|
||||
@@ -72,10 +97,25 @@ class AstraLibraryScannerModule : Module() {
|
||||
// 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) { extractWaveform(uri, if (bins > 0) bins else 512) }
|
||||
withContext(Dispatchers.IO) { decodeAndAnalyze(uri, if (bins > 0) bins else 512).peaks }
|
||||
}
|
||||
}
|
||||
|
||||
// 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 ->
|
||||
withContext(Dispatchers.IO) { readReplayGain(uri) }
|
||||
}
|
||||
|
||||
Function("getArtworkDirPath") {
|
||||
artworkDir().absolutePath
|
||||
}
|
||||
@@ -127,6 +167,100 @@ class AstraLibraryScannerModule : Module() {
|
||||
private fun artworkThumbDir(): File =
|
||||
File(requireContext().filesDir, "artwork-thumbs").apply { if (!exists()) mkdirs() }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ReplayGain tags (container metadata only — no audio decode)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Cap MetadataRetriever per file so a malformed/huge container can't hang a worker.
|
||||
private val metadataTimeoutMs = 12_000L
|
||||
|
||||
/**
|
||||
* Read ReplayGain track/album gain (dB) + peak (linear) from container tags via
|
||||
* ExoPlayer's MetadataRetriever (parses ID3 TXXX, Vorbis comments, MP4 freeform
|
||||
* atoms without decoding PCM). Mirrors the desktop's extractReplayGainDb fuzzy
|
||||
* matching. Returns all-null on any failure (unsupported container, IO, timeout).
|
||||
*/
|
||||
private fun readReplayGain(uriStr: String): ReplayGainTags {
|
||||
val result = ReplayGainTags()
|
||||
try {
|
||||
val mediaItem = MediaItem.fromUri(Uri.parse(uriStr))
|
||||
val trackGroups = MetadataRetriever.retrieveMetadata(requireContext(), mediaItem)
|
||||
.get(metadataTimeoutMs, TimeUnit.MILLISECONDS)
|
||||
|
||||
// R128 (Opus / EBU) is a fallback used only when no REPLAYGAIN_* tag is present.
|
||||
var r128Track: Double? = null
|
||||
var r128Album: Double? = null
|
||||
|
||||
fun consider(rawKey: String?, rawValue: String?) {
|
||||
if (rawKey == null || rawValue == null) return
|
||||
val key = normalizeRgKey(rawKey)
|
||||
when {
|
||||
result.trackGainDb == null && (key.contains("replaygain_track_gain") || key.contains("rg_track_gain")) ->
|
||||
result.trackGainDb = parseRgDb(rawValue)
|
||||
result.albumGainDb == null && (key.contains("replaygain_album_gain") || key.contains("rg_album_gain")) ->
|
||||
result.albumGainDb = parseRgDb(rawValue)
|
||||
result.trackPeak == null && (key.contains("replaygain_track_peak") || key.contains("rg_track_peak")) ->
|
||||
result.trackPeak = parsePeak(rawValue)
|
||||
result.albumPeak == null && (key.contains("replaygain_album_peak") || key.contains("rg_album_peak")) ->
|
||||
result.albumPeak = parsePeak(rawValue)
|
||||
r128Track == null && key.contains("r128_track_gain") -> r128Track = parseR128(rawValue)
|
||||
r128Album == null && key.contains("r128_album_gain") -> r128Album = parseR128(rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
for (g in 0 until trackGroups.length) {
|
||||
val group = trackGroups.get(g)
|
||||
for (f in 0 until group.length) {
|
||||
val metadata = group.getFormat(f).metadata ?: continue
|
||||
for (i in 0 until metadata.length()) {
|
||||
when (val entry = metadata.get(i)) {
|
||||
// ID3 user-defined text frame: description is the key, value the text.
|
||||
is TextInformationFrame -> if (entry.id == "TXXX") consider(entry.description, entry.value)
|
||||
// FLAC/Ogg/Opus Vorbis comments (vorbis.VorbisComment extends this).
|
||||
is VorbisComment -> consider(entry.key, entry.value)
|
||||
// MP4 iTunes freeform "----:com.apple.iTunes:replaygain_*" atoms.
|
||||
is InternalFrame -> consider(entry.description, entry.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.trackGainDb == null) result.trackGainDb = r128Track
|
||||
if (result.albumGainDb == null) result.albumGainDb = r128Album
|
||||
} catch (_: Throwable) {
|
||||
// Unsupported container, IO error, or timeout -> no tags.
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun normalizeRgKey(id: String): String =
|
||||
id.trim().lowercase().replace(Regex("[\\s-]+"), "_")
|
||||
|
||||
/** Parse a ReplayGain dB value like "-6.54 dB", "-6,54", or "+3.2". */
|
||||
private fun parseRgDb(raw: String): Double? {
|
||||
val trimmed = raw.trim()
|
||||
if (trimmed.isEmpty()) return null
|
||||
trimmed.toDoubleOrNull()?.let { return it }
|
||||
trimmed.replace(Regex("(?i)\\s*dB\\s*$"), "").trim().toDoubleOrNull()?.let { return it }
|
||||
val m = Regex("[+-]?\\d+(?:[.,]\\d+)?").find(trimmed) ?: return null
|
||||
return m.value.replace(',', '.').toDoubleOrNull()
|
||||
}
|
||||
|
||||
/** Parse a ReplayGain peak (linear amplitude, > 0). */
|
||||
private fun parsePeak(raw: String): Double? {
|
||||
val trimmed = raw.trim()
|
||||
trimmed.toDoubleOrNull()?.let { return if (it > 0.0) it else null }
|
||||
val m = Regex("[+-]?\\d+(?:[.,]\\d+)?").find(trimmed) ?: return null
|
||||
val v = m.value.replace(',', '.').toDoubleOrNull() ?: return null
|
||||
return if (v > 0.0) v else null
|
||||
}
|
||||
|
||||
/** R128_*_GAIN is Q7.8 dB relative to -23 LUFS; +5 dB realigns to the RG reference. */
|
||||
private fun parseR128(raw: String): Double? {
|
||||
val v = raw.trim().toIntOrNull() ?: return null
|
||||
return v / 256.0 + 5.0
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Directory walk
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -303,11 +437,12 @@ class AstraLibraryScannerModule : Module() {
|
||||
// Waveform peaks (offline RMS bins)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Decodes the whole file to PCM and accumulates RMS energy per bin (mirrors
|
||||
// desktop waveformExtractor.extractWaveformPeaks), normalized to [0,1]. Returns
|
||||
// an empty array on any failure (caller falls back to a flat seek bar).
|
||||
private fun extractWaveform(uriStr: String, bins: Int): FloatArray {
|
||||
// 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 {
|
||||
val context = requireContext()
|
||||
val result = AudioAnalysis()
|
||||
val uri = Uri.parse(uriStr)
|
||||
val extractor = MediaExtractor()
|
||||
var codec: MediaCodec? = null
|
||||
@@ -322,7 +457,7 @@ class AstraLibraryScannerModule : Module() {
|
||||
trackFormat = f; trackIndex = i; break
|
||||
}
|
||||
}
|
||||
val format = trackFormat ?: return FloatArray(0)
|
||||
val format = trackFormat ?: return result
|
||||
extractor.selectTrack(trackIndex)
|
||||
|
||||
val sampleRate =
|
||||
@@ -370,7 +505,7 @@ class AstraLibraryScannerModule : Module() {
|
||||
out.position(info.offset)
|
||||
out.limit(info.offset + info.size)
|
||||
out.order(ByteOrder.nativeOrder())
|
||||
frame = accumulate(out, pcmFloat, channelCount, bins, totalFrames, frame, sumSquares, counts)
|
||||
frame = accumulateAnalyze(out, pcmFloat, channelCount, bins, totalFrames, frame, sumSquares, counts)
|
||||
}
|
||||
codec.releaseOutputBuffer(outIndex, false)
|
||||
} else if (outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
|
||||
@@ -394,9 +529,10 @@ class AstraLibraryScannerModule : Module() {
|
||||
if (globalMax > 0) {
|
||||
for (i in 0 until bins) peaks[i] = (peaks[i] / globalMax).toFloat()
|
||||
}
|
||||
return peaks
|
||||
result.peaks = peaks
|
||||
return result
|
||||
} catch (_: Throwable) {
|
||||
return FloatArray(0)
|
||||
return result
|
||||
} finally {
|
||||
try { codec?.stop() } catch (_: Throwable) {}
|
||||
try { codec?.release() } catch (_: Throwable) {}
|
||||
@@ -404,9 +540,128 @@ class AstraLibraryScannerModule : Module() {
|
||||
}
|
||||
}
|
||||
|
||||
// Folds one decoded PCM buffer into the per-bin RMS accumulators. Handles
|
||||
// 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 accumulate(
|
||||
private fun accumulateAnalyze(
|
||||
out: java.nio.ByteBuffer,
|
||||
pcmFloat: Boolean,
|
||||
channelCount: Int,
|
||||
@@ -451,6 +706,111 @@ class AstraLibraryScannerModule : Module() {
|
||||
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
|
||||
// 400 ms blocks, then a two-stage gate (-70 LUFS absolute, -10 LU relative). Unity
|
||||
// channel weights (fine for mono/stereo). Non-overlapping blocks (vs the spec's 75%
|
||||
// overlap) — within ~0.1 LU and much cheaper.
|
||||
private class LoudnessMeter(private val channels: Int, sampleRate: Int) {
|
||||
private val b0a: Double; private val b1a: Double; private val b2a: Double
|
||||
private val a1a: Double; private val a2a: Double
|
||||
private val a1b: Double; private val a2b: Double
|
||||
|
||||
private val s1a = DoubleArray(channels)
|
||||
private val s2a = DoubleArray(channels)
|
||||
private val s1b = DoubleArray(channels)
|
||||
private val s2b = DoubleArray(channels)
|
||||
|
||||
private val blockSumSq = DoubleArray(channels)
|
||||
private val blockFrames: Int
|
||||
private var framesInBlock = 0
|
||||
// Per-block summed-channel mean-square energy (z), for gating.
|
||||
private val blockEnergies = ArrayList<Double>()
|
||||
|
||||
var peak: Double = 0.0
|
||||
private set
|
||||
|
||||
init {
|
||||
val fs = sampleRate.coerceAtLeast(1).toDouble()
|
||||
// Stage 1: high-shelf pre-filter.
|
||||
val f0a = 1681.974450955533
|
||||
val ga = 3.999843853973347
|
||||
val qa = 0.7071752369554196
|
||||
val ka = tan(PI * f0a / fs)
|
||||
val vh = Math.pow(10.0, ga / 20.0)
|
||||
val vb = Math.pow(vh, 0.4996667741545416)
|
||||
val a0a = 1.0 + ka / qa + ka * ka
|
||||
b0a = (vh + vb * ka / qa + ka * ka) / a0a
|
||||
b1a = 2.0 * (ka * ka - vh) / a0a
|
||||
b2a = (vh - vb * ka / qa + ka * ka) / a0a
|
||||
a1a = 2.0 * (ka * ka - 1.0) / a0a
|
||||
a2a = (1.0 - ka / qa + ka * ka) / a0a
|
||||
// Stage 2: RLB high-pass (b = [1, -2, 1]).
|
||||
val f0b = 38.13547087602444
|
||||
val qb = 0.5003270373238773
|
||||
val kb = tan(PI * f0b / fs)
|
||||
val a0b = 1.0 + kb / qb + kb * kb
|
||||
a1b = 2.0 * (kb * kb - 1.0) / a0b
|
||||
a2b = (1.0 - kb / qb + kb * kb) / a0b
|
||||
|
||||
blockFrames = max(1L, (0.4 * fs).toLong()).toInt() // 400 ms gating block
|
||||
}
|
||||
|
||||
fun process(sample: Double, ch: Int) {
|
||||
if (ch >= channels) return
|
||||
val a = abs(sample)
|
||||
if (a > peak) peak = a
|
||||
// Stage 1 (transposed direct form II).
|
||||
val y1 = b0a * sample + s1a[ch]
|
||||
s1a[ch] = b1a * sample - a1a * y1 + s2a[ch]
|
||||
s2a[ch] = b2a * sample - a2a * y1
|
||||
// Stage 2: b0=1, b1=-2, b2=1.
|
||||
val y2 = y1 + s1b[ch]
|
||||
s1b[ch] = -2.0 * y1 - a1b * y2 + s2b[ch]
|
||||
s2b[ch] = y1 - a2b * y2
|
||||
blockSumSq[ch] += y2 * y2
|
||||
// One frame completes when the last channel of the frame is processed.
|
||||
if (ch == channels - 1) {
|
||||
framesInBlock++
|
||||
if (framesInBlock >= blockFrames) finalizeBlock()
|
||||
}
|
||||
}
|
||||
|
||||
private fun finalizeBlock() {
|
||||
if (framesInBlock <= 0) return
|
||||
var energy = 0.0
|
||||
for (c in 0 until channels) {
|
||||
energy += blockSumSq[c] / framesInBlock
|
||||
blockSumSq[c] = 0.0
|
||||
}
|
||||
framesInBlock = 0
|
||||
if (energy > 0.0) blockEnergies.add(energy)
|
||||
}
|
||||
|
||||
fun lufs(): Double {
|
||||
finalizeBlock() // flush the trailing partial block
|
||||
if (blockEnergies.isEmpty()) return -70.0
|
||||
|
||||
// Absolute gate at -70 LUFS (energy terms).
|
||||
val absThresh = Math.pow(10.0, (-70.0 + 0.691) / 10.0)
|
||||
var sum = 0.0
|
||||
var cnt = 0
|
||||
for (e in blockEnergies) if (e >= absThresh) { sum += e; cnt++ }
|
||||
if (cnt == 0) return -70.0
|
||||
|
||||
// Relative gate: -10 LU below the abs-gated mean.
|
||||
val relLoudness = -0.691 + 10.0 * log10(sum / cnt)
|
||||
val relThresh = Math.pow(10.0, (relLoudness - 10.0 + 0.691) / 10.0)
|
||||
sum = 0.0
|
||||
cnt = 0
|
||||
for (e in blockEnergies) if (e >= absThresh && e >= relThresh) { sum += e; cnt++ }
|
||||
if (cnt == 0) return -70.0
|
||||
|
||||
return -0.691 + 10.0 * log10(sum / cnt)
|
||||
}
|
||||
}
|
||||
|
||||
private fun readBitsPerSample(format: MediaFormat): Int? {
|
||||
// The framework FLAC/WAV extractors expose "bits-per-sample"; other codecs
|
||||
// may expose a PCM encoding instead. Both are best-effort.
|
||||
|
||||
@@ -43,6 +43,14 @@ export interface ExtractedMetadata {
|
||||
artworkHash?: string | null;
|
||||
}
|
||||
|
||||
/** ReplayGain tags read from a file's container (null = tag absent). */
|
||||
export interface ReplayGainTags {
|
||||
trackGainDb: number | null;
|
||||
albumGainDb: number | null;
|
||||
trackPeak: number | null;
|
||||
albumPeak: number | null;
|
||||
}
|
||||
|
||||
export interface ScanProgressEvent {
|
||||
phase: 'discovering';
|
||||
found: number;
|
||||
@@ -60,6 +68,18 @@ declare class AstraLibraryScannerModuleType extends NativeModule<AstraLibrarySca
|
||||
* the waveform seek bar. Whole-file decode (heavy); returns [] on failure.
|
||||
*/
|
||||
extractWaveform(uri: string, bins: number): Promise<number[]>;
|
||||
/**
|
||||
* 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
|
||||
* are null when the tag is absent; the whole call is cheap (metadata only).
|
||||
*/
|
||||
readReplayGain(uri: string): Promise<ReplayGainTags>;
|
||||
getArtworkDirPath(): string;
|
||||
getArtworkThumbDirPath(): string;
|
||||
ensureArtworkThumbnails(hashes: string[]): Promise<number>;
|
||||
|
||||
@@ -29,5 +29,52 @@ class AstraScopeModule : Module() {
|
||||
Function("getOscilloscopeFrame") { out: Float32Array ->
|
||||
ScopeBridge.nativeFillOscilloscope(out.toDirectBuffer(), out.length)
|
||||
}
|
||||
|
||||
// --- M4: post-EQ spectrum (EQ screen overlay) ---
|
||||
// Gate the post-EQ tap (true only while the EQ screen is open).
|
||||
Function("setActivePostEq") { active: Boolean ->
|
||||
ScopeBridge.postEqActive = active
|
||||
}
|
||||
|
||||
Function("getSpectrumFramePostEq") { out: Float32Array ->
|
||||
ScopeBridge.nativeFillSpectrumPostEq(out.toDirectBuffer(), out.length)
|
||||
}
|
||||
|
||||
// --- M4: EQ params + per-track gain (consumed by the kotlin-audio processors) ---
|
||||
Function("setEqEnabled") { enabled: Boolean ->
|
||||
EqBridge.enabled = enabled
|
||||
EqBridge.revision += 1
|
||||
}
|
||||
|
||||
Function("setEqPreamp") { linear: Double ->
|
||||
EqBridge.preampLinear = linear.toFloat()
|
||||
EqBridge.revision += 1
|
||||
}
|
||||
|
||||
// Flat band params: 5 floats per band [typeOrdinal, frequency, gain, Q, enabled?1:0].
|
||||
Function("setEqBands") { params: FloatArray ->
|
||||
EqBridge.bands = params
|
||||
EqBridge.revision += 1
|
||||
}
|
||||
|
||||
Function("setNormalizationGain") { linear: Double ->
|
||||
GainBridge.linearGain = linear.toFloat()
|
||||
}
|
||||
|
||||
// Register a queued track's gain by URL so the player can switch to it natively at
|
||||
// the exact media-item transition (no JS round-trip on track change).
|
||||
Function("setTrackGain") { url: String, linear: Double ->
|
||||
GainBridge.putGain(url, linear.toFloat())
|
||||
}
|
||||
|
||||
// Make the registered gain for this URL active now (used for the current track on
|
||||
// mount / settings change, where no transition fires).
|
||||
Function("activateTrackGain") { url: String ->
|
||||
GainBridge.activateFor(url)
|
||||
}
|
||||
|
||||
Function("clearTrackGains") {
|
||||
GainBridge.clearGains()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package expo.modules.astrascope
|
||||
|
||||
/**
|
||||
* Holds the current parametric-EQ configuration, set from JS (raw band params, NOT
|
||||
* biquad coefficients). The vendored kotlin-audio `EqAudioProcessor` reads these
|
||||
* fields lock-free on the ExoPlayer audio thread and recomputes coefficients at the
|
||||
* real stream sample rate whenever [revision] changes.
|
||||
*
|
||||
* Single writer (JS thread via [AstraScopeModule]); single reader (audio thread).
|
||||
* `bands` is published before `revision` is bumped, so a reader that observes a new
|
||||
* revision also observes the matching band array.
|
||||
*/
|
||||
object EqBridge {
|
||||
/** Master bypass. When false the processor is passthrough. */
|
||||
@Volatile
|
||||
var enabled: Boolean = false
|
||||
|
||||
/** EQ preamp as a linear amplitude (1 = unity). */
|
||||
@Volatile
|
||||
var preampLinear: Float = 1f
|
||||
|
||||
/** 5 floats per band: [typeOrdinal, frequency, gain, Q, enabled?1:0]. */
|
||||
@Volatile
|
||||
var bands: FloatArray = FloatArray(0)
|
||||
|
||||
/** Bumped on every enabled/preamp/bands change so the processor recomputes. */
|
||||
@Volatile
|
||||
var revision: Int = 0
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package expo.modules.astrascope
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Per-track normalization / ReplayGain gain, read lock-free by the vendored
|
||||
* kotlin-audio `NormalizationGainProcessor` on the audio thread. Applied BEFORE the
|
||||
* scope taps so the scopes see normalized levels.
|
||||
*
|
||||
* JS pre-registers each queued track's gain by URL ([putGain]); the player then swaps
|
||||
* the active gain to the matching one natively at the real media-item transition
|
||||
* ([activateFor], called from BaseAudioPlayer.onMediaItemTransition). That lands the
|
||||
* gain at the actual audio boundary instead of after a JS round-trip on track change.
|
||||
*/
|
||||
object GainBridge {
|
||||
/** Active linear amplitude multiplier (1 = unity). Read on the audio thread. */
|
||||
@Volatile
|
||||
var linearGain: Float = 1f
|
||||
|
||||
// url -> linear gain, seeded from JS ahead of playback.
|
||||
private val gains = ConcurrentHashMap<String, Float>()
|
||||
|
||||
/** Register (or update) the gain for a track URL. */
|
||||
fun putGain(url: String, gain: Float) {
|
||||
gains[url] = gain
|
||||
}
|
||||
|
||||
/** Make the gain registered for [url] active (unity if unknown/null). */
|
||||
fun activateFor(url: String?) {
|
||||
linearGain = if (url != null) gains[url] ?: 1f else 1f
|
||||
}
|
||||
|
||||
/** Drop all registered gains (e.g. on full queue reset). */
|
||||
fun clearGains() {
|
||||
gains.clear()
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,14 @@ object ScopeBridge {
|
||||
@Volatile
|
||||
var active: Boolean = false
|
||||
|
||||
/**
|
||||
* Gates the POST-EQ tap specifically (true only while the EQ screen is open).
|
||||
* The post-EQ tap runs when [active] && [postEqActive], so the second downmix
|
||||
* costs nothing unless the EQ overlay is actually being viewed.
|
||||
*/
|
||||
@Volatile
|
||||
var postEqActive: Boolean = false
|
||||
|
||||
/** Audio thread. Tell the analyzer the stream's sample rate / channels. */
|
||||
external fun nativeConfigure(sampleRate: Int, channelCount: Int)
|
||||
|
||||
@@ -41,4 +49,13 @@ object ScopeBridge {
|
||||
* writes straight into JS memory.
|
||||
*/
|
||||
external fun nativeFillOscilloscope(buffer: java.nio.ByteBuffer, capacityFloats: Int): Int
|
||||
|
||||
/** Audio thread. Push POST-EQ interleaved float PCM (the M4 second tap). */
|
||||
external fun nativePushFramesPostEq(frames: FloatArray, frameCount: Int, channelCount: Int)
|
||||
|
||||
/**
|
||||
* Render thread. Fill `buffer` with the latest POST-EQ dB spectrum (feeds the
|
||||
* EQ screen's response-curve overlay). Returns bins written. Zero-copy.
|
||||
*/
|
||||
external fun nativeFillSpectrumPostEq(buffer: java.nio.ByteBuffer, capacityFloats: Int): Int
|
||||
}
|
||||
|
||||
@@ -73,4 +73,38 @@ Java_expo_modules_astrascope_ScopeBridge_nativeFillOscilloscope(
|
||||
return static_cast<jint>(n);
|
||||
}
|
||||
|
||||
// --- POST-EQ source (M4) ----------------------------------------------------
|
||||
// The post-EQ tap pushes here; the EQ screen pulls the post-EQ spectrum.
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_expo_modules_astrascope_ScopeBridge_nativePushFramesPostEq(
|
||||
JNIEnv* env, jobject /*thiz*/, jfloatArray frames, jint frameCount,
|
||||
jint channelCount) {
|
||||
if (frames == nullptr || frameCount <= 0 || channelCount <= 0) {
|
||||
return;
|
||||
}
|
||||
auto* data = static_cast<float*>(
|
||||
env->GetPrimitiveArrayCritical(frames, nullptr));
|
||||
if (data == nullptr) {
|
||||
return;
|
||||
}
|
||||
driver().pushInterleavedPostEq(data, static_cast<size_t>(frameCount),
|
||||
static_cast<int>(channelCount));
|
||||
env->ReleasePrimitiveArrayCritical(frames, data, JNI_ABORT);
|
||||
}
|
||||
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_expo_modules_astrascope_ScopeBridge_nativeFillSpectrumPostEq(
|
||||
JNIEnv* env, jobject /*thiz*/, jobject buffer, jint capacityFloats) {
|
||||
if (buffer == nullptr || capacityFloats <= 0) {
|
||||
return 0;
|
||||
}
|
||||
auto* dst = static_cast<float*>(env->GetDirectBufferAddress(buffer));
|
||||
if (dst == nullptr) {
|
||||
return 0;
|
||||
}
|
||||
const size_t n = driver().fillSpectrumPostEq(dst, static_cast<size_t>(capacityFloats));
|
||||
return static_cast<jint>(n);
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
|
||||
@@ -184,10 +184,70 @@ class ScopeDriver {
|
||||
return count;
|
||||
}
|
||||
|
||||
// ---- POST-EQ source (M4) -------------------------------------------------
|
||||
// A second, spectrum-only SPSC source fed by the post-EQ tap. Mirrors the
|
||||
// pre-EQ spectrum path exactly; used only by the EQ screen's response-curve
|
||||
// overlay, so there is no post-EQ oscilloscope.
|
||||
|
||||
// Audio thread. Downmix interleaved float frames to mono into the post-EQ ring.
|
||||
void pushInterleavedPostEq(const float* data, size_t frames, int channels) {
|
||||
if (data == nullptr || frames == 0 || channels <= 0) {
|
||||
return;
|
||||
}
|
||||
size_t w = postEqWritePos_.load(std::memory_order_relaxed);
|
||||
const float inv = 1.0f / static_cast<float>(channels);
|
||||
for (size_t f = 0; f < frames; ++f) {
|
||||
float sum = 0.0f;
|
||||
const float* frame = data + f * channels;
|
||||
for (int c = 0; c < channels; ++c) {
|
||||
sum += frame[c];
|
||||
}
|
||||
postEqRing_[w & kMask] = sum * inv;
|
||||
++w;
|
||||
}
|
||||
postEqWritePos_.store(w, std::memory_order_release);
|
||||
}
|
||||
|
||||
// Render thread. Latest post-EQ spectrum window -> `out` (dB magnitudes).
|
||||
size_t fillSpectrumPostEq(float* out, size_t cap) {
|
||||
if (out == nullptr || cap == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const int sr = pendingSampleRate_.load(std::memory_order_acquire);
|
||||
if (sr != postEqAppliedSampleRate_) {
|
||||
postEqSpectrum_.setSampleRate(static_cast<float>(sr));
|
||||
postEqAppliedSampleRate_ = sr;
|
||||
}
|
||||
|
||||
const size_t fftSize = postEqSpectrum_.getFFTSize();
|
||||
const size_t w = postEqWritePos_.load(std::memory_order_acquire);
|
||||
const size_t sampleRate = sr > 0 ? static_cast<size_t>(sr) : static_cast<size_t>(48000);
|
||||
const size_t delaySamples = scopeOutputDelaySamples(sampleRate);
|
||||
const size_t readHead = w > delaySamples ? w - delaySamples : 0;
|
||||
|
||||
const std::vector<float>* mags;
|
||||
if (readHead >= fftSize) {
|
||||
postEqScratch_.resize(fftSize);
|
||||
const size_t start = readHead - fftSize;
|
||||
for (size_t i = 0; i < fftSize; ++i) {
|
||||
postEqScratch_[i] = postEqRing_[(start + i) & kMask];
|
||||
}
|
||||
mags = &postEqSpectrum_.process(postEqScratch_.data(), fftSize);
|
||||
} else {
|
||||
mags = &postEqSpectrum_.process(nullptr, 0);
|
||||
}
|
||||
|
||||
const size_t n = std::min(cap, mags->size());
|
||||
std::memcpy(out, mags->data(), n * sizeof(float));
|
||||
return n;
|
||||
}
|
||||
|
||||
size_t binCount() const { return spectrum_.getFFTSize() / 2; }
|
||||
|
||||
void reset() {
|
||||
spectrum_.reset();
|
||||
postEqSpectrum_.reset();
|
||||
osc_.reset();
|
||||
oscReadPos_ = writePos_.load(std::memory_order_acquire);
|
||||
oscSamplesSeen_ = 0;
|
||||
@@ -196,9 +256,11 @@ class ScopeDriver {
|
||||
}
|
||||
|
||||
private:
|
||||
ScopeDriver() : spectrum_(kFftSize) {
|
||||
ScopeDriver() : spectrum_(kFftSize), postEqSpectrum_(kFftSize) {
|
||||
spectrum_.setSmoothing(0.92f);
|
||||
postEqSpectrum_.setSmoothing(0.92f);
|
||||
ring_.assign(kSize, 0.0f);
|
||||
postEqRing_.assign(kSize, 0.0f);
|
||||
}
|
||||
|
||||
static constexpr size_t kFftSize = 2048; // -> 1024 dB bins
|
||||
@@ -277,6 +339,13 @@ class ScopeDriver {
|
||||
Visualizer::Spectrum spectrum_;
|
||||
int appliedSampleRate_{0};
|
||||
|
||||
// Post-EQ source (M4) — second SPSC ring + spectrum-only analyzer.
|
||||
std::vector<float> postEqRing_;
|
||||
std::atomic<size_t> postEqWritePos_{0};
|
||||
std::vector<float> postEqScratch_;
|
||||
Visualizer::Spectrum postEqSpectrum_;
|
||||
int postEqAppliedSampleRate_{0};
|
||||
|
||||
Visualizer::Oscilloscope osc_;
|
||||
size_t oscReadPos_{0};
|
||||
size_t oscSamplesSeen_{0};
|
||||
|
||||
@@ -25,6 +25,37 @@ declare class AstraScopeModuleType extends NativeModule {
|
||||
* ~[-1, 1]. Returns the number of points written (0 before warmup).
|
||||
*/
|
||||
getOscilloscopeFrame(out: Float32Array): number;
|
||||
/** Gate the post-EQ tap (true only while the EQ screen is visible). */
|
||||
setActivePostEq(active: boolean): void;
|
||||
/**
|
||||
* Like {@link getSpectrumFrame} but for the POST-EQ tap (ring #2) — feeds the
|
||||
* EQ screen's spectrum behind the response curve. Returns bins written.
|
||||
*/
|
||||
getSpectrumFramePostEq(out: Float32Array): number;
|
||||
|
||||
// --- M4 EQ + per-track gain (params pushed from JS; biquad coeffs computed
|
||||
// natively at the real stream sample rate) ---
|
||||
|
||||
/** Master EQ bypass. When false the EqAudioProcessor is passthrough. */
|
||||
setEqEnabled(enabled: boolean): void;
|
||||
/** EQ preamp as a linear amplitude (1 = unity). */
|
||||
setEqPreamp(linear: number): void;
|
||||
/**
|
||||
* Flat band params: 5 values per band — [typeOrdinal, frequency, gain, Q,
|
||||
* enabled?1:0]. Native recomputes biquad coefficients at the stream rate.
|
||||
*/
|
||||
setEqBands(params: number[]): void;
|
||||
/** Set the active per-track normalization/ReplayGain gain (linear; 1 = unity). */
|
||||
setNormalizationGain(linear: number): void;
|
||||
/**
|
||||
* Register a queued track's gain by URL. The player switches the active gain to the
|
||||
* matching entry natively at the media-item transition (no JS round-trip).
|
||||
*/
|
||||
setTrackGain(url: string, linear: number): void;
|
||||
/** Activate the registered gain for this URL now (current track on mount/settings). */
|
||||
activateTrackGain(url: string): void;
|
||||
/** Drop all registered per-track gains. */
|
||||
clearTrackGains(): void;
|
||||
}
|
||||
|
||||
export const AstraScope = requireNativeModule<AstraScopeModuleType>('AstraScope');
|
||||
|
||||
Reference in New Issue
Block a user