commit 86e40ebc33d202f5ef74bf345fdf1bed3e20ef16
| author | 斟酌 鵬兄 <tgckpg@gmail.com> |
| date | 2026-10-17T17:24:15Z |
| subject | Gapless next track impl |
commit 86e40ebc33d202f5ef74bf345fdf1bed3e20ef16
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date: 2026-10-17T17:24:15Z
Gapless next track impl
---
.gitignore | 1 +
README.md | 28 +++++++
VALIDATION.md | 19 +++++
.../net/astropenguin/loopdeck/PcmPrebuffer.java | 37 ++++++++
.../net/astropenguin/loopdeck/PlaybackService.java | 98 +++++++++++++++++++++-
.../net/astropenguin/loopdeck/PrefetchSlot.java | 17 ++++
scripts/GaplessTest.java | 58 +++++++++++++
scripts/test-playback-handoff.py | 87 +++++++++++++++++++
8 files changed, 341 insertions(+), 4 deletions(-)
diff --git a/.gitignore b/.gitignore
index 4ef6bcb..d3ac873 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,4 @@ local.properties
.deps/
third_party/ffmpeg/
*.iml
+*.swp
diff --git a/README.md b/README.md
index 0a62444..b0fb072 100644
--- a/README.md
+++ b/README.md
@@ -184,3 +184,31 @@ marks each start; `INDEX 00` pregaps stay with the preceding track. Track positi
use millisecond precision. Tracks reopen/seek the image when advancing; seamless
transitions between tracks are not guaranteed. Embedded metadata support depends
on what the format's FFmpeg demuxer exposes.
+
+## Continuous albums / next-track buffering
+
+LoopDeck prepares about one second of the next queued track on a separate worker.
+For consecutive tracks with the same source sample rate, the prepared PCM is fed
+straight into the existing AudioTrack after the current track's last samples.
+The output is kept running, with no crossfade or added silence. Track titles and
+the playback position switch when the audio playback head reaches the boundary.
+This also applies to consecutive CUE tracks within an album image.
+
+Only the next track is prepared. Each stereo-float prefix uses about 345 KiB at
+44.1 kHz or 1.47 MiB at 192 kHz; a playing prefix and the next prefix can coexist.
+Seeking, choosing another track, stopping and activating an A–B loop invalidate
+pending handoffs. A sample-rate change or unavailable preload uses the normal
+track-loading path. Extremely short tracks, slow storage or decoding that cannot
+keep up can still underrun. Encoded silence in the files is preserved; codec
+padding handling depends on FFmpeg and the files' metadata.
+
+Host tests (JDK 17+):
+
+```sh
+python3 scripts/test-playback-handoff.py
+java -m jdk.compiler/com.sun.tools.javac.Main -d /tmp/loopdeck-tests \
+ app/src/main/java/net/astropenguin/loopdeck/PcmPrebuffer.java \
+ app/src/main/java/net/astropenguin/loopdeck/PrefetchSlot.java \
+ scripts/GaplessTest.java
+java -cp /tmp/loopdeck-tests net.astropenguin.loopdeck.GaplessTest
+```
diff --git a/VALIDATION.md b/VALIDATION.md
index 27366b5..db8cd3f 100644
--- a/VALIDATION.md
+++ b/VALIDATION.md
@@ -103,3 +103,22 @@ Host checks for this change:
invalid timestamps, duplicate indices, reversed starts and non-audio entries.
- Existing `LoopMathTest` passed.
- All application Java files passed the JDK parser; `git diff --check` passed.
+
+## Next-track buffering patch (base 64bee3e)
+
+- `GaplessTest` passed: exact stereo sample order across prefix/live reads,
+ irregular read sizes, short and empty tracks, explicit CUE limits, cancelled
+ preparation, resource ownership and 200 cancellation/publication races.
+- `test-playback-handoff.py` compiles the actual PlaybackService against fake
+ Android/native IO and simulates playback. Three tracks concatenate sample for
+ sample through one AudioTrack, with no intermediate flush/recreation. Metadata
+ follows the audible boundary. Seeking while the next track is queued discards
+ that handoff and restores the outgoing decoder; all decoder handles close.
+- Existing CUE parsing and loop arithmetic tests passed; application Java syntax
+ and `git diff --check` passed.
+
+These are host checks. No APK was built and no real AudioTrack/codec/storage
+playback was tested for this change. On the M11S, check a continuous lossless album
+with the screen off, seek/mark A–B close to a file boundary, pause/resume during a
+handoff, skip tracks, stop, and try a sample-rate change. Confirm the final samples
+of the last track play and no stale preloaded audio appears after a seek.
diff --git a/app/src/main/java/net/astropenguin/loopdeck/PcmPrebuffer.java b/app/src/main/java/net/astropenguin/loopdeck/PcmPrebuffer.java
new file mode 100644
index 0000000..317380d
--- /dev/null
+++ b/app/src/main/java/net/astropenguin/loopdeck/PcmPrebuffer.java
@@ -0,0 +1,37 @@
+package net.astropenguin.loopdeck;
+
+import java.io.IOException;
+import java.util.function.BooleanSupplier;
+
+/** A bounded stereo prefix. After preparation, ownership passes to playback. */
+final class PcmPrebuffer {
+ interface Reader { int read(float[] buffer,int frames) throws IOException; }
+ private final Reader reader;
+ private final long limit;
+ private final float[] prefix;
+ private int filled, offset;
+ private long consumed;
+ PcmPrebuffer(Reader reader,int frames,long limit) {
+ this.reader=reader; this.limit=limit; prefix=new float[Math.multiplyExact(frames,2)];
+ }
+ void prepare(BooleanSupplier cancelled) throws IOException {
+ float[] scratch=new float[4096];
+ while (filled<prefix.length && !cancelled.getAsBoolean()) {
+ int requested=(int)Math.min(Math.min(scratch.length/2,(prefix.length-filled)/2),limit-filled/2);
+ if (requested<=0) break;
+ int n=reader.read(scratch,requested);
+ if (n==0) break;
+ System.arraycopy(scratch,0,prefix,filled,n*2); filled+=n*2;
+ }
+ }
+ int read(float[] output,int frames) throws IOException {
+ frames=(int)Math.min(frames,limit-consumed);
+ if (frames<=0) return 0;
+ int n;
+ if (offset<filled) {
+ n=Math.min(frames,(filled-offset)/2);
+ System.arraycopy(prefix,offset,output,0,n*2); offset+=n*2;
+ } else n=reader.read(output,frames);
+ consumed+=n; return n;
+ }
+}
diff --git a/app/src/main/java/net/astropenguin/loopdeck/PlaybackService.java b/app/src/main/java/net/astropenguin/loopdeck/PlaybackService.java
index 98321e2..86e69a2 100644
--- a/app/src/main/java/net/astropenguin/loopdeck/PlaybackService.java
+++ b/app/src/main/java/net/astropenguin/loopdeck/PlaybackService.java
@@ -9,11 +9,14 @@ import android.os.*;
import java.io.IOException;
import java.util.*;
import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public final class PlaybackService extends Service {
private static final String CHANNEL = "playback";
private static final int NOTIFICATION = 1;
+ private static final int NEXT_TRACK_BUFFER_SECONDS = 1;
public final class LocalBinder extends Binder { PlaybackService service() { return PlaybackService.this; } }
private final LocalBinder binder = new LocalBinder();
private interface Command { void run() throws Exception; }
@@ -30,7 +33,20 @@ public final class PlaybackService extends Service {
private long decoder;
private AudioTrack output;
private List<Track> queue = Collections.emptyList();
- private int index = -1, rate;
+ private int index = -1, rate, sourceRate;
+ private final ExecutorService prefetcher=Executors.newSingleThreadExecutor();
+ private PrefetchSlot<Prepared> nextSlot;
+ private Prepared transition;
+ private long transitionAt;
+ private PcmPrebuffer activePrefix;
+
+ private static final class Prepared implements AutoCloseable {
+ long handle, start, end, duration, cursor;
+ int index, sourceRate;
+ String format;
+ PcmPrebuffer buffer;
+ @Override public void close() { if (handle!=0) { NativeDecoder.close(handle); handle=0; } }
+ }
private long sourceStart, trackEnd;
private long duration, a = -1, b = -1, cursor, base, written, headEpoch, lastHead;
private boolean playing, loop, eof, resumeOnFocus;
@@ -154,7 +170,7 @@ public final class PlaybackService extends Service {
if (file==null) throw new IOException("Cannot open selected file");
decoder=NativeDecoder.open(file.getFd());
}
- int sourceRate=NativeDecoder.sourceRate(decoder);
+ sourceRate=NativeDecoder.sourceRate(decoder);
Track selectedTrack=queue.get(index);
long sourceDuration=NativeDecoder.duration(decoder);
long end=selectedTrack.endMs;
@@ -169,7 +185,8 @@ public final class PlaybackService extends Service {
}
NativeDecoder.outputRate(decoder,rate);
sourceStart=LoopMath.frame(selectedTrack.startMs,rate);
- trackEnd=end<0 ? Long.MAX_VALUE : LoopMath.frame(end,rate)-sourceStart;
+ // Whole files must drain to actual decoder EOF: durationMs rounds off samples.
+ trackEnd=selectedTrack.endMs<0 ? Long.MAX_VALUE : LoopMath.frame(end,rate)-sourceStart;
if (sourceStart>0) NativeDecoder.seek(decoder,sourceStart);
format += "\nOutput: stereo float PCM · " + rate + " Hz (requested)";
cursor=base=written=headEpoch=lastHead=0; blockSamples=blockOffset=0; eof=false;
@@ -177,6 +194,61 @@ public final class PlaybackService extends Service {
session.setMetadata(new MediaMetadata.Builder().putString(MediaMetadata.METADATA_KEY_TITLE,title)
.putLong(MediaMetadata.METADATA_KEY_DURATION,duration).build());
setPlaying(true);
+ prepareNext();
+ }
+ private void cancelNext() {
+ if (nextSlot!=null) { nextSlot.cancel(); nextSlot=null; }
+ if (transition!=null) { transition.close(); transition=null; }
+ }
+ private void prepareNext() {
+ if (nextSlot!=null || transition!=null || loop || decoder==0 || index+1>=queue.size()) return;
+ final int nextIndex=index+1, outputRate=rate, expectedSourceRate=sourceRate;
+ final Track track=queue.get(nextIndex);
+ final PrefetchSlot<Prepared> slot=new PrefetchSlot<>(); nextSlot=slot;
+ prefetcher.submit(() -> {
+ android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_BACKGROUND);
+ Prepared ready=new Prepared();
+ try {
+ if (slot.cancelled()) return;
+ try (ParcelFileDescriptor file=getContentResolver().openFileDescriptor(track.uri,"r")) {
+ if (file==null) return;
+ ready.handle=NativeDecoder.open(file.getFd());
+ }
+ ready.sourceRate=NativeDecoder.sourceRate(ready.handle);
+ // Preserve the source-rate policy; incompatible tracks use normal load().
+ if (ready.sourceRate!=expectedSourceRate || slot.cancelled()) return;
+ long total=NativeDecoder.duration(ready.handle), end=track.endMs;
+ if (total>0) end=end<0 ? total : Math.min(end,total);
+ if (end>=0 && end<=track.startMs) return;
+ ready.duration=end<0 ? 0 : end-track.startMs;
+ ready.start=LoopMath.frame(track.startMs,outputRate);
+ ready.end=track.endMs<0 ? Long.MAX_VALUE : LoopMath.frame(end,outputRate)-ready.start;
+ ready.index=nextIndex;
+ ready.format=NativeDecoder.description(ready.handle)+"\nOutput: stereo float PCM · "+outputRate+" Hz (requested)";
+ NativeDecoder.outputRate(ready.handle,outputRate);
+ if (ready.start>0) NativeDecoder.seek(ready.handle,ready.start);
+ final long handle=ready.handle;
+ ready.buffer=new PcmPrebuffer((pcm,frames) -> NativeDecoder.read(handle,pcm,frames),NEXT_TRACK_BUFFER_SECONDS*outputRate,ready.end);
+ ready.buffer.prepare(slot::cancelled);
+ slot.offer(ready); ready=null; // Slot now owns this decoder, including cancellation.
+ } catch (Exception | LinkageError | OutOfMemoryError ignored) {
+ // Retry through regular load at the boundary; don't interrupt the current track.
+ } finally { if (ready!=null) ready.close(); }
+ });
+ }
+ private void adoptTransition() {
+ if (transition==null || playedFrames()<transitionAt) return;
+ Prepared ready=transition; transition=null;
+ NativeDecoder.close(decoder); decoder=ready.handle; ready.handle=0;
+ index=ready.index; sourceStart=ready.start; trackEnd=ready.end; duration=ready.duration;
+ sourceRate=ready.sourceRate; format=ready.format; title=queue.get(index).name;
+ activePrefix=ready.buffer; cursor=ready.cursor;
+ // Keep AudioTrack and its cumulative playback head/written count intact.
+ base=-transitionAt; a=b=-1; loop=false; cache=null; eof=false;
+ status="Ready · continuous playback";
+ session.setMetadata(new MediaMetadata.Builder().putString(MediaMetadata.METADATA_KEY_TITLE,title)
+ .putLong(MediaMetadata.METADATA_KEY_DURATION,duration).build());
+ publish(); updateSession(); prepareNext();
}
private AudioTrack createOutput(int hz) {
int minimum=AudioTrack.getMinBufferSize(hz,AudioFormat.CHANNEL_OUT_STEREO,AudioFormat.ENCODING_PCM_FLOAT);
@@ -225,6 +297,7 @@ public final class PlaybackService extends Service {
private long positionFrames() { return output==null ? 0 : LoopMath.position(base,playedFrames(),a,b,loop); }
private void resetAt(long target) throws IOException {
// A seek invalidates decoder state as well as queued PCM. One worker owns both.
+ cancelNext(); activePrefix=null;
boolean wasPlaying=playing;
output.pause(); output.flush(); headEpoch=lastHead=written=0;
base=cursor=target; blockSamples=blockOffset=0; eof=false;
@@ -234,6 +307,7 @@ public final class PlaybackService extends Service {
NativeDecoder.seek(decoder,sourceStart+target);
}
if (wasPlaying) output.play();
+ prepareNext();
}
private boolean pump() throws Exception {
if (blockOffset<blockSamples) {
@@ -243,7 +317,20 @@ public final class PlaybackService extends Service {
blockOffset+=n; written+=n/2;
return n>0;
}
+ if (transition!=null) {
+ int count=transition.buffer.read(block,block.length/2);
+ transition.cursor+=count; blockSamples=count*2; blockOffset=0;
+ return count>0;
+ }
if (eof) {
+ if (nextSlot!=null) {
+ Prepared ready=nextSlot.take();
+ if (ready!=null) {
+ nextSlot.cancel(); nextSlot=null;
+ transition=ready; transitionAt=written;
+ return true; // Queue the prepared prefix directly after the outgoing samples.
+ }
+ }
if (playedFrames()>=written) {
if (index+1<queue.size()) load(index+1);
else { setPlaying(false); status="End of queue"; }
@@ -263,7 +350,7 @@ public final class PlaybackService extends Service {
count=Math.min(requested,(cache.length-cacheRead)/2);
System.arraycopy(cache,cacheRead,block,0,count*2); cacheRead+=count*2;
} else {
- count=NativeDecoder.read(decoder,block,requested);
+ count=activePrefix!=null ? activePrefix.read(block,requested) : NativeDecoder.read(decoder,block,requested);
if (cache!=null && count>0) { System.arraycopy(block,0,cache,cacheFilled,count*2); cacheFilled+=count*2; }
}
if (count==0) {
@@ -278,6 +365,7 @@ public final class PlaybackService extends Service {
try {
while (running) {
try {
+ adoptTransition();
Command command=commands.poll(playing ? 0 : 100,TimeUnit.MILLISECONDS);
if (command!=null) { command.run(); publish(); updateSession(); }
if (playing && output!=null && !pump()) Thread.sleep(5);
@@ -293,6 +381,7 @@ public final class PlaybackService extends Service {
} finally { releaseAudio(); session.setActive(false); session.release(); }
}
private void releaseAudio() {
+ cancelNext(); activePrefix=null;
playing=false; cache=null; blockSamples=blockOffset=0;
if (output!=null) { output.pause(); output.flush(); output.release(); output=null; }
if (decoder!=0) { NativeDecoder.close(decoder); decoder=0; }
@@ -328,6 +417,7 @@ public final class PlaybackService extends Service {
}
@Override public void onDestroy() {
unregisterReceiver(noisy); running=false; worker.interrupt();
+ prefetcher.shutdownNow();
// Native IO may still be unwinding; the worker releases its own resources.
super.onDestroy();
}
diff --git a/app/src/main/java/net/astropenguin/loopdeck/PrefetchSlot.java b/app/src/main/java/net/astropenguin/loopdeck/PrefetchSlot.java
new file mode 100644
index 0000000..15c2017
--- /dev/null
+++ b/app/src/main/java/net/astropenguin/loopdeck/PrefetchSlot.java
@@ -0,0 +1,17 @@
+package net.astropenguin.loopdeck;
+
+/** Exactly one owner closes each result, even when cancellation races preparation. */
+final class PrefetchSlot<T extends AutoCloseable> {
+ private boolean cancelled;
+ private T value;
+ synchronized boolean cancelled() { return cancelled; }
+ synchronized void offer(T result) {
+ if (cancelled) close(result);
+ else value=result;
+ }
+ synchronized T take() { T result=value; value=null; return result; }
+ synchronized void cancel() { cancelled=true; close(value); value=null; }
+ private static void close(AutoCloseable value) {
+ if (value!=null) try { value.close(); } catch (Exception ignored) { }
+ }
+}
diff --git a/scripts/GaplessTest.java b/scripts/GaplessTest.java
new file mode 100644
index 0000000..af29685
--- /dev/null
+++ b/scripts/GaplessTest.java
@@ -0,0 +1,58 @@
+package net.astropenguin.loopdeck;
+
+import java.util.*;
+import java.util.concurrent.*;
+
+public final class GaplessTest {
+ private static void expect(boolean value) { if (!value) throw new AssertionError(); }
+ private static final class Source implements PcmPrebuffer.Reader {
+ int position, calls; final int frames;
+ Source(int frames) { this.frames=frames; }
+ public int read(float[] output,int requested) {
+ calls++; int n=Math.min(Math.min(requested,37),frames-position);
+ for (int i=0;i<n;i++) { output[i*2]=position+i; output[i*2+1]=-(position+i); }
+ position+=n; return n;
+ }
+ }
+ private static void stream(int total,int prefix,long limit) throws Exception {
+ Source source=new Source(total); PcmPrebuffer buffer=new PcmPrebuffer(source,prefix,limit);
+ buffer.prepare(() -> false);
+ int calls=source.calls, expected=0; float[] block=new float[202];
+ Random random=new Random(1);
+ while (true) {
+ int n=buffer.read(block,1+random.nextInt(101));
+ if (n==0) break;
+ for (int i=0;i<n;i++) {
+ expect(block[i*2]==expected && block[i*2+1]==-expected); expected++;
+ }
+ if (expected<Math.min(total,Math.min(prefix,limit))) expect(source.calls==calls);
+ }
+ expect(expected==Math.min(total,limit));
+ expect(buffer.read(block,101)==0);
+ }
+ private static final class Resource implements AutoCloseable {
+ int closed;
+ public void close() { closed++; expect(closed==1); }
+ }
+ public static void main(String[] args) throws Exception {
+ stream(12345,4410,Long.MAX_VALUE); // Prefix to live decoding, irregular reads.
+ stream(12345,4410,5217); // An explicit CUE boundary must not leak samples.
+ stream(113,4410,Long.MAX_VALUE); // Entire short track fits in the prefix.
+ stream(12345,4410,173); // CUE track shorter than the prefix.
+ stream(0,4410,Long.MAX_VALUE);
+ Source source=new Source(12345);
+ new PcmPrebuffer(source,4410,Long.MAX_VALUE).prepare(() -> true);
+ expect(source.calls==0);
+ PrefetchSlot<Resource> slot=new PrefetchSlot<>(); Resource result=new Resource();
+ slot.offer(result); expect(slot.take()==result); slot.cancel(); expect(result.closed==0); result.close();
+ slot=new PrefetchSlot<>(); result=new Resource(); slot.offer(result); slot.cancel(); slot.cancel(); expect(result.closed==1);
+ slot=new PrefetchSlot<>(); slot.cancel(); result=new Resource(); slot.offer(result); expect(result.closed==1 && slot.take()==null);
+ for (int i=0;i<200;i++) {
+ final PrefetchSlot<Resource> race=new PrefetchSlot<>(); final Resource resource=new Resource();
+ CountDownLatch start=new CountDownLatch(1);
+ Thread producer=new Thread(() -> { try { start.await(); race.offer(resource); } catch (InterruptedException e) { throw new AssertionError(e); } });
+ producer.start(); start.countDown(); race.cancel(); producer.join(); expect(resource.closed==1);
+ }
+ System.out.println("Gapless PCM continuity, boundaries, cancellation and ownership passed");
+ }
+}
diff --git a/scripts/test-playback-handoff.py b/scripts/test-playback-handoff.py
new file mode 100644
index 0000000..868f067
--- /dev/null
+++ b/scripts/test-playback-handoff.py
@@ -0,0 +1,87 @@
+#!/usr/bin/env python3
+"""Host simulation of the real playback service; uses fake Android and native IO.
+Requires JDK 17+. It does not test real AudioTrack timing or codecs.
+"""
+from pathlib import Path
+import tempfile, subprocess
+work=tempfile.TemporaryDirectory(prefix='loopdeck-playback-')
+root=Path(work.name)
+repo=Path(__file__).resolve().parents[1]
+def put(name,text):
+ p=root/(name+'.java');p.parent.mkdir(parents=True,exist_ok=True);p.write_text(text)
+classes={
+'android/content/Context':'public static final String AUDIO_SERVICE="audio",POWER_SERVICE="power",NOTIFICATION_SERVICE="notification"; public Object getSystemService(String s){ return s.equals(AUDIO_SERVICE)?new android.media.AudioManager():s.equals(POWER_SERVICE)?new android.os.PowerManager():new android.app.NotificationManager(); } public ContentResolver getContentResolver(){return new ContentResolver();} public void registerReceiver(BroadcastReceiver r,IntentFilter f){} public void unregisterReceiver(BroadcastReceiver r){}',
+'android/content/ContentResolver':'public android.os.ParcelFileDescriptor openFileDescriptor(android.net.Uri u,String mode){return new android.os.ParcelFileDescriptor(u.id);}',
+'android/content/Intent':'String action; public Intent(Context c,Class<?> t){} public Intent setAction(String a){action=a;return this;} public String getAction(){return action;}',
+'android/content/IntentFilter':'public IntentFilter(String a){}',
+'android/content/BroadcastReceiver':'public abstract void onReceive(Context c,Intent i);',
+'android/app/Service':'public static final int START_NOT_STICKY=2; public void onCreate(){} public android.os.IBinder onBind(android.content.Intent i){return null;} public int onStartCommand(android.content.Intent i,int f,int id){return 0;} public void onDestroy(){} public android.os.Looper getMainLooper(){return new android.os.Looper();} public void startForeground(int i,Notification n){} public void stopForeground(boolean b){} public void stopSelf(){}',
+'android/app/NotificationManager':'public static final int IMPORTANCE_LOW=1; public void createNotificationChannel(NotificationChannel c){} public void notify(int i,Notification n){}',
+'android/app/NotificationChannel':'public NotificationChannel(String a,String b,int c){}',
+'android/app/PendingIntent':'public static final int FLAG_UPDATE_CURRENT=1,FLAG_IMMUTABLE=2; public static PendingIntent getService(android.content.Context c,int i,android.content.Intent n,int f){return new PendingIntent();} public static PendingIntent getActivity(android.content.Context c,int i,android.content.Intent n,int f){return new PendingIntent();}',
+'android/os/IBinder':'',
+'android/os/Binder':'',
+'android/os/Looper':'',
+'android/os/Handler':'public Handler(Looper l){} public void post(Runnable r){r.run();}',
+'android/os/PowerManager':'public static final int PARTIAL_WAKE_LOCK=1; public WakeLock newWakeLock(int a,String b){return new WakeLock();} public static class WakeLock {boolean held; public void setReferenceCounted(boolean b){} public boolean isHeld(){return held;} public void acquire(){held=true;} public void release(){held=false;}}',
+'android/os/Process':'public static final int THREAD_PRIORITY_AUDIO=-16,THREAD_PRIORITY_BACKGROUND=10; public static void setThreadPriority(int p){}',
+'android/os/SystemClock':'public static long elapsedRealtime(){return System.nanoTime()/1000000;}',
+'android/os/ParcelFileDescriptor':'int fd; public ParcelFileDescriptor(int id){fd=id;} public int getFd(){return fd;} public void close(){}',
+'android/net/Uri':'public final int id; public Uri(int id){this.id=id;}',
+'android/media/AudioManager':'public static final int AUDIOFOCUS_GAIN=1,AUDIOFOCUS_REQUEST_GRANTED=1,AUDIOFOCUS_LOSS=-1,AUDIOFOCUS_LOSS_TRANSIENT=-2,AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK=-3; public static final String ACTION_AUDIO_BECOMING_NOISY="noisy"; public int requestAudioFocus(AudioFocusRequest f){return 1;} public void abandonAudioFocusRequest(AudioFocusRequest f){}',
+'android/media/AudioFocusRequest':'public static class Builder { public Builder(int i){} public Builder setAudioAttributes(AudioAttributes a){return this;} public Builder setOnAudioFocusChangeListener(java.util.function.IntConsumer c,android.os.Handler h){return this;} public AudioFocusRequest build(){return new AudioFocusRequest();}}',
+'android/media/AudioAttributes':'public static final int USAGE_MEDIA=1,CONTENT_TYPE_MUSIC=1; public static class Builder{ public Builder setUsage(int i){return this;} public Builder setContentType(int i){return this;} public AudioAttributes build(){return new AudioAttributes();}}',
+'android/media/AudioFormat':'public static final int CHANNEL_OUT_STEREO=2,ENCODING_PCM_FLOAT=4; public static class Builder{public Builder setSampleRate(int i){return this;} public Builder setEncoding(int i){return this;} public Builder setChannelMask(int i){return this;} public AudioFormat build(){return new AudioFormat();}}',
+'android/media/MediaMetadata':'public static final String METADATA_KEY_TITLE="title",METADATA_KEY_DURATION="duration"; public static class Builder{public Builder putString(String k,String v){return this;} public Builder putLong(String k,long v){return this;} public MediaMetadata build(){return new MediaMetadata();}}',
+'android/media/session/MediaSession':'public MediaSession(android.content.Context c,String s){} public void setCallback(Callback c){} public void setActive(boolean b){} public void release(){} public Object getSessionToken(){return this;} public void setMetadata(android.media.MediaMetadata m){} public void setPlaybackState(PlaybackState p){} public static class Callback{public void onPlay(){} public void onPause(){} public void onStop(){} public void onSkipToNext(){} public void onSkipToPrevious(){} public void onSeekTo(long p){}}',
+'android/media/session/PlaybackState':'public static final long ACTION_PLAY=1,ACTION_PAUSE=2,ACTION_PLAY_PAUSE=4,ACTION_STOP=8,ACTION_SEEK_TO=16,ACTION_SKIP_TO_NEXT=32,ACTION_SKIP_TO_PREVIOUS=64; public static final int STATE_PLAYING=1,STATE_PAUSED=2; public static class Builder{public Builder setActions(long a){return this;} public Builder setState(int s,long p,float v){return this;} public PlaybackState build(){return new PlaybackState();}}',
+'android/R':'public static class drawable{public static final int ic_media_previous=1,ic_media_pause=2,ic_media_play=3,ic_media_next=4,ic_menu_close_clear_cancel=5;}',
+'net/astropenguin/loopdeck/R':'public static class drawable{public static final int ic_music=1;}',
+'net/astropenguin/loopdeck/MainActivity':'',
+}
+for name,body in classes.items():
+ pkg,cls=name.rsplit('/',1); modifier='abstract ' if cls=='BroadcastReceiver' else ''; kind='interface' if cls=='IBinder' else 'class';extends=' extends android.content.Context' if cls=='Service' else ' implements IBinder' if cls=='Binder' else ' implements AutoCloseable' if cls=='ParcelFileDescriptor' else ''
+ put(name,f'package {pkg.replace("/",".")}; public {modifier}{kind} {cls}{extends} '+'{'+body+'}')
+put('android/app/Notification','''package android.app;
+public class Notification {
+public static final int VISIBILITY_PUBLIC=1;
+public static class Builder {
+public Builder(android.content.Context c,String ch){}
+public Builder setSmallIcon(int i){return this;} public Builder setContentTitle(String s){return this;} public Builder setContentText(String s){return this;} public Builder setContentIntent(PendingIntent p){return this;} public Builder setOnlyAlertOnce(boolean b){return this;} public Builder setOngoing(boolean b){return this;} public Builder setVisibility(int i){return this;} public Builder addAction(Action a){return this;} public Builder setStyle(MediaStyle m){return this;} public Notification build(){return new Notification();}
+}
+public static class Action{ public static class Builder{public Builder(int i,String s,PendingIntent p){} public Action build(){return new Action();}}}
+public static class MediaStyle{public MediaStyle setMediaSession(Object o){return this;} public MediaStyle setShowActionsInCompactView(int... i){return this;}}
+}''')
+put('android/media/AudioTrack','''package android.media;
+public class AudioTrack {
+public static final int STATE_INITIALIZED=1,MODE_STREAM=1,WRITE_NON_BLOCKING=1;
+public static int created,flushed; public long head,written; public boolean playing;
+public final java.util.ArrayList<Float> samples=new java.util.ArrayList<>();
+public static int getMinBufferSize(int a,int b,int c){return 4096;}
+public int getState(){return 1;} public void release(){} public void pause(){playing=false;} public void play(){playing=true;}
+public void flush(){flushed++;head=written=0; samples.clear();} public void setVolume(float f){} public int getPlaybackHeadPosition(){return (int)head;}
+public int write(float[] data,int offset,int count,int mode){int n=(int)Math.min(count,Math.max(0,4410-(written-head))*2);for(int i=0;i<n;i++) samples.add(data[offset+i]);written+=n/2;return n;}
+public void advance(int frames){if(playing)head=Math.min(written,head+frames);}
+public static class Builder{public Builder setAudioAttributes(AudioAttributes a){return this;}public Builder setAudioFormat(AudioFormat a){return this;}public Builder setBufferSizeInBytes(int i){return this;}public Builder setTransferMode(int i){return this;}public AudioTrack build(){created++;return new AudioTrack();}}
+}''')
+put('net/astropenguin/loopdeck/NativeDecoder','''package net.astropenguin.loopdeck;
+final class NativeDecoder {
+static long seq; static final java.util.concurrent.ConcurrentHashMap<Long,int[]> states=new java.util.concurrent.ConcurrentHashMap<>();
+static synchronized long open(int fd){long h=++seq;states.put(h,new int[]{fd,0});return h;}
+static void close(long h){if(states.remove(h)==null)throw new AssertionError("double close");}
+static int sourceRate(long h){return 44100;}
+static long duration(long h){return size(states.get(h)[0])*1000/44100;}
+static int size(int id){return id==1?7003:id==2?9007:6001;}
+static String description(long h){return "fake lossless";}
+static void outputRate(long h,int r){}
+static void seek(long h,long frame){states.get(h)[1]=(int)frame;}
+static int read(long h,float[] out,int frames){int[] s=states.get(h);int n=Math.min(frames,size(s[0])-s[1]);for(int i=0;i<n;i++){out[i*2]=s[0]*100000+s[1];out[i*2+1]=-out[i*2];s[1]++;}return n;}
+}''')
+
+put("net/astropenguin/loopdeck/PlaybackHandoffTest",'package net.astropenguin.loopdeck;\nimport java.lang.reflect.*;\nimport java.util.*;\nimport android.media.*;\nimport android.media.session.*;\nimport android.os.*;\nimport android.net.*;\npublic class PlaybackHandoffTest {\n static Field field(Object o,String name)throws Exception{Field f=o.getClass().getDeclaredField(name);f.setAccessible(true);return f;}\n static Object get(Object o,String name)throws Exception{return field(o,name).get(o);}\n static void set(Object o,String name,Object value)throws Exception{field(o,name).set(o,value);}\n static Object call(Object o,String name,Class<?>[] types,Object...args)throws Exception{Method m=o.getClass().getDeclaredMethod(name,types);m.setAccessible(true);try{return m.invoke(o,args);}catch(InvocationTargetException e){throw new AssertionError(name,e.getCause());}}\n static Object call(Object o,String name)throws Exception{return call(o,name,new Class<?>[0]);}\n static void expect(boolean b){if(!b)throw new AssertionError();}\n static PlaybackService create(List<Track> queue)throws Exception{\n PlaybackService s=new PlaybackService();set(s,"audioManager",new AudioManager());set(s,"wake",new PowerManager().newWakeLock(1,"test"));set(s,"session",new MediaSession(s,"test"));set(s,"queue",queue);call(s,"load",new Class<?>[]{int.class},0);return s;\n }\n static void ready(PlaybackService s)throws Exception{\n Object slot=get(s,"nextSlot");if(slot==null)return;\n long end=System.nanoTime()+2_000_000_000L;\n while(get(slot,"value")==null&&System.nanoTime()<end)Thread.sleep(1);\n expect(get(slot,"value")!=null);\n }\n static void finish(PlaybackService s)throws Exception{call(s,"releaseAudio");((java.util.concurrent.ExecutorService)get(s,"prefetcher")).shutdown();expect(((java.util.concurrent.ExecutorService)get(s,"prefetcher")).awaitTermination(2,java.util.concurrent.TimeUnit.SECONDS));expect(NativeDecoder.states.isEmpty());}\n public static void main(String[]args)throws Exception{\n List<Track> queue=List.of(new Track(new Uri(1),"one"),new Track(new Uri(2),"two"),new Track(new Uri(3),"three"));\n PlaybackService s=create(queue);ready(s);AudioTrack out=(AudioTrack)get(s,"output");int created=AudioTrack.created,flushed=AudioTrack.flushed;\n int lastIndex=0;\n for(int step=0;step<10000;step++){\n call(s,"adoptTransition");int index=(int)get(s,"index");if(index!=lastIndex){ready(s);lastIndex=index;}\n call(s,"pump");call(s,"publish");\n // Metadata/position must follow the audible track, even while next PCM is queued.\n int audible=out.head<7003?0:out.head<16010?1:2;\n if(out.head!=7003 && out.head!=16010)expect(((int)get(s,"index"))==audible);\n out.advance(64);\n if(!(boolean)get(s,"playing"))break;\n }\n expect(AudioTrack.created==created && AudioTrack.flushed==flushed);\n expect(out.samples.size()==(7003+9007+6001)*2);\n int pos=0;for(int id=1;id<=3;id++)for(int i=0;i<NativeDecoder.size(id);i++){\n expect(out.samples.get(pos++)==id*100000+i);expect(out.samples.get(pos++)==-(id*100000+i));\n }\n expect(s.snapshot.position==NativeDecoder.size(3)*1000L/44100);\n finish(s);\n s=create(queue);ready(s);out=(AudioTrack)get(s,"output");\n // Reach a queued transition while the old track is still audible, then seek.\n for(int step=0;step<10000 && get(s,"transition")==null;step++){call(s,"pump");out.advance(16);}\n expect(get(s,"transition")!=null && out.head<7003);\n call(s,"pump"); // Put some prefetched samples into the output queue.\n call(s,"resetAt",new Class<?>[]{long.class},1000L);\n expect(get(s,"transition")==null && (int)get(s,"index")==0);\n call(s,"pump");call(s,"pump");\n expect(out.samples.get(0)==101000f); // Outgoing decoder is correctly restored.\n finish(s);\n System.out.println("Playback service: three-track exact concatenation, single AudioTrack, audible metadata, seek cancellation and resource cleanup passed");\n }\n}\n')
+
+app=repo/'app/src/main/java/net/astropenguin/loopdeck'
+classes=root/'classes'
+files=list(root.rglob('*.java'))+[app/(name+'.java') for name in ('PlaybackService','Track','LoopMath','PcmPrebuffer','PrefetchSlot')]
+subprocess.run(['java','-m','jdk.compiler/com.sun.tools.javac.Main','-d',str(classes),*map(str,files)],check=True)
+subprocess.run(['java','-cp',str(classes),'net.astropenguin.loopdeck.PlaybackHandoffTest'],check=True)