commit 64bee3e1032c58583ae4724b461d0e72cf35e61b
| author | 斟酌 鵬兄 <tgckpg@gmail.com> |
| date | 2026-10-17T16:59:51Z |
| subject | Added cue sheet support |
commit 64bee3e1032c58583ae4724b461d0e72cf35e61b
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date: 2026-10-17T16:59:51Z
Added cue sheet support
---
README.md | 22 +++++
VALIDATION.md | 10 ++
app/src/main/cpp/decoder.cpp | 19 ++++
app/src/main/cpp/decoder.h | 2 +
app/src/main/cpp/jni.cpp | 28 ++++++
.../java/net/astropenguin/loopdeck/CueSheet.java | 55 +++++++++++
.../net/astropenguin/loopdeck/MainActivity.java | 105 ++++++++++++++++++++-
.../net/astropenguin/loopdeck/NativeDecoder.java | 2 +
.../net/astropenguin/loopdeck/PlaybackService.java | 19 +++-
.../main/java/net/astropenguin/loopdeck/Track.java | 6 +-
scripts/CueSheetTest.java | 30 ++++++
11 files changed, 291 insertions(+), 7 deletions(-)
diff --git a/README.md b/README.md
index e6375d0..0a62444 100644
--- a/README.md
+++ b/README.md
@@ -162,3 +162,25 @@ app data unless you intend to re-grant folder permission.
App source is MIT licensed. FFmpeg and the Gradle wrapper have their own licenses;
see `THIRD_PARTY_NOTICES.md` before redistributing binaries.
+
+## Album images and track views
+
+Tap an album image (TAK, TTA, FLAC, APE, etc.) to show its numbered track list
+when track information is available. LoopDeck checks same-folder external CUE
+sheets first (preferring the image's basename), then an embedded `CUESHEET` tag,
+then container chapters exposed by FFmpeg. You can also tap a `.cue` directly,
+including sheets referencing several audio files in the same folder.
+
+Choose a track to play it. Its SeekBar, elapsed time, duration, media-session
+position and A–B markers are relative to that track, starting at zero. Playback
+ends at its boundary and advances to the next listed track. Previous/Next use
+the track list. Use the up arrow or Back to return to the folder. Images without
+usable track information continue to play as one file.
+
+External CUE `FILE` names must match audio filenames in the same folder; relative
+subdirectories and absolute paths are not resolved. UTF-8 and BOM-marked UTF-16
+are supported, with Windows-31J fallback for older Japanese sheets. `INDEX 01`
+marks each start; `INDEX 00` pregaps stay with the preceding track. Track positions
+use millisecond precision. Tracks reopen/seek the image when advancing; seamless
+transitions between tracks are not guaranteed. Embedded metadata support depends
+on what the format's FFmpeg demuxer exposes.
diff --git a/VALIDATION.md b/VALIDATION.md
index bc861f0..27366b5 100644
--- a/VALIDATION.md
+++ b/VALIDATION.md
@@ -93,3 +93,13 @@ in this archive, and these checks do not cover all versions of every format.
AudioTrack timing, final DAC format, actual gaplessness and responsiveness require
the device. They are not established by host decoder tests.
+
+## Album-image track view patch
+
+Host checks for this change:
+
+- `CueSheetTest`: INDEX 01 boundaries, INDEX 00 pregaps, UTF-8 Japanese/emoji
+ titles, missing titles, titles after INDEX, multi-file sheets, long images,
+ invalid timestamps, duplicate indices, reversed starts and non-audio entries.
+- Existing `LoopMathTest` passed.
+- All application Java files passed the JDK parser; `git diff --check` passed.
diff --git a/app/src/main/cpp/decoder.cpp b/app/src/main/cpp/decoder.cpp
index cd061ac..f41e30c 100644
--- a/app/src/main/cpp/decoder.cpp
+++ b/app/src/main/cpp/decoder.cpp
@@ -236,3 +236,22 @@ void Decoder::seek(int64_t outputFrame) {
pending.clear(); pendingOffset = 0;
skipUntil = decodedNext = outputFrame;
}
+
+std::string Decoder::cueSheet() const {
+ auto* tag=av_dict_get(format->metadata,"cuesheet",nullptr,0);
+ if (!tag) tag=av_dict_get(stream->metadata,"cuesheet",nullptr,0);
+ return tag ? tag->value : "";
+}
+std::vector<std::string> Decoder::chapters() const {
+ std::vector<std::string> result;
+ for (unsigned i=0;i<format->nb_chapters;++i) {
+ const auto* chapter=format->chapters[i];
+ auto start=av_rescale_q(chapter->start,chapter->time_base,AVRational{1,1000});
+ auto end=av_rescale_q(chapter->end,chapter->time_base,AVRational{1,1000});
+ auto* title=av_dict_get(chapter->metadata,"title",nullptr,0);
+ result.push_back(std::to_string(start));
+ result.push_back(std::to_string(end));
+ result.push_back(title ? title->value : "Track "+std::to_string(i+1));
+ }
+ return result;
+}
diff --git a/app/src/main/cpp/decoder.h b/app/src/main/cpp/decoder.h
index 31e182c..2956c51 100644
--- a/app/src/main/cpp/decoder.h
+++ b/app/src/main/cpp/decoder.h
@@ -25,6 +25,8 @@ public:
int sourceRate() const { return codec->sample_rate; }
int64_t durationMs() const;
std::string description() const;
+ std::string cueSheet() const;
+ std::vector<std::string> chapters() const;
private:
int fd = -1;
AVFormatContext* format = nullptr;
diff --git a/app/src/main/cpp/jni.cpp b/app/src/main/cpp/jni.cpp
index 6cc1536..04ff0ac 100644
--- a/app/src/main/cpp/jni.cpp
+++ b/app/src/main/cpp/jni.cpp
@@ -52,3 +52,31 @@ Java_net_astropenguin_loopdeck_NativeDecoder_read(JNIEnv* env, jclass, jlong han
return count;
} catch (const std::exception& e) { report(env,e); return 0; }
}
+
+// Decode standard UTF-8 rather than JNI's modified UTF-8 (track names may contain emoji).
+static jstring utf8(JNIEnv* env, const std::string& value) {
+ auto bytes=env->NewByteArray(value.size());
+ if (!bytes) return nullptr;
+ env->SetByteArrayRegion(bytes,0,value.size(),reinterpret_cast<const jbyte*>(value.data()));
+ auto charset=env->NewStringUTF("UTF-8");
+ auto type=env->FindClass("java/lang/String");
+ auto result=static_cast<jstring>(env->NewObject(type,env->GetMethodID(type,"<init>","([BLjava/lang/String;)V"),bytes,charset));
+ env->DeleteLocalRef(bytes); env->DeleteLocalRef(charset); env->DeleteLocalRef(type);
+ return result;
+}
+extern "C" JNIEXPORT jstring JNICALL
+Java_net_astropenguin_loopdeck_NativeDecoder_cueSheet(JNIEnv* env,jclass,jlong handle) {
+ try { return utf8(env,decoder(handle)->cueSheet()); } catch (const std::exception& e) { report(env,e); return nullptr; }
+}
+extern "C" JNIEXPORT jobjectArray JNICALL
+Java_net_astropenguin_loopdeck_NativeDecoder_chapters(JNIEnv* env,jclass,jlong handle) {
+ try {
+ auto values=decoder(handle)->chapters();
+ auto result=env->NewObjectArray(values.size(),env->FindClass("java/lang/String"),nullptr);
+ if (!result) return nullptr;
+ for (size_t i=0;i<values.size();++i) {
+ auto value=utf8(env,values[i]); env->SetObjectArrayElement(result,i,value); env->DeleteLocalRef(value);
+ }
+ return result;
+ } catch (const std::exception& e) { report(env,e); return nullptr; }
+}
diff --git a/app/src/main/java/net/astropenguin/loopdeck/CueSheet.java b/app/src/main/java/net/astropenguin/loopdeck/CueSheet.java
new file mode 100644
index 0000000..e029165
--- /dev/null
+++ b/app/src/main/java/net/astropenguin/loopdeck/CueSheet.java
@@ -0,0 +1,55 @@
+package net.astropenguin.loopdeck;
+
+import java.util.*;
+import java.util.regex.*;
+
+/** INDEX 01 defines track starts; INDEX 00 remains with the preceding track. */
+final class CueSheet {
+ static final class Part {
+ final String file, title;
+ final int number;
+ final long start, end;
+ Part(String file,String title,int number,long start,long end) {
+ this.file=file; this.title=title; this.number=number; this.start=start; this.end=end;
+ }
+ }
+ private static final Pattern FILE=Pattern.compile("^FILE\\s+(?:\"([^\"]+)\"|(\\S+))\\s+\\S+\\s*$",Pattern.CASE_INSENSITIVE);
+ private static final Pattern TRACK=Pattern.compile("^TRACK\\s+(\\d+)\\s+(\\S+)\\s*$",Pattern.CASE_INSENSITIVE);
+ private static final Pattern INDEX=Pattern.compile("^INDEX\\s+01\\s+(\\d+):(\\d{2}):(\\d{2})\\s*$",Pattern.CASE_INSENSITIVE);
+ static List<Part> parse(String text) {
+ ArrayList<Part> raw=new ArrayList<>();
+ String file="", title=""; int number=0; boolean audio=false, indexed=false;
+ for (String line:text.replace("\ufeff","").split("\\r?\\n")) {
+ line=line.trim(); Matcher f=FILE.matcher(line), t=TRACK.matcher(line), i=INDEX.matcher(line);
+ if (f.matches()) { file=f.group(1)!=null ? f.group(1) : f.group(2); audio=false; }
+ else if (t.matches()) {
+ try { number=Integer.parseInt(t.group(1)); } catch (NumberFormatException e) { return Collections.emptyList(); }
+ audio=t.group(2).equalsIgnoreCase("AUDIO"); title=""; indexed=false;
+ } else if (audio && line.matches("(?i)^TITLE\\s+.*")) {
+ title=line.substring(5).trim();
+ if (title.startsWith("\"") && title.endsWith("\"") && title.length()>=2) title=title.substring(1,title.length()-1);
+ if (indexed) {
+ Part current=raw.get(raw.size()-1);
+ raw.set(raw.size()-1,new Part(current.file,title.isEmpty() ? "Track "+number : title,number,current.start,-1));
+ }
+ } else if (audio && i.matches()) {
+ if (indexed) return Collections.emptyList();
+ try {
+ long minutes=Long.parseLong(i.group(1)); int seconds=Integer.parseInt(i.group(2)), frames=Integer.parseInt(i.group(3));
+ if (seconds>=60 || frames>=75 || minutes>1000000) return Collections.emptyList();
+ raw.add(new Part(file,title.isEmpty() ? "Track "+number : title,number,(minutes*60+seconds)*1000+frames*1000/75,-1)); indexed=true;
+ } catch (NumberFormatException e) { return Collections.emptyList(); }
+ }
+ }
+ ArrayList<Part> result=new ArrayList<>();
+ Map<String,Long> last=new HashMap<>();
+ for (int n=raw.size()-1;n>=0;n--) {
+ Part part=raw.get(n); String key=part.file.toLowerCase(Locale.ROOT);
+ long end=last.getOrDefault(key,-1L);
+ if (end>=0 && end<=part.start) return Collections.emptyList();
+ result.add(new Part(part.file,part.title,part.number,part.start,end)); last.put(key,part.start);
+ }
+ Collections.reverse(result); return result;
+ }
+ private CueSheet() {}
+}
diff --git a/app/src/main/java/net/astropenguin/loopdeck/MainActivity.java b/app/src/main/java/net/astropenguin/loopdeck/MainActivity.java
index a768783..0d7c75d 100644
--- a/app/src/main/java/net/astropenguin/loopdeck/MainActivity.java
+++ b/app/src/main/java/net/astropenguin/loopdeck/MainActivity.java
@@ -11,6 +11,9 @@ import android.text.InputType;
import android.view.*;
import android.widget.*;
import java.util.*;
+import java.io.*;
+import java.nio.*;
+import java.nio.charset.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
@@ -25,6 +28,7 @@ public final class MainActivity extends Activity {
private final ArrayList<String> parents=new ArrayList<>();
private PlaybackService player;
private boolean bound, dragging, resumed;
+ private boolean trackView;
private Uri tree;
private String directory;
private TextView folderLabel, trackTitle, trackFormat, times, loopTimes, status;
@@ -129,6 +133,7 @@ public final class MainActivity extends Activity {
if (tree==null) return;
final Uri selectedTree=tree;
final String selectedDirectory=directory;
+ trackView=false;
final int request=generation.incrementAndGet();
entries.clear(); adapter.notifyDataSetChanged(); folderLabel.setText("Loading folder…");
files.submit(() -> {
@@ -160,17 +165,109 @@ public final class MainActivity extends Activity {
private void select(Entry entry) {
if (entry.folder) { parents.add(directory); directory=entry.id; listFolder(); return; }
if (player==null) { showError("Player is still starting"); return; }
+ if (entry.track==null) { openTrackView(entry); return; }
+ playEntry(entry);
+ }
+ private void playEntry(Entry entry) {
ArrayList<Track> tracks=new ArrayList<>(); int selected=0;
for (Entry candidate:entries) {
if (!candidate.folder && (candidate==entry || candidate.audio)) {
if (candidate==entry) selected=tracks.size();
- tracks.add(new Track(candidate.uri,candidate.name));
+ tracks.add(candidate.track!=null ? candidate.track : new Track(candidate.uri,candidate.name));
}
}
ensureForeground(); player.play(tracks,selected);
}
+ private static boolean isCue(Entry e) { return e.name.toLowerCase(Locale.ROOT).endsWith(".cue"); }
+ private String readCue(Entry e) throws IOException {
+ try (InputStream in=getContentResolver().openInputStream(e.uri)) {
+ if (in==null) throw new IOException("Cannot open CUE sheet");
+ ByteArrayOutputStream out=new ByteArrayOutputStream(); byte[] buffer=new byte[8192]; int n;
+ while ((n=in.read(buffer))!=-1) {
+ if (out.size()+n>2*1024*1024) throw new IOException("CUE sheet is too large");
+ out.write(buffer,0,n);
+ }
+ byte[] bytes=out.toByteArray();
+ if (bytes.length>=2 && ((bytes[0]==(byte)0xff && bytes[1]==(byte)0xfe) ||
+ (bytes[0]==(byte)0xfe && bytes[1]==(byte)0xff))) return new String(bytes,StandardCharsets.UTF_16);
+ try { return StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString(); }
+ catch (CharacterCodingException invalidUtf8) { return new String(bytes,Charset.forName("windows-31j")); }
+ }
+ }
+ private ArrayList<Track> cueTracks(String text,List<Entry> folder,Entry image) {
+ return cueTracks(text,folder,image,false);
+ }
+ private ArrayList<Track> cueTracks(String text,List<Entry> folder,Entry image,boolean embedded) {
+ ArrayList<Track> result=new ArrayList<>();
+ for (CueSheet.Part part:CueSheet.parse(text)) {
+ Entry target=null;
+ String name=part.file.replace('\\','/');
+ while (name.startsWith("./")) name=name.substring(2);
+ if (image!=null && (embedded || name.isEmpty() || name.equalsIgnoreCase(image.name))) target=image;
+ if (image==null) for (Entry candidate:folder)
+ if (candidate.audio && candidate.name.equalsIgnoreCase(name)) { target=candidate; break; }
+ if (target!=null) result.add(new Track(target.uri,part.number+". "+part.title,part.start,part.end));
+ else if (image==null) return new ArrayList<>(); // Never play a partial/misresolved sheet.
+ }
+ return result;
+ }
+ private void openTrackView(Entry entry) {
+ final ArrayList<Entry> folder=new ArrayList<>(entries);
+ final int request=generation.incrementAndGet();
+ folderLabel.setText("Reading track information…");
+ files.submit(() -> {
+ ArrayList<Track> found=new ArrayList<>(); String error=null;
+ try {
+ if (isCue(entry)) {
+ found=cueTracks(readCue(entry),folder,null);
+ if (found.isEmpty()) throw new IOException("No usable audio tracks. CUE FILE names must match audio files in this folder.");
+ } else {
+ // Prefer a same-named sheet, then sheets that explicitly reference this image.
+ String stem=entry.name.replaceFirst("\\.[^.]+$","");
+ folder.sort((x,y) -> Boolean.compare(!x.name.equalsIgnoreCase(stem+".cue"),!y.name.equalsIgnoreCase(stem+".cue")));
+ for (Entry candidate:folder) if (isCue(candidate)) {
+ try { found=cueTracks(readCue(candidate),folder,entry); } catch (IOException ignored) { continue; }
+ if (!found.isEmpty()) break;
+ }
+ if (found.isEmpty()) {
+ long handle=0;
+ try (ParcelFileDescriptor file=getContentResolver().openFileDescriptor(entry.uri,"r")) {
+ if (file==null) throw new IOException("Cannot open selected file");
+ handle=NativeDecoder.open(file.getFd());
+ found=cueTracks(NativeDecoder.cueSheet(handle),folder,entry,true);
+ if (found.isEmpty()) {
+ String[] chapters=NativeDecoder.chapters(handle);
+ long total=NativeDecoder.duration(handle), previousEnd=-1;
+ for (int i=0;i<chapters.length;i+=3) {
+ long start=Long.parseLong(chapters[i]), end=Long.parseLong(chapters[i+1]);
+ if (end<=start && i+3<chapters.length) end=Long.parseLong(chapters[i+3]);
+ if (end<=start) end=total;
+ if (start<0 || start<previousEnd || end<=start || (total>0 && end>total)) { found.clear(); break; }
+ found.add(new Track(entry.uri,(i/3+1)+". "+chapters[i+2],start,end)); previousEnd=end;
+ }
+ }
+ } finally { if (handle!=0) NativeDecoder.close(handle); }
+ }
+ }
+ } catch (Exception | LinkageError e) { error=e.getMessage(); }
+ final ArrayList<Track> tracks=found; final String failure=error;
+ main.post(() -> {
+ if (isDestroyed() || request!=generation.get()) return;
+ if (!tracks.isEmpty()) {
+ trackView=true; entries.clear();
+ for (Track track:tracks) entries.add(new Entry(track));
+ adapter.notifyDataSetChanged(); folderLabel.setText(entry.name+" · "+tracks.size()+" tracks");
+ } else {
+ folderLabel.setText(directory+" · "+entries.size()+" items");
+ if (isCue(entry)) showError(failure==null ? "No track information found" : failure);
+ else playEntry(entry);
+ }
+ });
+ });
+ }
private void ensureForeground() { startForegroundService(new Intent(this,PlaybackService.class)); }
private boolean goUp() {
+ if (trackView) { listFolder(); return true; }
if (parents.isEmpty()) return false;
directory=parents.remove(parents.size()-1); listFolder(); return true;
}
@@ -230,8 +327,12 @@ public final class MainActivity extends Activity {
}
private static final class Entry {
final String id,name; final Uri uri; final boolean folder,audio;
+ final Track track;
+ Entry(Track track) {
+ this.track=track; id=track.uri.toString(); name=track.name; uri=track.uri; folder=false; audio=true;
+ }
Entry(String id,String name,String mime,Uri uri) {
- this.id=id; this.name=name; this.uri=uri;
+ this.id=id; this.name=name; this.uri=uri; track=null;
folder=DocumentsContract.Document.MIME_TYPE_DIR.equals(mime);
audio=(mime!=null && mime.startsWith("audio/")) || name.toLowerCase(Locale.ROOT).matches(
".*\\.(mp3|mp2|mp1|flac|ape|tak|tta|wv|wavpack|wma|wav|wave|aif|aiff|aifc|m4a|mp4|aac|ogg|oga|opus|mpc|mpp|shn|dsf|dff|ac3|dts|amr|au|snd|caf|ra|rm|spx|mka)$");
diff --git a/app/src/main/java/net/astropenguin/loopdeck/NativeDecoder.java b/app/src/main/java/net/astropenguin/loopdeck/NativeDecoder.java
index 5b56c42..2fbd24a 100644
--- a/app/src/main/java/net/astropenguin/loopdeck/NativeDecoder.java
+++ b/app/src/main/java/net/astropenguin/loopdeck/NativeDecoder.java
@@ -9,6 +9,8 @@ final class NativeDecoder {
static native void close(long handle);
static native int sourceRate(long handle) throws IOException;
static native long duration(long handle) throws IOException;
+ static native String cueSheet(long handle) throws IOException;
+ static native String[] chapters(long handle) throws IOException;
static native String description(long handle) throws IOException;
static native void outputRate(long handle, int rate) throws IOException;
static native int read(long handle, float[] buffer, int frames) throws IOException;
diff --git a/app/src/main/java/net/astropenguin/loopdeck/PlaybackService.java b/app/src/main/java/net/astropenguin/loopdeck/PlaybackService.java
index b84c277..98321e2 100644
--- a/app/src/main/java/net/astropenguin/loopdeck/PlaybackService.java
+++ b/app/src/main/java/net/astropenguin/loopdeck/PlaybackService.java
@@ -31,6 +31,7 @@ public final class PlaybackService extends Service {
private AudioTrack output;
private List<Track> queue = Collections.emptyList();
private int index = -1, rate;
+ private long sourceStart, trackEnd;
private long duration, a = -1, b = -1, cursor, base, written, headEpoch, lastHead;
private boolean playing, loop, eof, resumeOnFocus;
private String title = "Choose a music folder", format = "", status = "Ready";
@@ -154,7 +155,12 @@ public final class PlaybackService extends Service {
decoder=NativeDecoder.open(file.getFd());
}
int sourceRate=NativeDecoder.sourceRate(decoder);
- duration=NativeDecoder.duration(decoder);
+ Track selectedTrack=queue.get(index);
+ long sourceDuration=NativeDecoder.duration(decoder);
+ long end=selectedTrack.endMs;
+ if (sourceDuration>0) end=end<0 ? sourceDuration : Math.min(end,sourceDuration);
+ 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;
try { output=createOutput(rate); }
@@ -162,6 +168,9 @@ public final class PlaybackService extends Service {
rate=48000; output=createOutput(rate);
}
NativeDecoder.outputRate(decoder,rate);
+ sourceStart=LoopMath.frame(selectedTrack.startMs,rate);
+ trackEnd=end<0 ? Long.MAX_VALUE : LoopMath.frame(end,rate)-sourceStart;
+ 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";
@@ -222,7 +231,7 @@ public final class PlaybackService extends Service {
if (cache!=null && cacheFilled==cache.length && loop) cacheRead=(int)((target-a)*2);
else {
if (cache!=null) cacheFilled=cacheRead=0;
- NativeDecoder.seek(decoder,target);
+ NativeDecoder.seek(decoder,sourceStart+target);
}
if (wasPlaying) output.play();
}
@@ -244,9 +253,11 @@ public final class PlaybackService extends Service {
if (loop && cursor>=b) {
cursor=a;
if (cache!=null && cacheFilled==cache.length) { cacheRead=0; status="A–B loop · cached"; }
- else { cache=null; NativeDecoder.seek(decoder,a); status="A–B loop · seek mode"; }
+ else { cache=null; NativeDecoder.seek(decoder,sourceStart+a); status="A–B loop · seek mode"; }
}
- int requested=loop ? (int)Math.min(block.length/2,b-cursor) : block.length/2;
+ long remaining=(loop ? b : trackEnd)-cursor;
+ if (remaining<=0) { eof=true; return false; }
+ int requested=(int)Math.min(block.length/2,remaining);
int count;
if (cache!=null && cacheFilled==cache.length) {
count=Math.min(requested,(cache.length-cacheRead)/2);
diff --git a/app/src/main/java/net/astropenguin/loopdeck/Track.java b/app/src/main/java/net/astropenguin/loopdeck/Track.java
index 3c79f96..6fdf711 100644
--- a/app/src/main/java/net/astropenguin/loopdeck/Track.java
+++ b/app/src/main/java/net/astropenguin/loopdeck/Track.java
@@ -5,5 +5,9 @@ import android.net.Uri;
final class Track {
final Uri uri;
final String name;
- Track(Uri uri, String name) { this.uri = uri; this.name = name; }
+ final long startMs, endMs;
+ Track(Uri uri, String name) { this(uri,name,0,-1); }
+ Track(Uri uri, String name, long startMs, long endMs) {
+ this.uri=uri; this.name=name; this.startMs=startMs; this.endMs=endMs;
+ }
}
diff --git a/scripts/CueSheetTest.java b/scripts/CueSheetTest.java
new file mode 100644
index 0000000..568543f
--- /dev/null
+++ b/scripts/CueSheetTest.java
@@ -0,0 +1,30 @@
+package net.astropenguin.loopdeck;
+
+import java.util.List;
+
+public final class CueSheetTest {
+ private static void expect(boolean condition) { if (!condition) throw new AssertionError(); }
+ public static void main(String[] args) {
+ List<CueSheet.Part> tracks=CueSheet.parse("\ufeffTITLE \"Album\"\r\nFILE \"image.tak\" WAVE\r\n"+
+ " TRACK 01 AUDIO\r\n TITLE \"日本語 🎵\"\r\n INDEX 01 00:00:00\r\n"+
+ " TRACK 02 AUDIO\r\n INDEX 00 03:58:00\r\n INDEX 01 04:00:37\r\n");
+ expect(tracks.size()==2);
+ expect(tracks.get(0).title.equals("日本語 🎵"));
+ expect(tracks.get(0).file.equals("image.tak"));
+ expect(tracks.get(0).start==0 && tracks.get(0).end==240493);
+ expect(tracks.get(1).start==240493 && tracks.get(1).end==-1 && tracks.get(1).title.equals("Track 2"));
+ tracks=CueSheet.parse("FILE a.tta WAVE\nTRACK 01 AUDIO\nINDEX 01 00:00:00\n"+
+ "FILE b.tta WAVE\nTRACK 02 AUDIO\nINDEX 01 00:00:00");
+ expect(tracks.size()==2 && tracks.get(0).end==-1);
+ expect(CueSheet.parse("TRACK 01 AUDIO\nINDEX 01 00:60:00").isEmpty());
+ expect(CueSheet.parse("TRACK 01 AUDIO\nINDEX 01 00:00:75").isEmpty());
+ expect(CueSheet.parse("TRACK 01 AUDIO\nINDEX 01 02:00:00\nTRACK 02 AUDIO\nINDEX 01 01:00:00").isEmpty());
+ expect(CueSheet.parse("TRACK 01 AUDIO\nINDEX 01 00:00:00\nINDEX 01 00:01:00").isEmpty());
+ expect(CueSheet.parse("TRACK 01 MODE1/2352\nINDEX 01 00:00:00").isEmpty());
+ tracks=CueSheet.parse("TRACK 01 AUDIO\nINDEX 01 00:00:00\nTRACK 02 AUDIO\nINDEX 01 100:00:00");
+ expect(tracks.size()==2 && tracks.get(0).end==6000000);
+ tracks=CueSheet.parse("TRACK 01 AUDIO\nINDEX 01 00:00:00\nTITLE \"After index\"");
+ expect(tracks.get(0).title.equals("After index"));
+ System.out.println("CUE parsing passed");
+ }
+}