commit ae2a86b024caee08195d4a6934356ac906382938
| author | 斟酌 鵬兄 <tgckpg@gmail.com> |
| date | 2026-10-19T05:25:03Z |
| subject | Fixed 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'