From d7938080331ba9b679ada36c9aba71797a2bc401 Mon Sep 17 00:00:00 2001 From: pedroSG94 Date: Wed, 8 Jul 2026 20:30:49 +0200 Subject: [PATCH] add resample method and allow g711 on any audio config --- .../java/com/pedro/encoder/audio/G711Codec.kt | 33 ++++--- .../java/com/pedro/encoder/utils/PCMUtil.java | 80 +++++++++++++++++ .../java/com/pedro/encoder/PCMUtilTest.kt | 88 +++++++++++++++++++ 3 files changed, 191 insertions(+), 10 deletions(-) diff --git a/encoder/src/main/java/com/pedro/encoder/audio/G711Codec.kt b/encoder/src/main/java/com/pedro/encoder/audio/G711Codec.kt index a5af77fa8..560c41a95 100644 --- a/encoder/src/main/java/com/pedro/encoder/audio/G711Codec.kt +++ b/encoder/src/main/java/com/pedro/encoder/audio/G711Codec.kt @@ -15,6 +15,7 @@ */ package com.pedro.encoder.audio +import com.pedro.encoder.utils.PCMUtil import kotlin.experimental.inv /** @@ -22,28 +23,40 @@ import kotlin.experimental.inv */ class G711Codec { + private var sampleRate = 8000 + private var channels = 1 + fun configure(sampleRate: Int, channels: Int) { - require(!(sampleRate != 8000 || channels != 1)) { - "G711 codec only support 8000 sampleRate and mono channel" - } + this.sampleRate = sampleRate + this.channels = channels } fun encode(buffer: ByteArray, offset: Int, size: Int): ByteArray { - var j = offset - val count = size / 2 + val validBuffer = PCMUtil.resample( + if (offset == 0 && size == buffer.size) buffer else buffer.copyOfRange(offset, offset + size), + channels, 1, + sampleRate, 8000 + ) + var j = 0 + val count = buffer.size / 2 val out = ByteArray(count) for (i in 0 until count) { - val sample = (buffer[j++].toInt() and 0xff or (buffer[j++].toInt() shl 8)).toShort() + val sample = (validBuffer[j++].toInt() and 0xff or (validBuffer[j++].toInt() shl 8)).toShort() out[i] = linearToALawSample(sample) } return out } - fun decode(src: ByteArray, offset: Int, len: Int): ByteArray { + fun decode(buffer: ByteArray, offset: Int, size: Int): ByteArray { + val validBuffer = PCMUtil.resample( + if (offset == 0 && size == buffer.size) buffer else buffer.copyOfRange(offset, offset + size), + channels, 1, + sampleRate, 8000 + ) var j = 0 - val out = ByteArray(len * 2) - for (i in 0 until len) { - val s = aLawDecompressTable[src[i + offset].toInt() and 0xff] + val out = ByteArray(validBuffer.size * 2) + for (i in validBuffer.indices) { + val s = aLawDecompressTable[validBuffer[i].toInt() and 0xff] out[j++] = s.toByte() out[j++] = (s.toInt() shr 8).toByte() } diff --git a/encoder/src/main/java/com/pedro/encoder/utils/PCMUtil.java b/encoder/src/main/java/com/pedro/encoder/utils/PCMUtil.java index f4e281bb8..265745fba 100644 --- a/encoder/src/main/java/com/pedro/encoder/utils/PCMUtil.java +++ b/encoder/src/main/java/com/pedro/encoder/utils/PCMUtil.java @@ -56,6 +56,86 @@ public static byte[] mixPCM(byte[] pcm1, byte[] pcm2) { return pcmL; } + /** + * Resample pcm 16 bits little endian (mono or stereo) to the given sample rate. + * Upsampling interpolates between the 2 nearest frames. Downsampling averages + * all the frames of each interval to reduce aliasing. + * + * @param pcm pcm buffer, 16 bits per sample little endian + * @param channels 1 (mono) or 2 (stereo) + * @param inputSampleRate sample rate of the pcm buffer in Hz + * @param outputSampleRate desired sample rate in Hz + * @return pcm buffer resampled to outputSampleRate + */ + public static byte[] resample(byte[] pcm, int channels, int inputSampleRate, int outputSampleRate) { + return resample(pcm, channels, channels, inputSampleRate, outputSampleRate); + } + + /** + * Resample pcm 16 bits little endian to the given sample rate and convert channels + * in the same pass. Mono to stereo duplicates the channel, stereo to mono averages + * both channels. Upsampling interpolates between the 2 nearest frames. Downsampling + * averages all the frames of each interval to reduce aliasing. + * + * @param pcm pcm buffer, 16 bits per sample little endian + * @param inputChannels 1 (mono) or 2 (stereo) + * @param outputChannels 1 (mono) or 2 (stereo) + * @param inputSampleRate sample rate of the pcm buffer in Hz + * @param outputSampleRate desired sample rate in Hz + * @return pcm buffer resampled to outputSampleRate with outputChannels channels + */ + public static byte[] resample(byte[] pcm, int inputChannels, int outputChannels, int inputSampleRate, int outputSampleRate) { + if (inputSampleRate == outputSampleRate && inputChannels == outputChannels) return pcm; + int inputFrameSize = inputChannels * 2; + int outputFrameSize = outputChannels * 2; + int inputFrames = pcm.length / inputFrameSize; + if (inputFrames == 0) return new byte[0]; + int outputFrames = (int) ((long) inputFrames * outputSampleRate / inputSampleRate); + byte[] resampled = new byte[outputFrames * outputFrameSize]; + double step = (double) inputSampleRate / outputSampleRate; + boolean downsampling = step > 1; + for (int i = 0; i < outputFrames; i++) { + double position = i * step; + int frame = (int) position; + double fraction = position - frame; + int nextFrame = Math.min(frame + 1, inputFrames - 1); + int endFrame = Math.min((int) (position + step), inputFrames); + for (int channel = 0; channel < outputChannels; channel++) { + short value; + if (downsampling) { //average all frames of the interval to reduce aliasing + long sum = 0; + for (int f = frame; f < endFrame; f++) { + sum += readSample(pcm, f, channel, inputChannels, outputChannels, inputFrameSize); + } + value = (short) Math.round((double) sum / (endFrame - frame)); + } else { //interpolate between the 2 nearest frames + int sample = readSample(pcm, frame, channel, inputChannels, outputChannels, inputFrameSize); + int nextSample = readSample(pcm, nextFrame, channel, inputChannels, outputChannels, inputFrameSize); + value = (short) Math.round(sample + (nextSample - sample) * fraction); + } + int outIndex = i * outputFrameSize + channel * 2; + resampled[outIndex] = (byte) (value & 0xFF); + resampled[outIndex + 1] = (byte) ((value >> 8) & 0xFF); + } + } + return resampled; + } + + private static int readSample(byte[] pcm, int frame, int outputChannel, int inputChannels, int outputChannels, int inputFrameSize) { + int base = frame * inputFrameSize; + if (outputChannels < inputChannels) { //downmix, average all input channels + int sum = 0; + for (int channel = 0; channel < inputChannels; channel++) { + int index = base + channel * 2; + sum += (short) ((pcm[index] & 0xFF) | (pcm[index + 1] << 8)); + } + return sum / inputChannels; + } else { //same channels or mono to stereo duplicating the channel + int index = base + Math.min(outputChannel, inputChannels - 1) * 2; + return (short) ((pcm[index] & 0xFF) | (pcm[index + 1] << 8)); + } + } + /** * Experimental method to downgrade pcm with 3 channels or more to stereo. * Keeps the first two channels (16 bits little endian per sample) and drops the rest. diff --git a/encoder/src/test/java/com/pedro/encoder/PCMUtilTest.kt b/encoder/src/test/java/com/pedro/encoder/PCMUtilTest.kt index b43d721bb..60551e116 100644 --- a/encoder/src/test/java/com/pedro/encoder/PCMUtilTest.kt +++ b/encoder/src/test/java/com/pedro/encoder/PCMUtilTest.kt @@ -17,6 +17,94 @@ class PCMUtilTest { assertArrayEquals(byteArrayOf(1, 2, 3, 4, 7, 8, 9, 10), stereo) } + @Test + fun `GIVEN a mono pcm WHEN resample to double rate THEN output has double frames and interpolates values`() { + // 4 mono samples 16 bits: 0, 100, 200, 300 + val pcm = shortsToBytes(shortArrayOf(0, 100, 200, 300)) + val resampled = PCMUtil.resample(pcm, 1, 8000, 16000) + // 8 samples, odd indexes interpolated between neighbors (last one clamps to last sample) + assertArrayEquals(shortArrayOf(0, 50, 100, 150, 200, 250, 300, 300), bytesToShorts(resampled)) + } + + @Test + fun `GIVEN a stereo pcm WHEN resample to half rate THEN average each interval keeping channels independent`() { + // 4 stereo frames: L = 0, 100, 200, 300 | R = 1000, 1100, 1200, 1300 + val pcm = shortsToBytes(shortArrayOf(0, 1000, 100, 1100, 200, 1200, 300, 1300)) + val resampled = PCMUtil.resample(pcm, 2, 16000, 8000) + // each output frame averages 2 input frames per channel + assertArrayEquals(shortArrayOf(50, 1050, 250, 1250), bytesToShorts(resampled)) + } + + @Test + fun `GIVEN same input and output sample rate WHEN resample THEN return the same buffer`() { + val pcm = shortsToBytes(shortArrayOf(0, 100, 200, 300)) + assertArrayEquals(pcm, PCMUtil.resample(pcm, 1, 44100, 44100)) + } + + @Test + fun `GIVEN a mono pcm WHEN resample to stereo keeping rate THEN duplicate the channel`() { + val pcm = shortsToBytes(shortArrayOf(0, 100, 200, 300)) + val resampled = PCMUtil.resample(pcm, 1, 2, 8000, 8000) + assertArrayEquals(shortArrayOf(0, 0, 100, 100, 200, 200, 300, 300), bytesToShorts(resampled)) + } + + @Test + fun `GIVEN a stereo pcm WHEN resample to mono keeping rate THEN average both channels`() { + // 3 stereo frames: L = 0, 100, 200 | R = 1000, 1100, 1300 + val pcm = shortsToBytes(shortArrayOf(0, 1000, 100, 1100, 200, 1300)) + val resampled = PCMUtil.resample(pcm, 2, 1, 8000, 8000) + assertArrayEquals(shortArrayOf(500, 600, 750), bytesToShorts(resampled)) + } + + @Test + fun `GIVEN a mono pcm WHEN resample to stereo and double rate THEN duplicate and interpolate in one pass`() { + val pcm = shortsToBytes(shortArrayOf(0, 100, 200, 300)) + val resampled = PCMUtil.resample(pcm, 1, 2, 8000, 16000) + assertArrayEquals( + shortArrayOf(0, 0, 50, 50, 100, 100, 150, 150, 200, 200, 250, 250, 300, 300, 300, 300), + bytesToShorts(resampled) + ) + } + + @Test + fun `GIVEN a stereo pcm WHEN resample to mono and half rate THEN downmix and average intervals in one pass`() { + // 4 stereo frames: L = 0, 100, 200, 300 | R = 1000, 1100, 1200, 1300 + // downmixed frames: 500, 600, 700, 800 -> averaged intervals of 2: 550, 750 + val pcm = shortsToBytes(shortArrayOf(0, 1000, 100, 1100, 200, 1200, 300, 1300)) + val resampled = PCMUtil.resample(pcm, 2, 1, 16000, 8000) + assertArrayEquals(shortArrayOf(550, 750), bytesToShorts(resampled)) + } + + @Test + fun `GIVEN a mono pcm WHEN resample from 32000 to 8000 THEN average intervals of 4 frames`() { + val pcm = shortsToBytes(shortArrayOf(0, 100, 200, 300, 1000, 1100, 1200, 1300)) + val resampled = PCMUtil.resample(pcm, 1, 32000, 8000) + assertArrayEquals(shortArrayOf(150, 1150), bytesToShorts(resampled)) + } + + @Test + fun `GIVEN a stereo pcm WHEN resample THEN output size matches rate ratio`() { + // 1 second of stereo at 44100Hz resampled to 32000Hz + val pcm = ByteArray(44100 * 4) + val resampled = PCMUtil.resample(pcm, 2, 44100, 32000) + assertEquals(32000 * 4, resampled.size) + } + + private fun shortsToBytes(samples: ShortArray): ByteArray { + val bytes = ByteArray(samples.size * 2) + samples.forEachIndexed { i, sample -> + bytes[i * 2] = (sample.toInt() and 0xFF).toByte() + bytes[i * 2 + 1] = ((sample.toInt() shr 8) and 0xFF).toByte() + } + return bytes + } + + private fun bytesToShorts(bytes: ByteArray): ShortArray { + return ShortArray(bytes.size / 2) { i -> + ((bytes[i * 2].toInt() and 0xFF) or (bytes[i * 2 + 1].toInt() shl 8)).toShort() + } + } + @Test fun `GIVEN a large multichannel frame WHEN pcmToStereo THEN output size is exact and does not overflow`() { // 4 channels, 2000 frames -> input 16000 bytes, stereo output 8000 bytes (old static buffer was 4096)