This commit is contained in:
Boof2015
2026-06-19 13:19:37 -04:00
parent 978d001db9
commit bc687dfe87
145 changed files with 4685 additions and 197 deletions
@@ -1 +1 @@
#Wed Jun 17 14:38:54 EDT 2026
#Fri Jun 19 12:51:01 EDT 2026
@@ -24,6 +24,7 @@ import com.doublesymmetry.kotlinaudio.models.AudioItem
import com.doublesymmetry.kotlinaudio.models.AudioItemHolder
import com.doublesymmetry.kotlinaudio.models.AudioItemTransitionReason
import com.doublesymmetry.kotlinaudio.models.AudioPlayerState
import expo.modules.astrascope.GainBridge
import com.doublesymmetry.kotlinaudio.models.BufferConfig
import com.doublesymmetry.kotlinaudio.models.CacheConfig
import com.doublesymmetry.kotlinaudio.models.DefaultPlayerOptions
@@ -666,6 +667,12 @@ abstract class BaseAudioPlayer internal constructor(
* playlist becomes non-empty or empty as a consequence of a playlist change.
*/
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
// Apply the per-track normalization gain natively, exactly at the audio
// transition — the gain map is pre-seeded from JS by URL, so this is just a
// lock-free lookup (no JS round-trip on track change).
val url = runCatching { mediaItem?.getAudioItemHolder()?.audioItem?.audioUrl }.getOrNull()
GainBridge.activateFor(url)
when (reason) {
Player.MEDIA_ITEM_TRANSITION_REASON_AUTO -> playerEventHolder.updateAudioItemTransition(
AudioItemTransitionReason.AUTO(oldPosition)
@@ -0,0 +1,258 @@
package com.doublesymmetry.kotlinaudio.scope
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.audio.AudioProcessor
import com.google.android.exoplayer2.audio.BaseAudioProcessor
import expo.modules.astrascope.EqBridge
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.roundToInt
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Parametric EQ as an ExoPlayer AudioProcessor (M4). Reads raw band params from
* [EqBridge] (set from JS) and computes Audio-EQ-Cookbook biquad coefficients at
* the real stream sample rate — mirroring Web Audio's BiquadFilterNode on desktop.
* A cascade of transposed-direct-form-II biquads runs per channel after a preamp.
*
* Passthrough (bit-exact) when the EQ is disabled or has no active bands and unity
* preamp, so toggling EQ off is lossless. Handles PCM float and 16-bit; coefficients
* are rebuilt only when [EqBridge.revision] changes (cheap, off the per-sample path).
*/
class EqAudioProcessor : BaseAudioProcessor() {
private var channels = 0
private var sampleRate = 0f
private var lastRevision = Int.MIN_VALUE
private var enabled = false
private var preamp = 1f
private var bandCount = 0
private var coeffs = FloatArray(0) // 5 per band: b0,b1,b2,a1,a2 (a0-normalized)
private var z1 = FloatArray(0) // bandCount * channels
private var z2 = FloatArray(0)
private var floatScratch = FloatArray(0)
override fun onConfigure(
inputAudioFormat: AudioProcessor.AudioFormat
): AudioProcessor.AudioFormat {
channels = inputAudioFormat.channelCount
sampleRate = inputAudioFormat.sampleRate.toFloat()
lastRevision = Int.MIN_VALUE // force a rebuild on the next buffer
return inputAudioFormat
}
override fun queueInput(inputBuffer: ByteBuffer) {
val remaining = inputBuffer.remaining()
if (remaining <= 0) return
rebuildIfNeeded()
val passthrough = !enabled || channels <= 0 || (bandCount == 0 && preamp == 1f)
if (passthrough) {
val out = replaceOutputBuffer(remaining)
out.put(inputBuffer)
out.flip()
return
}
when (inputAudioFormat.encoding) {
C.ENCODING_PCM_FLOAT -> processFloat(inputBuffer, remaining)
C.ENCODING_PCM_16BIT -> process16(inputBuffer, remaining)
else -> {
val out = replaceOutputBuffer(remaining)
out.put(inputBuffer)
out.flip()
}
}
}
override fun onFlush() {
z1.fill(0f)
z2.fill(0f)
}
private fun rebuildIfNeeded() {
val rev = EqBridge.revision
if (rev == lastRevision) return
lastRevision = rev
enabled = EqBridge.enabled
preamp = EqBridge.preampLinear
val params = EqBridge.bands
val total = params.size / 5
var active = 0
for (i in 0 until total) if (params[i * 5 + 4] != 0f) active++
// Reset filter state only when the band count changes (avoid clicks on tweaks).
if (active != bandCount) {
bandCount = active
coeffs = FloatArray(active * 5)
z1 = FloatArray(active * channels.coerceAtLeast(1))
z2 = FloatArray(active * channels.coerceAtLeast(1))
}
var bi = 0
for (i in 0 until total) {
if (params[i * 5 + 4] == 0f) continue
computeCoeffs(
params[i * 5].toInt(),
params[i * 5 + 1],
params[i * 5 + 2],
params[i * 5 + 3],
sampleRate,
coeffs,
bi * 5
)
bi++
}
}
private fun processFloat(inputBuffer: ByteBuffer, remaining: Int) {
val fb = inputBuffer.asFloatBuffer()
val n = fb.remaining()
if (n <= 0) return
if (floatScratch.size < n) floatScratch = FloatArray(n)
fb.get(floatScratch, 0, n)
inputBuffer.position(inputBuffer.limit()) // mark input consumed
processSamples(floatScratch, n)
val out = replaceOutputBuffer(n * 4).order(ByteOrder.nativeOrder())
out.asFloatBuffer().put(floatScratch, 0, n)
out.position(n * 4)
out.flip()
}
private fun process16(inputBuffer: ByteBuffer, remaining: Int) {
val sb = inputBuffer.asShortBuffer()
val n = sb.remaining()
if (n <= 0) return
if (floatScratch.size < n) floatScratch = FloatArray(n)
var i = 0
while (i < n) {
floatScratch[i] = sb.get(i) / 32768f
i++
}
inputBuffer.position(inputBuffer.limit())
processSamples(floatScratch, n)
val out = replaceOutputBuffer(n * 2).order(ByteOrder.nativeOrder())
val osb = out.asShortBuffer()
i = 0
while (i < n) {
val v = (floatScratch[i] * 32768f).roundToInt().coerceIn(-32768, 32767)
osb.put(v.toShort())
i++
}
out.position(n * 2)
out.flip()
}
/** Apply preamp + the biquad cascade in place over interleaved samples. */
private fun processSamples(buf: FloatArray, n: Int) {
val ch = channels
val bc = bandCount
val pre = preamp
var c = 0
var i = 0
while (i < n) {
var x = buf[i] * pre
var b = 0
while (b < bc) {
val co = b * 5
val b0 = coeffs[co]
val b1 = coeffs[co + 1]
val b2 = coeffs[co + 2]
val a1 = coeffs[co + 3]
val a2 = coeffs[co + 4]
val si = b * ch + c
val s1 = z1[si]
val s2 = z2[si]
val y = b0 * x + s1
z1[si] = b1 * x - a1 * y + s2
z2[si] = b2 * x - a2 * y
x = y
b++
}
buf[i] = x
c++
if (c == ch) c = 0
i++
}
}
/**
* Audio-EQ-Cookbook biquad coefficients (a0-normalized) into out[off..off+4].
* Type ordinals match EQ_BAND_TYPE_ORDINAL in src/audio/eq.ts:
* 0 lowshelf, 1 peaking, 2 highshelf, 3 highpass, 4 lowpass.
*/
private fun computeCoeffs(
type: Int,
freq: Float,
gainDb: Float,
q: Float,
sr: Float,
out: FloatArray,
off: Int
) {
if (sr <= 0f) {
out[off] = 1f; out[off + 1] = 0f; out[off + 2] = 0f; out[off + 3] = 0f; out[off + 4] = 0f
return
}
val w0 = 2.0 * PI * freq / sr
val cosW0 = cos(w0)
val sinW0 = sin(w0)
val a = 10.0.pow(gainDb / 40.0)
val alpha = sinW0 / (2.0 * q.coerceAtLeast(0.0001f))
var b0 = 1.0; var b1 = 0.0; var b2 = 0.0
var a0 = 1.0; var a1 = 0.0; var a2 = 0.0
when (type) {
1 -> { // peaking
b0 = 1 + alpha * a; b1 = -2 * cosW0; b2 = 1 - alpha * a
a0 = 1 + alpha / a; a1 = -2 * cosW0; a2 = 1 - alpha / a
}
0 -> { // lowshelf
val sqrtA = sqrt(a)
b0 = a * (a + 1 - (a - 1) * cosW0 + 2 * sqrtA * alpha)
b1 = 2 * a * (a - 1 - (a + 1) * cosW0)
b2 = a * (a + 1 - (a - 1) * cosW0 - 2 * sqrtA * alpha)
a0 = a + 1 + (a - 1) * cosW0 + 2 * sqrtA * alpha
a1 = -2 * (a - 1 + (a + 1) * cosW0)
a2 = a + 1 + (a - 1) * cosW0 - 2 * sqrtA * alpha
}
2 -> { // highshelf
val sqrtA = sqrt(a)
b0 = a * (a + 1 + (a - 1) * cosW0 + 2 * sqrtA * alpha)
b1 = -2 * a * (a - 1 + (a + 1) * cosW0)
b2 = a * (a + 1 + (a - 1) * cosW0 - 2 * sqrtA * alpha)
a0 = a + 1 - (a - 1) * cosW0 + 2 * sqrtA * alpha
a1 = 2 * (a - 1 - (a + 1) * cosW0)
a2 = a + 1 - (a - 1) * cosW0 - 2 * sqrtA * alpha
}
4 -> { // lowpass
b0 = (1 - cosW0) / 2; b1 = 1 - cosW0; b2 = (1 - cosW0) / 2
a0 = 1 + alpha; a1 = -2 * cosW0; a2 = 1 - alpha
}
3 -> { // highpass
b0 = (1 + cosW0) / 2; b1 = -(1 + cosW0); b2 = (1 + cosW0) / 2
a0 = 1 + alpha; a1 = -2 * cosW0; a2 = 1 - alpha
}
}
val inv = 1.0 / a0
out[off] = (b0 * inv).toFloat()
out[off + 1] = (b1 * inv).toFloat()
out[off + 2] = (b2 * inv).toFloat()
out[off + 3] = (a1 * inv).toFloat()
out[off + 4] = (a2 * inv).toFloat()
}
}
@@ -0,0 +1,80 @@
package com.doublesymmetry.kotlinaudio.scope
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.audio.AudioProcessor
import com.google.android.exoplayer2.audio.BaseAudioProcessor
import expo.modules.astrascope.GainBridge
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.math.roundToInt
/**
* Applies the per-track normalization / ReplayGain gain from [GainBridge] (set from
* JS on track/settings change). Sits FIRST in the chain — before the scope taps —
* so the visualizers see normalized levels (the user's "better for scopes" goal).
*
* Bit-exact passthrough when the gain is unity. Handles PCM float and 16-bit; the
* 16-bit path clamps to int16 range. The gain resolver already backs off so the
* post-gain peak stays <= 0.98, so clipping should not occur here in practice.
*/
class NormalizationGainProcessor : BaseAudioProcessor() {
private var floatScratch = FloatArray(0)
override fun onConfigure(
inputAudioFormat: AudioProcessor.AudioFormat
): AudioProcessor.AudioFormat = inputAudioFormat
override fun queueInput(inputBuffer: ByteBuffer) {
val remaining = inputBuffer.remaining()
if (remaining <= 0) return
val gain = GainBridge.linearGain
if (gain == 1f) {
val out = replaceOutputBuffer(remaining)
out.put(inputBuffer)
out.flip()
return
}
when (inputAudioFormat.encoding) {
C.ENCODING_PCM_FLOAT -> {
val fb = inputBuffer.asFloatBuffer()
val n = fb.remaining()
if (n <= 0) return
if (floatScratch.size < n) floatScratch = FloatArray(n)
fb.get(floatScratch, 0, n)
inputBuffer.position(inputBuffer.limit())
var i = 0
while (i < n) {
floatScratch[i] = floatScratch[i] * gain
i++
}
val out = replaceOutputBuffer(n * 4).order(ByteOrder.nativeOrder())
out.asFloatBuffer().put(floatScratch, 0, n)
out.position(n * 4)
out.flip()
}
C.ENCODING_PCM_16BIT -> {
val sb = inputBuffer.asShortBuffer()
val n = sb.remaining()
if (n <= 0) return
val out = replaceOutputBuffer(n * 2).order(ByteOrder.nativeOrder())
val osb = out.asShortBuffer()
var i = 0
while (i < n) {
val v = (sb.get(i) * gain).roundToInt().coerceIn(-32768, 32767)
osb.put(v.toShort())
i++
}
inputBuffer.position(inputBuffer.limit())
out.position(n * 2)
out.flip()
}
else -> {
val out = replaceOutputBuffer(remaining)
out.put(inputBuffer)
out.flip()
}
}
}
}
@@ -0,0 +1,66 @@
package com.doublesymmetry.kotlinaudio.scope
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.audio.AudioProcessor
import com.google.android.exoplayer2.audio.BaseAudioProcessor
import expo.modules.astrascope.ScopeBridge
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* Pass-through tap placed AFTER the EQ processor (M4). Identical to
* ScopeTapAudioProcessor but pushes to the native post-EQ ring (ring #2) which
* feeds the EQ screen's response-curve spectrum overlay. Gated by
* `ScopeBridge.active && ScopeBridge.postEqActive` so it costs ~zero unless the
* EQ screen is open and the app is foregrounded + playing.
*/
class PostEqTapAudioProcessor : BaseAudioProcessor() {
private var scratch = FloatArray(0)
override fun onConfigure(
inputAudioFormat: AudioProcessor.AudioFormat
): AudioProcessor.AudioFormat = inputAudioFormat
override fun queueInput(inputBuffer: ByteBuffer) {
val remaining = inputBuffer.remaining()
if (remaining <= 0) return
if (ScopeBridge.active && ScopeBridge.postEqActive) {
tap(inputBuffer)
}
val out = replaceOutputBuffer(remaining)
out.put(inputBuffer)
out.flip()
}
private fun tap(inputBuffer: ByteBuffer) {
val channels = inputAudioFormat.channelCount
if (channels <= 0) return
val dup = inputBuffer.duplicate().order(ByteOrder.nativeOrder())
when (inputAudioFormat.encoding) {
C.ENCODING_PCM_FLOAT -> {
val fb = dup.asFloatBuffer()
val n = fb.remaining()
if (n <= 0) return
if (scratch.size < n) scratch = FloatArray(n)
fb.get(scratch, 0, n)
ScopeBridge.nativePushFramesPostEq(scratch, n / channels, channels)
}
C.ENCODING_PCM_16BIT -> {
val sb = dup.asShortBuffer()
val n = sb.remaining()
if (n <= 0) return
if (scratch.size < n) scratch = FloatArray(n)
var i = 0
while (i < n) {
scratch[i] = sb.get(i) / 32768f
i++
}
ScopeBridge.nativePushFramesPostEq(scratch, n / channels, channels)
}
else -> { /* unsupported PCM encoding — forward only */ }
}
}
}
@@ -7,10 +7,14 @@ import com.google.android.exoplayer2.audio.AudioSink
import com.google.android.exoplayer2.audio.DefaultAudioSink
/**
* A DefaultRenderersFactory whose audio sink runs our pre-EQ PCM tap as the
* first (and, for M3, only) AudioProcessor. Float-output / playback-param
* capabilities are preserved by forwarding the flags. M4 will prepend the EQ
* AudioProcessor (and add a second post-EQ tap) to this same chain.
* A DefaultRenderersFactory whose audio sink runs the M4 processing chain.
* Order matters:
* 1. NormalizationGainProcessor — per-track gain (before the taps, so the
* scopes see normalized levels).
* 2. ScopeTapAudioProcessor — the pre-EQ tap (post-normalization) → scope ring #1.
* 3. EqAudioProcessor — preamp + parametric biquad chain.
* 4. PostEqTapAudioProcessor — post-EQ tap → scope ring #2 (EQ screen overlay).
* Float-output / playback-param capabilities are preserved by forwarding the flags.
*/
fun buildScopeRenderersFactory(context: Context): DefaultRenderersFactory =
object : DefaultRenderersFactory(context) {
@@ -23,6 +27,13 @@ fun buildScopeRenderersFactory(context: Context): DefaultRenderersFactory =
DefaultAudioSink.Builder(context)
.setEnableFloatOutput(enableFloatOutput)
.setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams)
.setAudioProcessors(arrayOf<AudioProcessor>(ScopeTapAudioProcessor()))
.setAudioProcessors(
arrayOf<AudioProcessor>(
NormalizationGainProcessor(),
ScopeTapAudioProcessor(),
EqAudioProcessor(),
PostEqTapAudioProcessor()
)
)
.build()
}