MicrophoneArrayCalibration.java

package org.hammer.audio.acquisition;

import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;

/**
 * Immutable timing/level calibration profile for one microphone-array channel mapping.
 *
 * @param profileId stable profile identity
 * @param array microphone geometry and channel mapping covered by the profile
 * @param referenceChannel channel used as the zero-delay timing anchor
 * @param channels per-channel calibration ordered by channel
 * @param calibratedAt wall-clock time of the calibration procedure
 * @param validUntil last wall-clock instant at which the profile may be trusted
 */
public record MicrophoneArrayCalibration(
    String profileId,
    MicrophoneArray array,
    int referenceChannel,
    List<ChannelTimingCalibration> channels,
    Instant calibratedAt,
    Instant validUntil) {

  private static final double DRIFT_EPSILON_PPM = 1.0e-9;

  // Validate one complete calibration profile.
  public MicrophoneArrayCalibration {
    if (profileId == null || profileId.isBlank()) {
      throw new IllegalArgumentException("profileId must not be blank");
    }
    Objects.requireNonNull(array, "array");
    Objects.requireNonNull(calibratedAt, "calibratedAt");
    Objects.requireNonNull(validUntil, "validUntil");
    if (validUntil.isBefore(calibratedAt)) {
      throw new IllegalArgumentException("validUntil must not be before calibratedAt");
    }
    if (referenceChannel < 0 || referenceChannel >= array.channels()) {
      throw new IllegalArgumentException("referenceChannel must exist in microphone array");
    }
    List<ChannelTimingCalibration> sorted =
        new ArrayList<>(Objects.requireNonNull(channels, "channels"));
    sorted.sort(Comparator.comparingInt(ChannelTimingCalibration::channel));
    if (sorted.size() != array.channels()) {
      throw new IllegalArgumentException("calibration must cover every microphone-array channel");
    }
    for (int channel = 0; channel < sorted.size(); channel++) {
      if (sorted.get(channel).channel() != channel) {
        throw new IllegalArgumentException("calibration channels must be contiguous from 0");
      }
    }
    ChannelTimingCalibration reference = sorted.get(referenceChannel);
    if (Math.abs(reference.offsetSamples()) > 1.0e-9
        || Math.abs(reference.driftPpm()) > DRIFT_EPSILON_PPM) {
      throw new IllegalArgumentException("reference channel must have zero offset and drift");
    }
    channels = List.copyOf(sorted);
  }

  /** Returns calibration for one channel. */
  public ChannelTimingCalibration channel(int channel) {
    return channels.get(channel);
  }

  /** Returns the predicted second-minus-first hardware delay at an absolute nominal frame. */
  public double relativeOffsetSamples(int firstChannel, int secondChannel, long frameIndex) {
    return channel(secondChannel).offsetAtFrame(frameIndex)
        - channel(firstChannel).offsetAtFrame(frameIndex);
  }

  /** Returns whether at least one non-reference channel has measurable drift compensation. */
  public SynchronizationMode mode() {
    boolean driftCompensated =
        channels.stream().anyMatch(channel -> Math.abs(channel.driftPpm()) > DRIFT_EPSILON_PPM);
    return driftCompensated
        ? SynchronizationMode.DRIFT_COMPENSATED
        : SynchronizationMode.CALIBRATED_OFFSET;
  }

  /**
   * Assesses this profile for one observation and a caller-defined one-sigma timing-error budget.
   */
  public SynchronizationAssessment assess(
      Instant observationTime, float sampleRate, double maximumErrorSamples) {
    Objects.requireNonNull(observationTime, "observationTime");
    requirePositiveFinite(sampleRate, "sampleRate");
    requirePositiveFinite(maximumErrorSamples, "maximumErrorSamples");

    boolean outsideValidityWindow =
        observationTime.isBefore(calibratedAt) || observationTime.isAfter(validUntil);
    boolean current = !outsideValidityWindow;
    double estimatedErrorSamples =
        channels.stream()
            .mapToDouble(ChannelTimingCalibration::timingUncertaintySamples)
            .max()
            .orElse(0.0);
    List<String> diagnostics = new ArrayList<>();
    SynchronizationStatus status =
        assessStatus(
            outsideValidityWindow, estimatedErrorSamples, maximumErrorSamples, diagnostics);
    diagnostics.add("Calibration profile: " + profileId);
    return new SynchronizationAssessment(
        mode(),
        status,
        estimatedErrorSamples,
        estimatedErrorSamples / sampleRate,
        current,
        diagnostics);
  }

  private static SynchronizationStatus assessStatus(
      boolean outsideValidityWindow,
      double estimatedErrorSamples,
      double maximumErrorSamples,
      List<String> diagnostics) {
    if (outsideValidityWindow) {
      diagnostics.add("Calibration is outside its validity window.");
      return SynchronizationStatus.REJECTED;
    }
    if (estimatedErrorSamples > maximumErrorSamples) {
      diagnostics.add("Estimated timing error exceeds the localization error budget.");
      return SynchronizationStatus.REJECTED;
    }
    if (estimatedErrorSamples > maximumErrorSamples * 0.5) {
      diagnostics.add("Estimated timing error consumes more than half of the error budget.");
      return SynchronizationStatus.DEGRADED;
    }
    diagnostics.add("Calibration is current and inside the timing error budget.");
    return SynchronizationStatus.TRUSTED;
  }

  private static void requirePositiveFinite(double value, String name) {
    if (Double.isFinite(value) && value > 0.0) {
      return;
    }
    throw new IllegalArgumentException(name + " must be finite and > 0");
  }
}