// Copyright 2014-2025 Jesper Larsson
//
// This file is part of Klipspringer, <https://klipspringer.avadeaux.net/>
//
// Klipspringer is free software: you can redistribute it and/or modify it under the terms of the
// GNU General Public License as published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// Klipspringer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
// even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
// General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with Klipspringer. If
// not, see <https://www.gnu.org/licenses/>.
package net.avadeaux.klipspringer;
import java.io.*;
import java.nio.*;
import java.util.regex.*;
import javax.sound.sampled.*;
import net.avadeaux.klipspringer.codec.*;
/**
* Dynamically loaded track information.
*/
public class Track {
/** Byte oriented positions relatively to beginning of the track list. */
public static class Limits {
final long start; // byte position where track starts
final long inEnd; // where fade-in ends, zero if no fade-in
final long outStart; // where next track starts to fade in
final long end; // where track including fade-out ends
private Limits(long start, long inEnd, long outStart, long end) {
this.start = start;
this.inEnd = inEnd;
this.outStart = outStart;
this.end = end;
}
}
public static class List {
public final int firstTrackNo; // normally 1, but can be 0 or anything else
public final int length; // total number of tracks
public final String[] paths; // file names
private final MixSpec[] mixs; // mix specs
private final PcmFormat[] formats;
private final long[] frames; // total played, including fade in and out
private final double[] startTime, outTime; // time where play and fade-out begins
private final double[] fadedInTime; // time where fade-in stops
private final Track[] tracks;
private AudioDecoder decoder = null;
private int decoderIx = -1, populatedTracks = 0;
private PcmWriter writer;
private AudioDecoder.Target target = new AudioDecoder.Target() {
public void metadata(PcmFormat fmt, long fileFrames) throws IOException {
formats[decoderIx] = fmt;
float r = fmt.getSampleRate();
long skipFrames = (long) (mixs[decoderIx].skip * r);
long fadeInFrames = (long) (mixs[decoderIx].fadeIn * r);
long fadeOutFrames = decoderIx+1 < length ? (long) (mixs[decoderIx+1].fadeIn * r) : 0;
long endFrames = (mixs[decoderIx].end >= 0 ? Math.min(fileFrames, (long) (mixs[decoderIx].end * r)) : fileFrames);
if (fadeInFrames+fadeOutFrames > endFrames-skipFrames) {
throw new IllegalArgumentException("Inconsistent mix info "+skipFrames+"/"+fadeInFrames+"/"+endFrames+"/"+paths[decoderIx]);
}
if ((decoderIx == 0 || !fmt.matches(formats[decoderIx-1])) && fadeInFrames > 0) {
if (System.getProperty("klipspringer.debug") != null) {
System.err.println("Ignoring fade-in at format-starting track: "+paths[decoderIx]);
}
fadeInFrames = 0;
}
frames[decoderIx] = endFrames-skipFrames;
startTime[decoderIx] = decoderIx == 0 ? 0 : outTime[decoderIx-1];
outTime[decoderIx] = startTime[decoderIx] + (frames[decoderIx] - fadeOutFrames)/r;
fadedInTime[decoderIx] = startTime[decoderIx] + fadeInFrames/r;
}
public boolean write(ByteBuffer buffer) throws IOException { return writer.write(buffer); }
public void drain() { throw new UnsupportedOperationException(); } // should never happen
public void close() { /* Not holding any resource to close. */ }
};
public List(String[] paths, MixSpec[] mixs, int firstTrackNo) {
this.firstTrackNo = firstTrackNo;
length = paths.length;
this.paths = paths;
this.mixs = mixs;
formats = new PcmFormat[length];
frames = new long[length];
tracks = new Track[length];
startTime = new double[length];
outTime = new double[length];
fadedInTime = new double[length];
for (int i = 0; i < tracks.length; i++) { tracks[i] = new Track(this, i); }
}
static List single(String path) {
return new List(new String[] { path }, new MixSpec[] { MixSpec.getInstance() }, 1);
}
private synchronized void open(int ix) throws IOException {
if (decoderIx != ix) {
if (decoder != null) {
decoder.close();
decoder = null;
}
decoderIx = ix;
String ext = paths[ix].substring(paths[ix].lastIndexOf('.')+1).toLowerCase();
if ("flac".equals(ext) || "oga".equals(ext)) {
decoder = new FlacDecoder(paths[ix], target);
} else if ("ogg".equals(ext)) {
decoder = new VorbisDecoder(paths[ix], target);
} else if ("opus".equals(ext)) {
decoder = new OpusDecoder(paths[ix], target);
} else if ("raw".equals(ext)) {
// Decode using format specification in the file name.
PcmFormat fmt = rawFormat(paths[ix]);
RandomAccessFile f = new RandomAccessFile(paths[ix], "r");
decoder = PcmChannelDecoder.ofRaw(f.getChannel(), fmt, f.length()/fmt.fs(), target);
} else if ("wav".equals(ext)) {
// Attempt to decode WAV file with NIO for efficiency.
RandomAccessFile f = new RandomAccessFile(paths[ix], "r");
try {
decoder = PcmChannelDecoder.ofWav(f.getChannel(), target);
} catch (UnsupportedFormatException ex) {
// Could not decode, let the Java audio system try it.
System.err.println("Warning: file unsupported by Klipspringer decoder (trying Java native): "+paths[ix]+", "+ex);
}
}
if (decoder == null) {
try {
decoder = new AudioInputStreamDecoder(AudioSystem.getAudioInputStream(new File(paths[ix])), target);
} catch (UnsupportedAudioFileException ex) {
throw new UnsupportedFormatException(ex);
}
}
decoder.decodeMetadata();
}
}
public synchronized String decodingQuality() {
return decoder == null ? null : decoder.quality();
}
private synchronized void populate(int ix) throws IOException {
while (populatedTracks <= ix) { open(populatedTracks++); }
}
private synchronized void closeDecoder() throws IOException {
if (decoder != null) { decoder.close(); }
decoder = null;
decoderIx = -1;
}
private boolean decodeTrack(int ix, long fromFrame, PcmWriter writer) throws IOException {
open(ix);
try {
this.writer = writer;
return decoder.decodeAll((long) (mixs[ix].skip * formats[ix].getSampleRate()) + fromFrame);
} finally {
this.writer = null;
if (decoder != null && !decoder.reusable()) { closeDecoder(); }
}
}
public synchronized Track ofIndex(int ix) throws IOException {
if (ix >= tracks.length) { return null; }
populate(ix);
return tracks[ix];
}
public Track ofTrackNo(int trackNo) throws IOException { return ofIndex(trackNo - firstTrackNo); }
public Track ofTime(double time) throws IOException {
int lo = 0, hi = populatedTracks-1;
while (lo < hi) {
int mid = (lo+hi+1)/2;
if (time < fadedInTime[mid]) { hi = mid-1; }
else { lo = mid; }
}
while (lo+1 == populatedTracks && populatedTracks < length) {
populate(lo+1);
if (time < fadedInTime[lo+1]) { break; }
++lo;
}
return tracks[lo];
}
}
private final static Pattern optP = Pattern.compile("[a-zA-Z](?:\\s+(\\S+))?\\s*");
private final static PcmFormat rawDefault = new PcmFormat(44100, 16, 2);
private final static Pattern rawP = Pattern
.compile("(.*?)(?:\\s+-[a-z](?:\\s+[a-z0-9+][a-z0-9+\\-]*)?)*\\.raw", Pattern.CASE_INSENSITIVE);
private static int uintarg(Matcher m) {
String arg = m.group(1);
if (arg == null) { throw new IllegalArgumentException("Argument missing: "+m.group()); }
return Integer.parseUnsignedInt(arg);
}
public static String fileNameStem(String fileName) {
Matcher m = rawP.matcher(fileName);
if (m.matches()) { return m.group(1); }
int dotPos = fileName.lastIndexOf('.');
return dotPos < 0 ? fileName : fileName.substring(0, dotPos);
}
public static PcmFormat rawFormat(String fileName) {
int pad = rawDefault.fs()/rawDefault.channels()*8 - rawDefault.bips();
String fileStem = fileName.regionMatches(true, fileName.length()-4, ".raw", 0, 4)
? fileName.substring(0, fileName.length()-4)
: fileName;
return rawFormat(fileStem, rawDefault.rate(), rawDefault.bips(), pad, rawDefault.channels(), rawDefault.signed(), rawDefault.bigend());
}
private static PcmFormat rawFormat(String fileStem, int rate, int bits, int pad, int channels, boolean signed, boolean bigend) {
boolean unsigned = !signed;
String[] opts = (" "+fileStem).split("\\s+-");
for (int i = 1; i < opts.length; i++) {
Matcher m = optP.matcher(opts[i]);
if (!m.matches()) { throw new IllegalArgumentException("Invalid file format specification: "+opts[i]); }
switch (opts[i].charAt(0)) {
case 'r':
rate = uintarg(m);
break;
case 'b':
String barg = m.group(1);
int pluspos = barg.indexOf('+');
bits = Integer.parseUnsignedInt(pluspos < 0 ? barg : barg.substring(0, pluspos));
pad = pluspos < 0 ? 0 : Integer.parseUnsignedInt(barg.substring(pluspos+1));
break;
case 'c':
channels = uintarg(m);
break;
case 'e':
String arg = m.group(1);
boolean s = "signed-integer".startsWith(arg);
boolean u = "unsigned-integer".startsWith(arg);
if (signed == unsigned) { throw new IllegalArgumentException("Invalid encoding: "+opts[i]); }
signed = s;
break;
case 'B':
case 'L':
if (m.group(1) != null) { throw new IllegalArgumentException("Invalid file format specification: "+opts[i]); }
bigend = opts[i].charAt(0) == 'B';
break;
default:
throw new IllegalArgumentException("Invalid file format specification: "+opts[i]);
}
}
if ((bits+pad) % 8 != 0 || bits+pad > 32) { throw new IllegalArgumentException("Invalid bits specification: "+bits+"+"+pad); }
return new PcmFormat(rate, bits, channels, signed, (bits+pad)/8*channels, bigend ? ByteOrder.BIG_ENDIAN : ByteOrder.LITTLE_ENDIAN, bits);
}
public static String rawParamString(PcmFormat format) {
return " -r "+format.rate()
+" -b "+rawBipsString(format)
+" -c "+format.getChannels()
+" -e "+(format.signed() ? "signed-integer" : "unsigned-integer")
+(format.isBigEndian() ? " -B" : " -L");
}
public static String rawParamQualityString(PcmFormat format) {
return " -r "+format.rate()
+" -b "+rawBipsString(format)
+" -c "+format.getChannels();
}
public static String rawBipsString(PcmFormat format) {
int pad = format.fs()/format.channels()*8 - format.bips();
return format.bips()+(pad == 0 ? "" : "+"+pad);
}
public static Track single(String path) throws IOException {
return List.single(path).ofIndex(0);
}
private final List list;
public final int index;
private Track(List list, int index) {
this.list = list;
this.index = index;
}
public int trackNo() { return list.firstTrackNo + index; }
public boolean decode(long fromFrame, PcmWriter writer) throws IOException {
return list.decodeTrack(index, fromFrame, writer);
}
/** Audio format obtained from metadata of file. */
public PcmFormat format() { return list.formats[index]; }
/** Start time of track from beginning of track list. */
public double startTimeSecs() { return list.startTime[index]; }
/** Time when the following track starts playing, or playing ends if this is last track. */
public double outTimeSecs() { return list.outTime[index]; }
/** Playing time of track. The same as <code>outTimeSecs() - startTimeSecs()</code>. */
public double playTimeSecs() { return outTimeSecs() - startTimeSecs(); }
/** Number of played frames in the track. */
public long frames() { return list.frames[index]; }
/** Type of fade. */
public MixSpec.Fade fade() { return list.mixs[index].fade; }
/** Checks if last in track list. */
public boolean lastTrack() { return index == list.length-1; }
/** Check if first in chain of compatible-format tracks. */
public boolean firstInChain() { return index == 0 || !list.formats[index].matches(list.formats[index-1]); }
/** Byte-unit limits of track, using given frame size rather than that of source metadata. */
public Limits limits(int fs, long start) {
float r = list.formats[index].getSampleRate();
long fadeInFrames = (long) (list.mixs[index].fadeIn * r);
long fadeOutFrames = index+1 < list.length ? (long) (list.mixs[index+1].fadeIn * r) : 0;
long relEndFrames = (list.mixs[index].end >= 0
? Math.min(list.frames[index], (long) (list.mixs[index].end * r))
: list.frames[index]);
long out = fadeOutFrames * fs;
long in = fadeInFrames * fs;
long len = relEndFrames * fs;
return new Limits(start, start+in, start+len-out, start+len);
}
public String path() { return list.paths[index]; }
/** If decoding is in progress, the value of {@link Audiodecoder#quality()}, otherwise null. */
public String quality() { return list.decodingQuality(); }
/** For debugging convenience. */
public String toString() { return "Track "+(list.firstTrackNo+index)+" "+list.paths[index]; }
}
Version: v4.3.2.2 (2026-05-16T17:03:34+02:00)
Raw file
Source code overview
Klipspringer home