penguin/loopdeck

A simple player that support A-B looping for FiiO M11S

commit ae2a86b024caee08195d4a6934356ac906382938

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-10-19T05:25:03Z
subjectFixed underrun
commit ae2a86b024caee08195d4a6934356ac906382938
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-10-19T05:25:03Z

    Fixed underrun
---
 VALIDATION.md                                      | 23 +++++++++++++++++
 .../net/astropenguin/loopdeck/BluetoothLink.java   | 30 +++++++++++++++++-----
 .../net/astropenguin/loopdeck/PlaybackService.java | 24 ++++++++++++++---
 .../astropenguin/loopdeck/PlayerController.java    |  2 +-
 scripts/test-bluetooth.py                          | 23 +++++++++++++++++
 scripts/test-playback-handoff.py                   | 11 ++++----
 6 files changed, 98 insertions(+), 15 deletions(-)

diff --git a/VALIDATION.md b/VALIDATION.md
index 66e5faf..751cd53 100644
--- a/VALIDATION.md
+++ b/VALIDATION.md
@@ -196,3 +196,26 @@ Bluetooth throughput, permissions and vendor background behavior remain unverifi
 
 No APK was built. Device UI, permission dialogs and real Bluetooth remain checks
 for the owner after applying the patch.
+
+## Bluetooth cadence and playback starvation mitigation (base 65a388d)
+
+- Server position corrections now arrive at most once per two seconds; changes
+  to transport, metadata and loop markers bypass that throttle. The client
+  estimates position locally, clamps at track duration and wraps A–B loops.
+  Unchanged states are not resent; the client writer blocks until a command.
+- Requested AudioTrack buffering increased from 100 ms to one second at the
+  selected output rate. Full-queue retries now wait up to 20 ms rather than 5 ms.
+  `LoopDeck` warnings report AudioTrack underrun counter increases and PCM reads
+  taking at least 100 ms. These are diagnostics, not proof of a particular cause.
+- The Bluetooth host integration test passed, including bounded correction
+  frequency during advancing playback, pause delivery bypassing the throttle,
+  stopped extrapolation on pause and loop position wrapping.
+- The playback host test passed using the requested buffer capacity, including
+  native 96 kHz/fallback 48 kHz buffer sizes, silent startup, EOF replay, exact
+  gapless samples, audible track metadata, seek cancellation and resource cleanup.
+
+No APK was built. Device temperature, CPU load, actual AudioTrack capacity and
+real underrun recovery require hardware measurements. Compare the same files
+with Server enabled/disabled and collect `adb logcat -s LoopDeck:W` if a gap recurs.
+A one-second output queue cannot hide stalls longer than the audio queued at the
+moment they occur.
diff --git a/app/src/main/java/net/astropenguin/loopdeck/BluetoothLink.java b/app/src/main/java/net/astropenguin/loopdeck/BluetoothLink.java
index 4759dd4..03c3b53 100644
--- a/app/src/main/java/net/astropenguin/loopdeck/BluetoothLink.java
+++ b/app/src/main/java/net/astropenguin/loopdeck/BluetoothLink.java
@@ -17,6 +17,7 @@ import net.astropenguin.loopdeck.MusicLibrary.Entry;
 final class BluetoothLink implements AutoCloseable {
     private static final UUID UUID_SERVICE=UUID.fromString("aca23821-cd14-47c2-9d9f-c821ef5c3aa0");
     static final int LOCAL=0, SERVER=1, CLIENT=2, PAGE_SIZE=50;
+    private static final long POSITION_UPDATE_MS=2000;
     private final PlaybackService player;
     private final Handler main=new Handler(Looper.getMainLooper());
     private final BluetoothAdapter adapter;
@@ -28,6 +29,7 @@ final class BluetoothLink implements AutoCloseable {
     volatile boolean connected;
     volatile String status="Bluetooth off", error="";
     volatile PlaybackService.Snapshot remote=emptySnapshot();
+    private long remoteUpdatedAt;
     volatile Listing listing=new Listing(0,"Connect to a player",false,0,0,Collections.emptyList());
 
     static final class Item {
@@ -127,6 +129,16 @@ final class BluetoothLink implements AutoCloseable {
         if (!peer.out.offer(message.toString())) { error="Bluetooth is busy; reconnect"; peer.close(); return false; }
         return true;
     }
+    synchronized PlaybackService.Snapshot remoteSnapshot() {
+        PlaybackService.Snapshot s=remote;
+        if (!connected || !s.playing) return s;
+        // Smooth the remote UI locally; stop estimating if fresh state stops arriving.
+        long elapsed=Math.min(POSITION_UPDATE_MS+500,Math.max(0,android.os.SystemClock.elapsedRealtime()-remoteUpdatedAt));
+        long position=s.position+elapsed;
+        if (s.loop && s.a>=0 && s.b>s.a && position>=s.b) position=s.a+(position-s.a)%(s.b-s.a);
+        if (s.duration>0) position=Math.min(position,s.duration);
+        return new PlaybackService.Snapshot(s.title,s.format,s.status,position,s.duration,s.a,s.b,s.playing,s.loop,s.loaded);
+    }
     void command(String name,Object... args) {
         error="";
         JSONObject json=message(name,args);
@@ -188,17 +200,22 @@ final class BluetoothLink implements AutoCloseable {
             } finally { main.removeCallbacks(timeout); }
         }
         void write() {
-            String previous=""; long last=0;
+            String previous="",previousDetails=""; long last=0;
             try {
                 DataOutputStream output=new DataOutputStream(socket.getOutputStream());
                 while (alive && current(run)) {
-                    String value=out.poll(250,TimeUnit.MILLISECONDS);
+                    String value=role==SERVER ? out.poll(500,TimeUnit.MILLISECONDS) : out.take();
                     if (value!=null) RemoteWire.write(output,value);
                     long now=android.os.SystemClock.elapsedRealtime();
-                    if (role==SERVER && ready && now-last>=500) {
-                        String state=state(player.snapshot).toString();
-                        if (!state.equals(previous)) { RemoteWire.write(output,state); previous=state; }
-                        last=now;
+                    if (role==SERVER && ready) {
+                        JSONObject update=state(player.snapshot);
+                        String state=update.toString();
+                        update.remove("position");
+                        String details=update.toString();
+                        // Track/transport/marker changes bypass the position-only throttle.
+                        if (!state.equals(previous) && (!details.equals(previousDetails) || now-last>=POSITION_UPDATE_MS)) {
+                            RemoteWire.write(output,state); previous=state; previousDetails=details; last=now;
+                        }
                     }
                 }
             } catch (Exception e) { if (alive && current(run)) error=detail(e); }
@@ -212,6 +229,7 @@ final class BluetoothLink implements AutoCloseable {
                     remote=new PlaybackService.Snapshot(value.getString("title"),value.getString("format"),value.getString("status"),
                         value.getLong("position"),value.getLong("duration"),value.getLong("a"),value.getLong("b"),
                         value.getBoolean("playing"),value.getBoolean("loop"),value.getBoolean("loaded"));
+                    remoteUpdatedAt=android.os.SystemClock.elapsedRealtime();
                     if (!remote.title.equals(previous.title) || remote.playing!=previous.playing || remote.loaded!=previous.loaded || remote.duration!=previous.duration)
                         player.bluetoothChanged();
                     break;
diff --git a/app/src/main/java/net/astropenguin/loopdeck/PlaybackService.java b/app/src/main/java/net/astropenguin/loopdeck/PlaybackService.java
index 4949c61..09d4149 100644
--- a/app/src/main/java/net/astropenguin/loopdeck/PlaybackService.java
+++ b/app/src/main/java/net/astropenguin/loopdeck/PlaybackService.java
@@ -16,6 +16,7 @@ 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 PLAYBACK_BUFFER_SECONDS = 1;
     private static final int NEXT_TRACK_BUFFER_SECONDS = 1;
     private static final int OUTPUT_WARMUP_SECONDS = 1;
     public final class LocalBinder extends Binder { PlaybackService service() { return PlaybackService.this; } }
@@ -59,6 +60,7 @@ public final class PlaybackService extends Service {
     private float[] cache;
     private int cacheFilled, cacheRead;
     private long lastPublish, lastSession;
+    private int lastUnderruns;
 
     static final class Snapshot {
         final String title, format, status;
@@ -285,8 +287,9 @@ public final class PlaybackService extends Service {
             .setUsage(AudioAttributes.USAGE_MEDIA).setContentType(AudioAttributes.CONTENT_TYPE_MUSIC).build())
             .setAudioFormat(new AudioFormat.Builder().setSampleRate(hz).setEncoding(AudioFormat.ENCODING_PCM_FLOAT)
                 .setChannelMask(AudioFormat.CHANNEL_OUT_STEREO).build())
-            .setBufferSizeInBytes(Math.max(minimum,hz / 10 * 8)).setTransferMode(AudioTrack.MODE_STREAM).build();
+            .setBufferSizeInBytes(Math.max(minimum,hz * PLAYBACK_BUFFER_SECONDS * 8)).setTransferMode(AudioTrack.MODE_STREAM).build();
         if (result.getState()!=AudioTrack.STATE_INITIALIZED) { result.release(); throw new IllegalArgumentException("Audio output initialization failed"); }
+        lastUnderruns=0;
         return result;
     }
     private void setPlaying(boolean value) throws IOException {
@@ -394,7 +397,10 @@ 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 {
+            long started=SystemClock.elapsedRealtime();
             count=activePrefix!=null ? activePrefix.read(block,requested) : NativeDecoder.read(decoder,block,requested);
+            long elapsed=SystemClock.elapsedRealtime()-started;
+            if (elapsed>=100) android.util.Log.w("LoopDeck", "Slow PCM read: "+elapsed+" ms, rate="+rate);
             if (cache!=null && count>0) { System.arraycopy(block,0,cache,cacheFilled,count*2); cacheFilled+=count*2; }
         }
         if (count==0) {
@@ -412,9 +418,21 @@ public final class PlaybackService extends Service {
                     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);
+                    if (playing && output!=null && !pump()) {
+                        // A full one-second output queue needs no 5 ms busy retry.
+                        // Keep commands responsive and wake well before queued audio drains.
+                        long queued=Math.max(0,written-playedFrames());
+                        Thread.sleep(Math.max(1,Math.min(20,queued*500/Math.max(1,rate))));
+                    }
                     long now=SystemClock.elapsedRealtime();
-                    if (now-lastPublish>=200) { publish(); lastPublish=now; }
+                    if (now-lastPublish>=200) {
+                        if (output!=null) {
+                            int underruns=output.getUnderrunCount();
+                            if (underruns>lastUnderruns) android.util.Log.w("LoopDeck", "AudioTrack underrun: +"+(underruns-lastUnderruns)+", rate="+rate);
+                            lastUnderruns=underruns;
+                        }
+                        publish(); lastPublish=now;
+                    }
                     if (now-lastSession>=1000) { updateSession(false); lastSession=now; }
                 } catch (InterruptedException e) { if (!running) break; }
                 catch (Exception | LinkageError e) {
diff --git a/app/src/main/java/net/astropenguin/loopdeck/PlayerController.java b/app/src/main/java/net/astropenguin/loopdeck/PlayerController.java
index d216e0d..cdb85e0 100644
--- a/app/src/main/java/net/astropenguin/loopdeck/PlayerController.java
+++ b/app/src/main/java/net/astropenguin/loopdeck/PlayerController.java
@@ -70,7 +70,7 @@ final class PlayerController implements AutoCloseable {
     boolean bluetoothEnabled() { return bluetooth.mode!=BluetoothLink.LOCAL; }
     boolean available() { return !remote() || bluetooth.connected; }
     boolean hasMusicFolder() { return tree!=null; }
-    PlaybackService.Snapshot snapshot() { return remote() ? bluetooth.remote : player.snapshot; }
+    PlaybackService.Snapshot snapshot() { return remote() ? bluetooth.remoteSnapshot() : player.snapshot; }
     String bluetoothStatus() { return bluetooth.status; }
     String status() {
         String error=!bluetooth.error.isEmpty() ? "Bluetooth: "+bluetooth.error : remote() ? "" : browserError;
diff --git a/scripts/test-bluetooth.py b/scripts/test-bluetooth.py
index 4b6b925..9428663 100644
--- a/scripts/test-bluetooth.py
+++ b/scripts/test-bluetooth.py
@@ -123,6 +123,29 @@ public final class BluetoothTest {
    client.start(BluetoothLink.CLIENT,"fiio");await(()->client.connected && server.connected && client.listing.total==128);
    expect(client.listing.items.size()==BluetoothLink.PAGE_SIZE);
    await(()->client.remote.title.equals("日本語🎵"));expect(client.remote.position==1234 && client.remote.playing);
+   // A steadily advancing position produces at most one correction per two seconds.
+   PlaybackService.Snapshot seen=client.remote;int corrections=0;
+   long started=android.os.SystemClock.elapsedRealtime();
+   while(android.os.SystemClock.elapsedRealtime()-started<2600){
+    long elapsed=android.os.SystemClock.elapsedRealtime()-started;
+    serverPlayer.snapshot=new PlaybackService.Snapshot("日本語🎵","FLAC 96k","Ready",1234+elapsed,120000,-1,-1,true,false,true);
+    Thread.sleep(10);
+    PlaybackService.Snapshot latest=client.remote;
+    if(latest!=seen){corrections++;seen=latest;}
+   }
+   expect(corrections>=1 && corrections<=2);
+   PlaybackService.Snapshot estimated=client.remoteSnapshot();
+   expect(estimated.position>=seen.position && estimated.position<=seen.position+2500);
+   // Pause bypasses the position throttle and stops local extrapolation.
+   long pausedAt=android.os.SystemClock.elapsedRealtime();
+   serverPlayer.snapshot=new PlaybackService.Snapshot("日本語🎵","FLAC 96k","Ready",5000,120000,-1,-1,false,false,true);
+   await(()->!client.remote.playing);
+   expect(android.os.SystemClock.elapsedRealtime()-pausedAt<1200 && client.remoteSnapshot().position==5000);
+   // Interpolation wraps at B rather than moving beyond the loop.
+   serverPlayer.snapshot=new PlaybackService.Snapshot("日本語🎵","FLAC 96k","Ready",1010,120000,1000,1020,true,true,true);
+   await(()->client.remote.loop);Thread.sleep(60);
+   expect(client.remoteSnapshot().position>=1000 && client.remoteSnapshot().position<1020);
+   serverPlayer.snapshot=new PlaybackService.Snapshot("日本語🎵","FLAC 96k","Ready",1234,120000,-1,-1,true,false,true);
    BluetoothLink.Listing root=client.listing;
    client.page(50);await(()->client.listing.offset==50);expect(client.listing.items.get(0).id==50);
    client.open(client.listing.items.get(0),client.listing);await(()->serverPlayer.calls.contains("play"));
diff --git a/scripts/test-playback-handoff.py b/scripts/test-playback-handoff.py
index 7fc453a..96bbc2f 100644
--- a/scripts/test-playback-handoff.py
+++ b/scripts/test-playback-handoff.py
@@ -19,6 +19,7 @@ classes={
 '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/util/Log':'public static int w(String tag,String text){return 0;}',
 'android/os/IBinder':'',
 'android/os/Binder':'',
 'android/os/Looper':'',
@@ -56,14 +57,14 @@ public static class MediaStyle{public MediaStyle setMediaSession(Object o){retur
 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,rejectedRate; public long head,written; public boolean playing;
+public static int created,flushed,rejectedRate; public int capacity; 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 a==rejectedRate?-1:4096;}
-public int getState(){return 1;} public void release(){} public void pause(){playing=false;} public void play(){playing=true;}
+public int getUnderrunCount(){return 0;} 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 int write(float[] data,int offset,int count,int mode){int n=(int)Math.min(count,Math.max(0,capacity-(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();}}
+public static class Builder{public Builder setAudioAttributes(AudioAttributes a){return this;}public Builder setAudioFormat(AudioFormat a){return this;}int bytes; public Builder setBufferSizeInBytes(int i){bytes=i;return this;}public Builder setTransferMode(int i){return this;}public AudioTrack build(){created++;AudioTrack out=new AudioTrack();out.capacity=bytes/8;return out;}}
 }''')
 put('net/astropenguin/loopdeck/NativeDecoder','''package net.astropenguin.loopdeck;
 final class NativeDecoder {
@@ -79,7 +80,7 @@ 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   long musicHead=out.head-44100;\n   int audible=musicHead<7003?0:musicHead<16010?1:2;\n   if(musicHead<0)expect(s.snapshot.position==0);\n   if(musicHead!=7003 && musicHead!=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()==(44100+7003+9007+6001)*2);\n  int pos=0;for(int i=0;i<44100*2;i++)expect(out.samples.get(pos++)==0f);\n  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<44100+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  // Seeking during warmup discards all silence and starts exactly at the target.\n  s=create(List.of(queue.get(0)));out=(AudioTrack)get(s,"output");\n  call(s,"pump");call(s,"pump");expect(out.samples.get(0)==0f);\n  call(s,"resetAt",new Class<?>[]{long.class},1000L);\n  call(s,"pump");call(s,"pump");expect(out.samples.get(0)==101000f);\n  finish(s);\n  // Replay after EOF warms the existing output again.\n  s=create(List.of(queue.get(0)));out=(AudioTrack)get(s,"output");\n  for(int step=0;step<10000 && (boolean)get(s,"playing");step++){call(s,"pump");out.advance(64);}\n  expect(!(boolean)get(s,"playing"));\n  call(s,"setPlaying",new Class<?>[]{boolean.class},true);\n  call(s,"pump");call(s,"pump");call(s,"publish");\n  expect(out.samples.get(0)==0f && s.snapshot.position==0);\n  finish(s);\n  // Always request the file rate, with the existing 48 kHz unsupported-rate fallback.\n  s=create(List.of(new Track(new Uri(4),"96k")));\n  expect((int)get(s,"rate")==96000 && (long)get(s,"warmupRemaining")==96000);\n  finish(s);\n  AudioTrack.rejectedRate=96000;\n  s=create(List.of(new Track(new Uri(4),"96k fallback")));\n  expect((int)get(s,"rate")==48000 && (long)get(s,"warmupRemaining")==48000);\n  expect(((String)get(s,"format")).contains("48000 Hz"));\n  finish(s);AudioTrack.rejectedRate=0;\n  // Stop keeps a controller-owned server alive; ending that session releases it.\n  s=create(List.of(queue.get(0)));s.controller=new PlayerController(s);s.controller.enabled=true;\n  s.stopPlayback();Object stopCommand=((java.util.Queue<?>)get(s,"commands")).poll();call(stopCommand,"run");call(s,"publish");\n  expect(s.stops==0 && s.controller.bluetoothEnabled());\n  s.controller.enabled=false;s.bluetoothStopped();expect(s.stops==1);\n  finish(s);\n  System.out.println("Playback service: one-second silent startup, EOF replay, warmup seek cancellation, three-track exact concatenation, single AudioTrack, audible metadata, seek cancellation and resource cleanup passed");\n }\n}\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   long musicHead=out.head-44100;\n   int audible=musicHead<7003?0:musicHead<16010?1:2;\n   if(musicHead<0)expect(s.snapshot.position==0);\n   if(musicHead!=7003 && musicHead!=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()==(44100+7003+9007+6001)*2);\n  int pos=0;for(int i=0;i<44100*2;i++)expect(out.samples.get(pos++)==0f);\n  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<44100+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  // Seeking during warmup discards all silence and starts exactly at the target.\n  s=create(List.of(queue.get(0)));out=(AudioTrack)get(s,"output");\n  call(s,"pump");call(s,"pump");expect(out.samples.get(0)==0f);\n  call(s,"resetAt",new Class<?>[]{long.class},1000L);\n  call(s,"pump");call(s,"pump");expect(out.samples.get(0)==101000f);\n  finish(s);\n  // Replay after EOF warms the existing output again.\n  s=create(List.of(queue.get(0)));out=(AudioTrack)get(s,"output");\n  for(int step=0;step<10000 && (boolean)get(s,"playing");step++){call(s,"pump");out.advance(64);}\n  expect(!(boolean)get(s,"playing"));\n  call(s,"setPlaying",new Class<?>[]{boolean.class},true);\n  call(s,"pump");call(s,"pump");call(s,"publish");\n  expect(out.samples.get(0)==0f && s.snapshot.position==0);\n  finish(s);\n  // Always request the file rate, with the existing 48 kHz unsupported-rate fallback.\n  s=create(List.of(new Track(new Uri(4),"96k")));\n  expect((int)get(s,"rate")==96000 && (long)get(s,"warmupRemaining")==96000 && ((AudioTrack)get(s,"output")).capacity==96000);\n  finish(s);\n  AudioTrack.rejectedRate=96000;\n  s=create(List.of(new Track(new Uri(4),"96k fallback")));\n  expect((int)get(s,"rate")==48000 && (long)get(s,"warmupRemaining")==48000 && ((AudioTrack)get(s,"output")).capacity==48000);\n  expect(((String)get(s,"format")).contains("48000 Hz"));\n  finish(s);AudioTrack.rejectedRate=0;\n  // Stop keeps a controller-owned server alive; ending that session releases it.\n  s=create(List.of(queue.get(0)));s.controller=new PlayerController(s);s.controller.enabled=true;\n  s.stopPlayback();Object stopCommand=((java.util.Queue<?>)get(s,"commands")).poll();call(stopCommand,"run");call(s,"publish");\n  expect(s.stops==0 && s.controller.bluetoothEnabled());\n  s.controller.enabled=false;s.bluetoothStopped();expect(s.stops==1);\n  finish(s);\n  System.out.println("Playback service: one-second silent startup, EOF replay, warmup seek cancellation, 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'