mirror of
git://f0xx.org/ac/ac-mobile-android
synced 2026-07-29 01:47:35 +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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <a.afanasieff@gmail.com>
|
||||
* Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8
|
||||
* Updated at: Sun 28 Jun 2026 22:00:00 +0200 by Anton Afanasyeu <a.afanasieff@gmail.com>
|
||||
* Contributors:
|
||||
* - Anton Afanasyeu <a.afanasieff@gmail.com> (2 commits, 21 lines)
|
||||
* - Anton Afanasyeu <a.afanasieff@gmail.com>
|
||||
* - Cursor Agent (project assistant)
|
||||
* Digest: SHA256 401e8483138f40becb1bcd3bfb46693c651c44dbc6f5d85cbcc645df93b51bfb
|
||||
**********************************************************************/
|
||||
**********************************************************************/
|
||||
#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
|
||||
#define ANDROIDCAST_HAVE_OPUS 0
|
||||
#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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 <a.afanasieff@gmail.com>
|
||||
* Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8
|
||||
* Updated at: Sun 28 Jun 2026 22:00:00 +0200 by Anton Afanasyeu <a.afanasieff@gmail.com>
|
||||
* Contributors:
|
||||
* - Anton Afanasyeu <a.afanasieff@gmail.com> (2 commits, 21 lines)
|
||||
* - Anton Afanasyeu <a.afanasieff@gmail.com>
|
||||
* - Cursor Agent (project assistant)
|
||||
* Digest: SHA256 5085e4a255dcad08ac2eb746ad1d1f64408a191f035d9a6ca83dab896b3e9a09
|
||||
**********************************************************************/
|
||||
**********************************************************************/
|
||||
#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
|
||||
#define ANDROIDCAST_HAVE_SPEEX 0
|
||||
#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
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user