mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-21 21:16:09 +02:00
m4
This commit is contained in:
+7
@@ -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)
|
||||
|
||||
+258
@@ -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()
|
||||
}
|
||||
}
|
||||
+80
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+66
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-5
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user