AudioAnalyseFrame.java
package org.hammer;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.EventQueue;
import java.awt.FlowLayout;
import java.awt.event.ActionEvent;
import java.awt.event.ItemEvent;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.Locale;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.imageio.ImageIO;
import javax.sound.sampled.AudioFormat;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.DataLine;
import javax.sound.sampled.Mixer;
import javax.sound.sampled.TargetDataLine;
import javax.swing.AbstractAction;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.ButtonGroup;
import javax.swing.JCheckBoxMenuItem;
import javax.swing.JComboBox;
import javax.swing.JFileChooser;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JMenu;
import javax.swing.JMenuBar;
import javax.swing.JMenuItem;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JRadioButton;
import javax.swing.JSlider;
import javax.swing.JSplitPane;
import javax.swing.JTextField;
import javax.swing.SwingConstants;
import javax.swing.SwingUtilities;
import javax.swing.Timer;
import javax.swing.border.EmptyBorder;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;
import javax.swing.filechooser.FileNameExtensionFilter;
import org.hammer.audio.AudioCaptureService;
import org.hammer.audio.AudioCaptureServiceImpl;
import org.hammer.audio.DemoAudioCaptureService;
import org.hammer.audio.DemoSignalType;
import org.hammer.audio.analysis.MeasurementCalculator;
import org.hammer.audio.analysis.MeasurementSnapshot;
import org.hammer.audio.analysis.SpectrumSnapshot;
import org.hammer.audio.core.AudioBlock;
import org.hammer.audio.localization.StereoDelayAnalyzer;
import org.hammer.audio.localization.StereoDelaySnapshot;
import org.hammer.audio.localization.StereoDelayStatus;
import org.hammer.audio.pluginhost.PluginManager;
import org.hammer.audio.pluginhost.PluginMenuBuilder;
import org.hammer.audio.ui.theme.UiTheme;
/**
* Main application frame for audio analysis and visualization.
*
* <p>Refactored to use dependency injection with AudioCaptureService instead of direct singleton
* access. The frame creates the service and injects it into UI panels. Audio capture is
* started/stopped via the menu.
*
* @author chammer
*/
@SuppressWarnings("PMD.CouplingBetweenObjects")
public class AudioAnalyseFrame extends JFrame {
private static final long serialVersionUID = 1L;
private static final Logger LOGGER = Logger.getLogger(AudioAnalyseFrame.class.getName());
private static final String ERROR_TITLE = "Error";
private static final int CONTENT_PANE_HGAP = 5;
private static final int CONTENT_PANE_VGAP = 5;
private static final int CONTENT_PANE_PADDING = 8;
private static final int DEFAULT_WINDOW_WIDTH = 1440;
private static final int DEFAULT_WINDOW_HEIGHT = 1000;
// Keep the historic capture format so existing tests and supported-device checks stay aligned.
private static final float DEFAULT_SAMPLE_RATE = 16000.0f;
private static final int DEFAULT_SAMPLE_BITS = 8;
private static final int DEFAULT_CHANNELS = 2;
private static final boolean DEFAULT_SIGNED = false;
private static final boolean DEFAULT_BIG_ENDIAN = false;
private static final int TOP_PANEL_HGAP = 8;
private static final MeasurementSnapshot NO_MEASUREMENT =
new MeasurementSnapshot(Double.NaN, Double.NaN, Double.NaN, Double.NaN, false, false);
private final JPanel contentPane;
private final JPanel visualizationPanel = new JPanel(new BorderLayout(4, 4));
private final WaveformPanel waveformPanel = new WaveformPanel();
private final PhaseDiagramPanel phaseDiagramPanel = new PhaseDiagramPanel();
private final SpectrumPanel spectrumPanel = new SpectrumPanel();
private final SpectrogramPanel spectrogramPanel = new SpectrogramPanel();
private final DiagnosisPanel diagnosisPanel = new DiagnosisPanel();
private final transient org.hammer.audio.diagnosis.DiagnosisAnalyzer diagnosisAnalyzer =
new org.hammer.audio.diagnosis.DiagnosisAnalyzer();
private transient org.hammer.audio.diagnosis.DiagnosisSnapshot lastDiagnosis =
org.hammer.audio.diagnosis.DiagnosisSnapshot.empty();
private final JTextField textFieldDataSize;
private final JTextField textFieldDivisor;
private final JTextField textFieldAudioFormat;
private final JTextField textFieldPeakFrequency;
private final JTextField textFieldRms;
private final JTextField textFieldPeakLevel;
private final JTextField textFieldDominantFrequency;
private final JTextField textFieldStereoCorrelation;
private final JTextField textFieldClipping;
private final JTextField textFieldMicrophoneSpacing;
private final JTextField textFieldStereoDelay;
private final JTextField textFieldStereoAngle;
private final JTextField textFieldStereoConfidence;
private final JComboBox<AudioDeviceItem> comboBoxAudioDevice;
private final JComboBox<DemoSignalType> comboBoxDemoSignal;
private final JRadioButton radioLiveMicrophone;
private final JRadioButton radioDemoMode;
private final MeasurementCalculator measurementCalculator = new MeasurementCalculator();
private final JCheckBoxMenuItem mntmStart;
private final JCheckBoxMenuItem mntmFreeze;
private final Timer refreshTimer;
// Audio capture service
private AudioCaptureService audioCaptureService;
private transient AudioBlock frozenBlock;
private transient InputMode inputMode = InputMode.LIVE;
private transient StereoDelayAnalyzer stereoDelayAnalyzer;
private double stereoDelayAnalyzerSpacingMeters = Double.NaN;
private transient org.hammer.audio.RecordingTap recordingTap;
public static void main(String[] args) {
EventQueue.invokeLater(
() -> {
try {
LOGGER.info("Starting AudioAnalyseFrame application");
UiTheme.installDarkTheme();
AudioAnalyseFrame frame = new AudioAnalyseFrame();
frame.setVisible(true);
} catch (Exception e) {
LOGGER.log(Level.SEVERE, "Failed to start application", e);
e.printStackTrace();
}
});
}
public AudioAnalyseFrame() {
LOGGER.info("AudioAnalyseFrame constructor started");
setTitle("AudioAnalyzer");
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
contentPane = new JPanel(new BorderLayout(CONTENT_PANE_HGAP, CONTENT_PANE_VGAP));
contentPane.setBorder(
new EmptyBorder(
CONTENT_PANE_PADDING,
CONTENT_PANE_PADDING,
CONTENT_PANE_PADDING,
CONTENT_PANE_PADDING));
setContentPane(contentPane);
// Initialize fields before calling init methods
textFieldDataSize = new JTextField();
textFieldDivisor = new JTextField();
textFieldAudioFormat = new JTextField();
textFieldPeakFrequency = new JTextField();
textFieldRms = new JTextField();
textFieldPeakLevel = new JTextField();
textFieldDominantFrequency = new JTextField();
textFieldStereoCorrelation = new JTextField();
textFieldClipping = new JTextField();
textFieldMicrophoneSpacing = new JTextField();
textFieldStereoDelay = new JTextField();
textFieldStereoAngle = new JTextField();
textFieldStereoConfidence = new JTextField();
comboBoxAudioDevice = new JComboBox<>();
comboBoxDemoSignal = new JComboBox<>(DemoSignalType.values());
radioLiveMicrophone = new JRadioButton("Live microphone", true);
radioDemoMode = new JRadioButton("Demo mode");
mntmStart = new JCheckBoxMenuItem("Start/Stop");
mntmFreeze = new JCheckBoxMenuItem("Pause/Freeze");
initializeAudioService(null);
initMenu();
initTopSettingsPanel();
initCenterAndEast();
initSouthSlider();
refreshTimer = new Timer(UiConstants.REFRESH_INTERVAL_MS, e -> updateUIFromModel());
refreshTimer.setRepeats(true);
refreshTimer.start();
addWindowListener(
new WindowAdapter() {
@Override
public void windowClosing(WindowEvent e) {
if (recordingTap != null && !recordingTap.isClosed()) {
try {
recordingTap.stop();
} catch (IOException ioe) {
LOGGER.log(Level.WARNING, "Failed to close recording on window close", ioe);
}
}
stopAudioIfRunning();
if (refreshTimer != null && refreshTimer.isRunning()) {
refreshTimer.stop();
}
}
});
pack();
setSize(DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT);
setLocationRelativeTo(null);
LOGGER.info("AudioAnalyseFrame initialized successfully");
}
/** Initialize the audio capture service and inject into panels. */
private void initializeAudioService(Mixer.Info mixerInfo) {
LOGGER.info("Initializing audio capture service");
int divisor = audioCaptureService != null ? audioCaptureService.getDivisor() : 1;
if (inputMode == InputMode.DEMO) {
audioCaptureService =
new DemoAudioCaptureService(
DEFAULT_SAMPLE_RATE,
DEFAULT_SAMPLE_BITS,
DEFAULT_CHANNELS,
divisor,
selectedDemoSignal());
} else {
audioCaptureService =
new AudioCaptureServiceImpl(
DEFAULT_SAMPLE_RATE,
DEFAULT_SAMPLE_BITS,
DEFAULT_CHANNELS,
DEFAULT_SIGNED,
DEFAULT_BIG_ENDIAN,
divisor,
mixerInfo);
}
waveformPanel.setAudioCaptureService(audioCaptureService);
phaseDiagramPanel.setAudioCaptureService(audioCaptureService);
spectrumPanel.setAudioCaptureService(audioCaptureService);
spectrogramPanel.setAudioCaptureService(audioCaptureService);
setFrozen(false);
}
private void initMenu() {
JMenuBar menuBar = new JMenuBar();
setJMenuBar(menuBar);
JMenu mnFile = new JMenu("File");
menuBar.add(mnFile);
mntmStart.setToolTipText("Start or stop audio input");
mntmStart.addActionListener(this::toggleAudioStartStop);
mnFile.add(mntmStart);
mntmFreeze.setToolTipText("Freeze waveform and spectrum snapshots for inspection/export");
mntmFreeze.addActionListener(e -> setFrozen(mntmFreeze.isSelected()));
mnFile.add(mntmFreeze);
mnFile.addSeparator();
JMenuItem exportCsv = new JMenuItem("Export measurement CSV...");
exportCsv.addActionListener(e -> exportMeasurementCsv());
mnFile.add(exportCsv);
JMenuItem exportPng = new JMenuItem("Export measurement PNG...");
exportPng.addActionListener(e -> exportMeasurementPng());
mnFile.add(exportPng);
JMenuItem exportBundle = new JMenuItem("Export evidence bundle...");
exportBundle.setToolTipText(
"Export screenshot, samples, spectrum, spectrogram, stereo delay, diagnosis and metadata"
+ " into one directory.");
exportBundle.addActionListener(e -> exportEvidenceBundle());
mnFile.add(exportBundle);
mnFile.addSeparator();
JCheckBoxMenuItem mntmPeakHold = new JCheckBoxMenuItem("Spectrum: peak hold");
mntmPeakHold.addActionListener(
e -> spectrumPanel.setPeakHoldEnabled(mntmPeakHold.isSelected()));
mnFile.add(mntmPeakHold);
JCheckBoxMenuItem mntmAveraging = new JCheckBoxMenuItem("Spectrum: averaging");
mntmAveraging.addActionListener(
e -> spectrumPanel.setAveragingEnabled(mntmAveraging.isSelected()));
mnFile.add(mntmAveraging);
JMenuItem mntmResetPeak = new JMenuItem("Spectrum: reset peak hold");
mntmResetPeak.addActionListener(e -> spectrumPanel.resetPeakHold());
mnFile.add(mntmResetPeak);
mnFile.addSeparator();
JCheckBoxMenuItem mntmTrigger = new JCheckBoxMenuItem("Waveform: trigger (oscilloscope)");
mntmTrigger.setToolTipText(
"Align the waveform display so each refresh starts on a rising zero crossing"
+ " (channel 0).");
mntmTrigger.addActionListener(e -> waveformPanel.setTriggerEnabled(mntmTrigger.isSelected()));
mnFile.add(mntmTrigger);
JMenu mnTriggerSlope = new JMenu("Waveform: trigger slope");
ButtonGroup slopeGroup = new ButtonGroup();
JRadioButton mntmTriggerRising = new JRadioButton("Rising â", true);
JRadioButton mntmTriggerFalling = new JRadioButton("Falling â");
slopeGroup.add(mntmTriggerRising);
slopeGroup.add(mntmTriggerFalling);
mntmTriggerRising.addActionListener(
e ->
waveformPanel
.getTrigger()
.setSlope(org.hammer.audio.analysis.WaveformTrigger.Slope.RISING));
mntmTriggerFalling.addActionListener(
e ->
waveformPanel
.getTrigger()
.setSlope(org.hammer.audio.analysis.WaveformTrigger.Slope.FALLING));
mnTriggerSlope.add(mntmTriggerRising);
mnTriggerSlope.add(mntmTriggerFalling);
mnFile.add(mnTriggerSlope);
JMenu mnTriggerMode = new JMenu("Waveform: trigger mode");
ButtonGroup modeGroup = new ButtonGroup();
JRadioButton mntmTriggerAuto = new JRadioButton("Auto (fall back if silent)", true);
JRadioButton mntmTriggerNormal = new JRadioButton("Normal (only on event)");
modeGroup.add(mntmTriggerAuto);
modeGroup.add(mntmTriggerNormal);
mntmTriggerAuto.addActionListener(
e ->
waveformPanel
.getTrigger()
.setMode(org.hammer.audio.analysis.WaveformTrigger.Mode.AUTO));
mntmTriggerNormal.addActionListener(
e ->
waveformPanel
.getTrigger()
.setMode(org.hammer.audio.analysis.WaveformTrigger.Mode.NORMAL));
mnTriggerMode.add(mntmTriggerAuto);
mnTriggerMode.add(mntmTriggerNormal);
mnFile.add(mnTriggerMode);
mnFile.addSeparator();
JMenuItem mntmStartRecording = new JMenuItem("Start recording...");
mntmStartRecording.setToolTipText(
"Capture every produced AudioBlock into an .aar file for later replay or A/B compare.");
mntmStartRecording.addActionListener(e -> startRecording());
mnFile.add(mntmStartRecording);
JMenuItem mntmStopRecording = new JMenuItem("Stop recording");
mntmStopRecording.addActionListener(e -> stopRecording());
mnFile.add(mntmStopRecording);
JMenuItem mntmOpenRecording = new JMenuItem("Open recording...");
mntmOpenRecording.setToolTipText("Replay a previously captured .aar file as the audio source.");
mntmOpenRecording.addActionListener(e -> openRecording());
mnFile.add(mntmOpenRecording);
JMenuItem mntmCompare = new JMenuItem("Compare two recordings...");
mntmCompare.setToolTipText(
"Compare measurement, spectrum and diagnosis of two .aar recordings and render a"
+ " Markdown A/B report.");
mntmCompare.addActionListener(e -> compareRecordings());
mnFile.add(mntmCompare);
menuBar.add(Box.createGlue());
JMenu mnPlugins = new PluginMenuBuilder(new PluginManager().loadPlugins()).buildMenu(this);
menuBar.add(mnPlugins);
JMenu mnHelp = new JMenu("Help");
menuBar.add(mnHelp);
JMenuItem mntmAbout = new JMenuItem();
mntmAbout.setAction(new SwingAction());
mnHelp.add(mntmAbout);
}
private void initTopSettingsPanel() {
JPanel topContainer = new JPanel();
topContainer.setLayout(new BoxLayout(topContainer, BoxLayout.Y_AXIS));
topContainer.setBorder(new EmptyBorder(0, 0, 4, 0));
contentPane.add(topContainer, BorderLayout.NORTH);
JPanel settingsPanel = createSettingsPanel();
JPanel measurementPanel = createMeasurementPanel();
settingsPanel.setAlignmentX(LEFT_ALIGNMENT);
measurementPanel.setAlignmentX(LEFT_ALIGNMENT);
topContainer.add(settingsPanel);
topContainer.add(Box.createVerticalStrut(TOP_PANEL_HGAP));
topContainer.add(measurementPanel);
updateModeControls();
updateUIFromModel();
}
private JPanel createSettingsPanel() {
JPanel settingsPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 4));
settingsPanel.setBorder(UiTheme.createPanelBorder());
settingsPanel.add(new JLabel("Input"));
JPanel modePanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0));
ButtonGroup buttonGroup = new ButtonGroup();
buttonGroup.add(radioLiveMicrophone);
buttonGroup.add(radioDemoMode);
radioLiveMicrophone.addActionListener(e -> switchInputMode(InputMode.LIVE));
radioDemoMode.addActionListener(e -> switchInputMode(InputMode.DEMO));
modePanel.add(radioLiveMicrophone);
modePanel.add(radioDemoMode);
settingsPanel.add(modePanel);
settingsPanel.add(Box.createHorizontalStrut(8));
settingsPanel.add(new JLabel("Demo"));
comboBoxDemoSignal.addItemListener(
event -> {
if (event.getStateChange() == ItemEvent.SELECTED && inputMode == InputMode.DEMO) {
switchServicePreservingRunning(null);
}
});
settingsPanel.add(comboBoxDemoSignal);
settingsPanel.add(new JLabel("Device"));
populateAudioDeviceChoices();
comboBoxAudioDevice.addItemListener(this::audioDeviceSelectionChanged);
settingsPanel.add(comboBoxAudioDevice);
settingsPanel.add(Box.createHorizontalStrut(8));
settingsPanel.add(new JLabel("Size"));
configureReadOnlyField(textFieldDataSize, 6);
settingsPanel.add(textFieldDataSize);
settingsPanel.add(new JLabel("Div"));
configureReadOnlyField(textFieldDivisor, 4);
settingsPanel.add(textFieldDivisor);
settingsPanel.add(new JLabel("Format"));
textFieldAudioFormat.setHorizontalAlignment(SwingConstants.CENTER);
configureReadOnlyField(textFieldAudioFormat, 18);
settingsPanel.add(textFieldAudioFormat);
settingsPanel.add(new JLabel("Peak"));
textFieldPeakFrequency.setHorizontalAlignment(SwingConstants.CENTER);
configureReadOnlyField(textFieldPeakFrequency, 10);
settingsPanel.add(textFieldPeakFrequency);
settingsPanel.add(new JLabel("Mic spacing m"));
textFieldMicrophoneSpacing.setText(
String.format(Locale.ROOT, "%.2f", StereoDelayAnalyzer.DEFAULT_MICROPHONE_SPACING_METERS));
textFieldMicrophoneSpacing.setColumns(5);
textFieldMicrophoneSpacing.setToolTipText(
"Stereo microphone spacing used for direction estimate");
settingsPanel.add(textFieldMicrophoneSpacing);
return settingsPanel;
}
private JPanel createMeasurementPanel() {
JPanel measurementPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 4));
measurementPanel.setBorder(UiTheme.createPanelBorder());
measurementPanel.add(new JLabel("RMS"));
configureReadOnlyField(textFieldRms, 7);
measurementPanel.add(textFieldRms);
measurementPanel.add(new JLabel("Peak level"));
configureReadOnlyField(textFieldPeakLevel, 7);
measurementPanel.add(textFieldPeakLevel);
measurementPanel.add(new JLabel("Dominant"));
configureReadOnlyField(textFieldDominantFrequency, 9);
measurementPanel.add(textFieldDominantFrequency);
measurementPanel.add(new JLabel("Correlation"));
configureReadOnlyField(textFieldStereoCorrelation, 7);
measurementPanel.add(textFieldStereoCorrelation);
measurementPanel.add(new JLabel("Clipping"));
textFieldClipping.setHorizontalAlignment(SwingConstants.CENTER);
textFieldClipping.setOpaque(true);
configureReadOnlyField(textFieldClipping, 5);
textFieldClipping.setEnabled(true);
measurementPanel.add(textFieldClipping);
measurementPanel.add(new JLabel("Delay"));
configureReadOnlyField(textFieldStereoDelay, 11);
measurementPanel.add(textFieldStereoDelay);
measurementPanel.add(new JLabel("Angle"));
configureReadOnlyField(textFieldStereoAngle, 8);
measurementPanel.add(textFieldStereoAngle);
measurementPanel.add(new JLabel("Conf"));
configureReadOnlyField(textFieldStereoConfidence, 7);
measurementPanel.add(textFieldStereoConfidence);
return measurementPanel;
}
private void configureReadOnlyField(JTextField textField, int columns) {
textField.setEnabled(false);
textField.setEditable(false);
textField.setColumns(columns);
}
private void initCenterAndEast() {
waveformPanel.setBorder(UiTheme.createPanelBorder());
phaseDiagramPanel.setBorder(UiTheme.createPanelBorder());
spectrumPanel.setBorder(UiTheme.createPanelBorder());
spectrogramPanel.setBorder(UiTheme.createPanelBorder());
diagnosisPanel.setBorder(UiTheme.createPanelBorder());
JPanel lowerPanel = new JPanel(new java.awt.GridLayout(1, 2, 8, 0));
lowerPanel.setBorder(new EmptyBorder(8, 0, 0, 0));
lowerPanel.add(spectrumPanel);
lowerPanel.add(phaseDiagramPanel);
JPanel spectrogramRow = new JPanel(new BorderLayout(8, 0));
spectrogramRow.add(spectrogramPanel, BorderLayout.CENTER);
spectrogramRow.add(diagnosisPanel, BorderLayout.EAST);
JSplitPane innerSplit = new JSplitPane(JSplitPane.VERTICAL_SPLIT, lowerPanel, spectrogramRow);
innerSplit.setResizeWeight(0.5);
innerSplit.setDividerSize(7);
innerSplit.setBorder(null);
innerSplit.setContinuousLayout(true);
JSplitPane splitPane = new JSplitPane(JSplitPane.VERTICAL_SPLIT, waveformPanel, innerSplit);
splitPane.setResizeWeight(0.45);
splitPane.setDividerSize(7);
splitPane.setBorder(null);
splitPane.setContinuousLayout(true);
visualizationPanel.add(splitPane, BorderLayout.CENTER);
contentPane.add(visualizationPanel, BorderLayout.CENTER);
}
private void initSouthSlider() {
JSlider slider = new JSlider();
slider.setMinimum(1);
slider.setValue(audioCaptureService != null ? audioCaptureService.getDivisor() : 1);
slider.setToolTipText("Adjust divisor (affects sampling / display)");
slider.addChangeListener(
new ChangeListener() {
@Override
public void stateChanged(ChangeEvent e) {
int value = ((JSlider) e.getSource()).getValue();
if (audioCaptureService != null) {
audioCaptureService.setDivisor(value);
}
textFieldDivisor.setText(String.valueOf(value));
contentPane.repaint();
}
});
contentPane.add(slider, BorderLayout.SOUTH);
}
private void populateAudioDeviceChoices() {
comboBoxAudioDevice.addItem(new AudioDeviceItem(null));
AudioFormat format = defaultAudioFormat();
DataLine.Info targetLineInfo = new DataLine.Info(TargetDataLine.class, format);
for (Mixer.Info mixerInfo : AudioSystem.getMixerInfo()) {
if (supportsTargetLine(mixerInfo, targetLineInfo)) {
comboBoxAudioDevice.addItem(new AudioDeviceItem(mixerInfo));
}
}
}
private boolean supportsTargetLine(Mixer.Info mixerInfo, DataLine.Info targetLineInfo) {
try (Mixer mixer = AudioSystem.getMixer(mixerInfo)) {
return mixer.isLineSupported(targetLineInfo);
}
}
private void audioDeviceSelectionChanged(ItemEvent event) {
if (event.getStateChange() != ItemEvent.SELECTED) {
return;
}
if (inputMode == InputMode.DEMO) {
return;
}
AudioDeviceItem item = (AudioDeviceItem) event.getItem();
switchServicePreservingRunning(item.mixerInfo());
}
private void switchInputMode(InputMode newMode) {
if (newMode == inputMode) {
updateModeControls();
return;
}
inputMode = newMode;
updateModeControls();
switchServicePreservingRunning(selectedMixerInfo());
}
private void updateModeControls() {
boolean demoMode = inputMode == InputMode.DEMO;
radioLiveMicrophone.setSelected(!demoMode);
radioDemoMode.setSelected(demoMode);
comboBoxAudioDevice.setEnabled(!demoMode);
comboBoxDemoSignal.setEnabled(demoMode);
}
private void switchServicePreservingRunning(Mixer.Info mixerInfo) {
boolean wasRunning = audioCaptureService != null && audioCaptureService.isRunning();
stopAudioIfRunning();
mntmStart.setSelected(false);
initializeAudioService(mixerInfo);
updateModeControls();
if (wasRunning) {
try {
audioCaptureService.start();
mntmStart.setSelected(true);
} catch (Exception ex) {
LOGGER.log(Level.SEVERE, "Failed to restart audio capture", ex);
JOptionPane.showMessageDialog(
this,
"Failed to start selected source: " + ex.getMessage(),
ERROR_TITLE,
JOptionPane.ERROR_MESSAGE);
}
}
}
private Mixer.Info selectedMixerInfo() {
Object selectedItem = comboBoxAudioDevice.getSelectedItem();
if (selectedItem instanceof AudioDeviceItem audioDeviceItem) {
return audioDeviceItem.mixerInfo();
}
return null;
}
private DemoSignalType selectedDemoSignal() {
Object selectedItem = comboBoxDemoSignal.getSelectedItem();
if (selectedItem instanceof DemoSignalType signalType) {
return signalType;
}
return DemoSignalType.SINE;
}
private void toggleAudioStartStop(ActionEvent evt) {
if (audioCaptureService == null) {
LOGGER.warning("toggleAudioStartStop: audioCaptureService is null");
JOptionPane.showMessageDialog(
this, "AudioCaptureService is not available.", ERROR_TITLE, JOptionPane.ERROR_MESSAGE);
return;
}
if (audioCaptureService.isRunning()) {
LOGGER.info("Stopping audio capture");
audioCaptureService.stop();
mntmStart.setSelected(false);
} else {
LOGGER.info("Starting audio capture");
try {
audioCaptureService.start();
mntmStart.setSelected(true);
} catch (Exception ex) {
LOGGER.log(Level.SEVERE, "Failed to start audio capture", ex);
JOptionPane.showMessageDialog(
this,
"Failed to start audio capture: " + ex.getMessage(),
ERROR_TITLE,
JOptionPane.ERROR_MESSAGE);
mntmStart.setSelected(false);
}
}
}
private void stopAudioIfRunning() {
if (audioCaptureService != null && audioCaptureService.isRunning()) {
audioCaptureService.stop();
}
}
private void setFrozen(boolean frozen) {
frozenBlock =
frozen && audioCaptureService != null ? audioCaptureService.getLatestBlock() : null;
waveformPanel.setFrozen(frozen);
spectrumPanel.setFrozen(frozen);
spectrogramPanel.setFrozen(frozen);
diagnosisPanel.setFrozen(frozen);
mntmFreeze.setSelected(frozen);
}
private void updateUIFromModel() {
if (!SwingUtilities.isEventDispatchThread()) {
SwingUtilities.invokeLater(this::updateUIFromModel);
return;
}
if (audioCaptureService != null) {
textFieldDataSize.setText(String.valueOf(audioCaptureService.getLatestModel().getDataSize()));
textFieldDivisor.setText(String.valueOf(audioCaptureService.getDivisor()));
textFieldAudioFormat.setText(
audioCaptureService.getFormat() != null
? audioCaptureService.getFormat().toString()
: defaultAudioFormat().toString());
double peakHz = spectrumPanel.getPeakFrequencyHz();
textFieldPeakFrequency.setText(
Double.isNaN(peakHz) ? "n/a" : String.format("%.1f Hz", peakHz));
AudioBlock measurementBlock = currentMeasurementBlock();
StereoDelaySnapshot delaySnapshot =
measurementBlock != null && measurementBlock.channels() >= 2
? stereoDelayAnalyzer().analyze(measurementBlock)
: null;
MeasurementSnapshot measurements =
measurementCalculator.calculate(measurementBlock, spectrumPanel.getCurrentSpectrum());
updateMeasurementFields(measurements);
updateStereoDelayFields(delaySnapshot);
updateDiagnosis(measurementBlock, delaySnapshot);
mntmStart.setSelected(audioCaptureService.isRunning());
} else {
textFieldDataSize.setText("");
textFieldDivisor.setText("");
textFieldAudioFormat.setText("");
textFieldPeakFrequency.setText("n/a");
updateMeasurementFields(NO_MEASUREMENT);
updateStereoDelayFields(null);
updateDiagnosis(null, null);
mntmStart.setSelected(false);
}
}
private void updateDiagnosis(AudioBlock block, StereoDelaySnapshot delay) {
SpectrumSnapshot spectrum = spectrumPanel.getCurrentSpectrum();
org.hammer.audio.spectrogram.SpectrogramHistory history = spectrogramPanel.getHistory();
lastDiagnosis = diagnosisAnalyzer.analyze(block, spectrum, history, delay);
diagnosisPanel.setFindings(lastDiagnosis);
}
private void updateMeasurementFields(MeasurementSnapshot measurements) {
textFieldRms.setText(formatLevel(measurements.rms()));
textFieldPeakLevel.setText(formatLevel(measurements.peakLevel()));
textFieldDominantFrequency.setText(
Double.isNaN(measurements.dominantFrequencyHz())
? "n/a"
: String.format(Locale.ROOT, "%.1f Hz", measurements.dominantFrequencyHz()));
textFieldStereoCorrelation.setText(
measurements.stereoCorrelationAvailable()
? String.format(Locale.ROOT, "%.3f", measurements.stereoCorrelation())
: "n/a");
if (measurements.clipping()) {
textFieldClipping.setText("YES");
textFieldClipping.setForeground(Color.WHITE);
textFieldClipping.setBackground(new Color(180, 0, 0));
} else {
textFieldClipping.setText("no");
textFieldClipping.setForeground(Color.BLACK);
textFieldClipping.setBackground(new Color(225, 240, 225));
}
}
private static String formatLevel(double value) {
return Double.isNaN(value) ? "n/a" : String.format(Locale.ROOT, "%.4f", value);
}
private void updateStereoDelayFields(StereoDelaySnapshot delay) {
if (delay == null) {
textFieldStereoDelay.setText("n/a");
textFieldStereoAngle.setText("n/a");
textFieldStereoConfidence.setText("n/a");
return;
}
textFieldStereoConfidence.setText(String.format(Locale.ROOT, "%.2f", delay.confidence()));
if (delay.valid()) {
textFieldStereoDelay.setText(
String.format(Locale.ROOT, "%+d / %+.2f ms", delay.delaySamples(), delay.delayMillis()));
textFieldStereoAngle.setText(String.format(Locale.ROOT, "%+.1f°", delay.angleDegrees()));
} else {
textFieldStereoDelay.setText(delayStatusLabel(delay.status()));
textFieldStereoAngle.setText("n/a");
}
}
private double microphoneSpacingMeters() {
try {
double spacing = Double.parseDouble(textFieldMicrophoneSpacing.getText().trim());
if (spacing > 0.0 && Double.isFinite(spacing)) {
return spacing;
}
} catch (NumberFormatException ex) {
LOGGER.fine(() -> "Invalid microphone spacing: " + textFieldMicrophoneSpacing.getText());
}
return StereoDelayAnalyzer.DEFAULT_MICROPHONE_SPACING_METERS;
}
private StereoDelayAnalyzer stereoDelayAnalyzer() {
double spacingMeters = microphoneSpacingMeters();
if (stereoDelayAnalyzer == null
|| !Double.isFinite(stereoDelayAnalyzerSpacingMeters)
|| Double.compare(stereoDelayAnalyzerSpacingMeters, spacingMeters) != 0) {
stereoDelayAnalyzer =
new StereoDelayAnalyzer(
spacingMeters, StereoDelayAnalyzer.DEFAULT_SPEED_OF_SOUND_METERS_PER_SECOND, 0.35);
stereoDelayAnalyzerSpacingMeters = spacingMeters;
}
return stereoDelayAnalyzer;
}
private static String delayStatusLabel(StereoDelayStatus status) {
return switch (status) {
case MONO_INPUT -> "mono";
case SILENCE -> "silence";
case LOW_CORRELATION -> "low corr";
case DELAY_OUTSIDE_PHYSICAL_RANGE -> "impossible";
case VALID -> "valid";
};
}
private void exportMeasurementCsv() {
AudioBlock block = currentMeasurementBlock();
SpectrumSnapshot spectrum = spectrumPanel.getCurrentSpectrum();
if (block == null && spectrum == null) {
JOptionPane.showMessageDialog(
this,
"No measurement data available to export.",
"Export CSV",
JOptionPane.INFORMATION_MESSAGE);
return;
}
JFileChooser chooser = new JFileChooser();
chooser.setFileFilter(new FileNameExtensionFilter("CSV files", "csv"));
chooser.setSelectedFile(new java.io.File("measurement.csv"));
if (chooser.showSaveDialog(this) != JFileChooser.APPROVE_OPTION) {
return;
}
java.io.File file = ensureExtension(chooser.getSelectedFile(), ".csv");
try (PrintWriter writer =
new PrintWriter(Files.newBufferedWriter(file.toPath(), StandardCharsets.UTF_8))) {
writeMeasurementCsv(writer, block, spectrum);
JOptionPane.showMessageDialog(
this, "Measurement exported to " + file, "Export CSV", JOptionPane.INFORMATION_MESSAGE);
} catch (IOException ex) {
LOGGER.log(Level.SEVERE, "Failed to export CSV", ex);
JOptionPane.showMessageDialog(
this, "Failed to export CSV: " + ex.getMessage(), ERROR_TITLE, JOptionPane.ERROR_MESSAGE);
}
}
private void exportMeasurementPng() {
if (visualizationPanel.getWidth() <= 0 || visualizationPanel.getHeight() <= 0) {
JOptionPane.showMessageDialog(
this,
"Visualization is not ready to export.",
"Export PNG",
JOptionPane.INFORMATION_MESSAGE);
return;
}
JFileChooser chooser = new JFileChooser();
chooser.setFileFilter(new FileNameExtensionFilter("PNG images", "png"));
chooser.setSelectedFile(new java.io.File("measurement.png"));
if (chooser.showSaveDialog(this) != JFileChooser.APPROVE_OPTION) {
return;
}
java.io.File file = ensureExtension(chooser.getSelectedFile(), ".png");
BufferedImage image =
new BufferedImage(
visualizationPanel.getWidth(),
visualizationPanel.getHeight(),
BufferedImage.TYPE_INT_ARGB);
java.awt.Graphics2D graphics = image.createGraphics();
try {
visualizationPanel.paintAll(graphics);
} finally {
graphics.dispose();
}
try {
ImageIO.write(image, "png", file);
JOptionPane.showMessageDialog(
this, "Measurement exported to " + file, "Export PNG", JOptionPane.INFORMATION_MESSAGE);
} catch (IOException ex) {
LOGGER.log(Level.SEVERE, "Failed to export PNG", ex);
JOptionPane.showMessageDialog(
this, "Failed to export PNG: " + ex.getMessage(), ERROR_TITLE, JOptionPane.ERROR_MESSAGE);
}
}
private void exportEvidenceBundle() {
AudioBlock block = currentMeasurementBlock();
SpectrumSnapshot spectrum = spectrumPanel.getCurrentSpectrum();
org.hammer.audio.spectrogram.SpectrogramHistory history = spectrogramPanel.getHistory();
StereoDelaySnapshot delay = null;
if (block != null && block.channels() >= 2) {
delay = stereoDelayAnalyzer().analyze(block);
}
org.hammer.audio.diagnosis.DiagnosisSnapshot diagnosis =
diagnosisAnalyzer.analyze(block, spectrum, history, delay);
lastDiagnosis = diagnosis;
diagnosisPanel.setFindings(diagnosis);
if (block == null
&& spectrum == null
&& (history == null || history.isEmpty())
&& delay == null
&& diagnosis.isEmpty()) {
JOptionPane.showMessageDialog(
this,
"No measurement data available to export.",
"Export evidence bundle",
JOptionPane.INFORMATION_MESSAGE);
return;
}
JFileChooser chooser = new JFileChooser();
chooser.setDialogTitle("Export evidence bundle");
chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY);
if (chooser.showSaveDialog(this) != JFileChooser.APPROVE_OPTION) {
return;
}
java.io.File parent = chooser.getSelectedFile();
if (parent == null) {
return;
}
BufferedImage screenshot = null;
if (visualizationPanel.getWidth() > 0 && visualizationPanel.getHeight() > 0) {
screenshot =
new BufferedImage(
visualizationPanel.getWidth(),
visualizationPanel.getHeight(),
BufferedImage.TYPE_INT_ARGB);
java.awt.Graphics2D graphics = screenshot.createGraphics();
try {
visualizationPanel.paintAll(graphics);
} finally {
graphics.dispose();
}
}
org.hammer.audio.export.EvidenceData payload =
org.hammer.audio.export.EvidenceData.builder()
.timestamp(java.time.Instant.now())
.screenshot(screenshot)
.block(block)
.spectrum(spectrum)
.spectrogram(history)
.stereoDelay(delay)
.diagnosis(diagnosis)
.notes(
inputMode == InputMode.DEMO ? "Demo signal: " + selectedDemoSignal() : "Live input")
.build();
try {
java.nio.file.Path bundleDir =
new org.hammer.audio.export.EvidenceBundleExporter().export(parent.toPath(), payload);
JOptionPane.showMessageDialog(
this,
"Evidence bundle exported to " + bundleDir,
"Export evidence bundle",
JOptionPane.INFORMATION_MESSAGE);
} catch (IOException | IllegalArgumentException ex) {
LOGGER.log(Level.SEVERE, "Failed to export evidence bundle", ex);
JOptionPane.showMessageDialog(
this,
"Failed to export evidence bundle: " + ex.getMessage(),
ERROR_TITLE,
JOptionPane.ERROR_MESSAGE);
}
}
private AudioBlock currentMeasurementBlock() {
if (frozenBlock != null) {
return frozenBlock;
}
return audioCaptureService != null ? audioCaptureService.getLatestBlock() : null;
}
private void writeMeasurementCsv(
PrintWriter writer, AudioBlock block, SpectrumSnapshot spectrum) {
writer.println("section,key,value");
if (block != null) {
writer.printf(Locale.ROOT, "metadata,sampleRate,%.3f%n", block.format().sampleRate());
writer.printf(Locale.ROOT, "metadata,channels,%d%n", block.channels());
writer.printf(Locale.ROOT, "metadata,frames,%d%n", block.frames());
writer.printf(Locale.ROOT, "metadata,frameIndex,%d%n", block.frameIndex());
writer.println();
writer.print("sampleIndex");
for (int channel = 0; channel < block.channels(); channel++) {
writer.print(",channel");
writer.print(channel);
}
writer.println();
float[][] samples = block.samples();
for (int frame = 0; frame < block.frames(); frame++) {
writer.print(frame);
for (int channel = 0; channel < block.channels(); channel++) {
writer.printf(Locale.ROOT, ",%.9f", samples[channel][frame]);
}
writer.println();
}
}
if (spectrum != null) {
writer.println();
writer.println("bin,frequencyHz,magnitude");
for (int bin = 0; bin < spectrum.binCount(); bin++) {
writer.printf(
Locale.ROOT,
"%d,%.6f,%.9f%n",
bin,
spectrum.frequencyOfBin(bin),
spectrum.magnitude(bin));
}
}
}
private static AudioFormat defaultAudioFormat() {
return new AudioFormat(
DEFAULT_SAMPLE_RATE,
DEFAULT_SAMPLE_BITS,
DEFAULT_CHANNELS,
DEFAULT_SIGNED,
DEFAULT_BIG_ENDIAN);
}
private static java.io.File ensureExtension(java.io.File file, String extension) {
String name = file.getName().toLowerCase(Locale.ROOT);
if (name.endsWith(extension)) {
return file;
}
return new java.io.File(file.getParentFile(), file.getName() + extension);
}
private void startRecording() {
if (recordingTap != null && !recordingTap.isClosed()) {
JOptionPane.showMessageDialog(
this,
"A recording is already in progress (" + recordingTap.file() + ").",
"Recording",
JOptionPane.WARNING_MESSAGE);
return;
}
if (audioCaptureService == null) {
return;
}
JFileChooser chooser = new JFileChooser();
chooser.setFileFilter(new FileNameExtensionFilter("AudioAnalyzer recording (*.aar)", "aar"));
chooser.setSelectedFile(new java.io.File("recording.aar"));
if (chooser.showSaveDialog(this) != JFileChooser.APPROVE_OPTION) {
return;
}
java.io.File file = ensureExtension(chooser.getSelectedFile(), ".aar");
try {
recordingTap =
org.hammer.audio.RecordingTap.start(
audioCaptureService, file.toPath(), UiConstants.REFRESH_INTERVAL_MS);
JOptionPane.showMessageDialog(
this,
"Recording to " + file + " (Stop recording when done).",
"Recording",
JOptionPane.INFORMATION_MESSAGE);
} catch (IOException ex) {
LOGGER.log(Level.SEVERE, "Failed to start recording", ex);
JOptionPane.showMessageDialog(
this,
"Failed to start recording: " + ex.getMessage(),
ERROR_TITLE,
JOptionPane.ERROR_MESSAGE);
}
}
private void stopRecording() {
if (recordingTap == null || recordingTap.isClosed()) {
JOptionPane.showMessageDialog(
this, "No recording is in progress.", "Recording", JOptionPane.INFORMATION_MESSAGE);
return;
}
try {
long blocks = recordingTap.blocksWritten();
java.nio.file.Path file = recordingTap.file();
recordingTap.stop();
JOptionPane.showMessageDialog(
this,
"Recording stopped (" + blocks + " blocks written to " + file + ").",
"Recording",
JOptionPane.INFORMATION_MESSAGE);
} catch (IOException ex) {
LOGGER.log(Level.SEVERE, "Failed to close recording", ex);
JOptionPane.showMessageDialog(
this,
"Failed to close recording: " + ex.getMessage(),
ERROR_TITLE,
JOptionPane.ERROR_MESSAGE);
}
}
private void stopActiveRecordingTapQuietly() {
if (recordingTap == null || recordingTap.isClosed()) {
return;
}
try {
recordingTap.stop();
} catch (IOException ex) {
LOGGER.log(Level.WARNING, "Failed to stop recording tap before switching source", ex);
}
}
private void openRecording() {
JFileChooser chooser = new JFileChooser();
chooser.setFileFilter(new FileNameExtensionFilter("AudioAnalyzer recording (*.aar)", "aar"));
if (chooser.showOpenDialog(this) != JFileChooser.APPROVE_OPTION) {
return;
}
java.io.File file = chooser.getSelectedFile();
try {
org.hammer.audio.RecordedAudioCaptureService replay =
org.hammer.audio.RecordedAudioCaptureService.open(file.toPath(), false);
// Stop any in-progress recording before swapping the capture service so the tap doesn't
// keep polling the previous source and silently corrupt the recording.
stopActiveRecordingTapQuietly();
stopAudioIfRunning();
audioCaptureService = replay;
waveformPanel.setAudioCaptureService(replay);
phaseDiagramPanel.setAudioCaptureService(replay);
spectrumPanel.setAudioCaptureService(replay);
spectrogramPanel.setAudioCaptureService(replay);
setFrozen(false);
replay.start();
mntmStart.setSelected(true);
JOptionPane.showMessageDialog(
this,
"Replaying " + file + " (" + replay.blockCount() + " blocks).",
"Recording",
JOptionPane.INFORMATION_MESSAGE);
} catch (IOException ex) {
LOGGER.log(Level.SEVERE, "Failed to open recording", ex);
JOptionPane.showMessageDialog(
this,
"Failed to open recording: " + ex.getMessage(),
ERROR_TITLE,
JOptionPane.ERROR_MESSAGE);
}
}
private void compareRecordings() {
JFileChooser chooserA = new JFileChooser();
chooserA.setDialogTitle("Select recording A");
chooserA.setFileFilter(new FileNameExtensionFilter("AudioAnalyzer recording (*.aar)", "aar"));
if (chooserA.showOpenDialog(this) != JFileChooser.APPROVE_OPTION) {
return;
}
JFileChooser chooserB = new JFileChooser();
chooserB.setDialogTitle("Select recording B");
chooserB.setFileFilter(new FileNameExtensionFilter("AudioAnalyzer recording (*.aar)", "aar"));
if (chooserB.showOpenDialog(this) != JFileChooser.APPROVE_OPTION) {
return;
}
java.io.File fileA = chooserA.getSelectedFile();
java.io.File fileB = chooserB.getSelectedFile();
try {
org.hammer.audio.compare.ComparisonReport report =
new org.hammer.audio.compare.RecordingComparator()
.compareFiles(fileA.toPath(), fileB.toPath(), fileA.getName(), fileB.getName());
String markdown =
new org.hammer.audio.compare.MarkdownComparisonReportRenderer().render(report);
JFileChooser save = new JFileChooser();
save.setDialogTitle("Save A/B comparison report");
save.setFileFilter(new FileNameExtensionFilter("Markdown (*.md)", "md"));
save.setSelectedFile(new java.io.File("ab-report.md"));
if (save.showSaveDialog(this) == JFileChooser.APPROVE_OPTION) {
java.io.File out = ensureExtension(save.getSelectedFile(), ".md");
Files.writeString(out.toPath(), markdown, StandardCharsets.UTF_8);
JOptionPane.showMessageDialog(
this, "Report saved to " + out, "A/B comparison", JOptionPane.INFORMATION_MESSAGE);
} else {
// Preview in a dialog if the user does not save.
javax.swing.JTextArea area = new javax.swing.JTextArea(markdown, 24, 80);
area.setEditable(false);
area.setCaretPosition(0);
JOptionPane.showMessageDialog(
this,
new javax.swing.JScrollPane(area),
"A/B comparison report",
JOptionPane.INFORMATION_MESSAGE);
}
} catch (IOException ex) {
LOGGER.log(Level.SEVERE, "Failed to compare recordings", ex);
JOptionPane.showMessageDialog(
this,
"Failed to compare recordings: " + ex.getMessage(),
ERROR_TITLE,
JOptionPane.ERROR_MESSAGE);
}
}
private record AudioDeviceItem(Mixer.Info mixerInfo) {
@Override
public String toString() {
if (mixerInfo == null) {
return "System default input";
}
return mixerInfo.getName() + " â " + mixerInfo.getDescription();
}
}
private enum InputMode {
LIVE,
DEMO
}
private class SwingAction extends AbstractAction {
private static final long serialVersionUID = 1L;
SwingAction() {
putValue(NAME, "About");
putValue(SHORT_DESCRIPTION, "Some short description");
}
@Override
public void actionPerformed(ActionEvent e) {
JOptionPane.showMessageDialog(
AudioAnalyseFrame.this, "Carsten Hammer carsten.hammer@t-online.de");
}
}
}