aboutsummaryrefslogtreecommitdiff
path: root/app/src/main/java/invalid/lena/scrcpy/AudioSink.java
diff options
context:
space:
mode:
authorLena <lena@omega>2026-01-01 00:00:00 +0000
committerLena <lena@omega>2026-06-24 22:14:50 +0300
commiteb0c8951196c637e44daf3c0617b131b997d5d2c (patch)
tree2f83b6a41cee745467e53fc401942b82cea286ea /app/src/main/java/invalid/lena/scrcpy/AudioSink.java
downloadscrcpy-android-eb0c8951196c637e44daf3c0617b131b997d5d2c.tar.gz
scrcpy-android: mirror an Android device over wireless ADB0.1
Native Java app for Android 12+ that mirrors another Android device over wireless ADB, forwarding video, audio, touch input, and clipboard. Bundles a pinned scrcpy-server.jar and the vendored libadb-android stack. Supports h264/h265/av1 video and raw/opus audio with in-app codec selection. No NDK, no Kotlin. Includes JVM unit tests and a Docker-based emulator e2e rig.
Diffstat (limited to 'app/src/main/java/invalid/lena/scrcpy/AudioSink.java')
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/AudioSink.java215
1 files changed, 215 insertions, 0 deletions
diff --git a/app/src/main/java/invalid/lena/scrcpy/AudioSink.java b/app/src/main/java/invalid/lena/scrcpy/AudioSink.java
new file mode 100644
index 0000000..2b3e32d
--- /dev/null
+++ b/app/src/main/java/invalid/lena/scrcpy/AudioSink.java
@@ -0,0 +1,215 @@
+package invalid.lena.scrcpy;
+
+import android.media.AudioAttributes;
+import android.media.AudioFormat;
+import android.media.AudioManager;
+import android.media.AudioTrack;
+import android.media.MediaCodec;
+import android.media.MediaFormat;
+import android.os.Handler;
+import android.os.HandlerThread;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+
+// Audio output: AudioTrack writing 48 kHz stereo 16-bit PCM. The
+// upstream feed is either raw PCM (passthrough) or Opus packets
+// (decoded through MediaCodec into PCM first).
+public final class AudioSink implements AudioFrames {
+
+ private static final int SAMPLE_RATE = 48_000;
+ private static final int CHANNEL_OUT = AudioFormat.CHANNEL_OUT_STEREO;
+ private static final int ENCODING = AudioFormat.ENCODING_PCM_16BIT;
+
+ // Defaults documented at <https://developer.android.com/reference/android/media/MediaCodec#CSD>.
+ private static final long DEFAULT_PRE_ROLL_NS = 80_000_000L;
+
+ private AudioTrack track;
+ private MediaCodec opusCodec;
+ private HandlerThread opusThread;
+ private Handler opusHandler;
+ private boolean opusConfigured;
+ private int fourcc;
+
+ private volatile boolean released;
+ public volatile long frames; // public read for the status overlay
+ private long droppedBytes;
+ private long lastDropLogMs;
+
+ @Override
+ public void start(int fourcc) {
+ this.fourcc = fourcc;
+
+ int minBuf = AudioTrack.getMinBufferSize(SAMPLE_RATE, CHANNEL_OUT, ENCODING);
+ if (minBuf <= 0) throw new IllegalStateException("AudioTrack.getMinBufferSize=" + minBuf);
+ int bufSize = Math.max(minBuf, 32 * 1024);
+
+ AudioAttributes attrs = new AudioAttributes.Builder()
+ .setUsage(AudioAttributes.USAGE_MEDIA)
+ .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
+ .build();
+ AudioFormat fmt = new AudioFormat.Builder()
+ .setSampleRate(SAMPLE_RATE)
+ .setChannelMask(CHANNEL_OUT)
+ .setEncoding(ENCODING)
+ .build();
+
+ track = new AudioTrack(
+ attrs, fmt, bufSize, AudioTrack.MODE_STREAM, AudioManager.AUDIO_SESSION_ID_GENERATE);
+ track.play();
+ Log.i("audio sink: AudioTrack started sr=%d ch=2 buf=%d", SAMPLE_RATE, bufSize);
+
+ if (fourcc == Wire.CODEC_OPUS) {
+ opusThread = new HandlerThread("audio-mc");
+ opusThread.start();
+ opusHandler = new Handler(opusThread.getLooper());
+ // Codec is created here, but only configured once the first
+ // FLAG_CONFIG frame arrives with the OpusHead bytes.
+ try {
+ opusCodec = MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS);
+ } catch (IOException e) {
+ throw new IllegalStateException("audio sink: no opus decoder", e);
+ }
+ opusCodec.setCallback(new MediaCodec.Callback() {
+ @Override public void onInputBufferAvailable(MediaCodec mc, int idx) {
+ // Frames are queued synchronously from feed(); we
+ // don't pull on this callback. This handler exists
+ // so MediaCodec's async machinery is wired up.
+ }
+ @Override public void onOutputBufferAvailable(MediaCodec mc, int idx,
+ MediaCodec.BufferInfo info) {
+ try {
+ ByteBuffer out = mc.getOutputBuffer(idx);
+ if (out != null && info.size > 0 && !released) {
+ byte[] pcm = new byte[info.size];
+ out.position(info.offset);
+ out.limit(info.offset + info.size);
+ out.get(pcm);
+ writePcm(pcm, 0, pcm.length);
+ }
+ } catch (IllegalStateException ignored) {
+ } finally {
+ try { mc.releaseOutputBuffer(idx, false); }
+ catch (IllegalStateException ignored) {}
+ }
+ }
+ @Override public void onError(MediaCodec mc, MediaCodec.CodecException e) {
+ Log.e(e, "audio sink: opus codec error");
+ }
+ @Override public void onOutputFormatChanged(MediaCodec mc, MediaFormat f) {
+ Log.i("audio sink: opus output format %s", f);
+ }
+ }, opusHandler);
+ }
+ }
+
+ @Override
+ public void feed(byte[] data, int off, int len, boolean isConfig) {
+ if (released || len <= 0) return;
+ if (fourcc == Wire.CODEC_OPUS) {
+ feedOpus(data, off, len, isConfig);
+ } else {
+ // Raw PCM: passthrough.
+ frames++;
+ writePcm(data, off, len);
+ }
+ }
+
+ private void feedOpus(byte[] data, int off, int len, boolean isConfig) {
+ if (isConfig) {
+ if (opusConfigured) return; // already configured
+ // OpusHead is 19 bytes; pre_skip lives at bytes [10..11]. Reject
+ // anything shorter before indexing into it.
+ if (len < 19) {
+ Log.e("audio sink: opus head too short (%d bytes)", len);
+ return;
+ }
+ try {
+ byte[] head = new byte[len];
+ System.arraycopy(data, off, head, 0, len);
+ int preSkipSamples = ((head[10] & 0xff) | ((head[11] & 0xff) << 8));
+ long preSkipNs = preSkipSamples * 1_000_000_000L / SAMPLE_RATE;
+
+ MediaFormat fmt = MediaFormat.createAudioFormat(
+ MediaFormat.MIMETYPE_AUDIO_OPUS, SAMPLE_RATE, 2);
+ fmt.setByteBuffer("csd-0", ByteBuffer.wrap(head));
+ fmt.setByteBuffer("csd-1", longLeBytes(preSkipNs));
+ fmt.setByteBuffer("csd-2", longLeBytes(DEFAULT_PRE_ROLL_NS));
+ opusCodec.configure(fmt, null, null, 0);
+ opusCodec.start();
+ opusConfigured = true;
+ Log.i("audio sink: opus configured, pre_skip=%d ns", preSkipNs);
+ } catch (Exception e) {
+ Log.e(e, "audio sink: opus configure");
+ }
+ return;
+ }
+ if (!opusConfigured) return;
+ // Encoded packet → MediaCodec input.
+ int idx;
+ try { idx = opusCodec.dequeueInputBuffer(0); }
+ catch (IllegalStateException e) { return; }
+ if (idx < 0) {
+ // No input buffer right now; drop the packet. Opus is forgiving
+ // about gaps for short stalls.
+ return;
+ }
+ try {
+ ByteBuffer in = opusCodec.getInputBuffer(idx);
+ if (in == null) return;
+ in.clear();
+ in.put(data, off, len);
+ opusCodec.queueInputBuffer(idx, 0, len, 0, 0);
+ frames++;
+ } catch (IllegalStateException e) {
+ Log.w("audio sink: opus queueInputBuffer: %s", e);
+ }
+ }
+
+ // Encode a long little-endian for MediaFormat csd-1 / csd-2.
+ private static ByteBuffer longLeBytes(long v) {
+ ByteBuffer b = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(v);
+ b.flip();
+ return b;
+ }
+
+ private void writePcm(byte[] data, int off, int len) {
+ AudioTrack t = track;
+ if (released || t == null || len <= 0) return;
+ int written = t.write(data, off, len, AudioTrack.WRITE_NON_BLOCKING);
+ if (written < 0) {
+ Log.w("audio sink: write rc=%d", written);
+ return;
+ }
+ if (written < len) {
+ droppedBytes += (len - written);
+ long now = System.currentTimeMillis();
+ if (now - lastDropLogMs > 1000L) {
+ lastDropLogMs = now;
+ Log.w("audio sink: ring full, dropped %d bytes so far", droppedBytes);
+ }
+ }
+ }
+
+ @Override
+ public void release() {
+ released = true;
+ MediaCodec c = opusCodec;
+ opusCodec = null;
+ HandlerThread ht = opusThread;
+ opusThread = null;
+ opusHandler = null;
+ if (c != null) {
+ try { c.stop(); } catch (Exception ignored) {}
+ try { c.release(); } catch (Exception ignored) {}
+ }
+ if (ht != null) ht.quitSafely();
+
+ AudioTrack t = track;
+ track = null;
+ if (t == null) return;
+ try { t.pause(); t.flush(); t.stop(); } catch (Exception ignored) {}
+ try { t.release(); } catch (Exception ignored) {}
+ }
+}