From 6fc22ab542e9283bd90a815e173966b66d7724bc Mon Sep 17 00:00:00 2001 From: Coloryr <402067010@qq.com> Date: Mon, 19 Oct 2026 21:29:35 +0800 Subject: [PATCH] flac --- .../allmusic/client/core/AllMusicPlayer.java | 29 +++ .../flac/AbstractFlacLowLevelInput.java | 14 ++ .../core/player/decoder/flac/FlacDecoder.java | 194 +++++++++++++++++- .../decoder/flac/FlacLowLevelInput.java | 13 ++ .../decoder/flac/SeekableFileFlacInput.java | 31 ++- 5 files changed, 268 insertions(+), 13 deletions(-) diff --git a/client/src/main/java/com/coloryr/allmusic/client/core/AllMusicPlayer.java b/client/src/main/java/com/coloryr/allmusic/client/core/AllMusicPlayer.java index 1b2ff0d0..15be1680 100644 --- a/client/src/main/java/com/coloryr/allmusic/client/core/AllMusicPlayer.java +++ b/client/src/main/java/com/coloryr/allmusic/client/core/AllMusicPlayer.java @@ -3,11 +3,13 @@ package com.coloryr.allmusic.client.core; import com.coloryr.allmusic.client.core.objs.PlayTaskObj; import com.coloryr.allmusic.client.core.player.decoder.BuffPack; import com.coloryr.allmusic.client.core.player.decoder.IDecoder; +import com.coloryr.allmusic.client.core.player.decoder.flac.FlacDecoder; import com.coloryr.allmusic.client.core.player.decoder.m4a.M4ADecoder; import com.coloryr.allmusic.client.core.player.decoder.mp3.Mp3Decoder; import com.coloryr.allmusic.client.core.player.decoder.ogg.OggDecoder; import org.apache.hc.client5.http.classic.methods.HttpGet; import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse; +import org.apache.hc.core5.http.Header; import org.apache.hc.core5.http.HttpEntity; import org.apache.hc.core5.io.CloseMode; import org.lwjgl.BufferUtils; @@ -41,6 +43,7 @@ public class AllMusicPlayer extends InputStream { private int index = -1; private final IntBuffer source; private long local; + private long contentLength = -1; private boolean isRun; private boolean isChat; private int chatCount = 0; @@ -85,6 +88,7 @@ public class AllMusicPlayer extends InputStream { public void connect() throws IOException { streamClose(); + contentLength = -1; HttpGet request = new HttpGet(nowTask.url); request.setHeader("Range", "bytes=" + local + "-"); response = AllMusicCore.client.execute(request); @@ -96,6 +100,20 @@ public class AllMusicPlayer extends InputStream { if (entity == null) { throw new IOException("Response entity is null"); } + Header contentRange = response.getFirstHeader("Content-Range"); + if (contentRange != null) { + String value = contentRange.getValue(); + int slash = value.lastIndexOf('/'); + if (slash >= 0 && slash + 1 < value.length()) { + try { + contentLength = Long.parseLong(value.substring(slash + 1).trim()); + } catch (NumberFormatException ignored) { + } + } + } + if (contentLength < 0 && local == 0) { + contentLength = entity.getContentLength(); + } content = new BufferedInputStream(entity.getContent()); } @@ -205,6 +223,8 @@ public class AllMusicPlayer extends InputStream { || (head[0] == (byte) 0xFF && head[1] == (byte) 0xE3) || (head[0] == (byte) 0xFF && head[1] == (byte) 0xE2)) { decoder = new Mp3Decoder(this); + } else if (head[0] == 'f' && head[1] == 'L' && head[2] == 'a' && head[3] == 'C') { + decoder = new FlacDecoder(this); } else { decoder = new OggDecoder(this); } @@ -432,4 +452,13 @@ public class AllMusicPlayer extends InputStream { this.local = local; connect(); } + + /** + * Returns the total number of bytes of the resource currently being played, or -1 if the + * server did not report it. This is used to estimate byte positions when seeking in formats + * that do not carry their own seek table. + */ + public long getContentLength() { + return contentLength; + } } \ No newline at end of file diff --git a/client/src/main/java/com/coloryr/allmusic/client/core/player/decoder/flac/AbstractFlacLowLevelInput.java b/client/src/main/java/com/coloryr/allmusic/client/core/player/decoder/flac/AbstractFlacLowLevelInput.java index 42f2329f..ae9507d6 100644 --- a/client/src/main/java/com/coloryr/allmusic/client/core/player/decoder/flac/AbstractFlacLowLevelInput.java +++ b/client/src/main/java/com/coloryr/allmusic/client/core/player/decoder/flac/AbstractFlacLowLevelInput.java @@ -117,6 +117,20 @@ public abstract class AbstractFlacLowLevelInput implements FlacLowLevelInput { resetCrcs(); } + // Seeks to the given byte position if it is still contained in the current byte buffer. + // This never touches the underlying stream, so it is cheap, and it allows a few bytes to be + // re-read (for example after probing a frame header). Returns true on success, or false if + // the position is not buffered anymore and a full seek is required. + public boolean seekWithinBuffer(long pos) { + if (byteBufferLen <= 0 || pos < byteBufferStartPos || pos >= byteBufferStartPos + byteBufferLen) + return false; + byteBufferIndex = (int) (pos - byteBufferStartPos); + bitBuffer = 0; // The bits are read from the byte buffer again below + bitBufferLen = 0; + resetCrcs(); + return true; + } + /*-- CRC calculations --*/ // Either returns silently or throws an exception. diff --git a/client/src/main/java/com/coloryr/allmusic/client/core/player/decoder/flac/FlacDecoder.java b/client/src/main/java/com/coloryr/allmusic/client/core/player/decoder/flac/FlacDecoder.java index d258a3c1..ab9c9218 100644 --- a/client/src/main/java/com/coloryr/allmusic/client/core/player/decoder/flac/FlacDecoder.java +++ b/client/src/main/java/com/coloryr/allmusic/client/core/player/decoder/flac/FlacDecoder.java @@ -17,11 +17,11 @@ package com.coloryr.allmusic.client.core.player.decoder.flac; -import com.coloryr.allmusic.client.core.AllMusicCore; import com.coloryr.allmusic.client.core.AllMusicPlayer; import com.coloryr.allmusic.client.core.player.decoder.BuffPack; import com.coloryr.allmusic.client.core.player.decoder.IDecoder; +import java.io.EOFException; import java.io.IOException; /** @@ -44,7 +44,7 @@ import java.io.IOException; * dec.readAudioBlock(samples, ...); * * // Seek to some position and continue reading - * dec.seekAndReadAudioBlock(..., samples, ...); + * dec.set(60000); * dec.readAudioBlock(samples, ...); * dec.readAudioBlock(samples, ...); * @@ -57,6 +57,10 @@ import java.io.IOException; */ public final class FlacDecoder implements AutoCloseable, IDecoder { + // The maximum number of times the byte position is re-estimated while seeking in a stream + // that does not contain a seek table. + private static final int MAX_SEEK_ATTEMPTS = 8; + private final AllMusicPlayer player; /*---- Fields ----*/ private final BuffPack pack = new BuffPack(); @@ -72,6 +76,12 @@ public final class FlacDecoder implements AutoCloseable, IDecoder { private int[][] samples; private byte[] sampleBytes; + // Samples that were decoded but not handed out yet. This happens after a seek when the + // requested sample lies in the middle of a block: [decodedOffset, decodedOffset + + // decodedLength) of every channel still has to be returned by decodeFrame(). + private int decodedOffset; + private int decodedLength; + // Constructs a new FLAC decoder to read the given file. // This immediately reads the basic header but not metadata blocks. public FlacDecoder(AllMusicPlayer player) { @@ -129,11 +139,16 @@ public final class FlacDecoder implements AutoCloseable, IDecoder { @Override public BuffPack decodeFrame() throws Exception { - int blockSamples = readAudioBlock(samples, 0); + int blockSamples = decodedLength; + if (blockSamples == 0) { + blockSamples = readAudioBlock(samples, 0); + decodedOffset = 0; + } + decodedLength = 0; int sampleBytesLen = 0; for (int i = 0; i < blockSamples; i++) { for (int ch = 0; ch < streamInfo.numChannels; ch++) { - int val = samples[ch][i]; + int val = samples[ch][decodedOffset + i]; if (streamInfo.sampleDepth == 24) { float temp = val / 16777216f; val = (int) (temp * 0x7FFF); @@ -159,6 +174,8 @@ public final class FlacDecoder implements AutoCloseable, IDecoder { input.close(); input = null; } + decodedOffset = 0; + decodedLength = 0; } @Override @@ -178,6 +195,8 @@ public final class FlacDecoder implements AutoCloseable, IDecoder { int bytesPerSample = streamInfo.sampleDepth / 8; samples = new int[streamInfo.numChannels][65536]; sampleBytes = new byte[65536 * streamInfo.numChannels * bytesPerSample]; + decodedOffset = 0; + decodedLength = 0; return true; } @@ -194,6 +213,171 @@ public final class FlacDecoder implements AutoCloseable, IDecoder { @Override public void set(int time) { - AllMusicCore.bridge.sendMessage("不支持中间播放"); + if (input == null || streamInfo == null || frameDec == null) { + return; + } + try { + long sample = (long) time * streamInfo.sampleRate / 1000L; + if (sample < 0) sample = 0; + if (streamInfo.numSamples > 0 && sample >= streamInfo.numSamples) { + sample = streamInfo.numSamples - 1; + } + seekToSample(sample); + } catch (Exception e) { + e.printStackTrace(); + } + } + + /*---- Seeking ----*/ + + // Seeks to the given sample index (counted from the beginning of the audio stream) and + // positions the decoder so that the next decoded block starts at that sample. The stream is + // moved with a seek (a range request), so the data before the target is not read or decoded. + private void seekToSample(long target) throws IOException { + decodedOffset = 0; + decodedLength = 0; + + // A seek table maps sample indices to byte positions, so it allows jumping directly to + // the closest known frame before the target and decoding only the frames in between. + SeekTable.SeekPoint point = findSeekPoint(target); + if (point != null) { + input.seekTo(metadataEndPos + point.fileOffset); + skipSamples(target - point.sampleOffset); + return; + } + + long length = input.getLength(); + if (streamInfo.numSamples <= 0 || length <= metadataEndPos) { + // The total length of the stream is unknown, so no byte position can be estimated; + // fall back to decoding from the beginning of the audio data. + input.seekTo(metadataEndPos); + skipSamples(target); + return; + } + + // There is no seek table: estimate the byte position from the average bitrate. Scanning + // forward from a position always finds the frame after it, so the position is moved back + // by a safety margin to make sure that the frame before the target (or an even earlier + // one) is found. If the bitrate is not constant the margin is doubled until it is enough. + long pos = estimatePosition(target, streamInfo.numSamples, metadataEndPos, length); + long margin = Math.max(65536L, streamInfo.maxFrameSize * 4L); + for (int i = 0; i < MAX_SEEK_ATTEMPTS; i++) { + pos = Math.max(metadataEndPos, pos - margin); + input.seekTo(pos); + long frameSample = scanToNextFrame(); + if (frameSample < 0) { + break; // No frame was found, the estimate was too close to the end of the stream + } + if (frameSample <= target) { + skipSamples(target - frameSample); + return; + } + // The frame found is still after the target: back off further and try again. + margin = Math.min(margin * 2, Math.max(1L, pos - metadataEndPos)); + } + + // Decoding from the beginning is always correct, it is only used as a last resort. + input.seekTo(metadataEndPos); + skipSamples(target); + } + + // Returns the seek point with the greatest sample offset that is not after the given sample, + // or null if the stream does not contain a usable seek table. + private SeekTable.SeekPoint findSeekPoint(long sample) { + if (seekTable == null || seekTable.points == null) return null; + SeekTable.SeekPoint result = null; + for (SeekTable.SeekPoint point : seekTable.points) { + if (point == null || point.sampleOffset < 0) continue; // Placeholder point + if (point.sampleOffset > sample) continue; + if (result == null || point.sampleOffset > result.sampleOffset) result = point; + } + return result; + } + + // Returns the byte position that corresponds to the given sample index, assuming that the + // samples are spread evenly over the byte range [start, end). Pure function. + private static long estimatePosition(long sample, long totalSamples, long start, long end) { + if (sample <= 0 || totalSamples <= 0 || end <= start) return start; + long pos = start + (long) ((double) sample / totalSamples * (end - start)); + if (pos < start) return start; + if (pos >= end) return end - 1; + return pos; + } + + // Scans forward from the current byte position for the next valid FLAC frame header, leaves + // the stream at the beginning of that frame, and returns its sample index, or -1 if the end + // of the stream was reached. This is used to seek in streams without a seek table. + private long scanToNextFrame() throws IOException { + long pos = input.getPosition(); + int previous = -1; + while (true) { + int current; + try { + current = input.readByte(); + } catch (EOFException e) { + return -1; + } + if (current == -1) return -1; + + // The sync code is 14 one-bits followed by a zero reserved bit, so the first two + // bytes of a frame are 0xFF and 0xF8 or 0xF9. + if (previous == 0xFF && (current & 0xFE) == 0xF8) { + long start = pos - 1; + if (!input.seekWithinBuffer(start)) input.seekTo(start); + + long index = -1; + try { + FrameInfo info = FrameInfo.readFrame(input); + if (info == null) return -1; // Clean end of stream + index = getSampleIndex(info); + } catch (DataFormatException e) { + // A false sync code inside a frame; keep searching + } catch (EOFException e) { + return -1; + } + + if (index >= 0) { + // Rewind so that the frame can be decoded from its very first byte + if (!input.seekWithinBuffer(start)) input.seekTo(start); + return index; + } + + // Continue scanning right after the sync code that turned out to be invalid + if (!input.seekWithinBuffer(start + 1)) input.seekTo(start + 1); + pos = start + 1; + previous = -1; + continue; + } + + previous = current; + pos++; + } + } + + // Returns the absolute sample index of the given frame header, or -1 if the header does not + // carry one (which the FLAC format guarantees, so this should not happen). + private long getSampleIndex(FrameInfo info) { + if (info.sampleOffset >= 0) return info.sampleOffset; + if (info.frameIndex >= 0) return (long) info.frameIndex * streamInfo.maxBlockSize; + return -1; + } + + // Decodes and discards audio blocks until the given number of samples has been skipped. The + // block that contains the target is kept for the following decodeFrame() calls, so playback + // continues exactly at the requested sample. + private void skipSamples(long skip) throws IOException { + decodedOffset = 0; + decodedLength = 0; + while (skip > 0) { + int blockSamples = readAudioBlock(samples, 0); + if (blockSamples == 0) return; // End of stream + if (skip >= blockSamples) { + skip -= blockSamples; + } else { + decodedOffset = (int) skip; + decodedLength = blockSamples - (int) skip; + return; + } + } } } diff --git a/client/src/main/java/com/coloryr/allmusic/client/core/player/decoder/flac/FlacLowLevelInput.java b/client/src/main/java/com/coloryr/allmusic/client/core/player/decoder/flac/FlacLowLevelInput.java index 46f68bbb..a73aefe3 100644 --- a/client/src/main/java/com/coloryr/allmusic/client/core/player/decoder/flac/FlacLowLevelInput.java +++ b/client/src/main/java/com/coloryr/allmusic/client/core/player/decoder/flac/FlacLowLevelInput.java @@ -47,6 +47,19 @@ public interface FlacLowLevelInput extends AutoCloseable { // increments for each bit consumed, maxes out at 7, then resets to 0 and repeats. public int getBitPosition(); + /*---- Seeking ----*/ + + // Seeks to the given byte position in the stream, a non-negative value. After this call, + // getPosition() returns pos, getBitPosition() returns 0, and the CRC calculations are reset. + // This method may throw UnsupportedOperationException if this stream cannot seek. + public void seekTo(long pos) throws IOException; + + // Tries to seek to the given byte position using only data that is still held in memory, + // without touching the underlying stream. Returns true if the position was reached, or false + // if the position is not buffered anymore (in which case seekTo() has to be used). + // The stream must be at a byte boundary (i.e. getBitPosition() == 0). + public boolean seekWithinBuffer(long pos); + /*---- Reading bitwise integers ----*/ // Reads the next given number of bits (0 <= n <= 32) as an unsigned integer (i.e. zero-extended to int32). diff --git a/client/src/main/java/com/coloryr/allmusic/client/core/player/decoder/flac/SeekableFileFlacInput.java b/client/src/main/java/com/coloryr/allmusic/client/core/player/decoder/flac/SeekableFileFlacInput.java index fde53259..f636106f 100644 --- a/client/src/main/java/com/coloryr/allmusic/client/core/player/decoder/flac/SeekableFileFlacInput.java +++ b/client/src/main/java/com/coloryr/allmusic/client/core/player/decoder/flac/SeekableFileFlacInput.java @@ -21,15 +21,22 @@ import com.coloryr.allmusic.client.core.AllMusicPlayer; import java.io.BufferedInputStream; import java.io.IOException; -import java.io.RandomAccessFile; /** - * A FLAC input stream based on a {@link RandomAccessFile}. + * A FLAC input stream backed by the stream of an {@link AllMusicPlayer}. + *
+ * The player streams the music over HTTP and can re-open its stream at any byte position + * (by issuing a range request), which is what {@link #seekTo(long)} uses. Whenever the + * stream is re-opened, all the data that was buffered for the previous position is dropped. + *
*/ public final class SeekableFileFlacInput extends AbstractFlacLowLevelInput { /*---- Fields ----*/ + // The player that provides the stream; also knows the total length and can seek it. + private final AllMusicPlayer player; + // The underlying byte-based input stream to read from. private BufferedInputStream raf; @@ -37,24 +44,32 @@ public final class SeekableFileFlacInput extends AbstractFlacLowLevelInput { public SeekableFileFlacInput(AllMusicPlayer player) { super(); + this.player = player; this.raf = new BufferedInputStream(player); } /*---- Methods ----*/ public long getLength() { - try { - return raf.available(); - } catch (IOException e) { - throw new RuntimeException(e); - } + // The total length of the resource; -1 if the server did not tell us. + return player.getContentLength(); } protected int readUnderlying(byte[] buf, int off, int len) throws IOException { return raf.read(buf, off, len); } - // Closes the underlying RandomAccessFile stream (very important). + // Re-opens the underlying stream at the given byte position and discards all the data + // that was buffered for the previous position. This is cheap compared to reading (and + // decoding) everything in between the old and the new position. + public void seekTo(long pos) throws IOException { + if (pos < 0) throw new IllegalArgumentException("Negative seek position"); + player.setLocal(pos); + raf = new BufferedInputStream(player); + positionChanged(pos); + } + + // Closes the underlying stream (very important). public void close() throws IOException { if (raf != null) { raf.close();