diff --git a/StreamPack b/StreamPack index bcdf025..9671a9d 160000 --- a/StreamPack +++ b/StreamPack @@ -1 +1 @@ -Subproject commit bcdf0253af0b14fa0a9163934011ff57aeb94ab2 +Subproject commit 9671a9de47350604aa20301b3326cc020c110dde diff --git a/app/src/main/java/com/dimadesu/lifestreamer/audio/VuMeterEffect.kt b/app/src/main/java/com/dimadesu/lifestreamer/audio/VuMeterEffect.kt new file mode 100644 index 0000000..4a78710 --- /dev/null +++ b/app/src/main/java/com/dimadesu/lifestreamer/audio/VuMeterEffect.kt @@ -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 + ) + } +} diff --git a/app/src/main/java/com/dimadesu/lifestreamer/ui/main/PreviewViewModel.kt b/app/src/main/java/com/dimadesu/lifestreamer/ui/main/PreviewViewModel.kt index ec64f77..d4cde08 100644 --- a/app/src/main/java/com/dimadesu/lifestreamer/ui/main/PreviewViewModel.kt +++ b/app/src/main/java/com/dimadesu/lifestreamer/ui/main/PreviewViewModel.kt @@ -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). @@ -2290,6 +2293,29 @@ 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) + val effect = vuMeterEffect!! + val processorList = audioProcessor as? MutableList<*> + if (processorList != null) { + @Suppress("UNCHECKED_CAST") + (processorList as MutableList).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 { @@ -2297,11 +2323,11 @@ class PreviewViewModel(private val application: Application) : ObservableViewMod 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 } } } @@ -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") } @@ -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).remove(effect) + Log.d(TAG, "VuMeterEffect removed from audio processor") + } + } + vuMeterEffect?.close() + vuMeterEffect = null + _audioLevelFlow.value = com.dimadesu.lifestreamer.audio.AudioLevel.SILENT } catch (_: Throwable) {} }