penguin/loopdeck

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

commit 3d1cfdade36172fadefdd00b553397307f3425ec

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-10-17T19:17:08Z
subjectAdded warmup silence on starting a track
commit 3d1cfdade36172fadefdd00b553397307f3425ec
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-10-17T19:17:08Z

    Added warmup silence on starting a track
---
 VALIDATION.md                                      | 29 ++++++++++-
 .../net/astropenguin/loopdeck/MainActivity.java    | 14 +++++-
 .../net/astropenguin/loopdeck/PlaybackService.java | 57 +++++++++++++++++++---
 scripts/test-playback-handoff.py                   |  6 +--
 4 files changed, 92 insertions(+), 14 deletions(-)

diff --git a/VALIDATION.md b/VALIDATION.md
index db8cd3f..c520591 100644
--- a/VALIDATION.md
+++ b/VALIDATION.md
@@ -104,7 +104,7 @@ Host checks for this change:
 - Existing `LoopMathTest` passed.
 - All application Java files passed the JDK parser; `git diff --check` passed.
 
-## Next-track buffering patch (base 64bee3e)
+## Next-track buffering patch
 
 - `GaplessTest` passed: exact stereo sample order across prefix/live reads,
   irregular read sizes, short and empty tracks, explicit CUE limits, cancelled
@@ -122,3 +122,30 @@ playback was tested for this change. On the M11S, check a continuous lossless al
 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.
