SimulationScenarios.java

package org.hammer.audio.experimental.acoustic.simulation;

import java.util.ArrayList;
import java.util.List;
import org.hammer.audio.acquisition.Microphone;
import org.hammer.audio.acquisition.MicrophoneArray;
import org.hammer.audio.experimental.acoustic.scenario.AcousticGroundTruth;
import org.hammer.audio.experimental.acoustic.scenario.ClassificationGroundTruth;
import org.hammer.audio.experimental.acoustic.scenario.Scenario;
import org.hammer.audio.experimental.acoustic.scenario.ScenarioEnvironment;
import org.hammer.audio.experimental.acoustic.scenario.ScenarioSource;
import org.hammer.audio.experimental.acoustic.scenario.ScenarioTrajectory;
import org.hammer.audio.geometry.Vector2;

/**
 * Catalog of reproducible localization scenarios used by validation tests and demos.
 *
 * <p>Every scenario is a self-contained, deterministic {@link SimulationScenario} record bundling a
 * {@link Room2D}, a {@link MicrophoneArray}, a list of {@link SoundEmitter2D}s, a sample rate, a
 * duration in seconds and a random seed. Two calls with identical scenario parameters produce
 * bit-identical signals via {@link SimulatedMicrophoneArraySource}.
 *
 * <p>The provided scenarios mirror the canonical research situations:
 *
 * <ul>
 *   <li>{@link #singleSource()} — one stationary tonal source in an anechoic room.
 *   <li>{@link #twoCloseFrequencies()} — two stationary sources at different positions whose
 *       frequencies are close enough to challenge naive single-peak trackers.
 *   <li>{@link #noisyRoom()} — single source with significant background noise.
 *   <li>{@link #movingSource()} — one source travelling across the room with constant velocity.
 *   <li>{@link #movingAcrossArray()} — one source travelling laterally across the array.
 *   <li>{@link #twoMovingSources()} — two tones with distinct velocities.
 *   <li>{@link #reflectedEnvironment()} — single source with wall reflections enabled.
 *   <li>{@link #twoMosquitoWingbeats()} — two stationary deterministic wingbeat emitters at close
 *       mosquito frequencies, paired with matching benchmark metadata via {@link
 *       #twoMosquitoWingbeatsGroundTruth()}.
 * </ul>
 */
public final class SimulationScenarios {

  private static final float SAMPLE_RATE = 16_000.0f;

  private SimulationScenarios() {
    // utility
  }

  /** One stationary 600 Hz tone at (1.5, 1.0) in an anechoic 3x2 m room. */
  public static SimulationScenario singleSource() {
    return new SimulationScenario(
        "single-source",
        new Room2D(3.0, 2.0, 0.0, 0.0),
        defaultArray(),
        List.of(new SoundEmitter2D(new Vector2(1.5, 1.0), Vector2.ZERO, 600.0, 0.5)),
        SAMPLE_RATE,
        0.5,
        1L);
  }

  /** Two stationary tones at 600 and 640 Hz, located at distinct positions in an anechoic room. */
  public static SimulationScenario twoCloseFrequencies() {
    return new SimulationScenario(
        "two-close-frequencies",
        new Room2D(3.0, 2.0, 0.0, 0.0),
        defaultArray(),
        List.of(
            new SoundEmitter2D(new Vector2(1.0, 1.0), Vector2.ZERO, 600.0, 0.5),
            new SoundEmitter2D(new Vector2(2.0, 1.0), Vector2.ZERO, 640.0, 0.5)),
        SAMPLE_RATE,
        0.5,
        2L);
  }

  /** Single source plus broadband room noise; tests robustness of peak detection. */
  public static SimulationScenario noisyRoom() {
    return new SimulationScenario(
        "noisy-room",
        new Room2D(3.0, 2.0, 0.0, 0.05),
        defaultArray(),
        List.of(new SoundEmitter2D(new Vector2(1.5, 1.2), Vector2.ZERO, 720.0, 0.5)),
        SAMPLE_RATE,
        0.5,
        3L);
  }

  /** One source travelling from (0.5, 1.0) to (2.5, 1.0) over the scenario duration. */
  public static SimulationScenario movingSource() {
    return new SimulationScenario(
        "moving-source",
        new Room2D(3.0, 2.0, 0.0, 0.0),
        defaultArray(),
        List.of(new SoundEmitter2D(new Vector2(0.5, 1.0), new Vector2(4.0, 0.0), 660.0, 0.5)),
        SAMPLE_RATE,
        0.5,
        4L);
  }

