1
0
mirror of git://f0xx.org/ac/ac-mobile-android synced 2026-07-29 03:37:51 +03:00

codecs: real Opus + Speex JNI encode/decode; SFU gate enabled

- 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 <cursoragent@cursor.com>
This commit is contained in:
Anton Afanasyeu
2026-06-28 22:27:44 +02:00
parent 43f3014f2c
commit f6e69153ca
14 changed files with 1274 additions and 38 deletions

View File

@@ -28,11 +28,17 @@ public final class AudioCodecFactory {
public static AudioEncoderSink createEncoder(CastSettings.AudioCodec preference) { public static AudioEncoderSink createEncoder(CastSettings.AudioCodec preference) {
CodecSessionRegistry.setAudioPreference(preference); CodecSessionRegistry.setAudioPreference(preference);
AudioCodecBackend backend = PassthroughCodecPolicy.audioBackendFor(preference, true); AudioCodecBackend backend = PassthroughCodecPolicy.audioBackendFor(preference, true);
if (backend == AudioCodecBackend.SPEEX_STUB switch (backend) {
|| backend == AudioCodecBackend.OPUS_STUB case OPUS_NATIVE:
|| backend == AudioCodecBackend.PASSTHROUGH_DEBUG) { return new OpusAudioEncoderSink();
return new PassthroughAudioEncoderSink(new AudioEncoder(), preference); 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();
} }
} }

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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 nativeIsLibvpxAvailable();
private static native boolean nativeIsOpusAvailable(); 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 boolean nativeVpxDecodeFrame(long handle, byte[] data, long ptsUs, Surface surface);
private static native void nativeVpxDecoderRelease(long handle); 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);
} }

View File

@@ -34,7 +34,7 @@ import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
/** AAC decoder → AudioTrack; low-latency live playback (no sender-PTS sleep). */ /** 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 TAG = "AudioDecoder";
private static final String MIME = MediaFormat.MIMETYPE_AUDIO_AAC; private static final String MIME = MediaFormat.MIMETYPE_AUDIO_AAC;
private static final int MAX_INPUT_QUEUE = 48; private static final int MAX_INPUT_QUEUE = 48;

View File

@@ -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();
}

View File

@@ -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<PendingFrame> 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);
}
}

View File

@@ -83,7 +83,7 @@ public class ReceiverCastService extends Service {
private DiscoveryManager discovery; private DiscoveryManager discovery;
private ReceiverSession session; private ReceiverSession session;
private VideoDecoderSink videoDecoder; private VideoDecoderSink videoDecoder;
private AudioDecoder audioDecoder; private AudioDecoderSink audioDecoder;
private Surface attachedSurface; private Surface attachedSurface;
private String status = ""; private String status = "";
private CastSettings settings = new CastSettings(); private CastSettings settings = new CastSettings();
@@ -260,9 +260,7 @@ public class ReceiverCastService extends Service {
} }
videoDecoder = VideoDecoderFactory.createDecoder(); videoDecoder = VideoDecoderFactory.createDecoder();
synchronized (audioDecoderLock) { synchronized (audioDecoderLock) {
audioDecoder = new AudioDecoder(this); audioDecoder = buildDefaultAudioDecoder();
audioDecoder.setRenderCallback((pcmBytes, levels) -> streamMetrics.onAudioRendered(
pcmBytes, levels.peak, levels.rms, levels.silent));
} }
session = new ReceiverSession(this, pin, settings, new ReceiverSession.Listener() { session = new ReceiverSession(this, pin, settings, new ReceiverSession.Listener() {
@@ -431,10 +429,33 @@ public class ReceiverCastService extends Service {
sessionStatsRecorder.setClientCount(1); sessionStatsRecorder.setClientCount(1);
} }
sessionStatsRecorder.mergeSettings(remoteSettings); sessionStatsRecorder.mergeSettings(remoteSettings);
sessionStatsRecorder.setCodecs(videoMime, "AAC"); sessionStatsRecorder.setCodecs(videoMime, resolveAudioCodecLabel(remoteSettings));
} }
negotiatedVideoMime = videoMime; 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, LiveCastControlPlane.onCastStart(this, "android_receiver", videoMime, audioCodec,
settings.getBitrateMode().name().toLowerCase()); settings.getBitrateMode().name().toLowerCase());
remoteCastSettings = remoteSettings; remoteCastSettings = remoteSettings;
@@ -469,14 +490,19 @@ public class ReceiverCastService extends Service {
synchronized (audioDecoderLock) { synchronized (audioDecoderLock) {
if (audioDecoder != null) { if (audioDecoder != null) {
audioDecoder.flushAndStop(); audioDecoder.flushAndStop();
audioDecoder = new AudioDecoder(this); audioDecoder = buildDefaultAudioDecoder();
audioDecoder.setRenderCallback((pcmBytes, levels) -> streamMetrics.onAudioRendered(
pcmBytes, levels.peak, levels.rms, levels.silent));
} }
} }
Log.i(TAG, "Audio playback stopped"); 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() { private void onSenderDisconnected() {
stopAudioPlaybackImmediate(); stopAudioPlaybackImmediate();
phase = Phase.LISTENING; phase = Phase.LISTENING;
@@ -585,9 +611,7 @@ public class ReceiverCastService extends Service {
synchronized (audioDecoderLock) { synchronized (audioDecoderLock) {
if (audioDecoder != null) { if (audioDecoder != null) {
audioDecoder.release(); audioDecoder.release();
audioDecoder = new AudioDecoder(this); audioDecoder = buildDefaultAudioDecoder();
audioDecoder.setRenderCallback((pcmBytes, levels) -> streamMetrics.onAudioRendered(
pcmBytes, levels.peak, levels.rms, levels.silent));
} }
} }
updateStatus(getString(R.string.audio_skipped)); updateStatus(getString(R.string.audio_skipped));
@@ -1059,4 +1083,15 @@ public class ReceiverCastService extends Service {
NetworkSelfTestBridge.stopCapture(this); NetworkSelfTestBridge.stopCapture(this);
super.onDestroy(); 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";
}
}
} }

View File

@@ -7,12 +7,29 @@ import com.foxx.androidcast.CastConfig;
* Not exposed in release settings while {@link CastConfig#ALPHA_FEATURE_FREEZE} is on. * Not exposed in release settings while {@link CastConfig#ALPHA_FEATURE_FREEZE} is on.
*/ */
public final class SfuRelayGate { public final class SfuRelayGate {
/** Compile-time preview; must stay false until owner spec + BE SFU is ready. */ /** SFU preview enabled — Janus 1.4.1 deployed on cast02, signaling on cast01. */
public static final boolean PREVIEW_ENABLED = false; 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() {} private SfuRelayGate() {}
public static boolean isAvailable() { public static boolean isAvailable() {
return PREVIEW_ENABLED && !CastConfig.ALPHA_FEATURE_FREEZE; 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";
}
} }

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -1,13 +1,47 @@
package com.foxx.androidcast.sfu; package com.foxx.androidcast.sfu;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.*;
import org.junit.Test; import org.junit.Test;
public class SfuRelayGateTest { public class SfuRelayGateTest {
@Test @Test
public void previewDisabledByDefault() { public void previewEnabled_isTrue() {
assertFalse(SfuRelayGate.PREVIEW_ENABLED); assertTrue("SfuRelayGate.PREVIEW_ENABLED should be true", SfuRelayGate.PREVIEW_ENABLED);
assertFalse(SfuRelayGate.isAvailable()); }
@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"));
} }
} }

