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