SimulatedMicrophoneArraySource.java

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

import java.util.List;
import java.util.Optional;
import java.util.Random;
import org.hammer.audio.acquisition.Microphone;
import org.hammer.audio.acquisition.MicrophoneArray;
import org.hammer.audio.acquisition.MultiChannelAudioSource;
import org.hammer.audio.acquisition.SampleClock;
import org.hammer.audio.core.AudioBlock;
import org.hammer.audio.core.AudioFormatDescriptor;
import org.hammer.audio.geometry.Vector2;

/** Deterministic synthetic synchronized multichannel source for virtual room experiments. */
public final class SimulatedMicrophoneArraySource implements MultiChannelAudioSource {

  /** Default speed of sound in air for experiments. */
  public static final double DEFAULT_SPEED_OF_SOUND_METERS_PER_SECOND = 343.0;

  private final Room2D room;
  private final MicrophoneArray array;
  private final List<SoundEmitter2D> emitters;
  private final AudioFormatDescriptor format;
  private final SampleClock clock;
  private final Random random;
  private final long totalFrames;
  private long nextFrameIndex;

  /** Create a deterministic simulation source. */
  public SimulatedMicrophoneArraySource(
      Room2D room,
      MicrophoneArray array,
      List<SoundEmitter2D> emitters,
      float sampleRate,
      double durationSeconds,
      long randomSeed) {
    if (room == null) {
      throw new IllegalArgumentException("room must not be null");
    }
    if (array == null) {
      throw new IllegalArgumentException("array must not be null");
    }
    if (emitters == null || emitters.isEmpty()) {
      throw new IllegalArgumentException("emitters must not be empty");
    }
    if (!(durationSeconds > 0.0) || !Double.isFinite(durationSeconds)) {
      throw new IllegalArgumentException("durationSeconds must be finite and > 0");
    }
    this.room = room;
    this.array = array;
    this.emitters = List.copyOf(emitters);
    this.format = new AudioFormatDescriptor(sampleRate, array.channels(), 32);
    this.clock = new SampleClock(sampleRate, 0L);
    this.random = new Random(randomSeed);
    this.totalFrames = Math.round(durationSeconds * sampleRate);
  }

  @Override
  public AudioFormatDescriptor format() {
    return format;
  }

  @Override
  public MicrophoneArray microphoneArray() {
    return array;
  }

  @Override
  public Optional<AudioBlock> readBlock(int frames) {
    if (frames <= 0) {
      throw new IllegalArgumentException("frames must be > 0");
    }
    if (nextFrameIndex >= totalFrames) {
      return Optional.empty();
    }
    int blockFrames = (int) Math.min(frames, totalFrames - nextFrameIndex);
    float[][] samples = new float[array.channels()][blockFrames];
    for (int frame = 0; frame < blockFrames; frame++) {
      long absoluteFrame = nextFrameIndex + frame;
      double receiverTime = absoluteFrame / format.sampleRate();
      for (Microphone mic : array.microphones()) {
        samples[mic.channel()][frame] = (float) sampleAt(mic.positionMeters(), receiverTime);
      }
    }
    AudioBlock block =
        AudioBlock.wrap(format, samples, nextFrameIndex, clock.timestampForFrame(nextFrameIndex));
    nextFrameIndex += blockFrames;
    return Optional.of(block);
  }

  private double sampleAt(Vector2 microphonePosition, double receiverTimeSeconds) {
    double sample = room.noiseAmplitude() * (random.nextDouble() * 2.0 - 1.0);
    for (SoundEmitter2D emitter : emitters) {
      Vector2 emitterPosition = emitter.positionAt(receiverTimeSeconds);
      double distance = Math.max(0.01, microphonePosition.distanceTo(emitterPosition));
      double travelSeconds = distance / DEFAULT_SPEED_OF_SOUND_METERS_PER_SECOND;
      double observedFrequency =
          observedFrequencyAt(emitter, microphonePosition, receiverTimeSeconds);
      sample += emitter.sampleAt(receiverTimeSeconds - travelSeconds, observedFrequency) / distance;
      if (room.reflectionGain() > 0.0) {
        Vector2 reflected =
            new Vector2(room.widthMeters() - emitterPosition.x(), emitterPosition.y());
        double reflectedDistance = Math.max(0.01, microphonePosition.distanceTo(reflected));
        double reflectedTravel = reflectedDistance / DEFAULT_SPEED_OF_SOUND_METERS_PER_SECOND;
        Vector2 reflectedVelocity =
            new Vector2(
                -emitter.velocityMetersPerSecond().x(), emitter.velocityMetersPerSecond().y());
        double reflectedObservedFrequency =
            observedFrequencyAt(
                reflected, reflectedVelocity, emitter.frequencyHz(), microphonePosition);
        sample +=
            room.reflectionGain()
                * emitter.sampleAt(
                    receiverTimeSeconds - reflectedTravel, reflectedObservedFrequency)
                / reflectedDistance;
      }
    }
    return Math.max(-1.0, Math.min(1.0, sample));
  }

  /**
   * Doppler-shifted frequency at a microphone; positive radial velocity means motion toward mic.
   */
  public static double observedFrequencyAt(
      SoundEmitter2D emitter, Vector2 microphonePosition, double receiverTimeSeconds) {
    Vector2 emitterPosition = emitter.positionAt(receiverTimeSeconds);
    return observedFrequencyAt(
        emitterPosition,
        emitter.velocityMetersPerSecond(),
        emitter.frequencyHz(),
        microphonePosition);
  }

  private static double observedFrequencyAt(
      Vector2 sourcePosition,
      Vector2 sourceVelocity,
      double sourceFrequencyHz,
      Vector2 microphonePosition) {
    Vector2 sourceToMicrophone = microphonePosition.minus(sourcePosition).normalized();
    double radialTowardMicrophone = sourceVelocity.dot(sourceToMicrophone);
    if (radialTowardMicrophone >= DEFAULT_SPEED_OF_SOUND_METERS_PER_SECOND) {
      throw new IllegalArgumentException("radial source speed must be below the speed of sound");
    }
    return sourceFrequencyHz
        * (DEFAULT_SPEED_OF_SOUND_METERS_PER_SECOND
            / (DEFAULT_SPEED_OF_SOUND_METERS_PER_SECOND - radialTowardMicrophone));
  }
}