Mix Android streams on one AudioTrack with independent clocks
This commit is contained in:
1 parent
57d28376b6
commit
4e386fc8e3
3 files changed
+244
No files matched your search
@@ -0,0 +1,53 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** Начало отдельного источника на шкале общего AudioTrack. PTS источников здесь не сравниваются. */
|
||||
internal class StreamMixTimeline {
|
||||
var origin = -1L
|
||||
private set
|
||||
var frames = 0L
|
||||
private set
|
||||
|
||||
fun append(firstOutputFrame: Long, count: Int) {
|
||||
require(firstOutputFrame >= 0 && count > 0)
|
||||
if (origin < 0) origin = firstOutputFrame
|
||||
check(firstOutputFrame == origin + frames) { "Non-contiguous mixer output $firstOutputFrame != ${origin + frames}" }
|
||||
frames += count
|
||||
}
|
||||
|
||||
fun played(outputHead: Long): Long = if (origin < 0) 0 else (outputHead - origin).coerceIn(0, frames)
|
||||
}
|
||||
|
||||
/** Одна порция stereo 48k. Тихий новый участник не снижает громкость остальных.
|
||||
* Буфер output переиспользуется только после полной записи предыдущей порции устройством. */
|
||||
internal class StreamMixBuffer(val frames: Int = 480) {
|
||||
private val sum = FloatArray(frames * 2)
|
||||
private val output = ShortArray(frames * 2)
|
||||
private var gain = 1f
|
||||
fun clear() = sum.fill(0f)
|
||||
|
||||
fun add(pcm: ShortArray, fadeFrames: Int = 480): Int {
|
||||
require(pcm.size <= sum.size && pcm.size % 2 == 0)
|
||||
for (i in 0 until pcm.size / 2) {
|
||||
val volume = min(1f, (fadeFrames + i + 1) / 480f) / 32768f
|
||||
sum[2 * i] += pcm[2 * i] * volume
|
||||
sum[2 * i + 1] += pcm[2 * i + 1] * volume
|
||||
}
|
||||
return min(480, fadeFrames + pcm.size / 2)
|
||||
}
|
||||
|
||||
fun finish(): ShortArray {
|
||||
for (i in 0 until frames) {
|
||||
val peak = max(abs(sum[i * 2]), abs(sum[i * 2 + 1]))
|
||||
val target = if (peak > 1f) 1f / peak else 1f
|
||||
gain = min(target, gain + (1f - gain) / 4800f)
|
||||
output[i * 2] = (sum[i * 2] * gain * 32768f).roundToInt().coerceIn(-32768, 32767).toShort()
|
||||
output[i * 2 + 1] = (sum[i * 2 + 1] * gain * 32768f).roundToInt().coerceIn(-32768, 32767).toShort()
|
||||
}
|
||||
return output
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
/** Один AudioTrack для всех трансляций. Worker не удерживает общий lock при обращении к decoder.
|
||||
* Input закрывается после исключения из списка; его собственный lock защищает native DSP от release. */
|
||||
internal object StreamAudioMixer {
|
||||
data class OutputState(val played: Long = 0, val leadUs: Long = 0)
|
||||
@Volatile var output = OutputState()
|
||||
private set
|
||||
private val lock = Any()
|
||||
private val inputs = linkedSetOf<StreamMixedPlayback>()
|
||||
private var worker: Thread? = null
|
||||
|
||||
fun attach(input: StreamMixedPlayback) = synchronized(lock) {
|
||||
check(inputs.add(input))
|
||||
if (worker == null) {
|
||||
output = OutputState()
|
||||
worker = Thread(::run, "stream-mixer").apply { start() }
|
||||
}
|
||||
LogManager.addLog("INFO", "stream", "mixer input attached id=${input.id} count=${inputs.size}")
|
||||
}
|
||||
|
||||
fun detach(input: StreamMixedPlayback) = synchronized(lock) {
|
||||
inputs.remove(input)
|
||||
LogManager.addLog("INFO", "stream", "mixer input detached id=${input.id} count=${inputs.size}")
|
||||
}
|
||||
|
||||
private fun run() {
|
||||
val audio = StreamAudioOutput(log = { LogManager.addLog("INFO", "stream", "mixer $it") })
|
||||
val mix = StreamMixBuffer()
|
||||
var generated = 0L
|
||||
try {
|
||||
while (true) {
|
||||
val current = synchronized(lock) {
|
||||
if (inputs.isEmpty()) {
|
||||
// Закрытие под lock исключает открытие второго AudioTrack новым attach.
|
||||
audio.close(); output = OutputState(); worker = null
|
||||
return
|
||||
}
|
||||
inputs.toList()
|
||||
}
|
||||
audio.step(System.nanoTime(), hold = false, pull = {
|
||||
mix.clear()
|
||||
var active = false
|
||||
for (input in current) active = input.mix(mix, generated) || active
|
||||
if (active) {
|
||||
generated += mix.frames
|
||||
mix.finish()
|
||||
} else null
|
||||
}, finished = { false })
|
||||
output = OutputState(audio.played, audio.leadUs)
|
||||
Thread.sleep(5)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "stream", "shared audio output failed: ${e.message}; ${audio.description}")
|
||||
synchronized(lock) {
|
||||
for (input in inputs) input.outputFailure = e.message ?: "Audio output failed"
|
||||
inputs.clear()
|
||||
try { audio.close() }
|
||||
catch (close: Exception) { LogManager.addLog("WARN", "stream", "mixer release: ${close.message}") }
|
||||
output = OutputState(); worker = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Независимые Opus/SoundTouch, темп и PTS. Video worker и mixer сериализуют только свой DSP.
|
||||
* Каждый 10ms блок общего выхода отражён в своей шкале, включая тишину при starvation/hold.
|
||||
* Reset создаёт новый Input с новой origin; AudioTrack и остальные источники продолжают играть. */
|
||||
internal class StreamMixedPlayback(val id: Long, pts: Long, private val audioEnabled: Boolean) {
|
||||
private val lock = Any()
|
||||
private var handle = NativeLib.streamPlaybackCreate(id, pts, audioEnabled).also { check(it != 0L) }
|
||||
private val timeline = StreamMixTimeline()
|
||||
private var started = false
|
||||
private var hold = false
|
||||
private var ready = -1L
|
||||
private var ended = false
|
||||
private var endFrame: Long? = null
|
||||
private var fadeFrames = 0
|
||||
@Volatile var outputFailure: String? = null
|
||||
init { if (audioEnabled) StreamAudioMixer.attach(this) }
|
||||
|
||||
val leadUs: Long get() = if (audioEnabled) StreamAudioMixer.output.leadUs else 0
|
||||
val empty: Boolean get() = synchronized(lock) { handle == 0L || NativeLib.streamPlaybackEmpty(handle) }
|
||||
val drained: Boolean get() = synchronized(lock) {
|
||||
!audioEnabled || (ended && (endFrame?.let { timeline.played(StreamAudioMixer.output.played) >= it }
|
||||
?: (timeline.frames == 0L && NativeLib.streamPlaybackEmpty(handle))))
|
||||
}
|
||||
val description: String get() = synchronized(lock) {
|
||||
"mixed=$audioEnabled origin=${timeline.origin} written=${timeline.frames} " +
|
||||
"played=${timeline.played(StreamAudioMixer.output.played)} hold=$hold leadUs=$leadUs"
|
||||
}
|
||||
|
||||
fun feed(pts: Long, packet: ByteArray): Int = synchronized(lock) {
|
||||
check(handle != 0L); NativeLib.streamPlaybackFeed(handle, pts, packet)
|
||||
}
|
||||
fun finish() = synchronized(lock) {
|
||||
check(handle != 0L); ended = true; NativeLib.streamPlaybackFinish(handle)
|
||||
}
|
||||
|
||||
fun update(now: Long, available: Long, guard: Long, videoHeld: Boolean): LongArray? = synchronized(lock) {
|
||||
check(handle != 0L); check(outputFailure == null) { outputFailure!! }
|
||||
ready = available; hold = videoHeld; started = true
|
||||
NativeLib.streamPlaybackUpdate(handle, now, timeline.played(StreamAudioMixer.output.played), available, guard, videoHeld)
|
||||
}
|
||||
|
||||
/** Mixer не блокирует чужой input ожиданием media; недостающее заменяется тишиной только этого источника. */
|
||||
fun mix(buffer: StreamMixBuffer, firstOutputFrame: Long): Boolean = synchronized(lock) {
|
||||
if (handle == 0L || !started || !audioEnabled || outputFailure != null) return false
|
||||
try {
|
||||
val pcm = if (hold) null else NativeLib.streamPlaybackPull(handle, buffer.frames, if (ended) Long.MAX_VALUE else ready)
|
||||
val count = (pcm?.size ?: 0) / 2
|
||||
if (pcm != null) fadeFrames = buffer.add(pcm, fadeFrames)
|
||||
if (ended && endFrame == null && NativeLib.streamPlaybackEmpty(handle)) endFrame = timeline.frames + count
|
||||
if (count < buffer.frames) NativeLib.streamPlaybackSilence(handle, buffer.frames - count)
|
||||
timeline.append(firstOutputFrame, buffer.frames)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
outputFailure = e.message ?: "Audio mixing failed"
|
||||
LogManager.addLog("ERROR", "stream", "mixer input failed id=$id: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
if (audioEnabled) StreamAudioMixer.detach(this)
|
||||
synchronized(lock) {
|
||||
if (handle != 0L) NativeLib.streamPlaybackClose(handle)
|
||||
handle = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.sin
|
||||
|
||||
class StreamAudioMixTest {
|
||||
@Test fun joiningAndResetUseTheirOwnOutputOrigin() {
|
||||
val first = StreamMixTimeline()
|
||||
first.append(0, 480); first.append(480, 480)
|
||||
val joined = StreamMixTimeline()
|
||||
joined.append(960, 480); first.append(960, 480)
|
||||
assertEquals(1200, first.played(1200))
|
||||
assertEquals(240, joined.played(1200))
|
||||
assertEquals(0, joined.played(700)) // Устройство ещё до подключения второго источника.
|
||||
val reset = StreamMixTimeline()
|
||||
reset.append(1440, 480); first.append(1440, 480)
|
||||
assertEquals(1600, first.played(1600))
|
||||
assertEquals(160, reset.played(1600))
|
||||
assertEquals(480, joined.played(1600))
|
||||
}
|
||||
|
||||
@Test fun partialDeviceWritesAdvanceOnlyHeardFrames() {
|
||||
val clock = StreamMixTimeline()
|
||||
clock.append(2000, 480)
|
||||
for (head in listOf(1900L, 2000L, 2120L, 2240L, 2480L, 3000L))
|
||||
assertEquals((head - 2000).coerceIn(0, 480), clock.played(head))
|
||||
}
|
||||
|
||||
@Test fun differentTonesAreSummedAndSilentInputDoesNotReduceVolume() {
|
||||
val a = ShortArray(960) { (4000 * sin(2 * PI * 1000 * (it / 2) / 48000)).toInt().toShort() }
|
||||
val b = ShortArray(960) { (2000 * sin(2 * PI * 500 * (it / 2) / 48000)).toInt().toShort() }
|
||||
val mix = StreamMixBuffer()
|
||||
mix.clear(); mix.add(a); mix.add(b)
|
||||
val sum = mix.finish().copyOf()
|
||||
for (i in sum.indices) assertTrue(abs(sum[i].toInt() - a[i] - b[i]) <= 1)
|
||||
mix.clear(); mix.add(a); mix.add(ShortArray(960))
|
||||
assertArrayEquals(a, mix.finish())
|
||||
}
|
||||
|
||||
@Test fun limiterKeepsStereoBalanceAndReleasesSmoothly() {
|
||||
val mix = StreamMixBuffer()
|
||||
val loud = ShortArray(960) { if (it % 2 == 0) 24000 else 12000 }
|
||||
mix.clear(); mix.add(loud); mix.add(loud)
|
||||
val limited = mix.finish().copyOf()
|
||||
assertEquals(32767, limited[0].toInt()); assertEquals(16384, limited[1].toInt())
|
||||
mix.clear(); mix.add(loud)
|
||||
val quieter = mix.finish()
|
||||
assertTrue(quieter.first() < 18000); assertTrue(quieter.last() < 12000)
|
||||
assertTrue(quieter[958] > quieter[0])
|
||||
}
|
||||
|
||||
@Test fun accidentalHoleInOutputMappingIsRejected() {
|
||||
val clock = StreamMixTimeline(); clock.append(1000, 480)
|
||||
try { clock.append(1500, 480); fail("Gap must not silently move this source's clock") }
|
||||
catch (_: IllegalStateException) {}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user