commit 3d1cfdade36172fadefdd00b553397307f3425ec
| author | 斟酌 鵬兄 <tgckpg@gmail.com> |
| date | 2026-10-17T19:17:08Z |
| subject | Added 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'