WorkbenchScenarioRunner.java
package org.hammer.audio.experimental.acoustic.workbench;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.hammer.audio.acquisition.LocalizationExperiment;
import org.hammer.audio.acquisition.LocalizationExperimentStage;
import org.hammer.audio.acquisition.LocalizationInputMode;
import org.hammer.audio.acquisition.MicrophoneArray;
import org.hammer.audio.acquisition.MicrophoneArrayLayout;
import org.hammer.audio.acquisition.MicrophoneArrayProfile;
import org.hammer.audio.core.AudioBlock;
import org.hammer.audio.experimental.acoustic.CrossCorrelationTdoaEstimator;
import org.hammer.audio.experimental.acoustic.DelayAndSumBeamformer;
import org.hammer.audio.experimental.acoustic.FrequencyBand;
import org.hammer.audio.experimental.acoustic.GccPhatTdoaEstimator;
import org.hammer.audio.experimental.acoustic.SubSampleGccPhatTdoaEstimator;
import org.hammer.audio.experimental.acoustic.TdoaEstimator;
import org.hammer.audio.experimental.acoustic.benchmark.BenchmarkMeasurements;
import org.hammer.audio.experimental.acoustic.benchmark.BenchmarkReport;
import org.hammer.audio.experimental.acoustic.benchmark.TrackingBenchmarkComparator;
import org.hammer.audio.experimental.acoustic.scenario.Scenario;
import org.hammer.audio.experimental.acoustic.simulation.SimulatedMicrophoneArraySource;
import org.hammer.audio.experimental.acoustic.simulation.SimulationScenarios.SimulationScenario;
import org.hammer.audio.experimental.acoustic.tracking.FrameSchedule;
import org.hammer.audio.experimental.acoustic.tracking.FrequencyClusterer;
import org.hammer.audio.experimental.acoustic.tracking.MultiPeakDetector;
import org.hammer.audio.experimental.acoustic.tracking.SourceTracker;
import org.hammer.audio.experimental.acoustic.tracking.TrackingPipeline;
import org.hammer.audio.experimental.acoustic.tracking.TrackingSnapshot;
import org.hammer.audio.geometry.Vector2;
/**
* Headless runner for acoustic localization workbench scenarios.
*
* <p>Runs a {@link SimulationScenario} block-by-block through a {@link TrackingPipeline} configured
* from {@link WorkbenchParameters}, collects all {@link TrackingSnapshot}s and returns a {@link
* WorkbenchRunResult}. This class contains no Swing or UI code and is safe to use in headless
* tests.
*
* <p>An optional {@link ProgressCallback} receives each snapshot as it is produced, which lets
* Swing panels update incrementally on a background thread without holding a reference to this
* class.
*/
public final class WorkbenchScenarioRunner {
private static final Logger LOGGER = Logger.getLogger(WorkbenchScenarioRunner.class.getName());
/**
* Maximum fraction of one block period that the pipeline may consume ({@value}). This value is
* used to configure the {@link FrameSchedule} and is intentionally shared with the UI so both log
* annotations and the result's budget compliance checks use the same threshold.
*/
static final double PIPELINE_MAX_LOAD_FRACTION = 0.8;
/** Called after each audio block is processed during an incremental run. */
@FunctionalInterface
public interface ProgressCallback {
/**
* Invoked after processing one block.
*
* @param snapshot the snapshot produced for the block just processed
* @param blockIndex 0-based index of the block just processed
*/
void onBlock(TrackingSnapshot snapshot, int blockIndex);
}
private WorkbenchScenarioRunner() {
// utility class
}
/** Run {@code scenario} with {@code parameters} and return the full result. */
public static WorkbenchRunResult run(
SimulationScenario scenario, WorkbenchParameters parameters) {
return run(scenario, parameters, null);
}
/** Run {@code scenario}, notifying {@code callback} after each block. */
public static WorkbenchRunResult run(
SimulationScenario scenario, WorkbenchParameters parameters, ProgressCallback callback) {
Objects.requireNonNull(scenario, "scenario");
Objects.requireNonNull(parameters, "parameters");
try (SimulatedMicrophoneArraySource source = scenario.newSource()) {
MicrophoneArray array = source.microphoneArray();
TrackingPipeline pipeline = buildPipeline(scenario, parameters);
List<TrackingSnapshot> snapshots = new ArrayList<>();
long totalProcessingNanos = 0L;
int blockIndex = 0;
while (true) {
if (Thread.currentThread().isInterrupted()) {
break;
}
AudioBlock block = source.readBlock(parameters.blockSize()).orElse(null);
if (block == null || block.frames() < parameters.blockSize()) {
break;
}
TrackingSnapshot snapshot = pipeline.process(block, array);
snapshots.add(snapshot);
totalProcessingNanos += snapshot.processingNanos();
if (callback != null) {
callback.onBlock(snapshot, blockIndex);
}
blockIndex++;
}
return new WorkbenchRunResult(
scenario,
parameters,
snapshots,
totalProcessingNanos,
computeBenchmarkReport(scenario, snapshots),
pipeline.schedule(),
simulationExperiment(scenario, parameters));
} catch (java.io.IOException exception) {
throw new IllegalStateException("Unexpected close failure on simulation source", exception);
}
}
private static LocalizationExperiment simulationExperiment(
SimulationScenario scenario, WorkbenchParameters parameters) {
MicrophoneArrayProfile profile =
new MicrophoneArrayProfile(
"simulation." + scenario.name(),
"Simulation array for " + scenario.name(),
MicrophoneArrayLayout.CUSTOM,
scenario.array(),
Set.of(LocalizationInputMode.SIMULATION),
null,
null);
return new LocalizationExperiment(
"simulation." + scenario.name() + '.' + scenario.randomSeed(),
"Simulation: " + scenario.name(),
profile,
LocalizationInputMode.SIMULATION,
"scenario:" + scenario.name(),
Instant.EPOCH,
LocalizationExperimentStage.LOCALIZED,
Map.of(
"blockSize", Integer.toString(parameters.blockSize()),
"randomSeed", Long.toString(scenario.randomSeed()),
"sampleRate", Float.toString(scenario.sampleRate())));
}
/** Compute a benchmark report, or {@code null} when comparison is unavailable. */
private static BenchmarkReport computeBenchmarkReport(
SimulationScenario scenario, List<TrackingSnapshot> snapshots) {
if (snapshots.isEmpty()) {
return null;
}
try {
Scenario truth = scenario.groundTruth();
BenchmarkMeasurements measurements = BenchmarkMeasurements.of(scenario.array(), snapshots);
return new TrackingBenchmarkComparator().compare(truth, measurements);
} catch (RuntimeException exception) {
LOGGER.log(Level.WARNING, "Benchmark report computation failed", exception);
return null;
}
}
/** Construct a {@link TrackingPipeline} from the given scenario and workbench parameters. */
static TrackingPipeline buildPipeline(SimulationScenario scenario, WorkbenchParameters params) {
FrequencyBand band = new FrequencyBand(params.bandMinHz(), params.bandMaxHz());
MultiPeakDetector detector =
new MultiPeakDetector(params.fftSize(), band, params.maxPeaks(), params.minSnr());
FrequencyClusterer clusterer =
new FrequencyClusterer(params.clusteringToleranceHz(), 0.0, 2, 4);
TdoaEstimator tdoaEstimator = buildTdoaEstimator(params);
DelayAndSumBeamformer beamformer =
new DelayAndSumBeamformer(
SimulatedMicrophoneArraySource.DEFAULT_SPEED_OF_SOUND_METERS_PER_SECOND);
SourceTracker tracker =
new SourceTracker(
params.trackerFrequencyMatchHz(),
params.trackerMissingFramesToDrop(),
0.5,
0.04,
1.0,
1.0,
params.trackerConfidenceDecay(),
params.trackerConfidenceGain());
List<Vector2> grid = buildCandidateGrid(scenario, params.candidateGridSteps());
FrameSchedule schedule =
new FrameSchedule(scenario.sampleRate(), params.blockSize(), PIPELINE_MAX_LOAD_FRACTION);
return new TrackingPipeline(
detector, clusterer, tdoaEstimator, beamformer, tracker, grid, schedule);
}
/** Creates the configured interchangeable TDOA stage. Visible for tests. */
static TdoaEstimator buildTdoaEstimator(WorkbenchParameters params) {
double speedOfSound = SimulatedMicrophoneArraySource.DEFAULT_SPEED_OF_SOUND_METERS_PER_SECOND;
return switch (params.tdoaEstimatorType()) {
case CROSS_CORRELATION -> new CrossCorrelationTdoaEstimator(speedOfSound);
case GCC_PHAT -> new GccPhatTdoaEstimator(speedOfSound);
case SUB_SAMPLE_GCC_PHAT -> new SubSampleGccPhatTdoaEstimator(speedOfSound);
};
}
private static List<Vector2> buildCandidateGrid(SimulationScenario scenario, int steps) {
if (steps <= 0) {
throw new IllegalArgumentException("candidateGridSteps must be positive, got " + steps);
}
List<Vector2> grid = new ArrayList<>((steps + 1) * (steps + 1));
double width = scenario.room().widthMeters();
double height = scenario.room().heightMeters();
for (int xIndex = 0; xIndex <= steps; xIndex++) {
for (int yIndex = 0; yIndex <= steps; yIndex++) {
grid.add(new Vector2(width * xIndex / steps, height * yIndex / steps));
}
}
return grid;
}
}