WaveformTrigger.java

package org.hammer.audio.analysis;

import java.util.Objects;
import java.util.Optional;
import org.hammer.audio.core.AudioBlock;

/**
 * Oscilloscope-style trigger that aligns repeated displays of a periodic or transient signal so
 * each frame begins at a stable reference (e.g. a rising zero crossing).
 *
 * <p>The trigger maintains a small rolling history of recent samples for one selected channel.
 * Every {@link #process(AudioBlock, int)} call appends the new samples to that history and then:
 *
 * <ol>
 *   <li>Scans forward (respecting {@link #holdoffFrames() holdoff}) for the first sample crossing
 *       the configured {@link #level() level} with the configured {@link Slope slope}.
 *   <li>If a trigger is found <em>and</em> at least {@link #viewFrames() viewFrames} samples are
 *       available after it, publishes a {@link TriggeredView snapshot} starting at the trigger
 *       sample and returns it.
 *   <li>If {@link Mode#AUTO} is selected and no trigger has fired for more than {@link
 *       #autoTimeoutFrames() autoTimeoutFrames}, publishes the most recent {@code viewFrames}
 *       samples instead (so non-periodic signals still produce a display).
 * </ol>
 *
 * <p>The published snapshot is independent of pixel space: it carries normalized {@code float}
 * samples plus the originating frame index/timestamp. UI code is responsible for any rendering.
 *
 * <p>Instances are <strong>not thread-safe</strong>; intended to be driven from a single consumer
 * thread (typically the UI refresh thread or a DSP worker).
 */
public final class WaveformTrigger {

  /** Trigger slope. */
  public enum Slope {
    /** Trigger when the signal rises through {@link #level()}. */
    RISING,
    /** Trigger when the signal falls through {@link #level()}. */
    FALLING
  }

  /** Trigger mode. */
  public enum Mode {
    /**
     * Only publish a snapshot when a trigger event is actually found within the configured holdoff
     * window. Silence or non-periodic signals produce no output.
     */
    NORMAL,
    /**
     * Like {@link #NORMAL}, but if no trigger fires for {@link #autoTimeoutFrames()} samples
     * publish the latest {@link #viewFrames()} samples anyway so the display does not freeze.
     */
    AUTO
  }

  /** Default view length used when the caller does not supply one. */
  public static final int DEFAULT_VIEW_FRAMES = 1024;

  /** Default holdoff used when the caller does not supply one. */
  public static final int DEFAULT_HOLDOFF_FRAMES = 64;

  private static final int MIN_HISTORY_FRAMES = 4 * 1024;

  private final int viewFrames;
  private final int historyCapacity;
  private final float[] history;

  private int historySize;
  private long firstSampleFrameIndex;
  private long firstSampleTimestampNanos;
  private float sampleRate;

  private float level;
  private Slope slope;
  private Mode mode;
  private int holdoffFrames;
  private int autoTimeoutFrames;

  private long framesSinceLastTrigger;
  private float prevSample;
  private boolean prevSampleValid;

  /** Create a trigger with sensible defaults: level 0, rising slope, AUTO mode. */
  public WaveformTrigger() {
    this(DEFAULT_VIEW_FRAMES);
  }

  /**
   * Create a trigger with the supplied view length and default parameters.
   *
   * @param viewFrames number of samples published per triggered snapshot; must be {@code > 0}
   */
  public WaveformTrigger(int viewFrames) {
    if (viewFrames <= 0) {
      throw new IllegalArgumentException("viewFrames must be > 0, was " + viewFrames);
    }
    this.viewFrames = viewFrames;
    this.historyCapacity = Math.max(MIN_HISTORY_FRAMES, viewFrames * 4);
    this.history = new float[historyCapacity];
    this.historySize = 0;
    this.firstSampleFrameIndex = 0L;
    this.firstSampleTimestampNanos = 0L;
    this.level = 0f;
    this.slope = Slope.RISING;
    this.mode = Mode.AUTO;
    this.holdoffFrames = DEFAULT_HOLDOFF_FRAMES;
    this.autoTimeoutFrames = Math.max(viewFrames, MIN_HISTORY_FRAMES / 2);
    this.framesSinceLastTrigger = Long.MAX_VALUE / 2;
    this.prevSample = 0f;
    this.prevSampleValid = false;
  }

  /**
   * @return number of samples published per snapshot
   */
  public int viewFrames() {
    return viewFrames;
  }

  /**
   * @return current trigger level in {@code [-1, 1]}
   */
  public float level() {
    return level;
  }

  /**
   * Set the trigger level. Must be a finite value in {@code [-1, 1]}.
   *
   * @param level new trigger level
   */
  public void setLevel(float level) {
    if (Float.isNaN(level) || Float.isInfinite(level) || level < -1f || level > 1f) {
      throw new IllegalArgumentException("level must be a finite value in [-1, 1], was " + level);
    }
    this.level = level;
  }