+
+## Output warmup patch
+
+- Playback service host simulation passed with exactly 44,100 stereo silent frames
+  before the first music sample, followed by exact three-track concatenation
+  through one AudioTrack with no extra silence at continuous boundaries.
+- Track position stays at zero during warmup; audible metadata boundaries and
+  the final track position remain correct. Seeking during warmup discards its
+  pending silence, and replay after EOF adds a fresh silent lead-in.
+- Existing queued-transition seek cancellation and decoder cleanup checks pass.
+
+No APK was built. The silent lead-in must still be checked on the device to
+confirm that its output route wakes within one second. Ordinary pause/resume
+uses its existing queued audio without an added lead-in.
+
+## Output sample-rate toggle
+
+- Added a persisted UI toggle: File native (default) / 44,100 Hz. Both retain
+  the existing 48,000 Hz fallback if Android rejects the requested format.
+- Host playback simulation passes: a 96 kHz source switches to 44.1 kHz and
+  back, preserving a 20 ms position, paused/playing state and 10–40 ms loop
+  markers. Reopening cancels prefetched PCM and adds output warmup silence.
+- Existing exact sample order, continuous handoff, EOF replay, warmup seek
+  cancellation and decoder ownership checks still pass.
+
+No APK was built; actual device routing and audible differences need device
+verification. File native requests the source sample rate, not a higher rate.
diff --git a/app/src/main/java/net/astropenguin/loopdeck/MainActivity.java b/app/src/main/java/net/astropenguin/loopdeck/MainActivity.java
index 0d7c75d..ba8a1b6 100644
--- a/app/src/main/java/net/astropenguin/loopdeck/MainActivity.java
+++ b/app/src/main/java/net/astropenguin/loopdeck/MainActivity.java
@@ -32,13 +32,14 @@ public final class MainActivity extends Activity {
     private Uri tree;
     private String directory;
     private TextView folderLabel, trackTitle, trackFormat, times, loopTimes, status;
-    private Button play, setA, setB, loopButton, clear, previous, next, stop;
+    private Button play, setA, setB, loopButton, clear, previous, next, stop, outputRate;
     private SeekBar seek;
     private EntryAdapter adapter;
 
     private final ServiceConnection connection=new ServiceConnection() {
         @Override public void onServiceConnected(ComponentName name, IBinder binder) {
-            player=((PlaybackService.LocalBinder)binder).service(); render();
+            player=((PlaybackService.LocalBinder)binder).service();
+            player.outputMode(getPreferences(MODE_PRIVATE).getBoolean("nativeRateOutput",true)); render();
         }
         @Override public void onServiceDisconnected(ComponentName name) { player=null; }
     };
@@ -88,6 +89,13 @@ public final class MainActivity extends Activity {
         View divider=new View(this); divider.setBackgroundColor(Color.rgb(54,75,63)); root.addView(divider,new LinearLayout.LayoutParams(-1,dp(1)));
         trackTitle=text("Choose a track",18,TEXT); trackTitle.setMaxLines(2); trackTitle.setPadding(0,dp(6),0,0); root.addView(trackTitle);
         trackFormat=text("FFmpeg audio player",11,MUTED); trackFormat.setMaxLines(2); root.addView(trackFormat);
+        LinearLayout outputOptions=row();
+        outputRate=button(outputOptions,"Rate: File native",v -> {
+            if (player==null) return;
+            boolean useNative=!player.nativeRateOutput;
+            getPreferences(MODE_PRIVATE).edit().putBoolean("nativeRateOutput",useNative).apply();
+            player.outputMode(useNative);
+        }); root.addView(outputOptions);
         times=text("0:00.000 / 0:00.000",13,TEXT); root.addView(times);
         seek=new SeekBar(this); seek.setMax(10000); root.addView(seek,new LinearLayout.LayoutParams(-1,dp(36)));
         seek.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
@@ -273,6 +281,8 @@ public final class MainActivity extends Activity {
     }
     @Override public void onBackPressed() { if (!goUp()) super.onBackPressed(); }
     private void render() {
+        outputRate.setEnabled(player!=null);
+        if (player!=null) replace(outputRate,player.nativeRateOutput ? "Rate: File native" : "Rate: 44,100 Hz");
         if (player==null) return;
         PlaybackService.Snapshot s=player.snapshot;
         replace(trackTitle,s.title); replace(trackFormat,s.format); replace(status,s.status);
diff --git a/app/src/main/java/net/astropenguin/loopdeck/PlaybackService.java b/app/src/main/java/net/astropenguin/loopdeck/PlaybackService.java
index 86e69a2..77176dd 100644
--- a/app/src/main/java/net/astropenguin/loopdeck/PlaybackService.java
+++ b/app/src/main/java/net/astropenguin/loopdeck/PlaybackService.java
@@ -17,6 +17,7 @@ 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;
+    private static final int OUTPUT_WARMUP_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; }
@@ -34,6 +35,7 @@ public final class PlaybackService extends Service {
     private AudioTrack output;
     private List<Track> queue = Collections.emptyList();
     private int index = -1, rate, sourceRate;
+    volatile boolean nativeRateOutput=true;
     private final ExecutorService prefetcher=Executors.newSingleThreadExecutor();
     private PrefetchSlot<Prepared> nextSlot;
     private Prepared transition;
@@ -53,6 +55,7 @@ public final class PlaybackService extends Service {
     private String title = "Choose a music folder", format = "", status = "Ready";
     private final float[] block = new float[4096]; // 2048 stereo frames
     private int blockSamples, blockOffset;
+    private long warmupRemaining, warmupPosition;
     private float[] cache;
     private int cacheFilled, cacheRead;
     private long lastPublish, lastSession;
@@ -115,6 +118,25 @@ public final class PlaybackService extends Service {
         List<Track> copy = new ArrayList<>(tracks);
         submit(() -> { queue=copy; load(selected); });
     }
+    void outputMode(boolean useNativeRate) { submit(() -> {
+        if (nativeRateOutput==useNativeRate) return;
+        long here=decoder==0 ? 0 : LoopMath.millis(positionFrames(),rate);
+        long oldA=a<0 ? -1 : LoopMath.millis(a,rate), oldB=b<0 ? -1 : LoopMath.millis(b,rate);
+        boolean wasPlaying=playing, wasLoop=loop;
+        nativeRateOutput=useNativeRate;
+        if (decoder==0) return;
+        // Reopen at the new rate; cancel prefetched PCM and translate frame positions.
+        load(index,false);
+        a=oldA<0 ? -1 : LoopMath.frame(oldA,rate);
+        b=oldB<0 ? -1 : LoopMath.frame(oldB,rate);
+        loop=wasLoop && LoopMath.valid(a,b);
+        long target=LoopMath.frame(here,rate);
+        if (loop) target=Math.max(a,Math.min(target,b-1));
+        resetAt(target);
+        warmupOutput();
+        status=loop ? "A–B loop · seek mode" : "Ready";
+        if (wasPlaying) setPlaying(true);
+    }); }
     void toggle() { submit(() -> { resumeOnFocus=false; setPlaying(!playing); }); }
     void next(int direction) { submit(() -> { if (!queue.isEmpty()) load(Math.floorMod(index + direction, queue.size())); }); }
     void seek(long ms) { submit(() -> {
@@ -161,7 +183,8 @@ public final class PlaybackService extends Service {
         resetAt(a);
         status=cache != null ? "A–B loop · buffering first pass" : "A–B loop · seek mode";
     }
-    private void load(int selected) throws Exception {
+    private void load(int selected) throws Exception { load(selected,true); }
+    private void load(int selected, boolean autoplay) throws Exception {
         if (selected < 0 || selected >= queue.size()) return;
         setPlaying(false); releaseAudio();
         index=selected; title=queue.get(index).name; format=""; status="Opening…"; resumeOnFocus=false;
@@ -178,7 +201,7 @@ public final class PlaybackService extends Service {
         if (end>=0 && end<=selectedTrack.startMs) throw new IOException("Track is outside the audio image");
         duration=end<0 ? 0 : end-selectedTrack.startMs;
         format=NativeDecoder.description(decoder);
-        rate=sourceRate;
+        rate=nativeRateOutput ? sourceRate : 44100;
         try { output=createOutput(rate); }
         catch (IllegalArgumentException | UnsupportedOperationException e) {
             rate=48000; output=createOutput(rate);
@@ -190,10 +213,11 @@ public final class PlaybackService extends Service {
         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;
-        status=sourceRate==rate ? "Ready" : "Resampling to 48000 Hz";
+        warmupOutput();
+        status=sourceRate==rate ? "Ready" : "Resampling to "+rate+" Hz";
         session.setMetadata(new MediaMetadata.Builder().putString(MediaMetadata.METADATA_KEY_TITLE,title)
             .putLong(MediaMetadata.METADATA_KEY_DURATION,duration).build());
-        setPlaying(true);
+        if (autoplay) setPlaying(true);
         prepareNext();
     }
     private void cancelNext() {
@@ -244,6 +268,7 @@ public final class PlaybackService extends Service {
         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.
+        warmupPosition=0;
         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)
@@ -263,7 +288,10 @@ public final class PlaybackService extends Service {
     }
     private void setPlaying(boolean value) throws IOException {
         if (output==null) return;
-        if (value && eof && playedFrames()>=written) resetAt(loop ? a : 0);
+        if (value && eof && playedFrames()>=written) {
+            resetAt(loop ? a : 0);
+            warmupOutput();
+        }
         if (value && !playing) {
             if (audioManager.requestAudioFocus(focus)!=AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { status="Audio focus denied"; return; }
             output.play(); playing=true; if (!wake.isHeld()) wake.acquire();
@@ -294,13 +322,20 @@ public final class PlaybackService extends Service {
         if (head<lastHead) headEpoch+=1L<<32;
         lastHead=head; return headEpoch+head;
     }
-    private long positionFrames() { return output==null ? 0 : LoopMath.position(base,playedFrames(),a,b,loop); }
+    private long positionFrames() { return output==null ? 0 : Math.max(warmupPosition,LoopMath.position(base,playedFrames(),a,b,loop)); }
+    private void warmupOutput() {
+        // Send real PCM silence so the DAC/route wakes before any music is consumed.
+        // Include it in output timing, but exclude it from track position and markers.
+        warmupPosition=base;
+        warmupRemaining=(long)rate*OUTPUT_WARMUP_SECONDS;
+        base-=warmupRemaining;
+    }
     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;
+        base=cursor=target; blockSamples=blockOffset=0; eof=false; warmupRemaining=warmupPosition=0;
         if (cache!=null && cacheFilled==cache.length && loop) cacheRead=(int)((target-a)*2);
         else {
             if (cache!=null) cacheFilled=cacheRead=0;
@@ -317,6 +352,12 @@ public final class PlaybackService extends Service {
             blockOffset+=n; written+=n/2;
             return n>0;
         }
+        if (warmupRemaining>0) {
+            int count=(int)Math.min(block.length/2,warmupRemaining);
+            Arrays.fill(block,0,count*2,0f);
+            warmupRemaining-=count; blockSamples=count*2; blockOffset=0;
+            return true;
+        }
         if (transition!=null) {
             int count=transition.buffer.read(block,block.length/2);
             transition.cursor+=count; blockSamples=count*2; blockOffset=0;
@@ -382,7 +423,7 @@ public final class PlaybackService extends Service {
     }
     private void releaseAudio() {
         cancelNext(); activePrefix=null;
-        playing=false; cache=null; blockSamples=blockOffset=0;
+        playing=false; cache=null; blockSamples=blockOffset=0; warmupRemaining=warmupPosition=0;
         if (output!=null) { output.pause(); output.flush(); output.release(); output=null; }
         if (decoder!=0) { NativeDecoder.close(decoder); decoder=0; }
         if (wake.isHeld()) wake.release();
diff --git a/scripts/test-playback-handoff.py b/scripts/test-playback-handoff.py
index 868f067..5140744 100644
--- a/scripts/test-playback-handoff.py
+++ b/scripts/test-playback-handoff.py
@@ -69,8 +69,8 @@ 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 sourceRate(long h){return states.get(h)[0]==4?96000:44100;}
+static long duration(long h){return size(states.get(h)[0])*1000/sourceRate(h);}
 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){}
@@ -78,7 +78,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   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')
+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  // Change a paused 96 kHz file to 44.1 kHz and back, preserving time and markers.\n  s=create(List.of(new Track(new Uri(4),"96k")));\n  expect((int)get(s,"rate")==96000);\n  call(s,"setPlaying",new Class<?>[]{boolean.class},false);\n  call(s,"resetAt",new Class<?>[]{long.class},1920L);\n  set(s,"a",960L);set(s,"b",3840L);set(s,"loop",true);\n  s.outputMode(false);call(((java.util.Queue<?>)get(s,"commands")).poll(),"run");call(s,"publish");\n  expect((int)get(s,"rate")==44100 && !s.nativeRateOutput);\n  expect(!(boolean)get(s,"playing") && s.snapshot.position==20);\n  expect((long)get(s,"a")==441 && (long)get(s,"b")==1764 && (boolean)get(s,"loop"));\n  expect((long)get(s,"warmupRemaining")==44100);\n  s.outputMode(true);call(((java.util.Queue<?>)get(s,"commands")).poll(),"run");call(s,"publish");\n  expect((int)get(s,"rate")==96000 && s.nativeRateOutput && s.snapshot.position==20);\n  expect((long)get(s,"a")==960 && (long)get(s,"b")==3840);\n  call(s,"setPlaying",new Class<?>[]{boolean.class},true);\n  s.outputMode(false);call(((java.util.Queue<?>)get(s,"commands")).poll(),"run");\n  expect((boolean)get(s,"playing") && (int)get(s,"rate")==44100);\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'