WaveformPanel.java
package org.hammer;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Rectangle;
import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent;
import java.util.logging.Logger;
import javax.swing.JPanel;
import org.hammer.audio.AudioCaptureService;
import org.hammer.audio.WaveformModel;
import org.hammer.audio.analysis.WaveformTrigger;
import org.hammer.audio.core.AudioBlock;
import org.hammer.audio.ui.theme.PlotRenderTheme;
/**
* Panel for displaying audio waveform visualization.
*
* <p>Refactored to use AudioCaptureService and WaveformModel instead of direct singleton access.
* The panel now receives the service via setter and uses thread-safe model snapshots for rendering.
*
* @author chammer
*/
public final class WaveformPanel extends JPanel {
private static final Logger LOGGER = Logger.getLogger(WaveformPanel.class.getName());
private static final int LEFT_MARGIN = 48;
private static final int RIGHT_MARGIN = 12;
private static final int TOP_MARGIN = 20;
private static final int BOTTOM_MARGIN = 34;
private AudioCaptureService audioCaptureService;
private transient WaveformModel frozenModel;
private transient AudioBlock frozenBlock;
private boolean frozen;
private final transient WaveformTrigger trigger = new WaveformTrigger();
private transient WaveformTrigger.TriggeredView lastTriggeredView;
private transient long lastTriggeredBlockFrameIndex = Long.MIN_VALUE;
private transient int[] triggerXs = new int[0];
private transient int[] triggerYs = new int[0];
private boolean triggerEnabled;
/**
* Create a new WaveformPanel.
*
* <p>Note: The audio capture service must be set via {@link #setAudioCaptureService} before the
* panel can display waveforms.
*/
public WaveformPanel() {
super(true);
LOGGER.info("WaveformPanel created");
// Timer to periodically repaint at consistent interval for smooth display updates
javax.swing.Timer t = new javax.swing.Timer(UiConstants.REFRESH_INTERVAL_MS, e -> repaint());
t.start();
// Notify service when panel is resized
addComponentListener(
new ComponentAdapter() {
@Override
public void componentResized(ComponentEvent e) {
if (LOGGER.isLoggable(java.util.logging.Level.FINE)) {
LOGGER.fine(String.format("WaveformPanel resized to %dx%d", getWidth(), getHeight()));
}
if (audioCaptureService != null) {
LOGGER.fine(
String.format(
"Panel resized to %dx%d, recomputing layout", getWidth(), getHeight()));
audioCaptureService.recomputeLayout(getWidth(), getHeight());
}
}
});
}
/**
* Set the audio capture service to use for waveform data.
*
* @param service the AudioCaptureService
*/
public void setAudioCaptureService(AudioCaptureService service) {
this.audioCaptureService = service;
this.frozenModel = null;
this.frozenBlock = null;
this.frozen = false;
LOGGER.info("AudioCaptureService set: " + (service != null));
if (service != null) {
// Initial layout computation
service.recomputeLayout(getWidth(), getHeight());
}
}
/**
* Enable or disable oscilloscope-style triggering. When enabled, the panel uses the configured
* {@link WaveformTrigger} to align each refresh to a stable trigger event (e.g. rising
* zero-crossing).
*
* @param enabled true to enable triggering, false to display raw blocks
*/
public void setTriggerEnabled(boolean enabled) {
this.triggerEnabled = enabled;
if (!enabled) {
lastTriggeredView = null;
trigger.reset();
}
repaint();
}
/**
* @return true if oscilloscope-style triggering is currently enabled
*/
public boolean isTriggerEnabled() {
return triggerEnabled;
}
/**
* @return the underlying {@link WaveformTrigger}; safe to reconfigure from the EDT
*/
@SuppressWarnings("PMD.AvoidProtectedFieldInFinalClass")
public WaveformTrigger getTrigger() {
return trigger;
}
/**
* Freeze or unfreeze the displayed waveform.
*
* @param frozen true to hold the current waveform snapshot
*/
public void setFrozen(boolean frozen) {
if (frozen && !this.frozen) {
frozenModel = getCurrentModel();
frozenBlock = getCurrentBlock();
} else if (!frozen) {
frozenModel = null;
frozenBlock = null;
}
this.frozen = frozen;
repaint();
}
/**
* @return current waveform model, respecting frozen display state
*/
public WaveformModel getCurrentModel() {
if (frozen && frozenModel != null) {
return frozenModel;
}
if (audioCaptureService == null) {
return WaveformModel.EMPTY;
}
return audioCaptureService.getLatestModel();
}
private AudioBlock getCurrentBlock() {
if (frozen && frozenBlock != null) {
return frozenBlock;
}
if (audioCaptureService == null) {
return null;
}
return audioCaptureService.getLatestBlock();
}
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
Graphics2D g2 = (Graphics2D) g.create();
try {
PlotRenderTheme.applyQualityRendering(g2);
paintWaveform(g2);
} finally {
g2.dispose();
}
}
private void paintWaveform(Graphics2D g2) {
if (LOGGER.isLoggable(java.util.logging.Level.FINE)) {
LOGGER.fine("paintComponent called");
}
int width = Math.max(1, getWidth());
int height = Math.max(1, getHeight());
Rectangle plotBounds =
new Rectangle(
LEFT_MARGIN,
TOP_MARGIN,
Math.max(1, width - LEFT_MARGIN - RIGHT_MARGIN),
Math.max(1, height - TOP_MARGIN - BOTTOM_MARGIN));
PlotRenderTheme.drawPlotBackground(g2, getWidth(), getHeight(), plotBounds);
PlotRenderTheme.drawGrid(g2, plotBounds, 10, 8);
PlotRenderTheme.drawTitle(g2, 10, 16, triggerEnabled ? "Waveform (triggered)" : "Waveform");
PlotRenderTheme.drawXAxisLabel(g2, plotBounds, "Sample index");
PlotRenderTheme.drawYAxisLabel(g2, plotBounds, "Amplitude [-1..1]");
PlotRenderTheme.drawYTicks(
g2, plotBounds, new double[] {0.0d, 0.5d, 1.0d}, new String[] {"+1", "0", "-1"});
if (audioCaptureService == null) {
LOGGER.warning("paintComponent: audioCaptureService is null");
PlotRenderTheme.drawEmptyState(g2, plotBounds, "No audio service connected");
return;
}
if (triggerEnabled) {
if (paintTriggeredWaveform(g2, plotBounds)) {
drawLevelOverlay(g2, plotBounds);
return;
}
// Fall through to free-running mode if trigger cannot produce a view yet.
}
WaveformModel model = getCurrentModel();
final int points = model.getNumberOfPoints();
if (points == 0) {
PlotRenderTheme.drawEmptyState(g2, plotBounds, "Waiting for waveform data...");
return;
}
int[] xPoints = model.getXPoints();
int[][] yPoints = model.getYPoints();
int channel0Points = yPoints.length > 0 ? Math.min(points, yPoints[0].length) : 0;
int channel1Points = yPoints.length > 1 ? Math.min(points, yPoints[1].length) : 0;
int xCount = xPoints.length;
int leftRenderPoints = Math.min(xCount, channel0Points);
int rightRenderPoints = Math.min(xCount, channel1Points);
if (channel0Points > 1 && xCount > 1) {
int[] scaledX = scaleXPoints(xPoints, leftRenderPoints, plotBounds);
int[] scaledY = scaleYPoints(yPoints[0], leftRenderPoints, plotBounds);
g2.setColor(PlotRenderTheme.WAVEFORM_LEFT);
g2.setStroke(PlotRenderTheme.TRACE_STROKE);
g2.drawPolyline(scaledX, scaledY, leftRenderPoints);
}
if (channel1Points > 1 && xCount > 1) {
int[] scaledX = scaleXPoints(xPoints, rightRenderPoints, plotBounds);
int[] scaledY = scaleYPoints(yPoints[1], rightRenderPoints, plotBounds);
g2.setColor(PlotRenderTheme.WAVEFORM_RIGHT);
g2.setStroke(PlotRenderTheme.THIN_TRACE_STROKE);
g2.drawPolyline(scaledX, scaledY, rightRenderPoints);
}
int centerY = plotBounds.y + plotBounds.height / 2;
g2.setColor(PlotRenderTheme.CENTER_LINE);
g2.setStroke(PlotRenderTheme.AXIS_STROKE);
g2.drawLine(plotBounds.x, centerY, plotBounds.x + plotBounds.width - 1, centerY);
PlotRenderTheme.drawXTicks(
g2,
plotBounds,
new double[] {0.0d, 0.5d, 1.0d},
new String[] {
"0", Integer.toString(Math.max(0, points / 2)), Integer.toString(points - 1)
});
drawLevelOverlay(g2, plotBounds);
}
/**
* Maps legacy full-panel X coordinates into the current plot bounds.
*
* @param source full-panel X coordinates from {@link WaveformModel}
* @param count number of coordinates to transform
* @param plotBounds target plot area
* @return scaled X coordinates inside {@code plotBounds}
*/
private int[] scaleXPoints(int[] source, int count, Rectangle plotBounds) {
int[] scaled = new int[count];
int sourceWidth = Math.max(1, getWidth() - 1);
for (int i = 0; i < count; i++) {
double normalized = Math.max(0.0d, Math.min(1.0d, source[i] / (double) sourceWidth));
scaled[i] = plotBounds.x + (int) Math.round(normalized * (plotBounds.width - 1));
}
return scaled;
}
/**
* Maps legacy full-panel Y coordinates into the current plot bounds.
*
* @param source full-panel Y coordinates from {@link WaveformModel}
* @param count number of coordinates to transform
* @param plotBounds target plot area
* @return scaled Y coordinates inside {@code plotBounds}
*/
private int[] scaleYPoints(int[] source, int count, Rectangle plotBounds) {
int[] scaled = new int[count];
int sourceHeight = Math.max(1, getHeight() - 1);
for (int i = 0; i < count; i++) {
double normalized = Math.max(0.0d, Math.min(1.0d, source[i] / (double) sourceHeight));
scaled[i] = plotBounds.y + (int) Math.round(normalized * (plotBounds.height - 1));
}
return scaled;
}
/**
* Render the latest triggered view. Returns {@code true} if a view was drawn (so the caller can
* skip the free-running model path).
*/
private boolean paintTriggeredWaveform(Graphics2D g2, Rectangle plotBounds) {
AudioBlock block = getCurrentBlock();
if (block != null
&& block.channels() > 0
&& block.frames() > 0
&& block.frameIndex() != lastTriggeredBlockFrameIndex) {
trigger.process(block, 0).ifPresent(v -> lastTriggeredView = v);
lastTriggeredBlockFrameIndex = block.frameIndex();
}
WaveformTrigger.TriggeredView view = lastTriggeredView;
if (view == null || view.samplesView().length == 0) {
PlotRenderTheme.drawEmptyState(g2, plotBounds, "Waiting for trigger...");
return true;
}
float[] samples = view.samplesView();
int n = samples.length;
int width = Math.max(1, plotBounds.width);
int height = Math.max(1, plotBounds.height);
int centerY = plotBounds.y + height / 2;
int amplitude = Math.max(1, height / 2 - 4);
if (triggerXs.length != n) {
triggerXs = new int[n];
triggerYs = new int[n];
}
int[] xs = triggerXs;
int[] ys = triggerYs;
for (int i = 0; i < n; i++) {
xs[i] = plotBounds.x + (int) ((long) i * (width - 1) / Math.max(1, n - 1));
float clamped = Math.max(-1f, Math.min(1f, samples[i]));
ys[i] = centerY - (int) (clamped * amplitude);
}
// Trigger level indicator.
int levelY = centerY - (int) (Math.max(-1f, Math.min(1f, view.level())) * amplitude);
g2.setColor(PlotRenderTheme.CENTER_LINE);
g2.setStroke(PlotRenderTheme.AXIS_STROKE);
g2.drawLine(plotBounds.x, levelY, plotBounds.x + width - 1, levelY);
// Center line.
g2.drawLine(plotBounds.x, centerY, plotBounds.x + width - 1, centerY);
// Trace.
g2.setColor(PlotRenderTheme.WAVEFORM_LEFT);
g2.setStroke(PlotRenderTheme.TRACE_STROKE);
g2.drawPolyline(xs, ys, n);
// Status text.
String status =
String.format(
"Trig: %s Slope: %s Level: %+.2f %s",
view.triggered() ? "FIRED" : "AUTO",
view.slope() == WaveformTrigger.Slope.RISING ? "↑" : "↓",
view.level(),
view.triggered() ? "" : "(timeout)");
g2.setFont(PlotRenderTheme.LABEL_FONT);
g2.setColor(PlotRenderTheme.TEXT_MUTED);
g2.drawString(status, plotBounds.x + 10, plotBounds.y + 32);
PlotRenderTheme.drawXTicks(
g2,
plotBounds,
new double[] {0.0d, 0.5d, 1.0d},
new String[] {"0", Integer.toString(n / 2), Integer.toString(n - 1)});
return true;
}
private void drawLevelOverlay(Graphics2D g2, Rectangle plotBounds) {
AudioBlock block = getCurrentBlock();
if (block == null || block.frames() == 0) {
return;
}
double sumSquares = 0.0d;
double peak = 0.0d;
long sampleCount = 0L;
for (int channel = 0; channel < block.channels(); channel++) {
float[] samples = block.channelView(channel);
for (float sample : samples) {
double abs = Math.abs(sample);
peak = Math.max(peak, abs);
sumSquares += sample * sample;
sampleCount++;
}
}
if (sampleCount == 0L) {
return;
}
double rms = Math.sqrt(sumSquares / sampleCount);
String overlay = String.format("RMS %.3f Peak %.3f", rms, peak);
int textWidth = g2.getFontMetrics(PlotRenderTheme.LABEL_FONT).stringWidth(overlay);
int x = plotBounds.x + Math.max(8, plotBounds.width - textWidth - 12);
int y = plotBounds.y + 18;
g2.setFont(PlotRenderTheme.LABEL_FONT);
g2.setColor(PlotRenderTheme.TEXT_MUTED);
g2.drawString(overlay, x, y);
}
}