From 91812a31058404667a9a387095ffec41d55edac2 Mon Sep 17 00:00:00 2001 From: rattatwinko Date: Sun, 31 May 2026 19:11:51 +0200 Subject: [PATCH] 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 --- pom.xml | 15 ++ src/main/java/com/rdesk/Main.java | 3 +- .../entertainment/media/AudioEngine.java | 192 ++++++++---------- .../entertainment/media/EqualizerPanel.java | 1 + .../entertainment/media/SpectrogramPanel.java | 42 ++-- .../entertainment/media/WavyPlayerPanel.java | 114 +++++++---- 6 files changed, 210 insertions(+), 157 deletions(-) diff --git a/pom.xml b/pom.xml index 46199da..7cb3a2a 100644 --- a/pom.xml +++ b/pom.xml @@ -120,6 +120,7 @@ languagesupport 3.4.1 + com.googlecode.soundlibs mp3spi @@ -130,6 +131,20 @@ jflac-codec 1.5.2 + + + org.bytedeco + ffmpeg + 6.1.1-1.5.10 + windows-x86_64 + + + + org.bytedeco + javacv + 1.5.10 + + \ No newline at end of file diff --git a/src/main/java/com/rdesk/Main.java b/src/main/java/com/rdesk/Main.java index e97cb64..14a3559 100644 --- a/src/main/java/com/rdesk/Main.java +++ b/src/main/java/com/rdesk/Main.java @@ -5,7 +5,8 @@ import com.rdesk.kernel.BootProc; public class Main { public static void main(String[] args) { - boolean dbg = true; + boolean dbg = false; + if (!dbg) { Login.main(args); } else { diff --git a/src/main/java/com/rdesk/applications/entertainment/media/AudioEngine.java b/src/main/java/com/rdesk/applications/entertainment/media/AudioEngine.java index 60e2762..c337091 100644 --- a/src/main/java/com/rdesk/applications/entertainment/media/AudioEngine.java +++ b/src/main/java/com/rdesk/applications/entertainment/media/AudioEngine.java @@ -1,7 +1,11 @@ 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 java.io.*; +import java.nio.ShortBuffer; import java.util.concurrent.*; import java.util.function.*; @@ -9,20 +13,18 @@ public class AudioEngine { public enum State { STOPPED, PLAYING, PAUSED } - private volatile State state = State.STOPPED; + private volatile State state = State.STOPPED; private volatile long positionFrames = 0; private volatile long totalFrames = 0; 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 float[] EQ_FREQS = { 32, 64, 125, 250, 500, 1000, 2000, 4000, 8000, 16000 }; private final float[] eqGainDb = new float[EQ_BANDS]; private final float[][] bqCoeff = new float[EQ_BANDS][5]; private final float[][][] bqState = new float[EQ_BANDS][2][2]; - // fft private static final int FFT_SIZE = 4096; private Consumer fftListener; private final float[] fftBuffer = new float[FFT_SIZE]; @@ -40,17 +42,16 @@ public class AudioEngine { private double requestedSeekFraction = 0.0; private boolean seekPending = false; - // Public API public AudioEngine() { initEq(); } - public void setOnTrackEnd(Runnable r) { this.onTrackEnd = r; } - public void setOnPositionUpdate(Consumer c) { this.onPositionUpdate = c; } - public void setFftListener(Consumer c) { this.fftListener = c; } + public void setOnTrackEnd(Runnable r) { this.onTrackEnd = r; } + public void setOnPositionUpdate(Consumer c) { this.onPositionUpdate = c; } + public void setFftListener(Consumer c) { this.fftListener = c; } - public State getState() { return state; } - public long getPositionFrames() { return positionFrames; } - public long getTotalFrames() { return totalFrames; } - public AudioFormat getFormat() { return currentFormat; } + public State getState() { return state; } + public long getPositionFrames() { return positionFrames; } + public long getTotalFrames() { return totalFrames; } + public AudioFormat getFormat() { return currentFormat; } public void setVolume(float v) { volume = Math.max(0f, Math.min(1f, v)); @@ -108,9 +109,9 @@ public class AudioEngine { recomputeBiquad(band); } - public float getEqBand(int band) { return eqGainDb[band]; } - public float[] getEqFrequencies() { return EQ_FREQS.clone(); } - public static float[] getStaticEqFrequencies() { return EQ_FREQS.clone(); } + public float getEqBand(int band) { return eqGainDb[band]; } + public float[] getEqFrequencies() { return EQ_FREQS.clone(); } + public static float[] getStaticEqFrequencies() { return EQ_FREQS.clone(); } public void shutdown() { stop(); } @@ -118,60 +119,49 @@ public class AudioEngine { currentTrack = track; state = State.PLAYING; + FFmpegFrameGrabber grabber = null; try { - AudioInputStream rawStream = AudioSystem.getAudioInputStream(track.getFilePath().toFile()); - AudioFormat rawFmt = rawStream.getFormat(); + grabber = new FFmpegFrameGrabber(track.getFilePath().toFile()); + 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, - rawFmt.getSampleRate(), - 16, - rawFmt.getChannels(), - rawFmt.getChannels() * 2, - rawFmt.getSampleRate(), - false); + sampleRate, 16, channels, + frameSize, sampleRate, false); - AudioInputStream pcmStream; - if (rawFmt.getEncoding() != AudioFormat.Encoding.PCM_SIGNED - || rawFmt.getSampleSizeInBits() != 16) { - pcmStream = AudioSystem.getAudioInputStream(pcmFmt, rawStream); - } 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; + long durationMicros = grabber.getLengthInTime(); + if (durationMicros > 0) { + long ffmpegSec = durationMicros / 1_000_000L; + totalFrames = (long)(durationMicros / 1_000_000.0 * sampleRate); if (track.getDurationSeconds() == 0) - track.setDurationSeconds((long)(pcmFrames / sampleRate)); - } else if (track.getDurationSeconds() > 0) { - totalFrames = (long)(track.getDurationSeconds() * sampleRate); + track.setDurationSeconds(ffmpegSec); } else { - totalFrames = 0; + if (track.getDurationSeconds() > 0) + totalFrames = track.getDurationSeconds() * sampleRate; + else + 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.open(pcmFmt, (int)(sampleRate * frameSize * 0.1f)); + dataLine.open(currentFormat, (int)(sampleRate * frameSize * 0.1f)); dataLine.start(); - applyVolumeToLine(); // set initial volume + applyVolumeToLine(); resetBiquadState(); - - final int BLOCK = 4096; - byte[] rawBuf = new byte[BLOCK]; - float[] samples = new float[BLOCK / 2]; - positionFrames = 0; + float[] samples = new float[8192]; + while (!stopRequested) { - // Pause + synchronized (this) { while (pauseRequested && !stopRequested) { try { wait(50); } catch (InterruptedException e) { break; } @@ -179,75 +169,69 @@ public class AudioEngine { } if (stopRequested) break; - // Seek if (seekPending) { 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) { - long targetFrame = (long)(requestedSeekFraction * totalFrames); - long skip = targetFrame * frameSize; - byte[] skipBuf = new byte[8192]; - long skipped = 0; - while (skipped < skip && !stopRequested) { - int toSkip = (int) Math.min(skipBuf.length, skip - skipped); - int r = pcmStream.read(skipBuf, 0, toSkip); - if (r < 0) break; - skipped += r; - } - positionFrames = targetFrame; + long targetMicros = (long)(requestedSeekFraction + * (durationMicros > 0 ? durationMicros + : track.getDurationSeconds() * 1_000_000L)); + grabber.setTimestamp(targetMicros, /* keyFrame */ true); + positionFrames = (long)((targetMicros / 1_000_000.0) * sampleRate); } resetBiquadState(); continue; } - int bytesRead = pcmStream.read(rawBuf); - if (bytesRead <= 0) break; - - // bytes to float - 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; + Frame frame = grabber.grabSamples(); + if (frame == null || frame.samples == null || frame.samples.length == 0) { + // End of stream + break; } + 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); 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++) { float s = Math.max(-1f, Math.min(1f, samples[i])); short sh = (short)(s * 32767f); - rawBuf[i*2] = (byte)(sh & 0xFF); - rawBuf[i*2+1] = (byte)((sh >> 8) & 0xFF); + pcmBytes[i*2] = (byte)( sh & 0xFF); + pcmBytes[i*2 + 1] = (byte)((sh >> 8) & 0xFF); } - dataLine.write(rawBuf, 0, bytesRead); - positionFrames += bytesRead / frameSize; + dataLine.write(pcmBytes, 0, pcmBytes.length); + + positionFrames += sampleCount / channels; if (onPositionUpdate != null) onPositionUpdate.accept(positionFrames); } - pcmStream.close(); - rawStream.close(); if (!stopRequested) dataLine.drain(); closeLine(); + grabber.stop(); + grabber.release(); + grabber = null; + state = State.STOPPED; if (!stopRequested && onTrackEnd != null) onTrackEnd.run(); - } catch (UnsupportedAudioFileException e) { - System.err.println("[Wavy] Unsupported format: " + track.getFilePath()); - state = State.STOPPED; - } catch (LineUnavailableException | IOException e) { - e.printStackTrace(); + } catch (Exception e) { + System.err.println("[Wavy] Playback error for: " + track.getFilePath() + " — " + e.getMessage()); 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.isControlSupported(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 maxDb = 0f; // 0 dB is the unity gain - float db = (volume == 0f) ? minDb : minDb + (maxDb - minDb) * volume; + float maxDb = 0f; + float db = (volume == 0f) ? minDb : minDb + (maxDb - minDb) * volume; gc.setValue(Math.max(minDb, Math.min(maxDb, db))); } } - // EQ private void initEq() { 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; } } - // fast fourier transform ( algo: cooley turkey, with the radix 2 dit case ) - // links / references which may be helpful while reading through the code: + // FFT (Cooley-Tukey radix-2 DIT — unchanged) + // 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#The_radix-2_DIT_case + private void feedFft(float[] samples, int count, int channels) { if (fftListener == null) return; for (int i = 0; i < count; i += channels) { @@ -341,7 +324,6 @@ public class AudioEngine { for (int i = 0; i < n; i++) re[i] *= 0.5f * (1 - (float)Math.cos(2*Math.PI*i/(n-1))); // bit reversal - int bits = (int)(Math.log(n)/Math.log(2)); for (int j=1, i=0; j>1; for (; (i&bit)!=0; bit>>=1) i^=bit; @@ -372,4 +354,4 @@ public class AudioEngine { mag[i] = (float)Math.sqrt(re[i]*re[i]+im[i]*im[i])/n; return mag; } -} +} \ No newline at end of file diff --git a/src/main/java/com/rdesk/applications/entertainment/media/EqualizerPanel.java b/src/main/java/com/rdesk/applications/entertainment/media/EqualizerPanel.java index 750d27b..c5bef54 100644 --- a/src/main/java/com/rdesk/applications/entertainment/media/EqualizerPanel.java +++ b/src/main/java/com/rdesk/applications/entertainment/media/EqualizerPanel.java @@ -11,6 +11,7 @@ public class EqualizerPanel extends JPanel { }; // 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 = { {"Flat", "0,0,0,0,0,0,0,0,0,0"}, {"Bass Boost", "8,6,4,2,0,0,0,0,0,0"}, diff --git a/src/main/java/com/rdesk/applications/entertainment/media/SpectrogramPanel.java b/src/main/java/com/rdesk/applications/entertainment/media/SpectrogramPanel.java index b28ea89..3a1f5d2 100644 --- a/src/main/java/com/rdesk/applications/entertainment/media/SpectrogramPanel.java +++ b/src/main/java/com/rdesk/applications/entertainment/media/SpectrogramPanel.java @@ -98,17 +98,29 @@ public class SpectrogramPanel extends JPanel { waterfallG.copyArea(0, 1, w, h - 1, 0, -1); - float[] bars = computeBars(); - int cellW = Math.max(1, w / BAR_COUNT); - for (int b = 0; b < BAR_COUNT; b++) { - int ci = (int)(bars[b] * (HEAT.length - 1)); - waterfallG.setColor(HEAT[ci]); - waterfallG.fillRect(b * cellW, h - 1, cellW, 1); - } - if (BAR_COUNT * cellW < w) { - waterfallG.setColor(HEAT[0]); - waterfallG.fillRect(BAR_COUNT * cellW, h - 1, w - BAR_COUNT * cellW, 1); + computeBars(); + + int fftLen = currentMag.length; + float dbMin = -80f, dbMax = 0f; + double logMin = Math.log10(20.0), logMax = Math.log10(22050.0); + + int[] raster = new int[w]; + for (int px = 0; px < w; px++) { + double freq = Math.pow(10, logMin + (logMax - logMin) * px / w); + 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) { @@ -193,14 +205,14 @@ public class SpectrogramPanel extends JPanel { float f = t - seg; Color a = stops[seg], b = stops[seg+1]; map[i] = new Color( - // hehe larp - lerp(a.getRed(), b.getRed(), f), - lerp(a.getGreen(), b.getGreen(), f), - lerp(a.getBlue(), b.getBlue(), f)); + // hehe larp + lerp(a.getRed(), b.getRed(), f), + lerp(a.getGreen(), b.getGreen(), f), + lerp(a.getBlue(), b.getBlue(), f)); } return map; } private static int lerp(int a, int b, float t) { return Math.max(0, Math.min(255, (int)(a + (b-a)*t))); } -} +} \ No newline at end of file diff --git a/src/main/java/com/rdesk/applications/entertainment/media/WavyPlayerPanel.java b/src/main/java/com/rdesk/applications/entertainment/media/WavyPlayerPanel.java index 447bfb5..68c060e 100644 --- a/src/main/java/com/rdesk/applications/entertainment/media/WavyPlayerPanel.java +++ b/src/main/java/com/rdesk/applications/entertainment/media/WavyPlayerPanel.java @@ -192,17 +192,17 @@ public class WavyPlayerPanel extends JPanel { playlistModel.clear(); lblStatus.setText("Scanning"); library.scan( - track -> SwingUtilities.invokeLater(() -> { - playlistModel.addElement(track); - lblStatus.setText(playlistModel.size() + " tracks found"); - }), - () -> SwingUtilities.invokeLater(() -> { - library.sortByArtistTitle(); - List sorted = library.getTracks(); - playlistModel.clear(); - for (Track t : sorted) playlistModel.addElement(t); - lblStatus.setText(playlistModel.size() + " tracks."); - }) + track -> SwingUtilities.invokeLater(() -> { + playlistModel.addElement(track); + lblStatus.setText(playlistModel.size() + " tracks found"); + }), + () -> SwingUtilities.invokeLater(() -> { + library.sortByArtistTitle(); + List sorted = library.getTracks(); + playlistModel.clear(); + for (Track t : sorted) playlistModel.addElement(t); + lblStatus.setText(playlistModel.size() + " tracks."); + }) ); } @@ -271,7 +271,10 @@ public class WavyPlayerPanel extends JPanel { if (totalFr > 0 && currentIndex >= 0) { 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)); @@ -283,30 +286,69 @@ public class WavyPlayerPanel extends JPanel { return String.format("%d:%02d", sec / 60, sec % 60); } - private class TrackCellRenderer extends DefaultListCellRenderer { - @Override - public Component getListCellRendererComponent(JList list, Object value, - int index, boolean isSelected, boolean hasFocus) { - Track t = (Track) value; - String dur = t.getDurationSeconds() > 0 ? t.getDurationString() : "--:--"; - String html = String.format( - "" + - "%s" + - "   " + - "%s - %s" + - "   " + - "[%s] %s" + - "", - 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 class TrackCellRenderer implements ListCellRenderer { + + private final JPanel panel = new JPanel(new BorderLayout(0, 1)); + private final JLabel lblTop = new JLabel(); + private final JLabel lblBot = new JLabel(); + + TrackCellRenderer() { + panel.setBorder(BorderFactory.createEmptyBorder(3, 6, 3, 6)); + lblTop.setFont(lblTop.getFont().deriveFont(Font.BOLD, 12f)); + lblBot.setFont(lblBot.getFont().deriveFont(Font.PLAIN, 10f)); + panel.add(lblTop, BorderLayout.NORTH); + panel.add(lblBot, BorderLayout.SOUTH); } - private String esc(String s) { - return s.replace("&","&").replace("<","<").replace(">",">"); + + @Override + public Component getListCellRendererComponent(JList 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))); } } -} +} \ No newline at end of file