  /**
   * @return current trigger slope (rising or falling)
   */
  public Slope slope() {
    return slope;
  }

  /**
   * Set the trigger slope.
   *
   * @param slope rising or falling; must not be {@code null}
   */
  public void setSlope(Slope slope) {
    this.slope = Objects.requireNonNull(slope, "slope");
  }

  /**
   * @return current trigger mode
   */
  public Mode mode() {
    return mode;
  }

  /**
   * Set the trigger mode.
   *
   * @param mode {@link Mode#NORMAL} or {@link Mode#AUTO}; must not be {@code null}
   */
  public void setMode(Mode mode) {
    this.mode = Objects.requireNonNull(mode, "mode");
  }

  /**
   * @return current holdoff in samples (minimum spacing between triggers)
   */
  public int holdoffFrames() {
    return holdoffFrames;
  }

  /**
   * Set the holdoff: minimum number of samples between two triggers. Useful to skip multiple
   * crossings within a single waveform period.
   *
   * @param holdoffFrames non-negative number of samples
   */
  public void setHoldoffFrames(int holdoffFrames) {
    if (holdoffFrames < 0) {
      throw new IllegalArgumentException("holdoffFrames must be >= 0, was " + holdoffFrames);
    }
    this.holdoffFrames = holdoffFrames;
  }

  /**
   * @return number of samples after which AUTO mode publishes an untriggered view
   */
  public int autoTimeoutFrames() {
    return autoTimeoutFrames;
  }

  /**
   * Set the AUTO-mode timeout in samples.
   *
   * @param autoTimeoutFrames number of samples without a trigger before AUTO publishes anyway; must
   *     be {@code >= viewFrames}
   */
  public void setAutoTimeoutFrames(int autoTimeoutFrames) {
    if (autoTimeoutFrames < viewFrames) {
      throw new IllegalArgumentException(
          "autoTimeoutFrames must be >= viewFrames (" + viewFrames + "), was " + autoTimeoutFrames);
    }
    this.autoTimeoutFrames = autoTimeoutFrames;
  }

  /** Clear the rolling history and prior-sample state. */
  public void reset() {
    historySize = 0;
    firstSampleFrameIndex = 0L;
    firstSampleTimestampNanos = 0L;
    sampleRate = 0f;
    framesSinceLastTrigger = Long.MAX_VALUE / 2;
    prevSample = 0f;
    prevSampleValid = false;
  }

  /**
   * Feed an {@link AudioBlock} into the trigger.
   *
   * @param block the audio block; must not be {@code null}
   * @param channel zero-based channel index to trigger on
   * @return a triggered snapshot if a trigger fired (or AUTO mode timed out), otherwise {@link
   *     Optional#empty()}
   */
  public Optional<TriggeredView> process(AudioBlock block, int channel) {
    Objects.requireNonNull(block, "block");
    if (channel < 0 || channel >= block.channels()) {
      throw new IllegalArgumentException(
          "channel " + channel + " out of range [0, " + block.channels() + ")");
    }
    float[] in = block.channelView(channel);
    int frames = block.frames();
    if (frames <= 0) {
      return Optional.empty();
    }
    sampleRate = block.format().sampleRate();
    int firstNewIndex = appendToHistory(in, frames, block.frameIndex(), block.timestampNanos());
    return findAndPublish(firstNewIndex, block);
  }

  /** Append samples into the rolling history; returns the history index of the first new sample. */
  private int appendToHistory(float[] in, int frames, long blockFrameIndex, long blockTimestamp) {
    if (frames >= historyCapacity) {
      // Block bigger than capacity: keep only the tail.
      int tail = historyCapacity;
      System.arraycopy(in, frames - tail, history, 0, tail);
      historySize = tail;
      long droppedFrames = (long) frames - tail;
      firstSampleFrameIndex = blockFrameIndex + droppedFrames;
      firstSampleTimestampNanos =
          blockTimestamp + (long) (droppedFrames * 1_000_000_000.0d / Math.max(1.0d, sampleRate));
      // The previous sample crossing the boundary is the sample right before the kept tail.
      prevSample = in[frames - tail - 1 < 0 ? 0 : frames - tail - 1];
      prevSampleValid = tail > 0;
      return 0;
    }
    int free = historyCapacity - historySize;
    if (frames > free) {
      // Shift old data out, advance frame-index/timestamp accordingly.
      int shift = frames - free;
      System.arraycopy(history, shift, history, 0, historySize - shift);
      historySize -= shift;
      firstSampleFrameIndex += shift;
      firstSampleTimestampNanos += (long) (shift * 1_000_000_000.0d / Math.max(1.0d, sampleRate));
    }
    int firstNewIndex = historySize;
    if (historySize == 0) {
      firstSampleFrameIndex = blockFrameIndex;
      firstSampleTimestampNanos = blockTimestamp;
    }
    System.arraycopy(in, 0, history, historySize, frames);
    historySize += frames;
    return firstNewIndex;
  }