  /** One source moving primarily toward the array for Doppler validation. */
  public static SimulationScenario movingTowardArray() {
    return new SimulationScenario(
        "moving-toward-array",
        new Room2D(3.0, 2.0, 0.0, 0.0),
        defaultArray(),
        List.of(new SoundEmitter2D(new Vector2(1.5, 1.8), new Vector2(0.0, -2.0), 700.0, 0.5)),
        SAMPLE_RATE,
        0.5,
        6L);
  }

  /** One source moving laterally across the array. */
  public static SimulationScenario movingAcrossArray() {
    return new SimulationScenario(
        "moving-across-array",
        new Room2D(3.0, 2.0, 0.0, 0.0),
        defaultArray(),
        List.of(new SoundEmitter2D(new Vector2(0.6, 1.0), new Vector2(2.0, 0.0), 760.0, 0.5)),
        SAMPLE_RATE,
        0.5,
        7L);
  }

  /** Two moving sources with different frequencies and velocities. */
  public static SimulationScenario twoMovingSources() {
    return new SimulationScenario(
        "two-moving-sources",
        new Room2D(3.0, 2.0, 0.0, 0.0),
        defaultArray(),
        List.of(
            new SoundEmitter2D(new Vector2(0.8, 1.0), new Vector2(1.4, 0.0), 620.0, 0.45),
            new SoundEmitter2D(new Vector2(2.2, 1.4), new Vector2(-0.8, -0.4), 840.0, 0.45)),
        SAMPLE_RATE,
        0.5,
        8L);
  }

  /**
   * Two stationary deterministic wingbeat sources at 600 Hz and 640 Hz.
   *
   * <p>Both sources are placed at distinct positions in an anechoic room. Their fundamentals are
   * intentionally close (40 Hz apart) to exercise the ability of narrow-band trackers to separate
   * overlapping tonal content, while the emitted waveforms reuse the mosquito-like harmonic, drift,
   * jitter and noise parameters exported through ground truth.
   */
  public static SimulationScenario twoMosquitoWingbeats() {
    List<WingbeatSignalParameters> params = mosquitoWingbeatParameters();
    return new SimulationScenario(
        "two-mosquito-wingbeats",
        new Room2D(3.0, 2.0, 0.0, 0.0),
        defaultArray(),
        List.of(
            new WingbeatEmitter2D(
                new Vector2(1.0, 1.0), Vector2.ZERO, 0.5, params.get(0), SAMPLE_RATE, 9L),
            new WingbeatEmitter2D(
                new Vector2(2.0, 1.0), Vector2.ZERO, 0.5, params.get(1), SAMPLE_RATE, 10L)),
        SAMPLE_RATE,
        0.5,
        9L);
  }

  /**
   * Build a rich benchmark ground-truth {@link Scenario} for the {@link #twoMosquitoWingbeats()}
   * scenario.
   *
   * <p>This richer ground-truth record is intended for benchmark comparison of frequency-extraction
   * and classification algorithms. Its acoustic metadata is derived from the same emitter
   * parameters used by {@link #twoMosquitoWingbeats()}.
   */
  public static Scenario twoMosquitoWingbeatsGroundTruth() {
    SimulationScenario scenario = twoMosquitoWingbeats();
    List<ScenarioSource> sources = new ArrayList<>(scenario.emitters().size());
    for (int i = 0; i < scenario.emitters().size(); i++) {
      AcousticEmitter2D emitter = scenario.emitters().get(i);
      ScenarioTrajectory trajectory =
          ScenarioTrajectory.linear(
              emitter.startMeters(),
              emitter.velocityMetersPerSecond(),
              scenario.durationSeconds(),
              2);
      AcousticGroundTruth acoustic = emitter.acousticGroundTruth();
      ClassificationGroundTruth labels = ClassificationGroundTruth.synthetic("synthetic-wingbeat");
      sources.add(
          ScenarioSource.builder("source-" + i, "mosquito")
              .trajectory(trajectory)
              .acousticProperties(acoustic)
              .labels(labels)
              .build());
    }
    ScenarioEnvironment environment =
        new ScenarioEnvironment(
            SimulatedMicrophoneArraySource.DEFAULT_SPEED_OF_SOUND_METERS_PER_SECOND,
            "Simulated air");
    return new Scenario(
        scenario.name(), "Simulated scenario: " + scenario.name(), sources, environment);
  }

