penguin/loopdeck

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

commit 4cbe5c511df9b871caa18f6f581ebfea334b77c4

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-10-17T19:20:45Z
subjectUse file-native sample-rate as output
commit 4cbe5c511df9b871caa18f6f581ebfea334b77c4
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-10-17T19:20:45Z

    Use file-native sample-rate as output
---
 VALIDATION.md                                      | 24 +++++++++----------
 .../net/astropenguin/loopdeck/MainActivity.java    | 14 ++---------
 .../net/astropenguin/loopdeck/PlaybackService.java | 27 +++-------------------
 scripts/test-playback-handoff.py                   |  6 ++---
 4 files changed, 20 insertions(+), 51 deletions(-)

diff --git a/VALIDATION.md b/VALIDATION.md
index c520591..a39a939 100644
--- a/VALIDATION.md
+++ b/VALIDATION.md
@@ -137,15 +137,15 @@ 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.
+## Native sample-rate output (base 3d1cfda)
+
+- Removed the rate toggle, persisted-setting reads/writes and forced 44.1 kHz
+  mode. Playback always requests the source rate, retaining the 48 kHz fallback
+  if Android rejects it. Existing saved toggle values are ignored.
+- Host playback simulation passed for native 96 kHz output and rejection of
+  96 kHz with fallback to 48 kHz, including rate-correct one-second warmup.
+- Existing silent startup, EOF replay, seek cancellation, gapless sample order,
+  audible metadata boundaries and decoder cleanup checks passed.
+- MainActivity and PlaybackService passed Java syntax parsing; diff checks passed.
+
+No APK was built. Android/vendor processing may still change the rate downstream.
diff --git a/app/src/main/java/net/astropenguin/loopdeck/MainActivity.java b/app/src/main/java/net/astropenguin/loopdeck/MainActivity.java
index ba8a1b6..0d7c75d 100644
--- a/app/src/main/java/net/astropenguin/loopdeck/MainActivity.java
+++ b/app/src/main/java/net/astropenguin/loopdeck/MainActivity.java
@@ -32,14 +32,13 @@ 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, outputRate;
+    private Button play, setA, setB, loopButton, clear, previous, next, stop;
     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();
-            player.outputMode(getPreferences(MODE_PRIVATE).getBoolean("nativeRateOutput",true)); render();
+            player=((PlaybackService.LocalBinder)binder).service(); render();
         }
         @Override public void onServiceDisconnected(ComponentName name) { player=null; }
     };
@@ -89,13 +88,6 @@ 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() {
@@ -281,8 +273,6 @@ 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 77176dd..8c2a801 100644
--- a/app/src/main/java/net/astropenguin/loopdeck/PlaybackService.java
+++ b/app/src/main/java/net/astropenguin/loopdeck/PlaybackService.java
@@ -35,7 +35,6 @@ 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;
@@ -118,25 +117,6 @@ 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(() -> {
@@ -183,8 +163,7 @@ 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 { load(selected,true); }
-    private void load(int selected, boolean autoplay) throws Exception {
+    private void load(int selected) throws Exception {
         if (selected < 0 || selected >= queue.size()) return;
         setPlaying(false); releaseAudio();
         index=selected; title=queue.get(index).name; format=""; status="Opening…"; resumeOnFocus=false;
@@ -201,7 +180,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=nativeRateOutput ? sourceRate : 44100;
+        rate=sourceRate;
         try { output=createOutput(rate); }
         catch (IllegalArgumentException | UnsupportedOperationException e) {
             rate=48000; output=createOutput(rate);
@@ -217,7 +196,7 @@ public final class PlaybackService extends Service {
         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());
-        if (autoplay) setPlaying(true);
+        setPlaying(true);
         prepareNext();
     }
     private void cancelNext() {
diff --git a/scripts/test-playback-handoff.py b/scripts/test-playback-handoff.py
index 5140744..0b75099 100644
--- a/scripts/test-playback-handoff.py
+++ b/scripts/test-playback-handoff.py
@@ -55,9 +55,9 @@ 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; public long head,written; public boolean playing;
+public static int created,flushed,rejectedRate; public long head,written; public boolean playing;
 public final java.util.ArrayList<Float> samples=new java.util.ArrayList<>();
-public static int getMinBufferSize(int a,int b,int c){return 4096;}
+public 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 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;}
@@ -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   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')
+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  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'