  private Optional<TriggeredView> findAndPublish(int firstNewIndex, AudioBlock source) {
    int triggerIndex = findTrigger(firstNewIndex);
    int newFrames = historySize - firstNewIndex;
    if (triggerIndex >= 0 && historySize - triggerIndex >= viewFrames) {
      framesSinceLastTrigger = 0L;
      return Optional.of(buildSnapshot(triggerIndex, true, source));
    }
    framesSinceLastTrigger = saturatedAdd(framesSinceLastTrigger, newFrames);
    if (mode == Mode.AUTO
        && framesSinceLastTrigger >= autoTimeoutFrames
        && historySize >= viewFrames) {
      framesSinceLastTrigger = 0L;
      int start = historySize - viewFrames;
      return Optional.of(buildSnapshot(start, false, source));
    }
    return Optional.empty();
  }

  private int findTrigger(int firstNewIndex) {
    int searchStart = firstNewIndex;
    int holdoffStart = computeHoldoffSearchStart();
    if (holdoffStart > searchStart) {
      searchStart = holdoffStart;
    }
    if (searchStart < 1) {
      // Need at least one earlier sample for slope detection. Use prevSample seeded from last call.
      if (!prevSampleValid) {
        searchStart = 1;
      }
    }
    for (int i = Math.max(searchStart, 0); i < historySize; i++) {
      float curr = history[i];
      float prev;
      if (i == 0) {
        if (!prevSampleValid) {
          continue;
        }
        prev = prevSample;
      } else {
        prev = history[i - 1];
      }
      boolean fired =
          slope == Slope.RISING ? (prev < level && curr >= level) : (prev > level && curr <= level);
      if (fired) {
        // Remember the last sample for next call before this position is overwritten.
        prevSample = history[historySize - 1];
        prevSampleValid = true;
        return i;
      }
    }
    // Update prev sample for next call.
    if (historySize > 0) {
      prevSample = history[historySize - 1];
      prevSampleValid = true;
    }
    return -1;
  }

  private int computeHoldoffSearchStart() {
    if (framesSinceLastTrigger >= holdoffFrames) {
      return 0;
    }
    long need = holdoffFrames - framesSinceLastTrigger;
    long start = historySize - need;
    return start < 0 ? 0 : (int) start;
  }

  private TriggeredView buildSnapshot(int start, boolean triggered, AudioBlock source) {
    float[] view = new float[viewFrames];
    System.arraycopy(history, start, view, 0, viewFrames);
    long sourceOffsetFrames = (long) start;
    long viewFrameIndex = firstSampleFrameIndex + sourceOffsetFrames;
    long viewTimestamp =
        firstSampleTimestampNanos
            + (long) (sourceOffsetFrames * 1_000_000_000.0d / Math.max(1.0d, sampleRate));
    return new TriggeredView(
        view,
        sampleRate,
        triggered,
        level,
        slope,
        viewFrameIndex,
        viewTimestamp,
        source.frameIndex(),
        source.timestampNanos());
  }

  private static long saturatedAdd(long a, long b) {
    long r = a + b;
    if (((a ^ r) & (b ^ r)) < 0L) {
      return Long.MAX_VALUE;
    }
    return r;
  }

  /**
   * Immutable triggered waveform view returned by {@link WaveformTrigger#process(AudioBlock, int)}.
   *
   * @param samples view samples starting at the trigger position (length = {@link #viewFrames()})
   * @param sampleRate sample rate in Hz
   * @param triggered {@code true} if a real trigger event fired; {@code false} if AUTO timeout
   * @param level trigger level used to produce this view
   * @param slope trigger slope used to produce this view
   * @param viewFrameIndex frame index of the first sample in {@link #samples()}
   * @param viewTimestampNanos timestamp of the first sample in {@link #samples()}
   * @param sourceFrameIndex frame index of the {@link AudioBlock} that produced this view
   * @param sourceTimestampNanos timestamp of the {@link AudioBlock} that produced this view
   */
  public record TriggeredView(
      float[] samples,
      float sampleRate,
      boolean triggered,
      float level,
      Slope slope,
      long viewFrameIndex,
      long viewTimestampNanos,
      long sourceFrameIndex,
      long sourceTimestampNanos) {

    /** Compact constructor that defensively clones {@code samples}. */
    public TriggeredView {
      Objects.requireNonNull(samples, "samples");
      Objects.requireNonNull(slope, "slope");
      samples = samples.clone();
    }

    /**
     * @return a defensive copy of the view samples
     */
    @Override
    public float[] samples() {
      return samples.clone();
    }

    /**
     * @return read-only access to the internal samples array (do not mutate)
     */
    public float[] samplesView() {
      return samples;
    }
  }
}