1 Commits

Author SHA1 Message Date
rattatwinko 91812a3105 fix: Wavy not being able to play FLAC, due too jflac
fix-details: Now using FFMpeg for anything audio related
dependency-add: ffmpeg (bytedeco) JavaCV (for ffmpeg; also by bytedeco)
soon-to-deprecate/remove: the old jflac and mp3spi dependencies, which are not needed anymore
add: a higher resolution waterfall diagram
TODO: Ship builds for Linux

notes: The Wavy Player is now much more stable. It still relies on the java sound API, but much less in terms of decoding any codecs. Which is now handled by FFMpeg instead of our own. Playback is still done through the java sound API tho!

---
7\ - rattatwinko 31/05/2026@19:11
2026-05-31 19:11:51 +02:00
6 changed files with 210 additions and 157 deletions
+15
View File
@@ -120,6 +120,7 @@
<artifactId>languagesupport</artifactId> <artifactId>languagesupport</artifactId>
<version>3.4.1</version> <version>3.4.1</version>
</dependency> </dependency>
<!-- multimedia -->
<dependency> <dependency>
<groupId>com.googlecode.soundlibs</groupId> <groupId>com.googlecode.soundlibs</groupId>
<artifactId>mp3spi</artifactId> <artifactId>mp3spi</artifactId>
@@ -130,6 +131,20 @@
<artifactId>jflac-codec</artifactId> <artifactId>jflac-codec</artifactId>
<version>1.5.2</version> <version>1.5.2</version>
</dependency> </dependency>
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>ffmpeg</artifactId>
<version>6.1.1-1.5.10</version>
<classifier>windows-x86_64</classifier>
</dependency>
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>javacv</artifactId>
<version>1.5.10</version>
</dependency>
</dependencies> </dependencies>
</project> </project>
+2 -1
View File
@@ -5,7 +5,8 @@ import com.rdesk.kernel.BootProc;
public class Main { public class Main {
public static void main(String[] args) { public static void main(String[] args) {
boolean dbg = true; boolean dbg = false;
if (!dbg) { if (!dbg) {
Login.main(args); Login.main(args);
} else { } else {
@@ -1,7 +1,11 @@
package com.rdesk.applications.entertainment.media; package com.rdesk.applications.entertainment.media;
import org.bytedeco.javacv.FFmpegFrameGrabber;
import org.bytedeco.javacv.Frame;
import org.bytedeco.javacv.FrameGrabber;
import javax.sound.sampled.*; import javax.sound.sampled.*;
import java.io.*; import java.nio.ShortBuffer;
import java.util.concurrent.*; import java.util.concurrent.*;
import java.util.function.*; import java.util.function.*;
@@ -15,14 +19,12 @@ public class AudioEngine {
private AudioFormat currentFormat; private AudioFormat currentFormat;
private volatile float volume = 0.8f; // 0..1 private volatile float volume = 0.8f; // 0..1
// eq
private static final int EQ_BANDS = 10; private static final int EQ_BANDS = 10;
private static final float[] EQ_FREQS = { 32, 64, 125, 250, 500, 1000, 2000, 4000, 8000, 16000 }; private static final float[] EQ_FREQS = { 32, 64, 125, 250, 500, 1000, 2000, 4000, 8000, 16000 };
private final float[] eqGainDb = new float[EQ_BANDS]; private final float[] eqGainDb = new float[EQ_BANDS];
private final float[][] bqCoeff = new float[EQ_BANDS][5]; private final float[][] bqCoeff = new float[EQ_BANDS][5];
private final float[][][] bqState = new float[EQ_BANDS][2][2]; private final float[][][] bqState = new float[EQ_BANDS][2][2];
// fft
private static final int FFT_SIZE = 4096; private static final int FFT_SIZE = 4096;
private Consumer<float[]> fftListener; private Consumer<float[]> fftListener;
private final float[] fftBuffer = new float[FFT_SIZE]; private final float[] fftBuffer = new float[FFT_SIZE];
@@ -40,7 +42,6 @@ public class AudioEngine {
private double requestedSeekFraction = 0.0; private double requestedSeekFraction = 0.0;
private boolean seekPending = false; private boolean seekPending = false;
// Public API
public AudioEngine() { initEq(); } public AudioEngine() { initEq(); }
public void setOnTrackEnd(Runnable r) { this.onTrackEnd = r; } public void setOnTrackEnd(Runnable r) { this.onTrackEnd = r; }
@@ -118,60 +119,49 @@ public class AudioEngine {
currentTrack = track; currentTrack = track;
state = State.PLAYING; state = State.PLAYING;
FFmpegFrameGrabber grabber = null;
try { try {
AudioInputStream rawStream = AudioSystem.getAudioInputStream(track.getFilePath().toFile()); grabber = new FFmpegFrameGrabber(track.getFilePath().toFile());
AudioFormat rawFmt = rawStream.getFormat(); grabber.setSampleMode(FrameGrabber.SampleMode.SHORT); // always 16-bit signed interleaved
grabber.setOption("probesize", "5000000");
grabber.setOption("analyzeduration", "5000000");
grabber.start();
AudioFormat pcmFmt = new AudioFormat( final int sampleRate = grabber.getSampleRate();
final int channels = grabber.getAudioChannels();
final int frameSize = channels * 2; // 16-bit to 2 bytes per sample per channel
currentFormat = new AudioFormat(
AudioFormat.Encoding.PCM_SIGNED, AudioFormat.Encoding.PCM_SIGNED,
rawFmt.getSampleRate(), sampleRate, 16, channels,
16, frameSize, sampleRate, false);
rawFmt.getChannels(),
rawFmt.getChannels() * 2,
rawFmt.getSampleRate(),
false);
AudioInputStream pcmStream; long durationMicros = grabber.getLengthInTime();
if (rawFmt.getEncoding() != AudioFormat.Encoding.PCM_SIGNED if (durationMicros > 0) {
|| rawFmt.getSampleSizeInBits() != 16) { long ffmpegSec = durationMicros / 1_000_000L;
pcmStream = AudioSystem.getAudioInputStream(pcmFmt, rawStream); totalFrames = (long)(durationMicros / 1_000_000.0 * sampleRate);
} else {
pcmStream = rawStream;
pcmFmt = rawFmt;
}
currentFormat = pcmFmt;
int frameSize = pcmFmt.getFrameSize();
float sampleRate = pcmFmt.getSampleRate();
int channels = pcmFmt.getChannels();
long pcmFrames = pcmStream.getFrameLength();
if (pcmFrames > 0 && pcmFrames != AudioSystem.NOT_SPECIFIED) {
totalFrames = pcmFrames;
if (track.getDurationSeconds() == 0) if (track.getDurationSeconds() == 0)
track.setDurationSeconds((long)(pcmFrames / sampleRate)); track.setDurationSeconds(ffmpegSec);
} else if (track.getDurationSeconds() > 0) {
totalFrames = (long)(track.getDurationSeconds() * sampleRate);
} else { } else {
if (track.getDurationSeconds() > 0)
totalFrames = track.getDurationSeconds() * sampleRate;
else
totalFrames = 0; totalFrames = 0;
} }
DataLine.Info lineInfo = new DataLine.Info(SourceDataLine.class, pcmFmt); DataLine.Info lineInfo = new DataLine.Info(SourceDataLine.class, currentFormat);
dataLine = (SourceDataLine) AudioSystem.getLine(lineInfo); dataLine = (SourceDataLine) AudioSystem.getLine(lineInfo);
dataLine.open(pcmFmt, (int)(sampleRate * frameSize * 0.1f)); dataLine.open(currentFormat, (int)(sampleRate * frameSize * 0.1f));
dataLine.start(); dataLine.start();
applyVolumeToLine(); // set initial volume applyVolumeToLine();
resetBiquadState(); resetBiquadState();
final int BLOCK = 4096;
byte[] rawBuf = new byte[BLOCK];
float[] samples = new float[BLOCK / 2];
positionFrames = 0; positionFrames = 0;
float[] samples = new float[8192];
while (!stopRequested) { while (!stopRequested) {
// Pause
synchronized (this) { synchronized (this) {
while (pauseRequested && !stopRequested) { while (pauseRequested && !stopRequested) {
try { wait(50); } catch (InterruptedException e) { break; } try { wait(50); } catch (InterruptedException e) { break; }
@@ -179,75 +169,69 @@ public class AudioEngine {
} }
if (stopRequested) break; if (stopRequested) break;
// Seek
if (seekPending) { if (seekPending) {
seekPending = false; seekPending = false;
pcmStream.close();
rawStream.close();
rawStream = AudioSystem.getAudioInputStream(track.getFilePath().toFile());
if (rawFmt.getEncoding() != AudioFormat.Encoding.PCM_SIGNED
|| rawFmt.getSampleSizeInBits() != 16) {
pcmStream = AudioSystem.getAudioInputStream(pcmFmt, rawStream);
} else {
pcmStream = rawStream;
}
if (totalFrames > 0) { if (totalFrames > 0) {
long targetFrame = (long)(requestedSeekFraction * totalFrames); long targetMicros = (long)(requestedSeekFraction
long skip = targetFrame * frameSize; * (durationMicros > 0 ? durationMicros
byte[] skipBuf = new byte[8192]; : track.getDurationSeconds() * 1_000_000L));
long skipped = 0; grabber.setTimestamp(targetMicros, /* keyFrame */ true);
while (skipped < skip && !stopRequested) { positionFrames = (long)((targetMicros / 1_000_000.0) * sampleRate);
int toSkip = (int) Math.min(skipBuf.length, skip - skipped);
int r = pcmStream.read(skipBuf, 0, toSkip);
if (r < 0) break;
skipped += r;
}
positionFrames = targetFrame;
} }
resetBiquadState(); resetBiquadState();
continue; continue;
} }
int bytesRead = pcmStream.read(rawBuf); Frame frame = grabber.grabSamples();
if (bytesRead <= 0) break; if (frame == null || frame.samples == null || frame.samples.length == 0) {
// End of stream
// bytes to float break;
int sampleCount = bytesRead / 2;
for (int i = 0; i < sampleCount; i++) {
int lo = rawBuf[i*2] & 0xFF;
int hi = rawBuf[i*2+1];
samples[i] = (hi << 8 | lo) / 32768.0f;
} }
ShortBuffer sb = (ShortBuffer) frame.samples[0];
sb.rewind();
int sampleCount = sb.remaining();
// Grow float buffer if needed
if (samples.length < sampleCount) samples = new float[sampleCount];
// short to float (-1..1)
for (int i = 0; i < sampleCount; i++)
samples[i] = sb.get() / 32768.0f;
applyEq(samples, sampleCount, channels); applyEq(samples, sampleCount, channels);
feedFft(samples, sampleCount, channels); feedFft(samples, sampleCount, channels);
// other way around, float to bytes // float to short to bytes
byte[] pcmBytes = new byte[sampleCount * 2];
for (int i = 0; i < sampleCount; i++) { for (int i = 0; i < sampleCount; i++) {
float s = Math.max(-1f, Math.min(1f, samples[i])); float s = Math.max(-1f, Math.min(1f, samples[i]));
short sh = (short)(s * 32767f); short sh = (short)(s * 32767f);
rawBuf[i*2] = (byte)(sh & 0xFF); pcmBytes[i*2] = (byte)( sh & 0xFF);
rawBuf[i*2+1] = (byte)((sh >> 8) & 0xFF); pcmBytes[i*2 + 1] = (byte)((sh >> 8) & 0xFF);
} }
dataLine.write(rawBuf, 0, bytesRead); dataLine.write(pcmBytes, 0, pcmBytes.length);
positionFrames += bytesRead / frameSize;
positionFrames += sampleCount / channels;
if (onPositionUpdate != null) onPositionUpdate.accept(positionFrames); if (onPositionUpdate != null) onPositionUpdate.accept(positionFrames);
} }
pcmStream.close();
rawStream.close();
if (!stopRequested) dataLine.drain(); if (!stopRequested) dataLine.drain();
closeLine(); closeLine();
grabber.stop();
grabber.release();
grabber = null;
state = State.STOPPED; state = State.STOPPED;
if (!stopRequested && onTrackEnd != null) onTrackEnd.run(); if (!stopRequested && onTrackEnd != null) onTrackEnd.run();
} catch (UnsupportedAudioFileException e) { } catch (Exception e) {
System.err.println("[Wavy] Unsupported format: " + track.getFilePath()); System.err.println("[Wavy] Playback error for: " + track.getFilePath() + "" + e.getMessage());
state = State.STOPPED;
} catch (LineUnavailableException | IOException e) {
e.printStackTrace();
state = State.STOPPED; state = State.STOPPED;
if (grabber != null) {
try { grabber.stop(); grabber.release(); } catch (Exception ignored) {}
}
} }
} }
@@ -262,15 +246,13 @@ public class AudioEngine {
if (dataLine == null || !dataLine.isOpen()) return; if (dataLine == null || !dataLine.isOpen()) return;
if (dataLine.isControlSupported(FloatControl.Type.MASTER_GAIN)) { if (dataLine.isControlSupported(FloatControl.Type.MASTER_GAIN)) {
FloatControl gc = (FloatControl) dataLine.getControl(FloatControl.Type.MASTER_GAIN); FloatControl gc = (FloatControl) dataLine.getControl(FloatControl.Type.MASTER_GAIN);
// map 0..1 linearly to the controls min..max db range
float minDb = gc.getMinimum(); float minDb = gc.getMinimum();
float maxDb = 0f; // 0 dB is the unity gain float maxDb = 0f;
float db = (volume == 0f) ? minDb : minDb + (maxDb - minDb) * volume; float db = (volume == 0f) ? minDb : minDb + (maxDb - minDb) * volume;
gc.setValue(Math.max(minDb, Math.min(maxDb, db))); gc.setValue(Math.max(minDb, Math.min(maxDb, db)));
} }
} }
// EQ
private void initEq() { private void initEq() {
for (int b = 0; b < EQ_BANDS; b++) { eqGainDb[b] = 0f; recomputeBiquad(b); } for (int b = 0; b < EQ_BANDS; b++) { eqGainDb[b] = 0f; recomputeBiquad(b); }
} }
@@ -315,10 +297,11 @@ public class AudioEngine {
for (float[][] b : bqState) for (float[] c : b) { c[0]=0; c[1]=0; } for (float[][] b : bqState) for (float[] c : b) { c[0]=0; c[1]=0; }
} }
// fast fourier transform ( algo: cooley turkey, with the radix 2 dit case ) // FFT (Cooley-Tukey radix-2 DIT — unchanged)
// links / references which may be helpful while reading through the code: // Links / references which may be helpful while reading through the code:
// https://en.wikipedia.org/wiki/Cooley%E2%80%93Tukey_FFT_algorithm // https://en.wikipedia.org/wiki/Cooley%E2%80%93Tukey_FFT_algorithm
// https://en.wikipedia.org/wiki/Cooley%E2%80%93Tukey_FFT_algorithm#The_radix-2_DIT_case // https://en.wikipedia.org/wiki/Cooley%E2%80%93Tukey_FFT_algorithm#The_radix-2_DIT_case
private void feedFft(float[] samples, int count, int channels) { private void feedFft(float[] samples, int count, int channels) {
if (fftListener == null) return; if (fftListener == null) return;
for (int i = 0; i < count; i += channels) { for (int i = 0; i < count; i += channels) {
@@ -341,7 +324,6 @@ public class AudioEngine {
for (int i = 0; i < n; i++) for (int i = 0; i < n; i++)
re[i] *= 0.5f * (1 - (float)Math.cos(2*Math.PI*i/(n-1))); re[i] *= 0.5f * (1 - (float)Math.cos(2*Math.PI*i/(n-1)));
// bit reversal // bit reversal
int bits = (int)(Math.log(n)/Math.log(2));
for (int j=1, i=0; j<n; j++) { for (int j=1, i=0; j<n; j++) {
int bit = n>>1; int bit = n>>1;
for (; (i&bit)!=0; bit>>=1) i^=bit; for (; (i&bit)!=0; bit>>=1) i^=bit;
@@ -11,6 +11,7 @@ public class EqualizerPanel extends JPanel {
}; };
// TODO: load from JSON in rsys directory (~/rsys/wavy_dat/eq/presets.json) // TODO: load from JSON in rsys directory (~/rsys/wavy_dat/eq/presets.json)
// TODO-TODO: This is actually a really shitty idea? We dont distribute the User rsys folder! (Keep this)
private static final String[][] PRESETS = { private static final String[][] PRESETS = {
{"Flat", "0,0,0,0,0,0,0,0,0,0"}, {"Flat", "0,0,0,0,0,0,0,0,0,0"},
{"Bass Boost", "8,6,4,2,0,0,0,0,0,0"}, {"Bass Boost", "8,6,4,2,0,0,0,0,0,0"},
@@ -98,17 +98,29 @@ public class SpectrogramPanel extends JPanel {
waterfallG.copyArea(0, 1, w, h - 1, 0, -1); waterfallG.copyArea(0, 1, w, h - 1, 0, -1);
float[] bars = computeBars(); computeBars();
int cellW = Math.max(1, w / BAR_COUNT);
for (int b = 0; b < BAR_COUNT; b++) { int fftLen = currentMag.length;
int ci = (int)(bars[b] * (HEAT.length - 1)); float dbMin = -80f, dbMax = 0f;
waterfallG.setColor(HEAT[ci]); double logMin = Math.log10(20.0), logMax = Math.log10(22050.0);
waterfallG.fillRect(b * cellW, h - 1, cellW, 1);
} int[] raster = new int[w];
if (BAR_COUNT * cellW < w) { for (int px = 0; px < w; px++) {
waterfallG.setColor(HEAT[0]); double freq = Math.pow(10, logMin + (logMax - logMin) * px / w);
waterfallG.fillRect(BAR_COUNT * cellW, h - 1, w - BAR_COUNT * cellW, 1); int bin = Math.min(fftLen - 1, (int)(freq / 22050.0 * fftLen));
int binL = Math.max(0, bin - 1);
int binR = Math.min(fftLen-1, bin + 1);
float mag = 0;
for (int k = binL; k <= binR; k++) mag += currentMag[k];
mag /= (binR - binL + 1);
float db = mag > 0 ? 20f * (float)Math.log10(mag) : dbMin;
float val = Math.max(0f, Math.min(1f, (db - dbMin) / (dbMax - dbMin)));
raster[px] = HEAT[(int)(val * (HEAT.length - 1))].getRGB();
} }
waterfallImg.setRGB(0, h - 1, w, 1, raster, 0, w);
} }
private void drawWaterfall(Graphics g, int x, int y, int w, int h) { private void drawWaterfall(Graphics g, int x, int y, int w, int h) {
@@ -271,7 +271,10 @@ public class WavyPlayerPanel extends JPanel {
if (totalFr > 0 && currentIndex >= 0) { if (totalFr > 0 && currentIndex >= 0) {
Track t = playlistModel.get(currentIndex); Track t = playlistModel.get(currentIndex);
if (t.getDurationSeconds() == 0) t.setDurationSeconds(totalSec); if (t.getDurationSeconds() == 0) {
t.setDurationSeconds(totalSec);
playlistView.repaint();
}
} }
lblTime.setText(fmt(posSec) + " / " + fmt(totalSec)); lblTime.setText(fmt(posSec) + " / " + fmt(totalSec));
@@ -283,30 +286,69 @@ public class WavyPlayerPanel extends JPanel {
return String.format("%d:%02d", sec / 60, sec % 60); return String.format("%d:%02d", sec / 60, sec % 60);
} }
private class TrackCellRenderer extends DefaultListCellRenderer { private class TrackCellRenderer implements ListCellRenderer<Track> {
@Override
public Component getListCellRendererComponent(JList<?> list, Object value, private final JPanel panel = new JPanel(new BorderLayout(0, 1));
int index, boolean isSelected, boolean hasFocus) { private final JLabel lblTop = new JLabel();
Track t = (Track) value; private final JLabel lblBot = new JLabel();
String dur = t.getDurationSeconds() > 0 ? t.getDurationString() : "--:--";
String html = String.format( TrackCellRenderer() {
"<html>" + panel.setBorder(BorderFactory.createEmptyBorder(3, 6, 3, 6));
"<b>%s</b>" + lblTop.setFont(lblTop.getFont().deriveFont(Font.BOLD, 12f));
" &nbsp; " + lblBot.setFont(lblBot.getFont().deriveFont(Font.PLAIN, 10f));
"<small>%s - %s</small>" + panel.add(lblTop, BorderLayout.NORTH);
" &nbsp; " + panel.add(lblBot, BorderLayout.SOUTH);
"<font color=gray>[%s] %s</font>" +
"</html>",
esc(t.getDisplayTitle()), esc(t.getArtist()), esc(t.getAlbum()),
t.getFormat(), dur);
JLabel lbl = (JLabel) super.getListCellRendererComponent(list, html, index, isSelected, hasFocus);
if (index == currentIndex)
lbl.setBorder(BorderFactory.createMatteBorder(0, 3, 0, 0,
UIManager.getColor("List.selectionBackground")));
return lbl;
} }
private String esc(String s) {
return s.replace("&","&amp;").replace("<","&lt;").replace(">","&gt;"); @Override
public Component getListCellRendererComponent(JList<? extends Track> list,
Track t, int index, boolean isSelected, boolean hasFocus) {
Color bg = isSelected
? UIManager.getColor("List.selectionBackground")
: (index % 2 == 0
? list.getBackground()
: blend(list.getBackground(), UIManager.getColor("Panel.background"), 0.5f));
Color fg = isSelected
? UIManager.getColor("List.selectionForeground")
: list.getForeground();
Color fgDim = isSelected ? fg : blend(fg, bg, 0.45f);
panel.setBackground(bg);
lblTop.setForeground(fg);
lblBot.setForeground(fgDim);
if (index == currentIndex) {
panel.setBorder(BorderFactory.createCompoundBorder(
BorderFactory.createMatteBorder(0, 3, 0, 0,
UIManager.getColor("List.selectionBackground")),
BorderFactory.createEmptyBorder(3, 4, 3, 6)));
} else {
panel.setBorder(BorderFactory.createEmptyBorder(3, 7, 3, 6));
}
lblTop.setText(t.getDisplayTitle());
String dur = t.getDurationSeconds() > 0 ? t.getDurationString() : "--:--";
String artist = t.getArtist(), album = t.getAlbum();
String sub = artist + " \u2013 " + album
+ " [" + t.getFormat() + "]"
+ " " + dur;
lblBot.setText(sub);
return panel;
}
private Color blend(Color a, Color b, float t) {
if (a == null) a = Color.WHITE;
if (b == null) b = Color.LIGHT_GRAY;
int r = (int)(a.getRed() + (b.getRed() - a.getRed()) * t);
int g = (int)(a.getGreen() + (b.getGreen() - a.getGreen()) * t);
int bl= (int)(a.getBlue() + (b.getBlue() - a.getBlue()) * t);
return new Color(
Math.max(0, Math.min(255, r)),
Math.max(0, Math.min(255, g)),
Math.max(0, Math.min(255, bl)));
} }
} }
} }