mirror of
git://f0xx.org/ac/ac-mobile-android
synced 2026-07-29 04:18:25 +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) {
|
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();
|
||||||
|
case SPEEX_NATIVE:
|
||||||
|
return new SpeexAudioEncoderSink();
|
||||||
|
case PASSTHROUGH_DEBUG:
|
||||||
|
case OPUS_STUB:
|
||||||
|
case SPEEX_STUB:
|
||||||
return new PassthroughAudioEncoderSink(new AudioEncoder(), preference);
|
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 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
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"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user