AcousticLocalizationWorkbenchPanel.java
package org.hammer.audio.experimental.acoustic.workbench;
import java.awt.BasicStroke;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.FlowLayout;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.awt.RenderingHints;
import java.awt.Stroke;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.swing.BorderFactory;
import javax.swing.JButton;
import javax.swing.JComboBox;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JSlider;
import javax.swing.JSpinner;
import javax.swing.JSplitPane;
import javax.swing.JTabbedPane;
import javax.swing.JTextArea;
import javax.swing.SpinnerListModel;
import javax.swing.SpinnerNumberModel;
import javax.swing.SwingUtilities;
import javax.swing.SwingWorker;
import javax.swing.Timer;
import org.hammer.audio.acquisition.Microphone;
import org.hammer.audio.acquisition.SynchronizationStatus;
import org.hammer.audio.experimental.acoustic.benchmark.AlignedSourceObservation;
import org.hammer.audio.experimental.acoustic.benchmark.SnapshotAlignment;
import org.hammer.audio.experimental.acoustic.benchmark.SnapshotGroundTruthAligner;
import org.hammer.audio.experimental.acoustic.benchmark.classifier.ClassifierBenchmarkResult;
import org.hammer.audio.experimental.acoustic.benchmark.classifier.ClassifierBenchmarkRunner;
import org.hammer.audio.experimental.acoustic.benchmark.localization.LocalizationBenchmarkResult;
import org.hammer.audio.experimental.acoustic.benchmark.localization.LocalizationBenchmarkRunner;
import org.hammer.audio.experimental.acoustic.feature.comparison.FeatureDifference;
import org.hammer.audio.experimental.acoustic.feature.comparison.SyntheticRealComparison;
import org.hammer.audio.experimental.acoustic.feature.comparison.SyntheticRealComparisonReport;
import org.hammer.audio.experimental.acoustic.feature.evaluation.FeatureEvaluationEntry;
import org.hammer.audio.experimental.acoustic.feature.evaluation.FeatureEvaluationReport;
import org.hammer.audio.experimental.acoustic.feature.evaluation.FeatureEvaluationService;
import org.hammer.audio.experimental.acoustic.feature.ranking.FeatureRankingEntry;
import org.hammer.audio.experimental.acoustic.feature.ranking.FeatureRankingService;
import org.hammer.audio.experimental.acoustic.scenario.Scenario;
import org.hammer.audio.experimental.acoustic.simulation.AcousticEmitter2D;
import org.hammer.audio.experimental.acoustic.simulation.SimulationScenarios;
import org.hammer.audio.experimental.acoustic.simulation.SimulationScenarios.SimulationScenario;
import org.hammer.audio.experimental.acoustic.simulation.WingbeatSignalParameters;
import org.hammer.audio.experimental.acoustic.simulation.calibration.CalibrationResult;
import org.hammer.audio.experimental.acoustic.simulation.calibration.GeneratorCalibrationService;
import org.hammer.audio.experimental.acoustic.tracking.FrameSchedule;
import org.hammer.audio.experimental.acoustic.tracking.FrequencyCluster;
import org.hammer.audio.experimental.acoustic.tracking.TrackedSource;
import org.hammer.audio.experimental.acoustic.tracking.TrackingSnapshot;
import org.hammer.audio.experimental.acoustic.wingbeat.WingbeatFeatureVector;
import org.hammer.audio.experimental.acoustic.wingbeat.WingbeatLabel;
import org.hammer.audio.geometry.Vector2;
/**
* Swing panel implementing the acoustic localization research workbench.
*
* <p>This panel lets a user:
*
* <ol>
* <li>Select one of the deterministic {@link SimulationScenarios simulation scenarios}.
* <li>Adjust core pipeline parameters (block size, FFT size, max peaks, SNR threshold, frequency
* band, TDOA estimator choice, candidate grid resolution, tracker tolerance).
* <li>Run the scenario block-by-block through the {@link WorkbenchScenarioRunner} on a background
* thread, with live per-frame updates in the log area.
* <li>Inspect a 2-D room map showing microphone positions, emitter ground-truth positions and
* estimated source tracks after the run.
* <li>Export the run summary as Markdown, CSV or JSON via the tabs at the bottom.
* </ol>
*
* <p><strong>Experimental research panel.</strong> This is not a production localization tool. All
* labels and descriptions preserve the experimental/non-production wording used throughout the
* {@code audio-experimental-acoustic} module.
*/
public final class AcousticLocalizationWorkbenchPanel extends JPanel {
private static final Logger LOGGER =
Logger.getLogger(AcousticLocalizationWorkbenchPanel.class.getName());
private static final long serialVersionUID = 1L;
private static final String COMPUTING_ANALYSIS_MESSAGE = "Computing analysis …";
// --- controls ---
private final JComboBox<ScenarioItem> scenarioCombo;
private final JSpinner blockSizeSpinner;
private final JSpinner fftSizeSpinner;
private final JSpinner maxPeaksSpinner;
private final JSpinner minSnrSpinner;
private final JSpinner bandMinSpinner;
private final JSpinner bandMaxSpinner;
private final JSpinner gridStepsSpinner;
private final JComboBox<WorkbenchParameters.TdoaEstimatorType> tdoaCombo;
private final JButton runButton;
private final JButton stopButton;
// --- output ---
private final JTextArea logArea;
private final JTextArea markdownArea;
private final JTextArea csvArea;
private final JTextArea jsonArea;
private final JTextArea benchmarkArea;
private final JTextArea featureRankingArea;
private final JTextArea featureComparisonArea;
private final JTextArea syntheticRealArea;
private final JTextArea classifierComparisonArea;
private final JTextArea localizationComparisonArea;
private final JTextArea calibrationArea;
private final JLabel statusLabel;
private final RoomMapPanel roomMapPanel;
// --- playback controls ---
private final JButton firstButton;
private final JButton stepBackButton;
private final JButton playPauseButton;
private final JButton stepForwardButton;
private final JButton lastButton;
private final JSlider timelineSlider;
private final JLabel frameLabel;
private final JTextArea playbackDetailArea;
// --- state ---
private transient volatile WorkbenchRunResult lastRunResult;
private final AtomicBoolean running = new AtomicBoolean(false);
private volatile SwingWorker<WorkbenchRunResult, String> currentWorker;
private volatile SwingWorker<AnalysisTabContent, Void> analysisWorker;
private transient PlaybackModel playbackModel;
private transient Timer playTimer;
/** Guard flag to prevent slider change listener from re-entering on programmatic updates. */
private boolean updatingSlider;
private static final class AnalysisTabContent {
private final String featureRankingText;
private final String featureComparisonText;
private final String syntheticRealText;
private final String classifierComparisonText;
private final String localizationComparisonText;
private final String calibrationText;
AnalysisTabContent(
String featureRankingText,
String featureComparisonText,
String syntheticRealText,
String classifierComparisonText,
String localizationComparisonText,
String calibrationText) {
this.featureRankingText = featureRankingText;
this.featureComparisonText = featureComparisonText;
this.syntheticRealText = syntheticRealText;
this.classifierComparisonText = classifierComparisonText;
this.localizationComparisonText = localizationComparisonText;
this.calibrationText = calibrationText;
}
}
/** Create the workbench panel. All Swing construction is performed on the calling thread. */
public AcousticLocalizationWorkbenchPanel() {
super(new BorderLayout(4, 4));
setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
// --- scenario combo ---
List<SimulationScenario> scenarios = SimulationScenarios.all();
ScenarioItem[] items = new ScenarioItem[scenarios.size()];
for (int i = 0; i < scenarios.size(); i++) {
items[i] = new ScenarioItem(scenarios.get(i));
}
scenarioCombo = new JComboBox<>(items);
// --- parameter spinners ---
blockSizeSpinner = new JSpinner(new SpinnerNumberModel(1024, 256, 8192, 256));
fftSizeSpinner = new JSpinner(new SpinnerListModel(List.of(256, 512, 1024, 2048, 4096, 8192)));
fftSizeSpinner.setValue(1024);
maxPeaksSpinner = new JSpinner(new SpinnerNumberModel(3, 1, 10, 1));
minSnrSpinner = new JSpinner(new SpinnerNumberModel(2.0, 0.0, 20.0, 0.5));
bandMinSpinner = new JSpinner(new SpinnerNumberModel(150.0, 50.0, 20000.0, 50.0));
bandMaxSpinner = new JSpinner(new SpinnerNumberModel(2500.0, 100.0, 24000.0, 100.0));
gridStepsSpinner = new JSpinner(new SpinnerNumberModel(8, 2, 32, 1));
tdoaCombo = new JComboBox<>(WorkbenchParameters.TdoaEstimatorType.values());
// --- buttons ---
runButton = new JButton("▶ Run");
stopButton = new JButton("■ Stop");
stopButton.setEnabled(false);
runButton.addActionListener(e -> startRun());
stopButton.addActionListener(e -> stopRun());
// --- output areas ---
logArea = newReadOnlyTextArea(20, 60);
markdownArea = newReadOnlyTextArea(20, 60);
csvArea = newReadOnlyTextArea(20, 60);
jsonArea = newReadOnlyTextArea(20, 60);
benchmarkArea = newReadOnlyTextArea(20, 60);
featureRankingArea = newReadOnlyTextArea(20, 60);
featureComparisonArea = newReadOnlyTextArea(20, 60);
syntheticRealArea = newReadOnlyTextArea(20, 60);
classifierComparisonArea = newReadOnlyTextArea(20, 60);
localizationComparisonArea = newReadOnlyTextArea(20, 60);
calibrationArea = newReadOnlyTextArea(20, 60);
statusLabel = new JLabel("Ready — select a scenario and press Run.");
roomMapPanel = new RoomMapPanel();
// --- playback controls (disabled until run completes) ---
firstButton = new JButton("|<");
stepBackButton = new JButton("<<");
playPauseButton = new JButton("▶");
stepForwardButton = new JButton(">>");
lastButton = new JButton(">|");
timelineSlider = new JSlider(0, 0, 0);
frameLabel = new JLabel("Frame: — / —");
playbackDetailArea = newReadOnlyTextArea(10, 60);
setPlaybackControlsEnabled(false);
firstButton.addActionListener(e -> onPlaybackFirst());
stepBackButton.addActionListener(e -> onPlaybackStepBack());
playPauseButton.addActionListener(e -> onPlaybackPlayPause());
stepForwardButton.addActionListener(e -> onPlaybackStepForward());
lastButton.addActionListener(e -> onPlaybackLast());
timelineSlider.addChangeListener(e -> onTimelineSliderChanged());
// --- layout ---
add(buildTopPanel(), BorderLayout.NORTH);
add(buildCenterSplit(), BorderLayout.CENTER);
add(buildStatusBar(), BorderLayout.SOUTH);
}
// -------------------------------------------------------------------------
// Layout helpers
// -------------------------------------------------------------------------
private JPanel buildTopPanel() {
JPanel top = new JPanel(new BorderLayout(4, 4));
top.setBorder(
BorderFactory.createTitledBorder(
"Experimental Acoustic Localization Workbench (simulation only)"));
top.add(buildScenarioRow(), BorderLayout.NORTH);
top.add(buildParamPanel(), BorderLayout.CENTER);
top.add(buildButtonRow(), BorderLayout.SOUTH);
return top;
}
private JPanel buildScenarioRow() {
JPanel row = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 2));
row.add(new JLabel("Scenario:"));
scenarioCombo.setPreferredSize(new Dimension(260, 24));
row.add(scenarioCombo);
return row;
}
private JPanel buildParamPanel() {
JPanel p = new JPanel(new GridBagLayout());
GridBagConstraints lc = new GridBagConstraints();
lc.anchor = GridBagConstraints.WEST;
lc.insets = new Insets(2, 4, 2, 2);
GridBagConstraints vc = new GridBagConstraints();
vc.fill = GridBagConstraints.HORIZONTAL;
vc.weightx = 0.15;
vc.insets = new Insets(2, 0, 2, 8);
int row = 0;
addParam(p, lc, vc, row, 0, "Block size:", blockSizeSpinner);
addParam(p, lc, vc, row, 3, "FFT size:", fftSizeSpinner);
row++;
addParam(p, lc, vc, row, 0, "Max peaks:", maxPeaksSpinner);
addParam(p, lc, vc, row, 3, "Min SNR:", minSnrSpinner);
row++;
addParam(p, lc, vc, row, 0, "Band min (Hz):", bandMinSpinner);
addParam(p, lc, vc, row, 3, "Band max (Hz):", bandMaxSpinner);
row++;
addParam(p, lc, vc, row, 0, "Grid steps:", gridStepsSpinner);
addParam(p, lc, vc, row, 3, "TDOA estimator:", tdoaCombo);
return p;
}
private static void addParam(
JPanel panel,
GridBagConstraints lc,
GridBagConstraints vc,
int row,
int colOffset,
String label,
java.awt.Component field) {
lc.gridx = colOffset;
lc.gridy = row;
panel.add(new JLabel(label), lc);
vc.gridx = colOffset + 1;
vc.gridy = row;
panel.add(field, vc);
}
private JPanel buildButtonRow() {
JPanel row = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 2));
row.add(runButton);
row.add(stopButton);
return row;
}
private JSplitPane buildCenterSplit() {
JTabbedPane outputTabs = buildOutputTabs();
JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, outputTabs, roomMapPanel);
split.setResizeWeight(0.55);
split.setDividerLocation(460);
return split;
}
private JTabbedPane buildOutputTabs() {
JTabbedPane tabs = new JTabbedPane();
tabs.addTab("Log", new JScrollPane(logArea));
tabs.addTab("Playback", buildPlaybackTab());
tabs.addTab("Benchmark", new JScrollPane(benchmarkArea));
tabs.addTab("Markdown", new JScrollPane(markdownArea));
tabs.addTab("CSV", new JScrollPane(csvArea));
tabs.addTab("JSON-lines", new JScrollPane(jsonArea));
tabs.addTab("Feature Ranking", new JScrollPane(featureRankingArea));
tabs.addTab("Feature Comparison", new JScrollPane(featureComparisonArea));
tabs.addTab("Synthetic vs Real", new JScrollPane(syntheticRealArea));
tabs.addTab("Classifier Comparison", new JScrollPane(classifierComparisonArea));
tabs.addTab("Localization Comparison", new JScrollPane(localizationComparisonArea));
tabs.addTab("Generator Calibration", new JScrollPane(calibrationArea));
return tabs;
}
private JPanel buildPlaybackTab() {
JPanel tab = new JPanel(new BorderLayout(4, 4));
tab.setBorder(BorderFactory.createEmptyBorder(4, 4, 4, 4));
// Controls row
JPanel controls = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 2));
controls.add(firstButton);
controls.add(stepBackButton);
controls.add(playPauseButton);
controls.add(stepForwardButton);
controls.add(lastButton);
controls.add(frameLabel);
// Slider row
JPanel sliderRow = new JPanel(new BorderLayout(4, 0));
sliderRow.add(new JLabel("Timeline:"), BorderLayout.WEST);
sliderRow.add(timelineSlider, BorderLayout.CENTER);
JPanel top = new JPanel(new BorderLayout(0, 2));
top.add(controls, BorderLayout.NORTH);
top.add(sliderRow, BorderLayout.SOUTH);
tab.add(top, BorderLayout.NORTH);
tab.add(new JScrollPane(playbackDetailArea), BorderLayout.CENTER);
return tab;
}
private JPanel buildStatusBar() {
JPanel bar = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 2));
bar.setBorder(BorderFactory.createEtchedBorder());
bar.add(statusLabel);
return bar;
}
private static JTextArea newReadOnlyTextArea(int rows, int cols) {
JTextArea area = new JTextArea(rows, cols);
area.setEditable(false);
area.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 11));
area.setLineWrap(false);
return area;
}
// -------------------------------------------------------------------------
// Run / stop logic
// -------------------------------------------------------------------------
private void startRun() {
if (running.get()) {
return;
}
ScenarioItem selected = (ScenarioItem) scenarioCombo.getSelectedItem();
if (selected == null) {
updateStatus("No scenario selected.");
return;
}
SimulationScenario scenario = selected.scenario;
WorkbenchParameters params;
try {
params = readParameters();
} catch (IllegalArgumentException ex) {
updateStatus("Invalid parameters: " + ex.getMessage());
return;
}
logArea.setText("");
markdownArea.setText("");
csvArea.setText("");
jsonArea.setText("");
benchmarkArea.setText("");
featureRankingArea.setText("");
featureComparisonArea.setText("");
syntheticRealArea.setText("");
classifierComparisonArea.setText("");
localizationComparisonArea.setText("");
calibrationArea.setText("");
SwingWorker<AnalysisTabContent, Void> currentAnalysis = analysisWorker;
if (currentAnalysis != null) {
currentAnalysis.cancel(true);
analysisWorker = null;
}
roomMapPanel.clear(scenario);
setRunning(true);
appendLog("Starting scenario: " + scenario.name());
appendLog(
String.format(
Locale.ROOT,
"Parameters: blockSize=%d fftSize=%d maxPeaks=%d minSnr=%.1f band=[%.0f,%.0f] "
+ "grid=%d TDOA=%s",
params.blockSize(),
params.fftSize(),
params.maxPeaks(),
params.minSnr(),
params.bandMinHz(),
params.bandMaxHz(),
params.candidateGridSteps(),
params.tdoaEstimatorType()));
appendLog("---");
currentWorker = new ScenarioWorker(scenario, params);
currentWorker.execute();
}
private void stopRun() {
SwingWorker<WorkbenchRunResult, String> w = currentWorker;
if (w != null) {
w.cancel(true);
}
}
private void setRunning(boolean isRunning) {
running.set(isRunning);
SwingUtilities.invokeLater(
() -> {
runButton.setEnabled(!isRunning);
stopButton.setEnabled(isRunning);
});
}
private void appendLog(String line) {
SwingUtilities.invokeLater(
() -> {
logArea.append(line);
logArea.append("\n");
logArea.setCaretPosition(logArea.getDocument().getLength());
});
}
private WorkbenchParameters readParameters() {
return WorkbenchParameters.defaults()
.blockSize((Integer) blockSizeSpinner.getValue())
.fftSize((Integer) fftSizeSpinner.getValue())
.maxPeaks((Integer) maxPeaksSpinner.getValue())
.minSnr((Double) minSnrSpinner.getValue())
.bandMinHz((Double) bandMinSpinner.getValue())
.bandMaxHz((Double) bandMaxSpinner.getValue())
.candidateGridSteps((Integer) gridStepsSpinner.getValue())
.tdoaEstimatorType((WorkbenchParameters.TdoaEstimatorType) tdoaCombo.getSelectedItem())
.build();
}
// -------------------------------------------------------------------------
// Playback logic
// -------------------------------------------------------------------------
private void loadPlayback(WorkbenchRunResult result) {
SwingUtilities.invokeLater(
() -> {
stopPlayTimer();
playbackModel = new PlaybackModel(result);
int last = Math.max(0, result.snapshots().size() - 1);
timelineSlider.setMinimum(0);
timelineSlider.setMaximum(last);
syncSliderToModel(playbackModel);
setPlaybackControlsEnabled(!result.snapshots().isEmpty());
if (result.snapshots().isEmpty()) {
frameLabel.setText("Frame: — / —");
playbackDetailArea.setText("No frames in this run.");
} else {
refreshPlaybackFrame(0);
}
});
}
private void setPlaybackControlsEnabled(boolean enabled) {
firstButton.setEnabled(enabled);
stepBackButton.setEnabled(enabled);
playPauseButton.setEnabled(enabled);
stepForwardButton.setEnabled(enabled);
lastButton.setEnabled(enabled);
timelineSlider.setEnabled(enabled);
}
private void onPlaybackFirst() {
PlaybackModel model = playbackModel;
if (model == null) {
return;
}
model.first();
syncSliderToModel(model);
refreshPlaybackFrame(model.currentFrame());
}
private void onPlaybackLast() {
PlaybackModel model = playbackModel;
if (model == null) {
return;
}
model.last();
syncSliderToModel(model);
refreshPlaybackFrame(model.currentFrame());
}
private void onPlaybackStepBack() {
PlaybackModel model = playbackModel;
if (model == null) {
return;
}
model.stepBack();
syncSliderToModel(model);
refreshPlaybackFrame(model.currentFrame());
}
private void onPlaybackStepForward() {
PlaybackModel model = playbackModel;
if (model == null) {
return;
}
model.stepForward();
syncSliderToModel(model);
refreshPlaybackFrame(model.currentFrame());
}
private void onPlaybackPlayPause() {
if (playTimer != null && playTimer.isRunning()) {
stopPlayTimer();
} else {
startPlayTimer();
}
}
private void startPlayTimer() {
if (playbackModel == null || playbackModel.isEmpty()) {
return;
}
playPauseButton.setText("⏸");
playTimer =
new Timer(
150,
e -> {
PlaybackModel model = playbackModel;
if (model == null) {
stopPlayTimer();
return;
}
boolean advanced = model.stepForward();
syncSliderToModel(model);
refreshPlaybackFrame(model.currentFrame());
if (!advanced) {
stopPlayTimer();
}
});
playTimer.start();
}
private void stopPlayTimer() {
if (playTimer != null) {
playTimer.stop();
playTimer = null;
}
playPauseButton.setText("▶");
}
private void onTimelineSliderChanged() {
if (updatingSlider) {
return;
}
PlaybackModel model = playbackModel;
if (model == null || model.isEmpty()) {
return;
}
int value = timelineSlider.getValue();
model.seekTo(value);
refreshPlaybackFrame(model.currentFrame());
}
// PMD cannot see that updatingSlider is read by onTimelineSliderChanged, which is triggered
// as a side-effect of timelineSlider.setValue() firing the registered ChangeListener.
@SuppressWarnings("PMD.UnusedAssignment")
private void syncSliderToModel(PlaybackModel model) {
updatingSlider = true;
try {
timelineSlider.setValue(model.currentFrame());
} finally {
updatingSlider = false;
}
}
static String formatSynchronizationDetails(TrackingSnapshot snapshot) {
return String.format(
Locale.ROOT,
"Synchronization: %s / %s (error=%.4f samples, %.2f µs)%n",
snapshot.synchronization().mode(),
snapshot.synchronization().status(),
snapshot.synchronization().estimatedErrorSamples(),
snapshot.synchronization().estimatedErrorSeconds() * 1_000_000.0);
}
@SuppressWarnings("PMD.ConsecutiveAppendsShouldReuse")
private void refreshPlaybackFrame(int frameIndex) {
PlaybackModel model = playbackModel;
if (model == null || model.isEmpty()) {
return;
}
TrackingSnapshot snap = model.result().snapshots().get(frameIndex);
int total = model.frameCount();
frameLabel.setText(
String.format(
Locale.ROOT,
"Frame: %d / %d (t=%.1f ms)",
frameIndex + 1,
total,
snap.sourceTimestampNanos() / 1_000_000.0));
// Update room map to show state up to this frame
roomMapPanel.setFrame(model.result(), frameIndex);
// Build per-frame detail text
StringBuilder sb = new StringBuilder(512);
sb.append(String.format(Locale.ROOT, "Frame %d / %d%n", frameIndex + 1, total));
sb.append(
String.format(
Locale.ROOT, "Timestamp: %.3f ms%n", snap.sourceTimestampNanos() / 1_000_000.0));
sb.append(String.format(Locale.ROOT, "Source frame: %d%n", snap.sourceFrameIndex()));
sb.append(
String.format(Locale.ROOT, "Processing: %.1f µs%n", snap.processingNanos() / 1_000.0));
sb.append(formatSynchronizationDetails(snap));
if (snap.synchronization().status() != SynchronizationStatus.TRUSTED) {
sb.append("⚠ Synchronization quality requires attention: ")
.append(String.join(" ", snap.synchronization().diagnostics()))
.append('\n');
}
if (model.result().isFrameOverBudget(snap)) {
sb.append("⚠ Frame exceeded real-time budget\n");
}
sb.append(String.format(Locale.ROOT, "Clusters: %d%n", snap.clusters().size()));
sb.append(String.format(Locale.ROOT, "Tracks: %d%n", snap.tracks().size()));
if (!snap.tracks().isEmpty()) {
sb.append("\n--- Estimated tracks ---\n");
for (org.hammer.audio.experimental.acoustic.tracking.TrackedSource t : snap.tracks()) {
sb.append(
String.format(
Locale.ROOT,
" id=%d freq=%.0f Hz pos=(%.3f, %.3f) m conf=%.2f obs=%d%n",
t.id(),
t.frequencyHz(),
t.positionMeters().x(),
t.positionMeters().y(),
t.confidence(),
t.observationCount()));
if (!snap.classificationResults().isEmpty()) {
org.hammer.audio.experimental.acoustic.wingbeat.ClassificationResult cls =
snap.classificationResults().get(t.id());
if (cls != null) {
sb.append(
String.format(
Locale.ROOT,
" classification: %s (confidence=%.3f)%n",
cls.label(),
cls.confidence()));
}
}
}
}
// Ground-truth comparison using SnapshotGroundTruthAligner
try {
org.hammer.audio.experimental.acoustic.scenario.Scenario truth =
model.result().scenario().groundTruth();
long startTs = model.result().snapshots().get(0).sourceTimestampNanos();
SnapshotAlignment alignment = new SnapshotGroundTruthAligner().align(truth, snap, startTs);
sb.append("\n--- Ground-truth alignment ---\n");
sb.append(
String.format(
Locale.ROOT,
"t=%.3f s matched=%d missing=%d spurious=%d%n",
alignment.timestampSeconds(),
alignment.matchedSources().size(),
alignment.missingSources().size(),
alignment.spuriousTracks().size()));
for (AlignedSourceObservation obs : alignment.matchedSources()) {
if (obs.groundTruth().hasPositionTruth()) {
double posErr =
obs.groundTruth()
.expectedPositionMeters()
.distanceTo(obs.trackedSource().positionMeters());
sb.append(
String.format(
Locale.ROOT,
" src=%s → track %d posErr=%.3f m",
obs.groundTruth().source().sourceId(),
obs.trackedSource().id(),
posErr));
}
if (obs.groundTruth().hasFrequencyTruth()) {
double freqErr =
Math.abs(obs.trackedSource().frequencyHz() - obs.groundTruth().expectedFrequencyHz());
sb.append(String.format(Locale.ROOT, " freqErr=%.1f Hz", freqErr));
}
sb.append('\n');
}
} catch (RuntimeException ignored) {
// alignment failure is non-critical; skip the ground-truth section
}
playbackDetailArea.setText(sb.toString());
playbackDetailArea.setCaretPosition(0);
}
private AnalysisTabContent buildAnalysisTabContent(SimulationScenario completedScenario) {
// Build a small deterministic demo dataset for feature analysis.
// Two groups: female-likely (430–550 Hz) and male-likely (580–750 Hz).
List<WingbeatFeatureVector> synthetic = buildDemoSyntheticVectors();
List<WingbeatFeatureVector> real = buildDemoRealVectors();
List<WingbeatFeatureVector> allVectors = new java.util.ArrayList<>();
allVectors.addAll(synthetic);
allVectors.addAll(real);
List<String> allLabels = new java.util.ArrayList<>();
for (WingbeatFeatureVector v : synthetic) {
allLabels.add(
v.fundamentalFrequencyHz() < 560.0
? WingbeatLabel.FEMALE_LIKELY
: WingbeatLabel.MALE_LIKELY);
}
for (WingbeatFeatureVector v : real) {
allLabels.add(
v.fundamentalFrequencyHz() < 560.0
? WingbeatLabel.FEMALE_LIKELY
: WingbeatLabel.MALE_LIKELY);
}
// Feature evaluation
FeatureEvaluationService evalService = new FeatureEvaluationService();
FeatureEvaluationReport evalReport = evalService.evaluate(allVectors, allLabels);
// Feature ranking
FeatureRankingService rankingService = FeatureRankingService.defaultService();
List<FeatureRankingEntry> ranking = rankingService.rank(evalReport);
// Synthetic vs real
SyntheticRealComparison srComparison = new SyntheticRealComparison();
SyntheticRealComparisonReport srReport = srComparison.compare(synthetic, real);
// Classifier comparison
ClassifierBenchmarkRunner classifierRunner = ClassifierBenchmarkRunner.defaultRunner();
Map<String, ClassifierBenchmarkResult> classifierResults =
classifierRunner.run(allVectors, allLabels);
// Localization comparison (run only the current scenario to keep the workbench responsive)
LocalizationBenchmarkRunner locRunner = LocalizationBenchmarkRunner.defaultRunner();
Map<String, List<LocalizationBenchmarkResult>> locResults =
locRunner.run(List.of(completedScenario));
// Calibration
WingbeatSignalParameters baseline = WingbeatSignalParameters.mosquitoLike(500.0);
GeneratorCalibrationService calibrationService = new GeneratorCalibrationService();
CalibrationResult calibrationResult = calibrationService.calibrate(baseline, real);
return new AnalysisTabContent(
buildFeatureRankingText(ranking),
buildFeatureComparisonText(evalReport),
buildSyntheticRealText(srReport),
buildClassifierComparisonText(classifierResults),
buildLocalizationComparisonText(locResults),
buildCalibrationText(calibrationResult));
}
private void populateAnalysisTabsAsync(SimulationScenario completedScenario) {
featureRankingArea.setText(COMPUTING_ANALYSIS_MESSAGE);
featureComparisonArea.setText(COMPUTING_ANALYSIS_MESSAGE);
syntheticRealArea.setText(COMPUTING_ANALYSIS_MESSAGE);
classifierComparisonArea.setText(COMPUTING_ANALYSIS_MESSAGE);
localizationComparisonArea.setText(COMPUTING_ANALYSIS_MESSAGE);
calibrationArea.setText(COMPUTING_ANALYSIS_MESSAGE);
SwingWorker<AnalysisTabContent, Void> worker =
new SwingWorker<>() {
@Override
protected AnalysisTabContent doInBackground() {
return buildAnalysisTabContent(completedScenario);
}
@Override
protected void done() {
if (isCancelled()) {
return;
}
try {
AnalysisTabContent content = get();
featureRankingArea.setText(content.featureRankingText);
featureComparisonArea.setText(content.featureComparisonText);
syntheticRealArea.setText(content.syntheticRealText);
classifierComparisonArea.setText(content.classifierComparisonText);
localizationComparisonArea.setText(content.localizationComparisonText);
calibrationArea.setText(content.calibrationText);
} catch (ExecutionException ex) {
LOGGER.log(Level.WARNING, "Failed to compute analysis tabs", ex);
String errorMessage = "Analysis failed: " + ex.getCause().getMessage();
featureRankingArea.setText(errorMessage);
featureComparisonArea.setText(errorMessage);
syntheticRealArea.setText(errorMessage);
classifierComparisonArea.setText(errorMessage);
localizationComparisonArea.setText(errorMessage);
calibrationArea.setText(errorMessage);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
};
analysisWorker = worker;
worker.execute();
}
private static List<WingbeatFeatureVector> buildDemoSyntheticVectors() {
double[] freqs = {450.0, 470.0, 490.0, 510.0, 530.0, 600.0, 630.0, 660.0, 690.0, 720.0};
List<WingbeatFeatureVector> list = new java.util.ArrayList<>();
for (double f : freqs) {
list.add(
new WingbeatFeatureVector(
f, List.of(), List.of(), f, 20.0, 0.0, 5.0, 0.1, 6.0, 1.0, 0.9));
}
return list;
}
private static List<WingbeatFeatureVector> buildDemoRealVectors() {
// Slightly different means to simulate real-vs-synthetic divergence
double[] freqs = {455.0, 475.0, 495.0, 515.0, 535.0, 590.0, 625.0, 655.0, 685.0, 715.0};
List<WingbeatFeatureVector> list = new java.util.ArrayList<>();
for (double f : freqs) {
list.add(
new WingbeatFeatureVector(
f, List.of(), List.of(), f, 22.0, 0.0, 6.0, 0.12, 5.5, 1.0, 0.85));
}
return list;
}
@SuppressWarnings("PMD.ConsecutiveAppendsShouldReuse")
private static String buildFeatureRankingText(List<FeatureRankingEntry> ranking) {
StringBuilder sb = new StringBuilder("# Feature Ranking\n\n");
sb.append(String.format(Locale.ROOT, "%-30s %12s%n", "Feature", "Mean Score"));
sb.append("-".repeat(45)).append('\n');
for (FeatureRankingEntry entry : ranking) {
sb.append(
String.format(Locale.ROOT, "%-30s %12.4f%n", entry.featureName(), entry.meanScore()));
}
return sb.toString();
}
@SuppressWarnings("PMD.ConsecutiveAppendsShouldReuse")
private static String buildFeatureComparisonText(FeatureEvaluationReport report) {
StringBuilder sb = new StringBuilder("# Feature Evaluation\n\n");
sb.append(
String.format(
Locale.ROOT, "%-30s %10s %10s %10s%n", "Feature", "Mean", "StdDev", "FisherRatio"));
sb.append("-".repeat(65)).append('\n');
for (FeatureEvaluationEntry entry : report.entries()) {
sb.append(
String.format(
Locale.ROOT,
"%-30s %10.3f %10.3f %10.4f%n",
entry.featureName(),
entry.statistics().mean(),
entry.statistics().stdDev(),
entry.separation().fisherRatio()));
}
return sb.toString();
}
@SuppressWarnings("PMD.ConsecutiveAppendsShouldReuse")
private static String buildSyntheticRealText(SyntheticRealComparisonReport report) {
StringBuilder sb = new StringBuilder(256);
sb.append("# Synthetic vs Real Comparison\n\n");
sb.append(
String.format(
Locale.ROOT, "%-30s %10s %10s %10s%n", "Feature", "Synthetic", "Real", "Rel.Diff"));
sb.append("-".repeat(65)).append('\n');
for (FeatureDifference diff : report.differences()) {
sb.append(
String.format(
Locale.ROOT,
"%-30s %10.3f %10.3f %9.1f%%%n",
diff.featureName(),
diff.syntheticMean(),
diff.realMean(),
diff.relativeDifference() * 100.0));
}
if (!report.generatorWeaknesses().isEmpty()) {
sb.append("\n## Generator Weaknesses (rel.diff > ")
.append(String.format(Locale.ROOT, "%.0f%%", report.weaknessThreshold() * 100.0))
.append(")\n");
for (FeatureDifference diff : report.generatorWeaknesses()) {
sb.append(" - ").append(diff.featureName()).append('\n');
}
}
return sb.toString();
}
@SuppressWarnings("PMD.ConsecutiveAppendsShouldReuse")
private static String buildClassifierComparisonText(
Map<String, ClassifierBenchmarkResult> results) {
StringBuilder sb = new StringBuilder("# Classifier Comparison\n\n");
for (Map.Entry<String, ClassifierBenchmarkResult> entry : results.entrySet()) {
ClassifierBenchmarkResult r = entry.getValue();
sb.append("## ").append(entry.getKey()).append('\n');
sb.append(
String.format(
Locale.ROOT,
"Accuracy: %.3f MacroF1: %.3f%n",
r.confusionMatrix().accuracy(),
r.macroF1()));
sb.append('\n');
sb.append(r.confusionMatrix().toMarkdown()).append('\n');
}
return sb.toString();
}
@SuppressWarnings("PMD.ConsecutiveAppendsShouldReuse")
private static String buildLocalizationComparisonText(
Map<String, List<LocalizationBenchmarkResult>> results) {
StringBuilder sb = new StringBuilder("# Localization Benchmark\n\n");
for (Map.Entry<String, List<LocalizationBenchmarkResult>> entry : results.entrySet()) {
sb.append("## ").append(entry.getKey()).append('\n');
sb.append(
String.format(
Locale.ROOT,
"%-25s %12s %10s %4s %4s%n",
"Scenario",
"MeanLocErr(m)",
"TrackErr",
"FP",
"FN"));
sb.append("-".repeat(60)).append('\n');
for (LocalizationBenchmarkResult r : entry.getValue()) {
sb.append(
String.format(
Locale.ROOT,
"%-25s %12.3f %10.3f %4d %4d%n",
r.scenarioId(),
r.meanLocalizationErrorMeters(),
r.trackingError(),
r.falsePositiveCount(),
r.falseNegativeCount()));
}
sb.append('\n');
}
return sb.toString();
}
@SuppressWarnings("PMD.ConsecutiveAppendsShouldReuse")
private static String buildCalibrationText(CalibrationResult result) {
StringBuilder sb = new StringBuilder(512);
sb.append("# Generator Calibration\n\n## Baseline Parameters\n\n");
appendParamRow(
sb, "fundamentalFrequencyHz", result.baselineParameters().fundamentalFrequencyHz());
appendParamRow(sb, "harmonicCount", result.baselineParameters().harmonicCount());
appendParamRow(sb, "jitterHz", result.baselineParameters().jitterHz());
appendParamRow(sb, "modulationDepth", result.baselineParameters().modulationDepth());
appendParamRow(sb, "noiseAmplitude", result.baselineParameters().noiseAmplitude());
sb.append("\n## Calibrated Parameters\n\n");
appendParamRow(
sb, "fundamentalFrequencyHz", result.calibratedParameters().fundamentalFrequencyHz());
appendParamRow(sb, "harmonicCount", result.calibratedParameters().harmonicCount());
appendParamRow(sb, "jitterHz", result.calibratedParameters().jitterHz());
appendParamRow(sb, "modulationDepth", result.calibratedParameters().modulationDepth());
appendParamRow(sb, "noiseAmplitude", result.calibratedParameters().noiseAmplitude());
sb.append("\n## Feature Deviation Report\n\n");
sb.append(
String.format(
Locale.ROOT, "%-30s %10s %10s %10s%n", "Feature", "Before", "After", "Improvement"));
sb.append("-".repeat(65)).append('\n');
List<FeatureDifference> beforeDiffs = result.beforeCalibration().differences();
List<FeatureDifference> afterDiffs = result.afterCalibration().differences();
int size = Math.min(beforeDiffs.size(), afterDiffs.size());
for (int i = 0; i < size; i++) {
double before = beforeDiffs.get(i).relativeDifference();
double after = afterDiffs.get(i).relativeDifference();
sb.append(
String.format(
Locale.ROOT,
"%-30s %9.1f%% %9.1f%% %9.1f%%%n",
beforeDiffs.get(i).featureName(),
before * 100.0,
after * 100.0,
(before - after) * 100.0));
}
sb.append('\n');
sb.append(
String.format(Locale.ROOT, "Overall improvement: %.1f%%%n", result.improvement() * 100.0));
sb.append(
String.format(
Locale.ROOT,
"Mean relative diff before: %.1f%% after: %.1f%%%n",
result.meanRelativeDifferenceBefore() * 100.0,
result.meanRelativeDifferenceAfter() * 100.0));
if (!result.afterCalibration().generatorWeaknesses().isEmpty()) {
sb.append("\n## Remaining Deviations (rel.diff > ")
.append(
String.format(
Locale.ROOT, "%.0f%%", result.afterCalibration().weaknessThreshold() * 100.0))
.append(")\n");
for (FeatureDifference diff : result.afterCalibration().generatorWeaknesses()) {
sb.append(
String.format(
Locale.ROOT,
" - %s (%.1f%%)%n",
diff.featureName(),
diff.relativeDifference() * 100.0));
}
}
return sb.toString();
}
private static void appendParamRow(StringBuilder sb, String name, double value) {
sb.append(String.format(Locale.ROOT, " %-30s %.6f%n", name + ":", value));
}
private static void appendParamRow(StringBuilder sb, String name, int value) {
sb.append(String.format(Locale.ROOT, " %-30s %d%n", name + ":", value));
}
// -------------------------------------------------------------------------
// Background worker
// -------------------------------------------------------------------------
private final class ScenarioWorker extends SwingWorker<WorkbenchRunResult, String> {
private final SimulationScenario scenario;
private final WorkbenchParameters params;
private final long budgetNanos;
private int blockIndex;
ScenarioWorker(SimulationScenario scenario, WorkbenchParameters params) {
this.scenario = scenario;
this.params = params;
this.budgetNanos =
new FrameSchedule(
scenario.sampleRate(),
params.blockSize(),
WorkbenchScenarioRunner.PIPELINE_MAX_LOAD_FRACTION)
.maxProcessingNanos();
}
@Override
protected WorkbenchRunResult doInBackground() {
return WorkbenchScenarioRunner.run(
scenario,
params,
(snapshot, idx) -> {
if (isCancelled()) {
return;
}
this.blockIndex = idx;
publish(formatBlockLine(snapshot, idx));
});
}
@Override
protected void process(List<String> chunks) {
for (String line : chunks) {
appendLog(line);
}
updateStatus("Running block " + blockIndex + " …");
}
@Override
protected void done() {
setRunning(false);
if (isCancelled()) {
updateStatus("Run cancelled.");
appendLog("--- Run cancelled ---");
return;
}
try {
WorkbenchRunResult result = get();
lastRunResult = result;
appendLog("--- Run complete ---");
appendLog(
String.format(
Locale.ROOT,
"Blocks: %d | Max tracks: %d | Distinct IDs: %d | Avg proc: %.1f µs",
result.blockCount(),
result.maxTracksInAnyFrame(),
result.distinctTrackCount(),
result.averageProcessingNanosPerBlock() / 1_000.0));
long overBudget = result.overBudgetFrameCount();
if (overBudget > 0) {
appendLog(
String.format(
Locale.ROOT,
"⚠ %d frame(s) exceeded the real-time budget (budget=%.1f µs per block).",
overBudget,
result.frameSchedule() != null
? result.frameSchedule().maxProcessingNanos() / 1_000.0
: 0.0));
}
updateStatus(
String.format(
Locale.ROOT,
"Done. %d blocks, %d distinct tracks, avg %.1f µs/block.",
result.blockCount(),
result.distinctTrackCount(),
result.averageProcessingNanosPerBlock() / 1_000.0));
appendLog("Synchronization: worst status " + result.worstSynchronizationStatus());
if (result.hasSynchronizationWarning()) {
appendLog("⚠ At least one frame has degraded or rejected synchronization.");
}
markdownArea.setText(WorkbenchRunExporter.toMarkdown(result));
csvArea.setText(WorkbenchRunExporter.toCsv(result));
jsonArea.setText(WorkbenchRunExporter.toJsonLines(result));
benchmarkArea.setText(WorkbenchRunExporter.toBenchmarkMarkdown(result));
roomMapPanel.setResult(result);
loadPlayback(result);
populateAnalysisTabsAsync(result.scenario());
} catch (ExecutionException ex) {
LOGGER.log(Level.WARNING, "Workbench run failed", ex);
updateStatus("Run failed: " + ex.getCause().getMessage());
appendLog("ERROR: " + ex.getCause().getMessage());
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
updateStatus("Run interrupted.");
}
}
private String formatBlockLine(TrackingSnapshot snapshot, int idx) {
StringBuilder sb = new StringBuilder();
sb.append(
String.format(
Locale.ROOT,
"Block %4d frame=%6d time=%6.1f ms clusters=%d tracks=%d proc=%.1f µs",
idx,
snapshot.sourceFrameIndex(),
snapshot.sourceTimestampNanos() / 1_000_000.0,
snapshot.clusters().size(),
snapshot.tracks().size(),
snapshot.processingNanos() / 1_000.0))
.append(
String.format(
Locale.ROOT,
" sync=%s/%s err=%.4f samples",
snapshot.synchronization().mode(),
snapshot.synchronization().status(),
snapshot.synchronization().estimatedErrorSamples()));
if (snapshot.processingNanos() > budgetNanos) {
sb.append(" ⚠ OVER BUDGET");
}
for (FrequencyCluster c : snapshot.clusters()) {
sb.append(String.format(Locale.ROOT, " [%.0f Hz]", c.centerFrequencyHz()));
}
for (TrackedSource t : snapshot.tracks()) {
sb.append(
String.format(
Locale.ROOT,
" {id=%d f=%.0f Hz pos=(%.2f,%.2f) conf=%.2f n=%d}",
t.id(),
t.frequencyHz(),
t.positionMeters().x(),
t.positionMeters().y(),
t.confidence(),
t.observationCount()));
}
return sb.toString();
}
}
private void updateStatus(String text) {
SwingUtilities.invokeLater(() -> statusLabel.setText(text));
}
// -------------------------------------------------------------------------
// Room map panel (inner class)
// -------------------------------------------------------------------------
/**
* Simple 2-D room-map visualization panel.
*
* <p>Renders the room rectangle, microphone positions, emitter ground-truth trajectories,
* accumulated estimated track paths, and localization error lines between matched ground-truth
* and estimated positions in the last frame. All rendering is done in Swing paint thread.
*/
static final class RoomMapPanel extends JPanel {
private static final long serialVersionUID = 1L;
private static final int MARGIN = 20;
private static final int LABEL_OFFSET_X = 9;
private static final int LABEL_OFFSET_Y = -4;
private static final Color COLOR_ROOM = new Color(245, 245, 245);
private static final Color COLOR_MIC = new Color(30, 100, 200);
private static final Color COLOR_EMITTER = new Color(20, 160, 60);
private static final Color COLOR_TRACK = new Color(200, 50, 30);
private static final Color COLOR_TRACK_PATH = new Color(200, 50, 30, 80);
private static final Color COLOR_ERROR = new Color(220, 140, 0);
private static final Color COLOR_GRID = new Color(210, 210, 210);
private transient SimulationScenario scenario;
private List<TrackedSource> lastTracks = List.of();
private List<Vector2> gridPoints = List.of();
/** Accumulated positions per track ID (in appearance order). */
private Map<Integer, List<Vector2>> trackHistory = new ConcurrentHashMap<>();
/** Alignment of the last snapshot against ground truth (for error lines). */
private transient SnapshotAlignment lastAlignment;
private transient WorkbenchRunResult cachedPlaybackResult;
private int cachedHistoryFrameIndex = -1;
private Map<Integer, List<Vector2>> cachedPlaybackHistory = new ConcurrentHashMap<>();
RoomMapPanel() {
setPreferredSize(new Dimension(300, 300));
setBorder(BorderFactory.createTitledBorder("Room map (2D, experimental)"));
setBackground(Color.WHITE);
}
void clear(SimulationScenario s) {
this.scenario = s;
this.lastTracks = List.of();
this.gridPoints = List.of();
this.trackHistory = new ConcurrentHashMap<>();
this.lastAlignment = null;
this.cachedPlaybackResult = null;
this.cachedHistoryFrameIndex = -1;
this.cachedPlaybackHistory = new ConcurrentHashMap<>();
repaint();
}
void setResult(WorkbenchRunResult result) {
this.scenario = result.scenario();
TrackingSnapshot last =
result.snapshots().isEmpty()
? null
: result.snapshots().get(result.snapshots().size() - 1);
this.lastTracks = last == null ? List.of() : last.tracks();
// Accumulate full track history (all positions per track ID across all frames)
Map<Integer, List<Vector2>> history = new ConcurrentHashMap<>();
for (TrackingSnapshot snap : result.snapshots()) {
for (TrackedSource track : snap.tracks()) {
history
.computeIfAbsent(track.id(), ignored -> new ArrayList<>())
.add(track.positionMeters());
}
}
this.trackHistory = history;
// Compute ground-truth-to-estimated alignment for the last snapshot (for error lines)
this.lastAlignment = null;
if (last != null) {
try {
Scenario truth = result.scenario().groundTruth();
long startTs =
result.snapshots().isEmpty() ? 0L : result.snapshots().get(0).sourceTimestampNanos();
this.lastAlignment = new SnapshotGroundTruthAligner().align(truth, last, startTs);
} catch (RuntimeException ignored) {
// alignment failure is non-critical; error lines will simply not be drawn
}
}
// Build candidate grid for display
List<Vector2> grid = new ArrayList<>();
int steps = result.parameters().candidateGridSteps();
double w = result.scenario().room().widthMeters();
double h = result.scenario().room().heightMeters();
for (int xi = 0; xi <= steps; xi++) {
for (int yi = 0; yi <= steps; yi++) {
grid.add(new Vector2(w * xi / steps, h * yi / steps));
}
}
this.gridPoints = List.copyOf(grid);
this.cachedPlaybackResult = null;
this.cachedHistoryFrameIndex = -1;
this.cachedPlaybackHistory = new ConcurrentHashMap<>();
repaint();
}
/**
* Render the room map for a specific frame index, showing track history up to that frame.
*
* @param result the completed run result
* @param frameIndex 0-based index of the frame to display; silently clamped to valid range
*/
void setFrame(WorkbenchRunResult result, int frameIndex) {
this.scenario = result.scenario();
if (result.snapshots().isEmpty()) {
this.lastTracks = List.of();
this.trackHistory = new ConcurrentHashMap<>();
this.lastAlignment = null;
this.gridPoints = List.of();
this.cachedPlaybackResult = null;
this.cachedHistoryFrameIndex = -1;
this.cachedPlaybackHistory = new ConcurrentHashMap<>();
repaint();
return;
}
boolean newResult = !Objects.equals(this.cachedPlaybackResult, result);
if (newResult) {
this.cachedPlaybackResult = result;
this.cachedHistoryFrameIndex = -1;
this.cachedPlaybackHistory = new ConcurrentHashMap<>();
List<Vector2> grid = new ArrayList<>();
int steps = result.parameters().candidateGridSteps();
double w = result.scenario().room().widthMeters();
double h = result.scenario().room().heightMeters();
for (int xi = 0; xi <= steps; xi++) {
for (int yi = 0; yi <= steps; yi++) {
grid.add(new Vector2(w * xi / steps, h * yi / steps));
}
}
this.gridPoints = List.copyOf(grid);
}
int idx = Math.max(0, Math.min(frameIndex, result.snapshots().size() - 1));
TrackingSnapshot snap = result.snapshots().get(idx);
this.lastTracks = snap.tracks();
if (idx < cachedHistoryFrameIndex) {
cachedPlaybackHistory = new ConcurrentHashMap<>();
cachedHistoryFrameIndex = -1;
}
if (idx > cachedHistoryFrameIndex) {
for (int i = cachedHistoryFrameIndex + 1; i <= idx; i++) {
for (TrackedSource track : result.snapshots().get(i).tracks()) {
cachedPlaybackHistory
.computeIfAbsent(track.id(), ignored -> new ArrayList<>())
.add(track.positionMeters());
}
}
cachedHistoryFrameIndex = idx;
}
this.trackHistory = cachedPlaybackHistory;
// Compute alignment for this specific frame
this.lastAlignment = null;
try {
Scenario truth = result.scenario().groundTruth();
long startTs = result.snapshots().get(0).sourceTimestampNanos();
this.lastAlignment = new SnapshotGroundTruthAligner().align(truth, snap, startTs);
} catch (RuntimeException ignored) {
// alignment failure is non-critical; error lines will simply not be drawn
}
repaint();
}
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
if (scenario == null) {
g.setFont(g.getFont().deriveFont(Font.ITALIC));
g.setColor(Color.GRAY);
g.drawString("No scenario loaded — press Run", MARGIN, getHeight() / 2);
return;
}
Graphics2D g2 = (Graphics2D) g;
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
double roomW = scenario.room().widthMeters();
double roomH = scenario.room().heightMeters();
int canvasW = getWidth() - 2 * MARGIN;
int canvasH = getHeight() - 2 * MARGIN;
double scaleX = canvasW / roomW;
double scaleY = canvasH / roomH;
paintRoomBackground(g2, canvasW, canvasH);
paintGridDots(g2, scaleX, scaleY, canvasH);
paintMicrophones(g2, scaleX, scaleY, canvasH);
paintEmitterTrajectories(g2, scaleX, scaleY, canvasH);
paintTrackPaths(g2, scaleX, scaleY, canvasH);
paintErrorLines(g2, scaleX, scaleY, canvasH);
paintEstimatedTracks(g2, scaleX, scaleY, canvasH);
paintLegend(g2);
}
private void paintRoomBackground(Graphics2D g2, int canvasW, int canvasH) {
g2.setColor(COLOR_ROOM);
g2.fillRect(MARGIN, MARGIN, canvasW, canvasH);
g2.setColor(Color.DARK_GRAY);
g2.drawRect(MARGIN, MARGIN, canvasW, canvasH);
}
private void paintGridDots(Graphics2D g2, double scaleX, double scaleY, int canvasH) {
g2.setColor(COLOR_GRID);
for (Vector2 pt : gridPoints) {
int px = toPixelX(pt.x(), MARGIN, scaleX);
int py = toPixelY(pt.y(), MARGIN, scaleY, canvasH);
g2.fillOval(px - 1, py - 1, 3, 3);
}
}
private void paintMicrophones(Graphics2D g2, double scaleX, double scaleY, int canvasH) {
g2.setColor(COLOR_MIC);
for (Microphone mic : scenario.array().microphones()) {
int px = toPixelX(mic.positionMeters().x(), MARGIN, scaleX);
int py = toPixelY(mic.positionMeters().y(), MARGIN, scaleY, canvasH);
g2.fillOval(px - 5, py - 5, 10, 10);
g2.setColor(Color.WHITE);
drawCenteredString(g2, mic.id(), px, py);
g2.setColor(COLOR_MIC);
}
}
private void paintEmitterTrajectories(
Graphics2D g2, double scaleX, double scaleY, int canvasH) {
Stroke defaultStroke = g2.getStroke();
g2.setColor(COLOR_EMITTER);
g2.setStroke(new BasicStroke(2.0f));
for (AcousticEmitter2D emitter : scenario.emitters()) {
int px = toPixelX(emitter.startMeters().x(), MARGIN, scaleX);
int py = toPixelY(emitter.startMeters().y(), MARGIN, scaleY, canvasH);
drawTriangle(g2, px, py, 8);
g2.drawString(
String.format(Locale.ROOT, "%.0f Hz", emitter.frequencyHz()),
px + LABEL_OFFSET_X,
py + LABEL_OFFSET_Y);
Vector2 vel = emitter.velocityMetersPerSecond();
if (vel.x() != 0.0 || vel.y() != 0.0) {
double duration = scenario.durationSeconds();
int ex = toPixelX(emitter.startMeters().x() + vel.x() * duration, MARGIN, scaleX);
int ey =
toPixelY(emitter.startMeters().y() + vel.y() * duration, MARGIN, scaleY, canvasH);
g2.drawLine(px, py, ex, ey);
drawTriangle(g2, ex, ey, 5);
}
}
g2.setStroke(defaultStroke);
}
private void paintTrackPaths(Graphics2D g2, double scaleX, double scaleY, int canvasH) {
Stroke defaultStroke = g2.getStroke();
Stroke dashed =
new BasicStroke(
1.0f, BasicStroke.CAP_BUTT, BasicStroke.JOIN_BEVEL, 0, new float[] {4, 4}, 0);
g2.setColor(COLOR_TRACK_PATH);
g2.setStroke(dashed);
for (List<Vector2> path : trackHistory.values()) {
for (int i = 1; i < path.size(); i++) {
g2.drawLine(
toPixelX(path.get(i - 1).x(), MARGIN, scaleX),
toPixelY(path.get(i - 1).y(), MARGIN, scaleY, canvasH),
toPixelX(path.get(i).x(), MARGIN, scaleX),
toPixelY(path.get(i).y(), MARGIN, scaleY, canvasH));
}
}
g2.setStroke(defaultStroke);
}
private void paintErrorLines(Graphics2D g2, double scaleX, double scaleY, int canvasH) {
if (lastAlignment == null) {
return;
}
Stroke defaultStroke = g2.getStroke();
Stroke errorStroke =
new BasicStroke(
1.5f, BasicStroke.CAP_BUTT, BasicStroke.JOIN_BEVEL, 0, new float[] {2, 4}, 0);
g2.setColor(COLOR_ERROR);
g2.setStroke(errorStroke);
for (AlignedSourceObservation obs : lastAlignment.matchedSources()) {
if (obs.groundTruth().expectedPositionMeters() != null) {
Vector2 truth = obs.groundTruth().expectedPositionMeters();
Vector2 est = obs.trackedSource().positionMeters();
int tx = toPixelX(truth.x(), MARGIN, scaleX);
int ty = toPixelY(truth.y(), MARGIN, scaleY, canvasH);
int ex = toPixelX(est.x(), MARGIN, scaleX);
int ey = toPixelY(est.y(), MARGIN, scaleY, canvasH);
g2.drawLine(tx, ty, ex, ey);
double errorM = truth.distanceTo(est);
g2.drawString(
String.format(Locale.ROOT, "err=%.2fm", errorM),
(tx + ex) / 2 + 2,
(ty + ey) / 2 - 2);
}
}
g2.setStroke(defaultStroke);
}
private void paintEstimatedTracks(Graphics2D g2, double scaleX, double scaleY, int canvasH) {
g2.setColor(COLOR_TRACK);
for (TrackedSource track : lastTracks) {
int px = toPixelX(track.positionMeters().x(), MARGIN, scaleX);
int py = toPixelY(track.positionMeters().y(), MARGIN, scaleY, canvasH);
g2.fillOval(px - 7, py - 7, 14, 14);
g2.setColor(Color.WHITE);
drawCenteredString(g2, String.valueOf(track.id()), px, py);
g2.setColor(COLOR_TRACK);
g2.drawString(
String.format(
Locale.ROOT, "%.0f Hz conf=%.2f", track.frequencyHz(), track.confidence()),
px + LABEL_OFFSET_X,
py + LABEL_OFFSET_Y);
}
}
private void paintLegend(Graphics2D g2) {
int lx = MARGIN + 4;
int ly = getHeight() - MARGIN - 70;
g2.setColor(new Color(255, 255, 255, 200));
g2.fillRoundRect(lx - 2, ly - 12, 160, 76, 4, 4);
g2.setColor(COLOR_MIC);
g2.fillOval(lx, ly, 8, 8);
g2.setColor(Color.BLACK);
g2.drawString("Microphone", lx + 14, ly + 9);
ly += 16;
g2.setColor(COLOR_EMITTER);
drawTriangle(g2, lx + 4, ly + 4, 5);
g2.setColor(Color.BLACK);
g2.drawString("Ground truth (trajectory)", lx + 14, ly + 9);
ly += 16;
g2.setColor(COLOR_TRACK);
g2.fillOval(lx, ly, 8, 8);
g2.setColor(Color.BLACK);
g2.drawString("Estimated track (last frame)", lx + 14, ly + 9);
ly += 16;
g2.setColor(COLOR_ERROR);
g2.drawLine(lx, ly + 4, lx + 10, ly + 4);
g2.setColor(Color.BLACK);
g2.drawString("Localization error", lx + 14, ly + 9);
}
private static int toPixelX(double meters, int margin, double scale) {
return margin + (int) Math.round(meters * scale);
}
private static int toPixelY(double meters, int margin, double scale, int canvasH) {
// flip Y so that y=0 is at the bottom of the canvas
return margin + canvasH - (int) Math.round(meters * scale);
}
private static void drawCenteredString(Graphics2D g2, String text, int cx, int cy) {
Font f = g2.getFont().deriveFont(Font.BOLD, 9f);
FontMetrics fm = g2.getFontMetrics(f);
Font orig = g2.getFont();
g2.setFont(f);
int tx = cx - fm.stringWidth(text) / 2;
int ty = cy + fm.getAscent() / 2 - 1;
g2.drawString(text, tx, ty);
g2.setFont(orig);
}
private static void drawTriangle(Graphics2D g2, int cx, int cy, int r) {
int[] xs = {cx, cx - r, cx + r};
int[] ys = {cy - r, cy + r, cy + r};
g2.fillPolygon(xs, ys, 3);
}
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
/** Wrapper for combo box display. */
private static final class ScenarioItem {
final SimulationScenario scenario;
ScenarioItem(SimulationScenario scenario) {
this.scenario = Objects.requireNonNull(scenario, "scenario");
}
@Override
public String toString() {
return scenario.name();
}
}
/** Return the last completed run result, or {@code null} if no run has completed yet. */
public WorkbenchRunResult lastResult() {
return lastRunResult;
}
}