react-native-queue-player 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -71,6 +71,11 @@ android {
71
71
  targetSdkVersion getExtOrDefault("targetSdkVersion")
72
72
  testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
73
73
 
74
+ // Shipped in the AAR and applied to the consumer's R8 run so a consuming
75
+ // app's release build needs no manual rules for this library's transitive
76
+ // dependencies (e.g. Ktor's java.lang.management reference on Android).
77
+ consumerProguardFiles "consumer-rules.pro"
78
+
74
79
  externalNativeBuild {
75
80
  cmake {
76
81
  cppFlags "-frtti -fexceptions -Wall -fstack-protector-all"
@@ -0,0 +1,14 @@
1
+ # React Native Queue Player — consumer R8 / ProGuard rules.
2
+ #
3
+ # Packaged into the published AAR via `consumerProguardFiles` and applied
4
+ # automatically to a consuming app's R8 run, so consumers need no manual rules
5
+ # for this library's transitive dependencies.
6
+
7
+ # The embedded Ktor CIO HTTP server (the Android AirPlay 2 receiver) ships
8
+ # io.ktor.util.debug.IntellijIdeaDebugDetector, which references
9
+ # java.lang.management.ManagementFactory and RuntimeMXBean to detect an attached
10
+ # IntelliJ debugger. Those JMX classes are JVM-only and absent on Android, so a
11
+ # consumer's R8 release build (full mode treats missing classes as errors) fails
12
+ # on the missing references. The detector is debug tooling that never executes
13
+ # at runtime on Android; ignore the missing classes rather than fail the build.
14
+ -dontwarn java.lang.management.**
@@ -23,12 +23,14 @@ import androidx.media3.exoplayer.audio.DefaultAudioSink
23
23
  * interleave their `flush` + buffer state. The CrossfadeEngine builds two
24
24
  * factories (one per leg); each receives its own chain.
25
25
  *
26
- * Float-output is opted in at construction via [setEnableAudioFloatOutput]:
27
- * Media3's [DefaultRenderersFactory] defaults that flag to `false`, which would
28
- * propagate as `enableFloatOutput = false` into [buildAudioSink] and produce an
29
- * Int16 PCM sink. [ReplayGainAudioProcessor.isActive] and
30
- * [SignalsmithStretchAudioProcessor.isActive] gate on `ENCODING_PCM_FLOAT`, so
31
- * without the explicit opt-in they would silently run as pass-throughs.
26
+ * Float output is deliberately NOT enabled. With Media3 float output, a hi-res
27
+ * (24-bit+) source produces a float sink, and [DefaultAudioSink.configure] then
28
+ * DROPS the entire custom [AudioProcessorChain] its float branch omits the
29
+ * chain because the built-in Sonic outputs 16-bit — silently disabling playback
30
+ * speed, pitch-correction, ReplayGain, and the visualizer on hi-res content.
31
+ * Keeping the sink at 16-bit (Media3 downconverts hi-res) keeps the chain in the
32
+ * pipeline for every source; [ReplayGainAudioProcessor] and
33
+ * [SignalsmithStretchAudioProcessor] both process 16-bit PCM directly.
32
34
  */
33
35
  @UnstableApi
34
36
  internal class AudioPipelineRenderersFactory(
@@ -36,10 +38,6 @@ internal class AudioPipelineRenderersFactory(
36
38
  private val audioProcessorChain: AudioProcessorChain,
37
39
  ) : DefaultRenderersFactory(context) {
38
40
 
39
- init {
40
- setEnableAudioFloatOutput(true)
41
- }
42
-
43
41
  override fun buildAudioSink(
44
42
  context: Context,
45
43
  enableFloatOutput: Boolean,
@@ -5,23 +5,22 @@ import androidx.media3.common.audio.AudioProcessor
5
5
  import androidx.media3.common.audio.BaseAudioProcessor
6
6
  import androidx.media3.common.util.UnstableApi
7
7
  import java.nio.ByteBuffer
8
+ import kotlin.math.roundToInt
8
9
 
9
10
  /**
10
11
  * Standalone Media3 [AudioProcessor] that applies a per-track linear
11
- * gain to a Float32 PCM stream. No knowledge of the engine, the EQ
12
- * stage, or the visualizer pipeline — purely "given a Float32 buffer,
13
- * multiply by [linearGain]". The owning engine pushes a new gain via
14
- * [setLinearGain] from its `Player.Listener.onMetadata` callback;
15
- * `onMediaItemTransition` resets to unity until the next metadata
16
- * arrival.
12
+ * gain to a PCM stream. No knowledge of the engine, the EQ stage, or the
13
+ * visualizer pipeline — purely "given a PCM buffer, multiply by
14
+ * [linearGain]". The owning engine pushes a new gain via [setLinearGain]
15
+ * from its `Player.Listener` callbacks; `onMediaItemTransition` resets to
16
+ * unity until the next track's tags arrive.
17
17
  *
18
- * Float32-only by design. [AudioPipelineRenderersFactory] opts the
19
- * factory into `setEnableAudioFloatOutput(true)` at construction so
20
- * Media3 propagates `enableFloatOutput = true` into `buildAudioSink`,
21
- * and the resulting `DefaultAudioSink` produces `ENCODING_PCM_FLOAT`.
22
- * Sources decoded to Int16 PCM transit through this processor
23
- * untouched via [isActive] returning `false`, matching the documented
24
- * non-Float32 no-op limitation in NOTES.md §25.
18
+ * Handles both 16-bit and float PCM. The sink runs at 16-bit (see
19
+ * [AudioPipelineRenderersFactory] float output would drop the whole
20
+ * chain on hi-res sources), so a 16-bit block is scaled in float precision
21
+ * and narrowed back with a round + full-scale clamp; a float block is
22
+ * scaled in place. The gain is already clip-capped against the track peak
23
+ * (`ReplayGainGain`), so the clamp only guards sentinel/missing-peak tags.
25
24
  */
26
25
  @UnstableApi
27
26
  internal class ReplayGainAudioProcessor : BaseAudioProcessor() {
@@ -37,11 +36,11 @@ internal class ReplayGainAudioProcessor : BaseAudioProcessor() {
37
36
  private var linearGain: Float = 1.0f
38
37
 
39
38
  /**
40
- * `true` when [onConfigure] saw `ENCODING_PCM_FLOAT`. Volatile for
41
- * the same render-thread / main-thread split as [linearGain].
39
+ * Input PCM encoding captured at [onConfigure]. Volatile for the same
40
+ * render-thread / main-thread split as [linearGain].
42
41
  */
43
42
  @Volatile
44
- private var inputIsFloat: Boolean = false
43
+ private var encoding: Int = C.ENCODING_INVALID
45
44
 
46
45
  /**
47
46
  * Push a new linear gain. `1.0f` disables the processor's per-sample
@@ -52,7 +51,7 @@ internal class ReplayGainAudioProcessor : BaseAudioProcessor() {
52
51
  }
53
52
 
54
53
  override fun onConfigure(input: AudioProcessor.AudioFormat): AudioProcessor.AudioFormat {
55
- inputIsFloat = (input.encoding == C.ENCODING_PCM_FLOAT)
54
+ encoding = input.encoding
56
55
  // Pass-through: this stage doesn't change rate, channel count, or
57
56
  // encoding. Returning [AudioProcessor.AudioFormat.NOT_SET] would
58
57
  // tell Media3 the processor consumes the stream and drops the
@@ -61,23 +60,36 @@ internal class ReplayGainAudioProcessor : BaseAudioProcessor() {
61
60
  }
62
61
 
63
62
  override fun isActive(): Boolean =
64
- super.isActive() && linearGain != 1.0f && inputIsFloat
63
+ super.isActive() && linearGain != 1.0f &&
64
+ (encoding == C.ENCODING_PCM_FLOAT || encoding == C.ENCODING_PCM_16BIT)
65
65
 
66
66
  override fun queueInput(input: ByteBuffer) {
67
67
  val gain = linearGain
68
68
  val byteCount = input.remaining()
69
69
  if (byteCount == 0) return
70
70
  val out = replaceOutputBuffer(byteCount)
71
- val inF = input.asFloatBuffer()
72
- val outF = out.asFloatBuffer()
73
- while (inF.hasRemaining()) {
74
- outF.put(inF.get() * gain)
71
+ if (encoding == C.ENCODING_PCM_FLOAT) {
72
+ val inF = input.asFloatBuffer()
73
+ val outF = out.asFloatBuffer()
74
+ while (inF.hasRemaining()) {
75
+ outF.put(inF.get() * gain)
76
+ }
77
+ } else {
78
+ // 16-bit: scale in float precision, narrow back with round + clamp.
79
+ // The gain is peak-capped upstream, so the clamp only guards
80
+ // sentinel/missing-peak tags against wraparound.
81
+ val inS = input.asShortBuffer()
82
+ val outS = out.asShortBuffer()
83
+ while (inS.hasRemaining()) {
84
+ val scaled = inS.get() * gain
85
+ outS.put(scaled.roundToInt().coerceIn(-32768, 32767).toShort())
86
+ }
75
87
  }
76
88
  input.position(input.limit())
77
89
  // Advance the byte-level position so [getOutput] hands back a
78
- // buffer with the produced bytes between position and limit. The
79
- // float-view writes mutate the underlying bytes but don't move
80
- // the parent ByteBuffer's position.
90
+ // buffer with the produced bytes between position and limit — the
91
+ // typed-view writes mutate the underlying bytes but don't move the
92
+ // parent ByteBuffer's position.
81
93
  out.position(byteCount)
82
94
  out.flip()
83
95
  }
@@ -56,11 +56,12 @@ internal class SignalsmithStretchAudioProcessor : BaseAudioProcessor() {
56
56
  private var floatIn: ByteBuffer? = null
57
57
  private var floatOut: ByteBuffer? = null
58
58
 
59
- // Input (media) vs output (playout) byte tallies since the last flush, for
60
- // the chain's getMediaDuration position mapping. Accumulated only while
61
- // stretching (the pass-through span never delegates to this processor).
62
- private var inputBytes: Long = 0L
63
- private var outputBytes: Long = 0L
59
+ // True once a stretched block has been produced, so the engine's tail
60
+ // latency awaits a drain. [onQueueEndOfStream] drains it exactly once and
61
+ // clears this — the native `outputLatency()` reports the fixed algorithmic
62
+ // latency (not the live buffer fill), so re-draining re-emits the same tail
63
+ // frames (an audible stutter) and never ends the stream.
64
+ private var pendingDrain = false
64
65
 
65
66
  // Fractional frame remainder so per-block outFrames average to inFrames/speed.
66
67
  private var frameCarry: Double = 0.0
@@ -73,13 +74,16 @@ internal class SignalsmithStretchAudioProcessor : BaseAudioProcessor() {
73
74
  fun isStretching(): Boolean = isActive && speed != 1f
74
75
 
75
76
  /**
76
- * Map a playout duration back to media duration, mirroring Sonic's model:
77
- * `playout * inputBytes / outputBytes`. Returns the playout unchanged until
78
- * the first stretched block has been produced.
77
+ * Map a playout duration back to media duration. For a pure time-stretch,
78
+ * `s` seconds of media play per playout second at speed `s`, so media =
79
+ * playout * speed — computed from the live speed rather than a cumulative
80
+ * input/output byte ratio, which mis-maps while the engine's latency is
81
+ * drained (the drained tail inflates output-vs-input) and freezes the
82
+ * position clock on a speed change.
79
83
  */
80
84
  fun getMediaDuration(playoutDuration: Long): Long =
81
- if (outputBytes == 0L) playoutDuration
82
- else Util.scaleLargeTimestamp(playoutDuration, inputBytes, outputBytes)
85
+ if (speed == 1f) playoutDuration
86
+ else Util.scaleLargeTimestamp(playoutDuration, (speed * 1_000_000f).toLong(), 1_000_000L)
83
87
 
84
88
  override fun onConfigure(inputAudioFormat: AudioProcessor.AudioFormat): AudioProcessor.AudioFormat {
85
89
  encoding = inputAudioFormat.encoding
@@ -87,8 +91,11 @@ internal class SignalsmithStretchAudioProcessor : BaseAudioProcessor() {
87
91
  sampleRateHz = inputAudioFormat.sampleRate
88
92
  bytesPerFrame = channelCount * if (encoding == C.ENCODING_PCM_FLOAT) 4 else 2
89
93
  // A reconfigure invalidates any engine built for the previous format;
90
- // the next stretching queueInput lazily rebuilds it.
94
+ // the next stretching queueInput lazily rebuilds it. Clear the drain flag
95
+ // so the invariant holds locally, independent of the flush that Media3
96
+ // issues after a (re)configure.
91
97
  releaseNative()
98
+ pendingDrain = false
92
99
  // Pass-through format — the stretch changes duration, not rate/channels/
93
100
  // encoding. Returning NOT_SET would drop the sink.
94
101
  return inputAudioFormat
@@ -148,13 +155,17 @@ internal class SignalsmithStretchAudioProcessor : BaseAudioProcessor() {
148
155
  }
149
156
 
150
157
  input.position(input.limit())
151
- inputBytes += inFrames.toLong() * bytesPerFrame
152
- outputBytes += outFrames.toLong() * bytesPerFrame
158
+ pendingDrain = true
153
159
  }
154
160
 
155
161
  override fun onQueueEndOfStream() {
156
162
  val stretch = nativeStretch ?: return
157
- if (speed == 1f) return // not stretching nothing buffered to drain
163
+ // Drain the engine's tail latency exactly once per stretched span. Media3
164
+ // can call this repeatedly around a reconfigure; `outputLatency()` reports
165
+ // the fixed algorithmic latency, so re-draining re-emits the same tail
166
+ // frames (stutter) and the stream never ends, stalling a speed change.
167
+ if (!pendingDrain || speed == 1f) return
168
+ pendingDrain = false
158
169
  val latency = stretch.outputLatency()
159
170
  if (latency <= 0) return
160
171
  if (encoding == C.ENCODING_PCM_FLOAT) {
@@ -176,14 +187,12 @@ internal class SignalsmithStretchAudioProcessor : BaseAudioProcessor() {
176
187
  out.position(latency * bytesPerFrame)
177
188
  out.flip()
178
189
  }
179
- outputBytes += latency.toLong() * bytesPerFrame
180
190
  }
181
191
 
182
192
  override fun onFlush(streamMetadata: AudioProcessor.StreamMetadata) {
183
193
  nativeStretch?.reset()
184
194
  frameCarry = 0.0
185
- inputBytes = 0L
186
- outputBytes = 0L
195
+ pendingDrain = false
187
196
  }
188
197
 
189
198
  override fun onReset() {
@@ -191,8 +200,7 @@ internal class SignalsmithStretchAudioProcessor : BaseAudioProcessor() {
191
200
  floatIn = null
192
201
  floatOut = null
193
202
  frameCarry = 0.0
194
- inputBytes = 0L
195
- outputBytes = 0L
203
+ pendingDrain = false
196
204
  }
197
205
 
198
206
  private fun ensureNative(): SignalsmithStretchNative {
@@ -3,8 +3,8 @@ package com.margelo.nitro.queueplayer
3
3
  import androidx.media3.common.util.UnstableApi
4
4
  import androidx.media3.exoplayer.DefaultRenderersFactory
5
5
  import androidx.test.core.app.ApplicationProvider
6
+ import org.junit.Assert.assertFalse
6
7
  import org.junit.Assert.assertNotNull
7
- import org.junit.Assert.assertTrue
8
8
  import org.junit.Test
9
9
  import org.junit.runner.RunWith
10
10
  import org.robolectric.RobolectricTestRunner
@@ -13,8 +13,8 @@ import java.lang.reflect.Field
13
13
 
14
14
  /**
15
15
  * Unit coverage for [AudioPipelineRenderersFactory] construction — it wraps a
16
- * caller-supplied [androidx.media3.common.audio.AudioProcessorChain] and opts
17
- * the produced sink into float output. The engines drive end-to-end exercise of
16
+ * caller-supplied [androidx.media3.common.audio.AudioProcessorChain] and leaves
17
+ * the produced sink at 16-bit (float output off). The engines drive end-to-end exercise of
18
18
  * `buildAudioSink` via `ExoPlayer.Builder.build()` in
19
19
  * [GaplessEngineLifecycleTest] / [CrossfadeEngineLifecycleTest]; `buildAudioSink`
20
20
  * itself is `protected` on [DefaultRenderersFactory] and cannot be called
@@ -36,21 +36,22 @@ class AudioPipelineRenderersFactoryTest {
36
36
  }
37
37
 
38
38
  @Test
39
- fun `factory enables audio float output so the float processors see Float32 PCM`() {
40
- // [DefaultRenderersFactory.enableAudioFloatOutput] defaults to false;
41
- // without an explicit opt-in Media3 propagates `enableFloatOutput = false`
42
- // into [buildAudioSink], which makes the sink emit Int16 PCM and silently
43
- // disables the float-gated processors (ReplayGain + the music stretcher,
44
- // whose isActive gate on ENCODING_PCM_FLOAT). The factory's init block flips
45
- // the flag to true; pin that by reading the private field via reflection —
46
- // no public accessor exists, but the field name is stable across Media3 1.x.
39
+ fun `factory leaves audio float output off so the chain survives on hi-res sources`() {
40
+ // Float output would make a hi-res (24-bit+) source produce a float sink,
41
+ // and DefaultAudioSink.configure()'s float branch DROPS the custom
42
+ // AudioProcessorChain silently disabling playback speed, pitch-correction,
43
+ // ReplayGain, and the visualizer on hi-res content. Keeping the sink at
44
+ // 16-bit (Media3 downconverts hi-res) keeps the chain in the pipeline for
45
+ // every source. Pin that the factory leaves float output off (the
46
+ // DefaultRenderersFactory default) via reflection no public accessor
47
+ // exists, but the field name is stable across Media3 1.x.
47
48
  val factory =
48
49
  AudioPipelineRenderersFactory(context, PitchAwareAudioProcessorChain(emptyArray()))
49
50
  val field: Field =
50
51
  DefaultRenderersFactory::class.java.getDeclaredField("enableFloatOutput")
51
52
  field.isAccessible = true
52
- assertTrue(
53
- "AudioPipelineRenderersFactory must enable float output",
53
+ assertFalse(
54
+ "AudioPipelineRenderersFactory must NOT enable float output (it drops the chain on hi-res)",
54
55
  field.getBoolean(factory),
55
56
  )
56
57
  }
@@ -14,10 +14,11 @@ import java.nio.ByteBuffer
14
14
  import java.nio.ByteOrder
15
15
 
16
16
  /**
17
- * Unit coverage for [ReplayGainAudioProcessor] — the per-sample
18
- * Float32 gain stage Media3 wires into the AudioSink chain. Exercises
19
- * the [AudioProcessor] contract: configure, isActive gating, in-place
20
- * multiply, encoding gate.
17
+ * Unit coverage for [ReplayGainAudioProcessor] — the per-sample gain
18
+ * stage Media3 wires into the AudioSink chain. Exercises the
19
+ * [AudioProcessor] contract: configure, isActive gating, in-place multiply
20
+ * on both 16-bit and float PCM, the 16-bit full-scale clamp, and the
21
+ * encoding gate.
21
22
  */
22
23
  @RunWith(RobolectricTestRunner::class)
23
24
  @Config(sdk = [34])
@@ -43,10 +44,18 @@ class ReplayGainAudioProcessorTest {
43
44
  }
44
45
 
45
46
  @Test
46
- fun `isActive is false on a non-Float32 input regardless of gain`() {
47
+ fun `isActive is true with non-unity gain on a 16-bit input`() {
47
48
  val proc = ReplayGainAudioProcessor()
48
49
  proc.configure(int16Format(48000, 2))
49
50
  proc.setLinearGain(0.5f)
51
+ assertTrue(proc.isActive)
52
+ }
53
+
54
+ @Test
55
+ fun `isActive is false on an unsupported encoding`() {
56
+ val proc = ReplayGainAudioProcessor()
57
+ proc.configure(AudioProcessor.AudioFormat(48000, 2, C.ENCODING_PCM_24BIT))
58
+ proc.setLinearGain(0.5f)
50
59
  assertFalse(proc.isActive)
51
60
  }
52
61
 
@@ -82,6 +91,39 @@ class ReplayGainAudioProcessorTest {
82
91
  assertEquals(0.6f, output[2], 1e-6f)
83
92
  }
84
93
 
94
+ @Test
95
+ fun `queueInput scales 16-bit samples by the linear gain`() {
96
+ val proc = ReplayGainAudioProcessor()
97
+ proc.configure(int16Format(48000, 1))
98
+ proc.setLinearGain(0.5f)
99
+
100
+ val input = shortBuffer(shortArrayOf(10000, -8000, 4, 0, -10000))
101
+ proc.queueInput(input)
102
+
103
+ val output = readShorts(proc.output, 5)
104
+ assertEquals(5000, output[0].toInt())
105
+ assertEquals(-4000, output[1].toInt())
106
+ assertEquals(2, output[2].toInt())
107
+ assertEquals(0, output[3].toInt())
108
+ assertEquals(-5000, output[4].toInt())
109
+ }
110
+
111
+ @Test
112
+ fun `queueInput clamps a 16-bit boost that exceeds full scale`() {
113
+ val proc = ReplayGainAudioProcessor()
114
+ proc.configure(int16Format(44100, 1))
115
+ proc.setLinearGain(2.0f)
116
+
117
+ // 20000*2 = 40000 > 32767 and -20000*2 = -40000 < -32768 both clamp.
118
+ val input = shortBuffer(shortArrayOf(20000, -20000, 1000))
119
+ proc.queueInput(input)
120
+
121
+ val output = readShorts(proc.output, 3)
122
+ assertEquals(32767, output[0].toInt())
123
+ assertEquals(-32768, output[1].toInt())
124
+ assertEquals(2000, output[2].toInt())
125
+ }
126
+
85
127
  @Test
86
128
  fun `queueInput consumes the input buffer`() {
87
129
  val proc = ReplayGainAudioProcessor()
@@ -137,5 +179,22 @@ class ReplayGainAudioProcessorTest {
137
179
  return out
138
180
  }
139
181
 
182
+ private fun shortBuffer(values: ShortArray): ByteBuffer {
183
+ val buffer = ByteBuffer
184
+ .allocateDirect(values.size * Short.SIZE_BYTES)
185
+ .order(ByteOrder.nativeOrder())
186
+ val asShort = buffer.asShortBuffer()
187
+ for (v in values) asShort.put(v)
188
+ buffer.position(0).limit(values.size * Short.SIZE_BYTES)
189
+ return buffer
190
+ }
191
+
192
+ private fun readShorts(buffer: ByteBuffer, count: Int): ShortArray {
193
+ val out = ShortArray(count)
194
+ val asShort = buffer.asShortBuffer()
195
+ for (i in 0 until count) out[i] = asShort.get()
196
+ return out
197
+ }
198
+
140
199
  // endregion
141
200
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-queue-player",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Nitro Modules audio playback library for React Native — gapless, EQ, crossfade, automotive, voice, casting",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/index.d.ts",
@@ -39,7 +39,11 @@
39
39
  "nitrogen": "nitrogen && bash scripts/strip-nitrogen-unused-let.sh",
40
40
  "typecheck": "tsc",
41
41
  "test": "jest",
42
- "test:coverage": "jest --coverage"
42
+ "test:coverage": "jest --coverage",
43
+ "release:patch": "node scripts/release.mjs patch",
44
+ "release:minor": "node scripts/release.mjs minor",
45
+ "release:major": "node scripts/release.mjs major",
46
+ "publish:npm": "npm publish --access public"
43
47
  },
44
48
  "keywords": [
45
49
  "react-native",