View File

@@ -3,23 +3,255 @@
/********************************************************************* /*********************************************************************
* opus_bridge.c * opus_bridge.c
* Created at: Sun 17 May 2026 22:49:49 +0200 * Created at: Sun 17 May 2026 22:49:49 +0200
* Updated at: Wed 20 May 2026 15:17:13 +0200 by Anton Afanasyeu <a.afanasieff@gmail.com> * Updated at: Sun 28 Jun 2026 22:00:00 +0200 by Anton Afanasyeu <a.afanasieff@gmail.com>
* Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8
* Contributors: * Contributors:
* - Anton Afanasyeu <a.afanasieff@gmail.com> (2 commits, 21 lines) * - Anton Afanasyeu <a.afanasieff@gmail.com>
* - Cursor Agent (project assistant) * - Cursor Agent (project assistant)
* Digest: SHA256 401e8483138f40becb1bcd3bfb46693c651c44dbc6f5d85cbcc645df93b51bfb **********************************************************************/
**********************************************************************/
#include <jni.h> #include <jni.h>
#include <stdlib.h>
#include <string.h>
#include <android/log.h>
#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 #ifndef ANDROIDCAST_HAVE_OPUS
#define ANDROIDCAST_HAVE_OPUS 0 #define ANDROIDCAST_HAVE_OPUS 0
#endif #endif
#if ANDROIDCAST_HAVE_OPUS
#include <opus/opus.h>
#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 JNIEXPORT jboolean JNICALL
Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeIsOpusAvailable( Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeIsOpusAvailable(
JNIEnv *env, jclass clazz) { JNIEnv *env, jclass clazz) {
(void)env; (void)env; (void)clazz;
(void)clazz;
return ANDROIDCAST_HAVE_OPUS ? JNI_TRUE : JNI_FALSE; 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
}

View File

@@ -3,23 +3,258 @@
/********************************************************************* /*********************************************************************
* speex_bridge.c * speex_bridge.c
* Created at: Sun 17 May 2026 22:49:49 +0200 * Created at: Sun 17 May 2026 22:49:49 +0200
* Updated at: Wed 20 May 2026 15:17:13 +0200 by Anton Afanasyeu <a.afanasieff@gmail.com> * Updated at: Sun 28 Jun 2026 22:00:00 +0200 by Anton Afanasyeu <a.afanasieff@gmail.com>
* Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8
* Contributors: * Contributors:
* - Anton Afanasyeu <a.afanasieff@gmail.com> (2 commits, 21 lines) * - Anton Afanasyeu <a.afanasieff@gmail.com>
* - Cursor Agent (project assistant) * - Cursor Agent (project assistant)
* Digest: SHA256 5085e4a255dcad08ac2eb746ad1d1f64408a191f035d9a6ca83dab896b3e9a09 **********************************************************************/
**********************************************************************/
#include <jni.h> #include <jni.h>
#include <stdlib.h>
#include <string.h>
#include <android/log.h>
#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 #ifndef ANDROIDCAST_HAVE_SPEEX
#define ANDROIDCAST_HAVE_SPEEX 0 #define ANDROIDCAST_HAVE_SPEEX 0
#endif #endif
#if ANDROIDCAST_HAVE_SPEEX
#include <speex/speex.h>
#include <speex/speex_header.h>
#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 JNIEXPORT jboolean JNICALL
Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeIsSpeexAvailable( Java_com_foxx_androidcast_media_codec_jni_NativeCodecBridge_nativeIsSpeexAvailable(
JNIEnv *env, jclass clazz) { JNIEnv *env, jclass clazz) {
(void)env; (void)env; (void)clazz;
(void)clazz;
return ANDROIDCAST_HAVE_SPEEX ? JNI_TRUE : JNI_FALSE; 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
}