  private static List<WingbeatSignalParameters> mosquitoWingbeatParameters() {
    return List.of(
        WingbeatSignalParameters.mosquitoLike(600.0), WingbeatSignalParameters.mosquitoLike(640.0));
  }

  /** Single source with reflective walls (specular x-axis reflection in the simulator). */
  public static SimulationScenario reflectedEnvironment() {
    return new SimulationScenario(
        "reflected-environment",
        new Room2D(3.0, 2.0, 0.35, 0.01),
        defaultArray(),
        List.of(new SoundEmitter2D(new Vector2(0.8, 1.0), Vector2.ZERO, 580.0, 0.5)),
        SAMPLE_RATE,
        0.5,
        5L);
  }

  /** All bundled scenarios in canonical order. */
  public static List<SimulationScenario> all() {
    return List.of(
        singleSource(),
        twoCloseFrequencies(),
        noisyRoom(),
        movingSource(),
        movingTowardArray(),
        movingAcrossArray(),
        twoMovingSources(),
        reflectedEnvironment(),
        twoMosquitoWingbeats());
  }

  /** Default 4-microphone square array spanning roughly 30 cm, centered near (1.5, 0.1). */
  public static MicrophoneArray defaultArray() {
    return new MicrophoneArray(
        List.of(
            new Microphone("m0", new Vector2(1.35, 0.0), 0),
            new Microphone("m1", new Vector2(1.65, 0.0), 1),
            new Microphone("m2", new Vector2(1.35, 0.3), 2),
            new Microphone("m3", new Vector2(1.65, 0.3), 3)));
  }

  /**
   * One reproducible simulation scenario.
   *
   * @param name scenario name
   * @param room room geometry and acoustic parameters
   * @param array microphone array definition
   * @param emitters emitters active in the scenario
   * @param sampleRate sample rate in Hz
   * @param durationSeconds simulation duration in seconds
   * @param randomSeed deterministic seed for generated noise
   */
  public record SimulationScenario(
      String name,
      Room2D room,
      MicrophoneArray array,
      List<AcousticEmitter2D> emitters,
      float sampleRate,
      double durationSeconds,
      long randomSeed) {

    /* Validate and defensively copy emitters. */
    public SimulationScenario {
      if (name == null || name.isBlank()) {
        throw new IllegalArgumentException("name must not be blank");
      }
      if (room == null || array == null) {
        throw new IllegalArgumentException("room and array must not be null");
      }
      if (emitters == null || emitters.isEmpty()) {
        throw new IllegalArgumentException("emitters must not be empty");
      }
      if (sampleRate <= 0.0f) {
        throw new IllegalArgumentException("sampleRate must be > 0");
      }
      if (durationSeconds <= 0.0) {
        throw new IllegalArgumentException("durationSeconds must be > 0");
      }
      emitters = List.copyOf(emitters);
    }

    /** Create a fresh deterministic audio source for this scenario. */
    public SimulatedMicrophoneArraySource newSource() {
      return new SimulatedMicrophoneArraySource(
          room, array, emitters, sampleRate, durationSeconds, randomSeed);
    }

    /**
     * Build the ground-truth {@link org.hammer.audio.experimental.acoustic.scenario.Scenario} for
     * this simulation scenario.
     *
     * <p>Each emitter is mapped to a {@link ScenarioSource} with a linear {@link
     * ScenarioTrajectory} and the emitter-provided {@link AcousticGroundTruth}.
     */
    public Scenario groundTruth() {
      List<ScenarioSource> sources = new ArrayList<>(emitters.size());
      for (int i = 0; i < emitters.size(); i++) {
        AcousticEmitter2D emitter = emitters.get(i);
        ScenarioTrajectory trajectory =
            ScenarioTrajectory.linear(
                emitter.startMeters(), emitter.velocityMetersPerSecond(), durationSeconds, 2);
        AcousticGroundTruth acoustic = emitter.acousticGroundTruth();
        sources.add(
            ScenarioSource.builder("source-" + i, "emitter")
                .trajectory(trajectory)
                .acousticProperties(acoustic)
                .build());
      }
      ScenarioEnvironment environment =
          new ScenarioEnvironment(
              SimulatedMicrophoneArraySource.DEFAULT_SPEED_OF_SOUND_METERS_PER_SECOND,
              "Simulated air");
      return new Scenario(name, "Simulated scenario: " + name, sources, environment);
    }
  }
}