commit 4cbe5c511df9b871caa18f6f581ebfea334b77c4
| author | 斟酌 鵬兄 <tgckpg@gmail.com> |
| date | 2026-10-17T19:20:45Z |
| subject | Use 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'