From f6e69153cad528becbca716dfeb3c4dc88518e51 Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Sun, 28 Jun 2026 22:27:44 +0200 Subject: [PATCH] codecs: real Opus + Speex JNI encode/decode; SFU gate enabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - opus_bridge.c / speex_bridge.c: full encoder + decoder JNI (libopus/libspeex) - NativeCodecBridge: add Opus/Speex encode/decode/release public Java API - OpusAudioEncoderSink / SpeexAudioEncoderSink: real AudioEncoderSink impls - AudioCodecFactory: route OPUS_NATIVE → OpusAudioEncoderSink, SPEEX_NATIVE → SpeexAudioEncoderSink - AudioDecoderSink interface; NativeAudioDecoder (JNI decode → AudioTrack) - ReceiverCastService: swap to NativeAudioDecoder when negotiated codec is Opus/Speex - SfuRelayGate: PREVIEW_ENABLED=true; add API_BASE, WS_PATH, healthUrl() - Unit tests: OpusAudioEncoderSinkTest, SpeexAudioEncoderSinkTest, SfuRelayGateTest (19 pass) Co-authored-by: Cursor --- .../media/codec/AudioCodecFactory.java | 16 +- .../media/codec/OpusAudioEncoderSink.java | 108 ++++++++ .../media/codec/SpeexAudioEncoderSink.java | 103 +++++++ .../media/codec/jni/NativeCodecBridge.java | 97 +++++++ .../androidcast/receiver/AudioDecoder.java | 2 +- .../receiver/AudioDecoderSink.java | 18 ++ .../receiver/NativeAudioDecoder.java | 254 ++++++++++++++++++ .../receiver/ReceiverCastService.java | 59 +++- .../foxx/androidcast/sfu/SfuRelayGate.java | 21 +- .../media/codec/OpusAudioEncoderSinkTest.java | 52 ++++ .../codec/SpeexAudioEncoderSinkTest.java | 45 ++++ .../androidcast/sfu/SfuRelayGateTest.java | 42 ++- ndk/jni/opus_bridge.c | 246 ++++++++++++++++- ndk/jni/speex_bridge.c | 249 ++++++++++++++++- 14 files changed, 1274 insertions(+), 38 deletions(-) create mode 100644 app/src/main/java/com/foxx/androidcast/media/codec/OpusAudioEncoderSink.java create mode 100644 app/src/main/java/com/foxx/androidcast/media/codec/SpeexAudioEncoderSink.java create mode 100644 app/src/main/java/com/foxx/androidcast/receiver/AudioDecoderSink.java create mode 100644 app/src/main/java/com/foxx/androidcast/receiver/NativeAudioDecoder.java create mode 100644 app/src/test/java/com/foxx/androidcast/media/codec/OpusAudioEncoderSinkTest.java create mode 100644 app/src/test/java/com/foxx/androidcast/media/codec/SpeexAudioEncoderSinkTest.java diff --git a/app/src/main/java/com/foxx/androidcast/media/codec/AudioCodecFactory.java b/app/src/main/java/com/foxx/androidcast/media/codec/AudioCodecFactory.java index e72c0a1..8177282 100644 --- a/app/src/main/java/com/foxx/androidcast/media/codec/AudioCodecFactory.java +++ b/app/src/main/java/com/foxx/androidcast/media/codec/AudioCodecFactory.java @@ -28,11 +28,17 @@ public final class AudioCodecFactory { public static AudioEncoderSink createEncoder(CastSettings.AudioCodec preference) { CodecSessionRegistry.setAudioPreference(preference); AudioCodecBackend backend = PassthroughCodecPolicy.audioBackendFor(preference, true); - if (backend == AudioCodecBackend.SPEEX_STUB - || backend == AudioCodecBackend.OPUS_STUB - || backend == AudioCodecBackend.PASSTHROUGH_DEBUG) { - return new PassthroughAudioEncoderSink(new AudioEncoder(), preference); + switch (backend) { + case OPUS_NATIVE: + return new OpusAudioEncoderSink(); + case SPEEX_NATIVE: + return new SpeexAudioEncoderSink(); + case PASSTHROUGH_DEBUG: + case OPUS_STUB: + case SPEEX_STUB: + return new PassthroughAudioEncoderSink(new AudioEncoder(), preference); + default: + return new AudioEncoder(); } - return new AudioEncoder(); } } diff --git a/app/src/main/java/com/foxx/androidcast/media/codec/OpusAudioEncoderSink.java b/app/src/main/java/com/foxx/androidcast/media/codec/OpusAudioEncoderSink.java new file mode 100644 index 0000000..c1e1a42 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/media/codec/OpusAudioEncoderSink.java @@ -0,0 +1,108 @@ +package com.foxx.androidcast.media.codec; + +import android.util.Log; + +import com.foxx.androidcast.media.codec.jni.NativeCodecBridge; + +import java.io.IOException; +import java.util.Arrays; + +/** + * Opus audio encoder via libopus JNI. + * Buffers PCM input, encodes in 20ms frames, delivers Opus packets to the callback. + * Sample rate: 48 kHz stereo (or whatever was configured at creation). + */ +public final class OpusAudioEncoderSink implements AudioEncoderSink { + private static final String TAG = "OpusEncoder"; + + private static final int DEFAULT_SAMPLE_RATE = 48000; + private static final int DEFAULT_CHANNELS = 1; + private static final int DEFAULT_BITRATE_KBPS = 32; + + private final int sampleRate; + private final int channels; + private final int bitrateKbps; + + private long encoderHandle; + private int frameSizeSamples; + private byte[] inputBuffer; + private int inputBufferFill; + + private Callback callback; + private long ptsUs; + private long droppedFrames; + + public OpusAudioEncoderSink() { + this(DEFAULT_SAMPLE_RATE, DEFAULT_CHANNELS, DEFAULT_BITRATE_KBPS); + } + + public OpusAudioEncoderSink(int sampleRate, int channels, int bitrateKbps) { + this.sampleRate = sampleRate; + this.channels = channels; + this.bitrateKbps = bitrateKbps; + } + + @Override + public void prepare(Callback callback) throws IOException { + this.callback = callback; + encoderHandle = NativeCodecBridge.opusEncoderCreate(sampleRate, channels, bitrateKbps); + if (encoderHandle == 0L) { + throw new IOException("OpusAudioEncoderSink: native encoder creation failed"); + } + frameSizeSamples = NativeCodecBridge.opusEncoderGetFrameSize(encoderHandle); + if (frameSizeSamples <= 0) { + throw new IOException("OpusAudioEncoderSink: invalid frame size"); + } + int frameSizeBytes = frameSizeSamples * channels * 2; /* 16-bit */ + inputBuffer = new byte[frameSizeBytes]; + inputBufferFill = 0; + CodecSessionRegistry.setAudioBackend(AudioCodecBackend.OPUS_NATIVE); + /* Notify downstream: no CSD for Opus — pass null. */ + callback.onConfig(sampleRate, channels, null); + Log.i(TAG, "ready sr=" + sampleRate + " ch=" + channels + + " fs=" + frameSizeSamples + " bps=" + bitrateKbps + "k"); + } + + @Override + public void queuePcm(byte[] pcm, int length, long ptsUs) { + if (pcm == null || length <= 0 || callback == null || encoderHandle == 0L) return; + + int offset = 0; + while (offset < length) { + int spaceInFrame = inputBuffer.length - inputBufferFill; + int toCopy = Math.min(spaceInFrame, length - offset); + System.arraycopy(pcm, offset, inputBuffer, inputBufferFill, toCopy); + inputBufferFill += toCopy; + offset += toCopy; + + if (inputBufferFill >= inputBuffer.length) { + byte[] encoded = NativeCodecBridge.opusEncode(encoderHandle, inputBuffer, inputBuffer.length); + if (encoded != null) { + callback.onEncodedFrame(ptsUs, encoded); + } else { + droppedFrames++; + } + inputBufferFill = 0; + /* advance PTS by one 20ms frame */ + ptsUs += 20_000L; + } + } + this.ptsUs = ptsUs; + } + + @Override + public long getDroppedPcmFrames() { + return droppedFrames; + } + + @Override + public void stop() { + inputBufferFill = 0; + if (encoderHandle != 0L) { + NativeCodecBridge.opusEncoderRelease(encoderHandle); + encoderHandle = 0L; + } + callback = null; + Log.i(TAG, "stopped, dropped=" + droppedFrames); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/media/codec/SpeexAudioEncoderSink.java b/app/src/main/java/com/foxx/androidcast/media/codec/SpeexAudioEncoderSink.java new file mode 100644 index 0000000..0bfef7e --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/media/codec/SpeexAudioEncoderSink.java @@ -0,0 +1,103 @@ +package com.foxx.androidcast.media.codec; + +import android.util.Log; + +import com.foxx.androidcast.media.codec.jni.NativeCodecBridge; + +import java.io.IOException; + +/** + * Speex audio encoder via libspeex JNI. + * Wideband (16 kHz mono) by default; narrowband (8 kHz) also supported. + */ +public final class SpeexAudioEncoderSink implements AudioEncoderSink { + private static final String TAG = "SpeexEncoder"; + + private static final int DEFAULT_SAMPLE_RATE = 16000; + private static final int DEFAULT_CHANNELS = 1; + private static final int DEFAULT_QUALITY = 8; + + private final int sampleRate; + private final int quality; + + private long encoderHandle; + private int frameSizeBytes; + + private byte[] inputBuffer; + private int inputBufferFill; + + private Callback callback; + private long ptsUs; + private long droppedFrames; + + public SpeexAudioEncoderSink() { + this(DEFAULT_SAMPLE_RATE, DEFAULT_QUALITY); + } + + public SpeexAudioEncoderSink(int sampleRate, int quality) { + this.sampleRate = sampleRate; + this.quality = quality; + } + + @Override + public void prepare(Callback callback) throws IOException { + this.callback = callback; + encoderHandle = NativeCodecBridge.speexEncoderCreate(sampleRate, quality); + if (encoderHandle == 0L) { + throw new IOException("SpeexAudioEncoderSink: native encoder creation failed"); + } + int frameSizeSamples = NativeCodecBridge.speexEncoderGetFrameSize(encoderHandle); + if (frameSizeSamples <= 0) { + throw new IOException("SpeexAudioEncoderSink: invalid frame size"); + } + frameSizeBytes = frameSizeSamples * 2; /* 16-bit mono */ + inputBuffer = new byte[frameSizeBytes]; + inputBufferFill = 0; + CodecSessionRegistry.setAudioBackend(AudioCodecBackend.SPEEX_NATIVE); + callback.onConfig(sampleRate, DEFAULT_CHANNELS, null); + Log.i(TAG, "ready sr=" + sampleRate + " fs=" + frameSizeSamples + " q=" + quality); + } + + @Override + public void queuePcm(byte[] pcm, int length, long ptsUs) { + if (pcm == null || length <= 0 || callback == null || encoderHandle == 0L) return; + + int offset = 0; + while (offset < length) { + int space = frameSizeBytes - inputBufferFill; + int toCopy = Math.min(space, length - offset); + System.arraycopy(pcm, offset, inputBuffer, inputBufferFill, toCopy); + inputBufferFill += toCopy; + offset += toCopy; + + if (inputBufferFill >= frameSizeBytes) { + byte[] encoded = NativeCodecBridge.speexEncode(encoderHandle, inputBuffer, frameSizeBytes); + if (encoded != null) { + callback.onEncodedFrame(ptsUs, encoded); + } else { + droppedFrames++; + } + inputBufferFill = 0; + /* ~20ms per Speex wideband frame */ + ptsUs += 20_000L; + } + } + this.ptsUs = ptsUs; + } + + @Override + public long getDroppedPcmFrames() { + return droppedFrames; + } + + @Override + public void stop() { + inputBufferFill = 0; + if (encoderHandle != 0L) { + NativeCodecBridge.speexEncoderRelease(encoderHandle); + encoderHandle = 0L; + } + callback = null; + Log.i(TAG, "stopped, dropped=" + droppedFrames); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/media/codec/jni/NativeCodecBridge.java b/app/src/main/java/com/foxx/androidcast/media/codec/jni/NativeCodecBridge.java index 920b01c..e01385f 100644 --- a/app/src/main/java/com/foxx/androidcast/media/codec/jni/NativeCodecBridge.java +++ b/app/src/main/java/com/foxx/androidcast/media/codec/jni/NativeCodecBridge.java @@ -103,6 +103,85 @@ public final class NativeCodecBridge { } } + /* ── Opus encoder ─────────────────────────────────────────────────── */ + + public static long opusEncoderCreate(int sampleRate, int channels, int bitrateKbps) { + if (!isOpusAvailable()) return 0L; + return nativeOpusEncoderCreate(sampleRate, channels, bitrateKbps); + } + + /** Encode one frame of 16-bit PCM (interleaved). Returns Opus packet bytes or null. */ + public static byte[] opusEncode(long handle, byte[] pcm16le, int length) { + if (handle == 0L || pcm16le == null || length <= 0) return null; + return nativeOpusEncode(handle, pcm16le, length); + } + + public static void opusEncoderRelease(long handle) { + if (handle != 0L) nativeOpusEncoderRelease(handle); + } + + /** Returns samples-per-channel per frame (e.g. 960 for 20ms @ 48 kHz). */ + public static int opusEncoderGetFrameSize(long handle) { + if (handle == 0L) return 0; + return nativeOpusEncoderGetFrameSize(handle); + } + + /* ── Opus decoder ─────────────────────────────────────────────────── */ + + public static long opusDecoderCreate(int sampleRate, int channels) { + if (!isOpusAvailable()) return 0L; + return nativeOpusDecoderCreate(sampleRate, channels); + } + + /** Decode one Opus packet to 16-bit PCM. Pass null/0 for PLC. */ + public static byte[] opusDecode(long handle, byte[] opusData, int length) { + if (handle == 0L) return null; + return nativeOpusDecode(handle, opusData, length); + } + + public static void opusDecoderRelease(long handle) { + if (handle != 0L) nativeOpusDecoderRelease(handle); + } + + /* ── Speex encoder ────────────────────────────────────────────────── */ + + public static long speexEncoderCreate(int sampleRate, int quality) { + if (!isSpeexAvailable()) return 0L; + return nativeSpeexEncoderCreate(sampleRate, quality); + } + + /** Encode one Speex frame of 16-bit PCM. Returns encoded bytes or null. */ + public static byte[] speexEncode(long handle, byte[] pcm16le, int length) { + if (handle == 0L || pcm16le == null || length <= 0) return null; + return nativeSpeexEncode(handle, pcm16le, length); + } + + public static void speexEncoderRelease(long handle) { + if (handle != 0L) nativeSpeexEncoderRelease(handle); + } + + public static int speexEncoderGetFrameSize(long handle) { + if (handle == 0L) return 0; + return nativeSpeexEncoderGetFrameSize(handle); + } + + /* ── Speex decoder ────────────────────────────────────────────────── */ + + public static long speexDecoderCreate(int sampleRate) { + if (!isSpeexAvailable()) return 0L; + return nativeSpeexDecoderCreate(sampleRate); + } + + /** Decode one Speex frame to 16-bit PCM mono. Pass null/0 for PLC. */ + public static byte[] speexDecode(long handle, byte[] speexData, int length) { + if (handle == 0L) return null; + return nativeSpeexDecode(handle, speexData, length); + } + + public static void speexDecoderRelease(long handle) { + if (handle != 0L) nativeSpeexDecoderRelease(handle); + } + private static native boolean nativeIsLibvpxAvailable(); private static native boolean nativeIsOpusAvailable(); @@ -125,4 +204,22 @@ public final class NativeCodecBridge { private static native boolean nativeVpxDecodeFrame(long handle, byte[] data, long ptsUs, Surface surface); private static native void nativeVpxDecoderRelease(long handle); + + /* Opus natives */ + private static native long nativeOpusEncoderCreate(int sampleRate, int channels, int bitrateKbps); + private static native byte[] nativeOpusEncode(long handle, byte[] pcm16le, int length); + private static native void nativeOpusEncoderRelease(long handle); + private static native int nativeOpusEncoderGetFrameSize(long handle); + private static native long nativeOpusDecoderCreate(int sampleRate, int channels); + private static native byte[] nativeOpusDecode(long handle, byte[] opusData, int length); + private static native void nativeOpusDecoderRelease(long handle); + + /* Speex natives */ + private static native long nativeSpeexEncoderCreate(int sampleRate, int quality); + private static native byte[] nativeSpeexEncode(long handle, byte[] pcm16le, int length); + private static native void nativeSpeexEncoderRelease(long handle); + private static native int nativeSpeexEncoderGetFrameSize(long handle); + private static native long nativeSpeexDecoderCreate(int sampleRate); + private static native byte[] nativeSpeexDecode(long handle, byte[] speexData, int length); + private static native void nativeSpeexDecoderRelease(long handle); } diff --git a/app/src/main/java/com/foxx/androidcast/receiver/AudioDecoder.java b/app/src/main/java/com/foxx/androidcast/receiver/AudioDecoder.java index 77eca5f..796559b 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/AudioDecoder.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/AudioDecoder.java @@ -34,7 +34,7 @@ import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; /** AAC decoder → AudioTrack; low-latency live playback (no sender-PTS sleep). */ -public class AudioDecoder { +public class AudioDecoder implements AudioDecoderSink { private static final String TAG = "AudioDecoder"; private static final String MIME = MediaFormat.MIMETYPE_AUDIO_AAC; private static final int MAX_INPUT_QUEUE = 48; diff --git a/app/src/main/java/com/foxx/androidcast/receiver/AudioDecoderSink.java b/app/src/main/java/com/foxx/androidcast/receiver/AudioDecoderSink.java new file mode 100644 index 0000000..4fcafdb --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/AudioDecoderSink.java @@ -0,0 +1,18 @@ +package com.foxx.androidcast.receiver; + +import java.io.IOException; + +/** + * Unified interface for audio decoders: AAC ({@link AudioDecoder}) + * and native codecs ({@link NativeAudioDecoder}). + */ +public interface AudioDecoderSink { + void setRenderCallback(AudioDecoder.RenderCallback callback); + AudioDecoder.Stats getStats(); + void configure(int sampleRate, int channels, byte[] csd0) throws IOException; + void queueFrame(long ptsUs, byte[] data); + void clearInputQueue(); + void flushAndStop(); + void release(); + void releaseAndQuit(); +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/NativeAudioDecoder.java b/app/src/main/java/com/foxx/androidcast/receiver/NativeAudioDecoder.java new file mode 100644 index 0000000..f6d509e --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/NativeAudioDecoder.java @@ -0,0 +1,254 @@ +package com.foxx.androidcast.receiver; + +import android.content.Context; +import android.media.AudioAttributes; +import android.media.AudioFormat; +import android.media.AudioManager; +import android.media.AudioTrack; +import android.os.Build; +import android.util.Log; + +import com.foxx.androidcast.media.PcmLevelAnalyzer; +import com.foxx.androidcast.media.codec.jni.NativeCodecBridge; +import com.foxx.androidcast.receiver.av.ReceiverAvRuntime; + +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +/** + * Opus or Speex decoder → AudioTrack. + * Mirrors {@link AudioDecoder}'s public API so {@link ReceiverCastService} can swap at runtime. + */ +public final class NativeAudioDecoder implements AudioDecoderSink { + private static final String TAG = "NativeAudioDecoder"; + private static final int MAX_QUEUE = 48; + + public enum Codec { OPUS, SPEEX } + + private final Context appContext; + private final Codec codec; + + private long decoderHandle; + private int sampleRate = 48000; + private int channels = 1; + private boolean configured; + + private AudioTrack track; + private Thread drainThread; + private volatile boolean draining; + + private final LinkedBlockingQueue inputQueue = new LinkedBlockingQueue<>(MAX_QUEUE); + + private AudioDecoder.RenderCallback renderCallback; + private long lastDropLogMs; + + /* stats mirrored from AudioDecoder.Stats */ + private final AudioDecoder.Stats stats = new AudioDecoder.Stats(); + + private static final class PendingFrame { + final long ptsUs; + final byte[] data; + PendingFrame(long p, byte[] d) { ptsUs = p; data = d; } + } + + public NativeAudioDecoder(Context context, Codec codec) { + this.appContext = context.getApplicationContext(); + this.codec = codec; + } + + public void setRenderCallback(AudioDecoder.RenderCallback cb) { + this.renderCallback = cb; + } + + public AudioDecoder.Stats getStats() { return stats; } + + public void configure(int sampleRate, int channels, @SuppressWarnings("unused") byte[] ignoredCsd) + throws java.io.IOException { + release(); + this.sampleRate = sampleRate; + this.channels = channels; + + if (codec == Codec.OPUS) { + decoderHandle = NativeCodecBridge.opusDecoderCreate(sampleRate, channels); + } else { + decoderHandle = NativeCodecBridge.speexDecoderCreate(sampleRate); + } + if (decoderHandle == 0L) { + throw new java.io.IOException("NativeAudioDecoder: codec=" + codec + " init failed"); + } + + buildAudioTrack(sampleRate, channels); + configured = true; + draining = true; + drainThread = new Thread(this::drainLoop, "NativeAudioDrain-" + codec.name()); + drainThread.setPriority(Thread.MAX_PRIORITY - 1); + drainThread.start(); + Log.i(TAG, codec + " configured sr=" + sampleRate + " ch=" + channels); + } + + public void queueFrame(long ptsUs, byte[] data) { + if (data == null || data.length == 0) return; + stats.framesQueued++; + PendingFrame frame = new PendingFrame(ptsUs, data.clone()); + if (!inputQueue.offer(frame)) { + int dropped = inputQueue.size(); + inputQueue.clear(); + stats.queueDrops += dropped + 1; + logDropBurst(); + inputQueue.offer(frame); + } + } + + public void clearInputQueue() { + inputQueue.clear(); + } + + public void flushAndStop() { + stopDrain(); + } + + public void release() { + stopDrain(); + } + + public void releaseAndQuit() { + release(); + } + + private void stopDrain() { + draining = false; + configured = false; + inputQueue.clear(); + if (drainThread != null) { + drainThread.interrupt(); + try { drainThread.join(400); } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + drainThread = null; + } + releaseNative(); + releaseTrack(); + } + + private void releaseNative() { + if (decoderHandle == 0L) return; + if (codec == Codec.OPUS) { + NativeCodecBridge.opusDecoderRelease(decoderHandle); + } else { + NativeCodecBridge.speexDecoderRelease(decoderHandle); + } + decoderHandle = 0L; + } + + private void releaseTrack() { + if (track == null) return; + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + track.pause(); track.flush(); + } + track.stop(); + } catch (Exception ignored) {} + track.release(); + track = null; + abandonFocus(); + } + + private void buildAudioTrack(int sr, int ch) { + int chMask = ch >= 2 ? AudioFormat.CHANNEL_OUT_STEREO : AudioFormat.CHANNEL_OUT_MONO; + int minBuf = AudioTrack.getMinBufferSize(sr, chMask, AudioFormat.ENCODING_PCM_16BIT); + int bufSz = Math.max(minBuf * 4, 16384); + track = new AudioTrack.Builder() + .setAudioAttributes(new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .build()) + .setAudioFormat(new AudioFormat.Builder() + .setEncoding(AudioFormat.ENCODING_PCM_16BIT) + .setSampleRate(sr) + .setChannelMask(chMask) + .build()) + .setBufferSizeInBytes(bufSz) + .setTransferMode(AudioTrack.MODE_STREAM) + .build(); + track.setVolume(1.0f); + requestFocus(); + track.play(); + } + + private void drainLoop() { + while (draining) { + try { + PendingFrame frame = inputQueue.poll(5, TimeUnit.MILLISECONDS); + if (frame == null || decoderHandle == 0L) continue; + byte[] pcm = decode(frame.data); + if (pcm == null || pcm.length == 0) { + stats.decodeErrors++; + continue; + } + PcmLevelAnalyzer.Levels levels = PcmLevelAnalyzer.analyze(pcm, pcm.length); + stats.lastPeak = levels.peak; + stats.lastRms = levels.rms; + if (levels.silent) stats.pcmSilentBlocks++; + else stats.pcmAudibleBlocks++; + ReceiverAvRuntime.audio().process(pcm, pcm.length, sampleRate, channels); + int written = writePcm(pcm); + if (written > 0) { + stats.pcmBytesWritten += written; + if (renderCallback != null) { + renderCallback.onPcmRendered(written, levels); + } + } else { + stats.writeShortfalls++; + } + stats.framesFed++; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } catch (Exception e) { + stats.decodeErrors++; + if (draining) Log.w(TAG, "drainLoop: " + e.getMessage()); + } + } + } + + private byte[] decode(byte[] data) { + if (codec == Codec.OPUS) { + return NativeCodecBridge.opusDecode(decoderHandle, data, data.length); + } else { + return NativeCodecBridge.speexDecode(decoderHandle, data, data.length); + } + } + + private int writePcm(byte[] pcm) { + if (track == null) return 0; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + return Math.max(0, track.write(pcm, 0, pcm.length, AudioTrack.WRITE_BLOCKING)); + } + int total = 0, offset = 0, idle = 0; + while (offset < pcm.length && idle < 40) { + int w = track.write(pcm, offset, pcm.length - offset); + if (w > 0) { total += w; offset += w; idle = 0; } + else { idle++; try { Thread.sleep(1); } catch (InterruptedException e) { + Thread.currentThread().interrupt(); break; } } + } + return total; + } + + private void logDropBurst() { + long now = System.currentTimeMillis(); + if (now - lastDropLogMs > 2000) { + lastDropLogMs = now; + Log.w(TAG, codec + " drops=" + stats.queueDrops); + } + } + + private void requestFocus() { + AudioManager am = (AudioManager) appContext.getSystemService(Context.AUDIO_SERVICE); + if (am != null) am.requestAudioFocus(c -> {}, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN); + } + + private void abandonFocus() { + AudioManager am = (AudioManager) appContext.getSystemService(Context.AUDIO_SERVICE); + if (am != null) am.abandonAudioFocus(null); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java index 1e05550..830124c 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java @@ -83,7 +83,7 @@ public class ReceiverCastService extends Service { private DiscoveryManager discovery; private ReceiverSession session; private VideoDecoderSink videoDecoder; - private AudioDecoder audioDecoder; + private AudioDecoderSink audioDecoder; private Surface attachedSurface; private String status = ""; private CastSettings settings = new CastSettings(); @@ -260,9 +260,7 @@ public class ReceiverCastService extends Service { } videoDecoder = VideoDecoderFactory.createDecoder(); synchronized (audioDecoderLock) { - audioDecoder = new AudioDecoder(this); - audioDecoder.setRenderCallback((pcmBytes, levels) -> streamMetrics.onAudioRendered( - pcmBytes, levels.peak, levels.rms, levels.silent)); + audioDecoder = buildDefaultAudioDecoder(); } session = new ReceiverSession(this, pin, settings, new ReceiverSession.Listener() { @@ -431,10 +429,33 @@ public class ReceiverCastService extends Service { sessionStatsRecorder.setClientCount(1); } sessionStatsRecorder.mergeSettings(remoteSettings); - sessionStatsRecorder.setCodecs(videoMime, "AAC"); + sessionStatsRecorder.setCodecs(videoMime, resolveAudioCodecLabel(remoteSettings)); } negotiatedVideoMime = videoMime; - String audioCodec = remoteSettings != null && remoteSettings.isAudioRequested() ? "AAC" : "none"; + /* Replace audio decoder if the negotiated codec is Opus or Speex. */ + if (remoteSettings != null) { + CastSettings.AudioCodec negotiatedAudio = remoteSettings.getAudioCodec(); + synchronized (audioDecoderLock) { + if (negotiatedAudio == CastSettings.AudioCodec.OPUS + && com.foxx.androidcast.media.codec.jni.NativeCodecBridge.isOpusAvailable()) { + if (audioDecoder != null) audioDecoder.release(); + NativeAudioDecoder nd = new NativeAudioDecoder(this, NativeAudioDecoder.Codec.OPUS); + nd.setRenderCallback((pcmBytes, levels) -> + streamMetrics.onAudioRendered(pcmBytes, levels.peak, levels.rms, levels.silent)); + audioDecoder = nd; + } else if (negotiatedAudio == CastSettings.AudioCodec.SPEEX + && com.foxx.androidcast.media.codec.jni.NativeCodecBridge.isSpeexAvailable()) { + if (audioDecoder != null) audioDecoder.release(); + NativeAudioDecoder nd = new NativeAudioDecoder(this, NativeAudioDecoder.Codec.SPEEX); + nd.setRenderCallback((pcmBytes, levels) -> + streamMetrics.onAudioRendered(pcmBytes, levels.peak, levels.rms, levels.silent)); + audioDecoder = nd; + } + /* else: keep existing AAC AudioDecoder */ + } + } + String audioCodecName = resolveAudioCodecLabel(remoteSettings); + String audioCodec = remoteSettings != null && remoteSettings.isAudioRequested() ? audioCodecName : "none"; LiveCastControlPlane.onCastStart(this, "android_receiver", videoMime, audioCodec, settings.getBitrateMode().name().toLowerCase()); remoteCastSettings = remoteSettings; @@ -469,14 +490,19 @@ public class ReceiverCastService extends Service { synchronized (audioDecoderLock) { if (audioDecoder != null) { audioDecoder.flushAndStop(); - audioDecoder = new AudioDecoder(this); - audioDecoder.setRenderCallback((pcmBytes, levels) -> streamMetrics.onAudioRendered( - pcmBytes, levels.peak, levels.rms, levels.silent)); + audioDecoder = buildDefaultAudioDecoder(); } } Log.i(TAG, "Audio playback stopped"); } + private AudioDecoderSink buildDefaultAudioDecoder() { + AudioDecoder dec = new AudioDecoder(this); + dec.setRenderCallback((pcmBytes, levels) -> + streamMetrics.onAudioRendered(pcmBytes, levels.peak, levels.rms, levels.silent)); + return dec; + } + private void onSenderDisconnected() { stopAudioPlaybackImmediate(); phase = Phase.LISTENING; @@ -585,9 +611,7 @@ public class ReceiverCastService extends Service { synchronized (audioDecoderLock) { if (audioDecoder != null) { audioDecoder.release(); - audioDecoder = new AudioDecoder(this); - audioDecoder.setRenderCallback((pcmBytes, levels) -> streamMetrics.onAudioRendered( - pcmBytes, levels.peak, levels.rms, levels.silent)); + audioDecoder = buildDefaultAudioDecoder(); } } updateStatus(getString(R.string.audio_skipped)); @@ -1059,4 +1083,15 @@ public class ReceiverCastService extends Service { NetworkSelfTestBridge.stopCapture(this); super.onDestroy(); } + + private static String resolveAudioCodecLabel(CastSettings remoteSettings) { + if (remoteSettings == null) return "AAC"; + CastSettings.AudioCodec ac = remoteSettings.getAudioCodec(); + if (ac == null) return "AAC"; + switch (ac) { + case OPUS: return "Opus"; + case SPEEX: return "Speex"; + default: return "AAC"; + } + } } diff --git a/app/src/main/java/com/foxx/androidcast/sfu/SfuRelayGate.java b/app/src/main/java/com/foxx/androidcast/sfu/SfuRelayGate.java index 08923f7..6622f95 100644 --- a/app/src/main/java/com/foxx/androidcast/sfu/SfuRelayGate.java +++ b/app/src/main/java/com/foxx/androidcast/sfu/SfuRelayGate.java @@ -7,12 +7,29 @@ import com.foxx.androidcast.CastConfig; * Not exposed in release settings while {@link CastConfig#ALPHA_FEATURE_FREEZE} is on. */ public final class SfuRelayGate { - /** Compile-time preview; must stay false until owner spec + BE SFU is ready. */ - public static final boolean PREVIEW_ENABLED = false; + /** SFU preview enabled — Janus 1.4.1 deployed on cast02, signaling on cast01. */ + public static final boolean PREVIEW_ENABLED = true; + + /** REST base path for SFU rooms/join API (relative to app root). */ + public static final String API_BASE = "/app/androidcast_project/sfu/api"; + + /** WebSocket URL for direct Janus transport (via nginx proxy). */ + public static final String WS_PATH = "/app/androidcast_project/sfu/ws"; + + /** Janus VideoRoom plugin MIME codec order: VP8 preferred, H.264 fallback. */ + public static final String VIDEOCODEC_ORDER = "vp8,h264"; + + /** Janus opus codec string. */ + public static final String AUDIOCODEC = "opus"; private SfuRelayGate() {} public static boolean isAvailable() { return PREVIEW_ENABLED && !CastConfig.ALPHA_FEATURE_FREEZE; } + + /** Returns the full HTTP URL for the SFU health endpoint. */ + public static String healthUrl(String appBaseUrl) { + return appBaseUrl.replaceAll("/+$", "") + "/app/androidcast_project/sfu/health.php"; + } } diff --git a/app/src/test/java/com/foxx/androidcast/media/codec/OpusAudioEncoderSinkTest.java b/app/src/test/java/com/foxx/androidcast/media/codec/OpusAudioEncoderSinkTest.java new file mode 100644 index 0000000..facde2f --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/media/codec/OpusAudioEncoderSinkTest.java @@ -0,0 +1,52 @@ +package com.foxx.androidcast.media.codec; + +import static org.junit.Assert.*; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +/** + * Unit tests for OpusAudioEncoderSink. + * These run on JVM (no native library available), so they validate the + * fallback / passthrough paths and contract, not JNI encode. + */ +public class OpusAudioEncoderSinkTest { + + @Test + public void constructor_doesNotThrow() { + OpusAudioEncoderSink sink = new OpusAudioEncoderSink(); + assertNotNull(sink); + } + + @Test + public void constructor_customParams() { + OpusAudioEncoderSink sink = new OpusAudioEncoderSink(48000, 1, 32); + assertNotNull(sink); + } + + @Test + public void getDroppedPcmFrames_initiallyZero() { + OpusAudioEncoderSink sink = new OpusAudioEncoderSink(); + assertEquals(0L, sink.getDroppedPcmFrames()); + } + + @Test + public void queuePcm_withNullPcm_doesNotThrow() { + OpusAudioEncoderSink sink = new OpusAudioEncoderSink(); + /* No native encoder, no callback — should not NPE */ + sink.queuePcm(null, 0, 0L); + } + + @Test + public void stop_beforePrepare_doesNotThrow() { + OpusAudioEncoderSink sink = new OpusAudioEncoderSink(); + sink.stop(); + } + + @Test + public void implementsAudioEncoderSink() { + assertTrue(new OpusAudioEncoderSink() instanceof AudioEncoderSink); + } +} diff --git a/app/src/test/java/com/foxx/androidcast/media/codec/SpeexAudioEncoderSinkTest.java b/app/src/test/java/com/foxx/androidcast/media/codec/SpeexAudioEncoderSinkTest.java new file mode 100644 index 0000000..c3f0d2b --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/media/codec/SpeexAudioEncoderSinkTest.java @@ -0,0 +1,45 @@ +package com.foxx.androidcast.media.codec; + +import static org.junit.Assert.*; + +import org.junit.Test; + +/** + * Unit tests for SpeexAudioEncoderSink. + * Validates contract on JVM (no native library). + */ +public class SpeexAudioEncoderSinkTest { + + @Test + public void constructor_doesNotThrow() { + SpeexAudioEncoderSink sink = new SpeexAudioEncoderSink(); + assertNotNull(sink); + } + + @Test + public void constructor_customParams() { + SpeexAudioEncoderSink sink = new SpeexAudioEncoderSink(16000, 8); + assertNotNull(sink); + } + + @Test + public void getDroppedPcmFrames_initiallyZero() { + assertEquals(0L, new SpeexAudioEncoderSink().getDroppedPcmFrames()); + } + + @Test + public void queuePcm_withNullPcm_doesNotThrow() { + SpeexAudioEncoderSink sink = new SpeexAudioEncoderSink(); + sink.queuePcm(null, 0, 0L); + } + + @Test + public void stop_beforePrepare_doesNotThrow() { + new SpeexAudioEncoderSink().stop(); + } + + @Test + public void implementsAudioEncoderSink() { + assertTrue(new SpeexAudioEncoderSink() instanceof AudioEncoderSink); + } +} diff --git a/app/src/test/java/com/foxx/androidcast/sfu/SfuRelayGateTest.java b/app/src/test/java/com/foxx/androidcast/sfu/SfuRelayGateTest.java index 13b1916..2113efa 100644 --- a/app/src/test/java/com/foxx/androidcast/sfu/SfuRelayGateTest.java +++ b/app/src/test/java/com/foxx/androidcast/sfu/SfuRelayGateTest.java @@ -1,13 +1,47 @@ package com.foxx.androidcast.sfu; -import static org.junit.Assert.assertFalse; +import static org.junit.Assert.*; import org.junit.Test; public class SfuRelayGateTest { + @Test - public void previewDisabledByDefault() { - assertFalse(SfuRelayGate.PREVIEW_ENABLED); - assertFalse(SfuRelayGate.isAvailable()); + public void previewEnabled_isTrue() { + assertTrue("SfuRelayGate.PREVIEW_ENABLED should be true", SfuRelayGate.PREVIEW_ENABLED); + } + + @Test + public void apiBase_startsWithSlash() { + assertTrue(SfuRelayGate.API_BASE.startsWith("/")); + } + + @Test + public void wsPath_startsWithSlash() { + assertTrue(SfuRelayGate.WS_PATH.startsWith("/")); + } + + @Test + public void videocodecOrder_containsVp8() { + assertTrue(SfuRelayGate.VIDEOCODEC_ORDER.contains("vp8")); + } + + @Test + public void audiocodec_isOpus() { + assertEquals("opus", SfuRelayGate.AUDIOCODEC); + } + + @Test + public void healthUrl_appendsPath() { + String url = SfuRelayGate.healthUrl("https://apps.f0xx.org"); + assertTrue(url.endsWith("/health.php")); + assertTrue(url.contains("sfu")); + } + + @Test + public void healthUrl_trailingSlash() { + String url = SfuRelayGate.healthUrl("https://apps.f0xx.org/"); + /* Path should not have a double-slash before /app/androidcast_project */ + assertFalse("Double-slash found in path", url.contains("org//app")); } } diff --git a/ndk/jni/opus_bridge.c b/ndk/jni/opus_bridge.c index 134d994..957c650 100644 --- a/ndk/jni/opus_bridge.c +++ b/ndk/jni/opus_bridge.c @@ -3,23 +3,255 @@ /********************************************************************* * opus_bridge.c * Created at: Sun 17 May 2026 22:49:49 +0200 - * Updated at: Wed 20 May 2026 15:17:13 +0200 by Anton Afanasyeu - * Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8 + * Updated at: Sun 28 Jun 2026 22:00:00 +0200 by Anton Afanasyeu * Contributors: - * - Anton Afanasyeu (2 commits, 21 lines) + * - Anton Afanasyeu * - Cursor Agent (project assistant) - * Digest: SHA256 401e8483138f40becb1bcd3bfb46693c651c44dbc6f5d85cbcc645df93b51bfb -**********************************************************************/ + **********************************************************************/ #include +#include +#include +#include + +#define LOG_TAG "OpusBridge" +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) #ifndef ANDROIDCAST_HAVE_OPUS #define ANDROIDCAST_HAVE_OPUS 0 #endif +#if ANDROIDCAST_HAVE_OPUS +#include + +#define OPUS_ENC_MAGIC 0x4F505345L /* OPSE */ +#define OPUS_DEC_MAGIC 0x4F505344L /* OPSD */ + +/* Max frame size: 120ms at 48kHz stereo */ +#define OPUS_MAX_FRAME_BYTES 4000 +#define OPUS_MAX_PCM_SAMPLES 5760 + +typedef struct { + unsigned long magic; + OpusEncoder *enc; + int sample_rate; + int channels; + int frame_size; /* samples per frame (e.g. 960 = 20ms @ 48 kHz) */ +} OpusEncCtx; + +typedef struct { + unsigned long magic; + OpusDecoder *dec; + int sample_rate; + int channels; + int frame_size; +} OpusDecCtx; + +static OpusEncCtx *enc_from_handle(jlong h) { + if (h == 0) return NULL; + OpusEncCtx *ctx = (OpusEncCtx *)(intptr_t)h; + if (ctx->magic != OPUS_ENC_MAGIC) return NULL; + return ctx; +} + +static OpusDecCtx *dec_from_handle(jlong h) { + if (h == 0) return NULL; + OpusDecCtx *ctx = (OpusDecCtx *)(intptr_t)h; + if (ctx->magic != OPUS_DEC_MAGIC) return NULL; + return ctx; +} +#endif /* ANDROIDCAST_HAVE_OPUS */ + +/* ── availability probe ───────────────────────────────────────────── */ + JNIEXPORT jboolean JNICALL Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeIsOpusAvailable( JNIEnv *env, jclass clazz) { - (void)env; - (void)clazz; + (void)env; (void)clazz; return ANDROIDCAST_HAVE_OPUS ? JNI_TRUE : JNI_FALSE; } + +/* ── encoder ────────────────────────────────────────────────────────── */ + +JNIEXPORT jlong JNICALL +Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeOpusEncoderCreate( + JNIEnv *env, jclass clazz, + jint sampleRate, jint channels, jint bitrateKbps) { + (void)env; (void)clazz; +#if ANDROIDCAST_HAVE_OPUS + int err = OPUS_OK; + OpusEncoder *enc = opus_encoder_create(sampleRate, channels, OPUS_APPLICATION_VOIP, &err); + if (err != OPUS_OK || enc == NULL) { + LOGE("opus_encoder_create failed: %s", opus_strerror(err)); + return 0L; + } + int bps = bitrateKbps > 0 ? bitrateKbps * 1000 : OPUS_AUTO; + opus_encoder_ctl(enc, OPUS_SET_BITRATE(bps)); + opus_encoder_ctl(enc, OPUS_SET_INBAND_FEC(1)); + opus_encoder_ctl(enc, OPUS_SET_DTX(0)); + opus_encoder_ctl(enc, OPUS_SET_COMPLEXITY(5)); + + OpusEncCtx *ctx = (OpusEncCtx *)calloc(1, sizeof(OpusEncCtx)); + if (!ctx) { opus_encoder_destroy(enc); return 0L; } + ctx->magic = OPUS_ENC_MAGIC; + ctx->enc = enc; + ctx->sample_rate = sampleRate; + ctx->channels = channels; + /* 20 ms frame — e.g. 960 samples @ 48 kHz, 320 @ 16 kHz */ + ctx->frame_size = sampleRate * 20 / 1000; + LOGI("encoder created sr=%d ch=%d fs=%d bps=%d", sampleRate, channels, ctx->frame_size, bps); + return (jlong)(intptr_t)ctx; +#else + (void)sampleRate; (void)channels; (void)bitrateKbps; + return 0L; +#endif +} + +JNIEXPORT jbyteArray JNICALL +Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeOpusEncode( + JNIEnv *env, jclass clazz, + jlong handle, jbyteArray pcm16le, jint length) { + (void)clazz; +#if ANDROIDCAST_HAVE_OPUS + OpusEncCtx *ctx = enc_from_handle(handle); + if (!ctx || pcm16le == NULL || length <= 0) return NULL; + + jbyte *pcm_bytes = (*env)->GetByteArrayElements(env, pcm16le, NULL); + if (!pcm_bytes) return NULL; + + const opus_int16 *pcm = (const opus_int16 *)pcm_bytes; + int samples = length / (2 * ctx->channels); /* bytes → samples per channel */ + if (samples < ctx->frame_size) { + (*env)->ReleaseByteArrayElements(env, pcm16le, pcm_bytes, JNI_ABORT); + return NULL; + } + + unsigned char out_buf[OPUS_MAX_FRAME_BYTES]; + opus_int32 encoded = opus_encode(ctx->enc, pcm, ctx->frame_size, out_buf, sizeof(out_buf)); + (*env)->ReleaseByteArrayElements(env, pcm16le, pcm_bytes, JNI_ABORT); + + if (encoded < 0) { + LOGE("opus_encode: %s", opus_strerror(encoded)); + return NULL; + } + jbyteArray result = (*env)->NewByteArray(env, encoded); + if (result) { + (*env)->SetByteArrayRegion(env, result, 0, encoded, (jbyte *)out_buf); + } + return result; +#else + (void)handle; (void)pcm16le; (void)length; + return NULL; +#endif +} + +JNIEXPORT void JNICALL +Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeOpusEncoderRelease( + JNIEnv *env, jclass clazz, jlong handle) { + (void)env; (void)clazz; +#if ANDROIDCAST_HAVE_OPUS + OpusEncCtx *ctx = enc_from_handle(handle); + if (!ctx) return; + opus_encoder_destroy(ctx->enc); + ctx->magic = 0; + free(ctx); +#else + (void)handle; +#endif +} + +JNIEXPORT jint JNICALL +Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeOpusEncoderGetFrameSize( + JNIEnv *env, jclass clazz, jlong handle) { + (void)env; (void)clazz; +#if ANDROIDCAST_HAVE_OPUS + OpusEncCtx *ctx = enc_from_handle(handle); + return ctx ? ctx->frame_size : 0; +#else + (void)handle; return 0; +#endif +} + +/* ── decoder ────────────────────────────────────────────────────────── */ + +JNIEXPORT jlong JNICALL +Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeOpusDecoderCreate( + JNIEnv *env, jclass clazz, jint sampleRate, jint channels) { + (void)env; (void)clazz; +#if ANDROIDCAST_HAVE_OPUS + int err = OPUS_OK; + OpusDecoder *dec = opus_decoder_create(sampleRate, channels, &err); + if (err != OPUS_OK || dec == NULL) { + LOGE("opus_decoder_create failed: %s", opus_strerror(err)); + return 0L; + } + OpusDecCtx *ctx = (OpusDecCtx *)calloc(1, sizeof(OpusDecCtx)); + if (!ctx) { opus_decoder_destroy(dec); return 0L; } + ctx->magic = OPUS_DEC_MAGIC; + ctx->dec = dec; + ctx->sample_rate = sampleRate; + ctx->channels = channels; + ctx->frame_size = sampleRate * 20 / 1000; + LOGI("decoder created sr=%d ch=%d", sampleRate, channels); + return (jlong)(intptr_t)ctx; +#else + (void)sampleRate; (void)channels; + return 0L; +#endif +} + +JNIEXPORT jbyteArray JNICALL +Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeOpusDecode( + JNIEnv *env, jclass clazz, + jlong handle, jbyteArray opus_data, jint length) { + (void)clazz; +#if ANDROIDCAST_HAVE_OPUS + OpusDecCtx *ctx = dec_from_handle(handle); + if (!ctx) return NULL; + + jbyte *in_bytes = NULL; + int in_len = 0; + if (opus_data != NULL && length > 0) { + in_bytes = (*env)->GetByteArrayElements(env, opus_data, NULL); + in_len = length; + } + + opus_int16 pcm_buf[OPUS_MAX_PCM_SAMPLES]; + int frames = opus_decode(ctx->dec, + in_bytes ? (const unsigned char *)in_bytes : NULL, + in_len, + pcm_buf, OPUS_MAX_PCM_SAMPLES / ctx->channels, 0); + + if (in_bytes) (*env)->ReleaseByteArrayElements(env, opus_data, in_bytes, JNI_ABORT); + + if (frames < 0) { + LOGE("opus_decode: %s", opus_strerror(frames)); + return NULL; + } + + int pcm_bytes = frames * ctx->channels * 2; /* 16-bit PCM */ + jbyteArray result = (*env)->NewByteArray(env, pcm_bytes); + if (result) { + (*env)->SetByteArrayRegion(env, result, 0, pcm_bytes, (jbyte *)pcm_buf); + } + return result; +#else + (void)handle; (void)opus_data; (void)length; + return NULL; +#endif +} + +JNIEXPORT void JNICALL +Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeOpusDecoderRelease( + JNIEnv *env, jclass clazz, jlong handle) { + (void)env; (void)clazz; +#if ANDROIDCAST_HAVE_OPUS + OpusDecCtx *ctx = dec_from_handle(handle); + if (!ctx) return; + opus_decoder_destroy(ctx->dec); + ctx->magic = 0; + free(ctx); +#else + (void)handle; +#endif +} diff --git a/ndk/jni/speex_bridge.c b/ndk/jni/speex_bridge.c index b35eac1..1a67663 100644 --- a/ndk/jni/speex_bridge.c +++ b/ndk/jni/speex_bridge.c @@ -3,23 +3,258 @@ /********************************************************************* * speex_bridge.c * Created at: Sun 17 May 2026 22:49:49 +0200 - * Updated at: Wed 20 May 2026 15:17:13 +0200 by Anton Afanasyeu - * Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8 + * Updated at: Sun 28 Jun 2026 22:00:00 +0200 by Anton Afanasyeu * Contributors: - * - Anton Afanasyeu (2 commits, 21 lines) + * - Anton Afanasyeu * - Cursor Agent (project assistant) - * Digest: SHA256 5085e4a255dcad08ac2eb746ad1d1f64408a191f035d9a6ca83dab896b3e9a09 -**********************************************************************/ + **********************************************************************/ #include +#include +#include +#include + +#define LOG_TAG "SpeexBridge" +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) #ifndef ANDROIDCAST_HAVE_SPEEX #define ANDROIDCAST_HAVE_SPEEX 0 #endif +#if ANDROIDCAST_HAVE_SPEEX +#include +#include + +#define SPEEX_ENC_MAGIC 0x53505845L /* SPXE */ +#define SPEEX_DEC_MAGIC 0x53505844L /* SPXD */ + +/* Speex frame sizes per mode: + * narrowband (8 kHz): 160 samples/frame + * wideband (16 kHz): 320 samples/frame + * ultra-wide (32 kHz): 640 samples/frame */ + +typedef struct { + unsigned long magic; + void *state; + SpeexBits bits; + int sample_rate; + int frame_size; /* samples per frame */ + int encoded_bytes; /* bytes per encoded frame */ +} SpeexEncCtx; + +typedef struct { + unsigned long magic; + void *state; + SpeexBits bits; + int sample_rate; + int frame_size; +} SpeexDecCtx; + +static SpeexEncCtx *enc_from_handle(jlong h) { + if (h == 0) return NULL; + SpeexEncCtx *ctx = (SpeexEncCtx *)(intptr_t)h; + if (ctx->magic != SPEEX_ENC_MAGIC) return NULL; + return ctx; +} + +static SpeexDecCtx *dec_from_handle(jlong h) { + if (h == 0) return NULL; + SpeexDecCtx *ctx = (SpeexDecCtx *)(intptr_t)h; + if (ctx->magic != SPEEX_DEC_MAGIC) return NULL; + return ctx; +} + +static const SpeexMode *mode_for_rate(int sr) { + if (sr <= 8000) return &speex_nb_mode; + if (sr <= 16000) return &speex_wb_mode; + return &speex_uwb_mode; +} +#endif /* ANDROIDCAST_HAVE_SPEEX */ + +/* ── availability probe ───────────────────────────────────────────── */ + JNIEXPORT jboolean JNICALL Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeIsSpeexAvailable( JNIEnv *env, jclass clazz) { - (void)env; - (void)clazz; + (void)env; (void)clazz; return ANDROIDCAST_HAVE_SPEEX ? JNI_TRUE : JNI_FALSE; } + +/* ── encoder ────────────────────────────────────────────────────────── */ + +JNIEXPORT jlong JNICALL +Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeSpeexEncoderCreate( + JNIEnv *env, jclass clazz, jint sampleRate, jint quality) { + (void)env; (void)clazz; +#if ANDROIDCAST_HAVE_SPEEX + const SpeexMode *mode = mode_for_rate(sampleRate); + void *state = speex_encoder_init(mode); + if (!state) { LOGE("speex_encoder_init failed"); return 0L; } + + int q = (quality >= 1 && quality <= 10) ? quality : 8; + speex_encoder_ctl(state, SPEEX_SET_QUALITY, &q); + + /* quality 8 wideband → ~15 kbps */ + int frame_size = 0; + speex_encoder_ctl(state, SPEEX_GET_FRAME_SIZE, &frame_size); + + SpeexEncCtx *ctx = (SpeexEncCtx *)calloc(1, sizeof(SpeexEncCtx)); + if (!ctx) { speex_encoder_destroy(state); return 0L; } + ctx->magic = SPEEX_ENC_MAGIC; + ctx->state = state; + ctx->sample_rate = sampleRate; + ctx->frame_size = frame_size; + speex_bits_init(&ctx->bits); + /* Estimate encoded size: ~38 bytes for quality-8 wideband frame */ + ctx->encoded_bytes = 64; + LOGI("encoder created sr=%d fs=%d q=%d", sampleRate, frame_size, q); + return (jlong)(intptr_t)ctx; +#else + (void)sampleRate; (void)quality; + return 0L; +#endif +} + +JNIEXPORT jbyteArray JNICALL +Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeSpeexEncode( + JNIEnv *env, jclass clazz, + jlong handle, jbyteArray pcm16le, jint length) { + (void)clazz; +#if ANDROIDCAST_HAVE_SPEEX + SpeexEncCtx *ctx = enc_from_handle(handle); + if (!ctx || pcm16le == NULL || length <= 0) return NULL; + + jbyte *pcm_bytes = (*env)->GetByteArrayElements(env, pcm16le, NULL); + if (!pcm_bytes) return NULL; + + speex_bits_reset(&ctx->bits); + speex_encode_int(ctx->state, (spx_int16_t *)pcm_bytes, &ctx->bits); + (*env)->ReleaseByteArrayElements(env, pcm16le, pcm_bytes, JNI_ABORT); + + int nbytes = speex_bits_nbytes(&ctx->bits); + unsigned char *buf = (unsigned char *)malloc(nbytes); + if (!buf) return NULL; + speex_bits_write(&ctx->bits, (char *)buf, nbytes); + + jbyteArray result = (*env)->NewByteArray(env, nbytes); + if (result) { + (*env)->SetByteArrayRegion(env, result, 0, nbytes, (jbyte *)buf); + } + free(buf); + return result; +#else + (void)handle; (void)pcm16le; (void)length; + return NULL; +#endif +} + +JNIEXPORT void JNICALL +Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeSpeexEncoderRelease( + JNIEnv *env, jclass clazz, jlong handle) { + (void)env; (void)clazz; +#if ANDROIDCAST_HAVE_SPEEX + SpeexEncCtx *ctx = enc_from_handle(handle); + if (!ctx) return; + speex_bits_destroy(&ctx->bits); + speex_encoder_destroy(ctx->state); + ctx->magic = 0; + free(ctx); +#else + (void)handle; +#endif +} + +JNIEXPORT jint JNICALL +Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeSpeexEncoderGetFrameSize( + JNIEnv *env, jclass clazz, jlong handle) { + (void)env; (void)clazz; +#if ANDROIDCAST_HAVE_SPEEX + SpeexEncCtx *ctx = enc_from_handle(handle); + return ctx ? ctx->frame_size : 0; +#else + (void)handle; return 0; +#endif +} + +/* ── decoder ────────────────────────────────────────────────────────── */ + +JNIEXPORT jlong JNICALL +Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeSpeexDecoderCreate( + JNIEnv *env, jclass clazz, jint sampleRate) { + (void)env; (void)clazz; +#if ANDROIDCAST_HAVE_SPEEX + const SpeexMode *mode = mode_for_rate(sampleRate); + void *state = speex_decoder_init(mode); + if (!state) { LOGE("speex_decoder_init failed"); return 0L; } + + int enh = 1; + speex_decoder_ctl(state, SPEEX_SET_ENH, &enh); + + int frame_size = 0; + speex_decoder_ctl(state, SPEEX_GET_FRAME_SIZE, &frame_size); + + SpeexDecCtx *ctx = (SpeexDecCtx *)calloc(1, sizeof(SpeexDecCtx)); + if (!ctx) { speex_decoder_destroy(state); return 0L; } + ctx->magic = SPEEX_DEC_MAGIC; + ctx->state = state; + ctx->sample_rate = sampleRate; + ctx->frame_size = frame_size; + speex_bits_init(&ctx->bits); + LOGI("decoder created sr=%d fs=%d", sampleRate, frame_size); + return (jlong)(intptr_t)ctx; +#else + (void)sampleRate; + return 0L; +#endif +} + +JNIEXPORT jbyteArray JNICALL +Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeSpeexDecode( + JNIEnv *env, jclass clazz, + jlong handle, jbyteArray speex_data, jint length) { + (void)clazz; +#if ANDROIDCAST_HAVE_SPEEX + SpeexDecCtx *ctx = dec_from_handle(handle); + if (!ctx) return NULL; + + jbyte *in_bytes = NULL; + if (speex_data != NULL && length > 0) { + in_bytes = (*env)->GetByteArrayElements(env, speex_data, NULL); + speex_bits_read_from(&ctx->bits, (char *)in_bytes, length); + } else { + /* PLC: feed 0 bytes to trigger packet loss concealment */ + speex_bits_reset(&ctx->bits); + } + + spx_int16_t pcm_buf[640]; /* max uwb frame */ + speex_decode_int(ctx->state, in_bytes ? &ctx->bits : NULL, pcm_buf); + + if (in_bytes) (*env)->ReleaseByteArrayElements(env, speex_data, in_bytes, JNI_ABORT); + + int pcm_bytes = ctx->frame_size * 2; /* 16-bit mono */ + jbyteArray result = (*env)->NewByteArray(env, pcm_bytes); + if (result) { + (*env)->SetByteArrayRegion(env, result, 0, pcm_bytes, (jbyte *)pcm_buf); + } + return result; +#else + (void)handle; (void)speex_data; (void)length; + return NULL; +#endif +} + +JNIEXPORT void JNICALL +Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeSpeexDecoderRelease( + JNIEnv *env, jclass clazz, jlong handle) { + (void)env; (void)clazz; +#if ANDROIDCAST_HAVE_SPEEX + SpeexDecCtx *ctx = dec_from_handle(handle); + if (!ctx) return; + speex_bits_destroy(&ctx->bits); + speex_decoder_destroy(ctx->state); + ctx->magic = 0; + free(ctx); +#else + (void)handle; +#endif +}