Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions app/src/main/java/com/dimadesu/lifestreamer/audio/VuMeterEffect.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package com.dimadesu.lifestreamer.audio

import io.github.thibaultbee.streampack.core.elements.data.RawFrame
import io.github.thibaultbee.streampack.core.elements.processing.audio.IConsumerAudioEffect
import java.nio.ByteOrder
import kotlin.math.abs
import kotlin.math.sqrt

/**
* An audio effect that calculates VU meter levels from audio frames.
*
* This effect implements [IConsumerAudioEffect], meaning it runs on a separate coroutine
* and doesn't block the audio pipeline. It receives a copy of the audio buffer.
*
* @param channelCount Number of audio channels (1 for mono, 2 for stereo)
* @param onLevelUpdate Callback invoked with calculated audio levels
*/
class VuMeterEffect(
var channelCount: Int = 1,
private val onLevelUpdate: (AudioLevel) -> Unit
) : IConsumerAudioEffect {

/**
* Process the audio frame and calculate levels.
* This is called on a separate coroutine, so it won't block the audio pipeline.
*/
override fun consume(isMuted: Boolean, data: RawFrame) {
if (isMuted) {
// When muted, report silence
onLevelUpdate(AudioLevel.SILENT)
return
}

val levels = calculateAudioLevels(data, channelCount)
onLevelUpdate(levels)
}

override fun close() {
// Nothing to clean up
}

/**
* Calculate RMS and peak audio levels from 16-bit PCM audio buffer.
* Supports mono (1 channel) and stereo (2 channels, interleaved L-R-L-R).
*
* @param frame RawFrame containing 16-bit PCM audio samples
* @param channels Number of audio channels (1 or 2)
* @return AudioLevel with per-channel RMS and peak values
*/
private fun calculateAudioLevels(frame: RawFrame, channels: Int): AudioLevel {
val buffer = frame.rawBuffer
val remaining = buffer.remaining()

if (remaining < 2) {
return AudioLevel.SILENT
}

// The buffer is already a copy provided by AudioFrameProcessor,
// so we can read directly from it. Just set byte order.
buffer.order(ByteOrder.LITTLE_ENDIAN)

var maxSampleLeft = 0
var maxSampleRight = 0
var sumSquaresLeft = 0.0
var sumSquaresRight = 0.0
var sampleCountLeft = 0
var sampleCountRight = 0

val isStereo = channels >= 2
var isLeftChannel = true

while (buffer.remaining() >= 2) {
val sample = buffer.short.toInt()
val absSample = abs(sample)
val sampleSquared = (sample.toLong() * sample.toLong()).toDouble()

if (!isStereo || isLeftChannel) {
if (absSample > maxSampleLeft) maxSampleLeft = absSample
sumSquaresLeft += sampleSquared
sampleCountLeft++
} else {
if (absSample > maxSampleRight) maxSampleRight = absSample
sumSquaresRight += sampleSquared
sampleCountRight++
}

if (isStereo) isLeftChannel = !isLeftChannel
}

// Normalize to 0.0-1.0 range (32767 is max for 16-bit signed)
val peakLeft = if (sampleCountLeft > 0) (maxSampleLeft / 32767f).coerceIn(0f, 1f) else 0f
val rmsLeft = if (sampleCountLeft > 0) (sqrt(sumSquaresLeft / sampleCountLeft) / 32767.0).toFloat().coerceIn(0f, 1f) else 0f

val peakRight = if (sampleCountRight > 0) (maxSampleRight / 32767f).coerceIn(0f, 1f) else 0f
val rmsRight = if (sampleCountRight > 0) (sqrt(sumSquaresRight / sampleCountRight) / 32767.0).toFloat().coerceIn(0f, 1f) else 0f

return AudioLevel(
rms = rmsLeft,
peak = peakLeft,
rmsRight = rmsRight,
peakRight = peakRight,
isStereo = isStereo
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2279,6 +2279,9 @@ class PreviewViewModel(private val application: Application) : ObservableViewMod
private var audioConfigObserverJob: kotlinx.coroutines.Job? = null
private var streamingStateObserverJob: kotlinx.coroutines.Job? = null

// VU meter effect for audio level monitoring
private var vuMeterEffect: com.dimadesu.lifestreamer.audio.VuMeterEffect? = null

/**
* Set up audio level monitoring on the streamer's audio processor.
* This works for ALL audio sources (mic, Bluetooth, ExoPlayer/MediaProjection).
Expand All @@ -2290,18 +2293,41 @@ class PreviewViewModel(private val application: Application) : ObservableViewMod
return
}

// Create VU meter effect if not already created
if (vuMeterEffect == null) {
vuMeterEffect = com.dimadesu.lifestreamer.audio.VuMeterEffect { levels ->
// Update the flow (this is called from a separate coroutine, so it's safe)
_audioLevelFlow.value = levels
}
}

// Add the effect to the processor (it implements MutableList<IAudioEffect>)
val effect = vuMeterEffect!!
val processorList = audioProcessor as? MutableList<*>
if (processorList != null) {
@Suppress("UNCHECKED_CAST")
(processorList as MutableList<Any>).apply {
if (!contains(effect)) {
add(effect)
Log.i(TAG, "VuMeterEffect added to audio processor")
}
}
} else {
Log.w(TAG, "Audio processor does not support effects list")
}

// Observe the streamer's audioConfigFlow for channel count changes
audioConfigObserverJob?.cancel()
audioConfigObserverJob = viewModelScope.launch {
streamer.audioConfigFlow.collect { audioConfig ->
if (audioConfig != null) {
try {
val channelCount = io.github.thibaultbee.streampack.core.elements.encoders.AudioCodecConfig.getNumberOfChannels(audioConfig.channelConfig)
audioProcessor.channelCount = channelCount
effect.channelCount = channelCount
Log.i(TAG, "Audio level monitoring: channelCount=$channelCount")
} catch (t: Throwable) {
Log.w(TAG, "Failed to get channel count: ${t.message}")
audioProcessor.channelCount = 1
effect.channelCount = 1
}
}
}
Expand All @@ -2319,17 +2345,6 @@ class PreviewViewModel(private val application: Application) : ObservableViewMod
}
}

audioProcessor.audioLevelCallback = { levels ->
// Update the flow (this is called from audio thread, so be efficient)
_audioLevelFlow.value = com.dimadesu.lifestreamer.audio.AudioLevel(
rms = levels.rmsLeft,
peak = levels.peakLeft,
rmsRight = levels.rmsRight,
peakRight = levels.peakRight,
isStereo = levels.isStereo
)
}

Log.i(TAG, "Audio level monitoring enabled")
}

Expand All @@ -2342,7 +2357,21 @@ class PreviewViewModel(private val application: Application) : ObservableViewMod
audioConfigObserverJob = null
streamingStateObserverJob?.cancel()
streamingStateObserverJob = null
serviceStreamer?.audioInput?.processor?.audioLevelCallback = null

// Remove VU meter effect from processor
val audioProcessor = serviceStreamer?.audioInput?.processor
val effect = vuMeterEffect
if (audioProcessor != null && effect != null) {
val processorList = audioProcessor as? MutableList<*>
if (processorList != null) {
@Suppress("UNCHECKED_CAST")
(processorList as MutableList<Any>).remove(effect)
Log.d(TAG, "VuMeterEffect removed from audio processor")
}
}
vuMeterEffect?.close()
vuMeterEffect = null

_audioLevelFlow.value = com.dimadesu.lifestreamer.audio.AudioLevel.SILENT
} catch (_: Throwable) {}
}
Expand Down