SpectrumAverager.java

package org.hammer.audio.analysis;

import java.util.Objects;

/**
 * Exponential moving average over a one-sided FFT magnitude spectrum.
 *
 * <p>For each bin the displayed value is updated with {@code avg' = (1 - alpha) * avg + alpha * x}.
 * Smaller alpha values yield slower averaging and more flicker suppression; alpha {@code = 1.0}
 * disables averaging.
 *
 * <p>Instances are <strong>not thread-safe</strong>; callers must synchronize externally if shared.
 */
public final class SpectrumAverager {

  /** Default smoothing factor (moderate). */
  public static final float DEFAULT_ALPHA = 0.3f;

  private static final float[] EMPTY = new float[0];

  private float[] average;
  private float alpha;
  private int updates;

  /** Create an averager with the {@link #DEFAULT_ALPHA default alpha}. */
  public SpectrumAverager() {
    this(DEFAULT_ALPHA);
  }

  /**
   * Create an averager with the given smoothing factor.
   *
   * @param alpha smoothing factor in {@code (0, 1]}, where larger values track the input faster
   */
  public SpectrumAverager(float alpha) {
    setAlpha(alpha);
  }

  /**
   * @return current smoothing factor in {@code (0, 1]}
   */
  public float alpha() {
    return alpha;
  }

  /**
   * Update the smoothing factor.
   *
   * @param alpha in {@code (0, 1]}; {@code 1.0} disables averaging
   */
  public void setAlpha(float alpha) {
    if (!(alpha > 0f) || !(alpha <= 1f) || Float.isNaN(alpha)) {
      throw new IllegalArgumentException("alpha must be in (0,1], was " + alpha);
    }
    this.alpha = alpha;
  }

  /**
   * @return number of bins currently tracked, or {@code 0} if empty
   */
  public int binCount() {
    return average == null ? 0 : average.length;
  }

  /**
   * @return number of accepted updates since the last {@link #reset()}
   */
  public int updates() {
    return updates;
  }

  /**
   * @return defensive copy of the current averaged spectrum, or an empty array if no updates yet
   */
  public float[] average() {
    return average == null ? EMPTY : average.clone();
  }

  /**
   * Read-only access to the internal averaged spectrum. Callers must not mutate the returned array.
   * Intended for hot rendering paths that need to avoid per-frame allocations.
   *
   * @return the internal averaged spectrum (do not mutate), or an empty array if no updates yet
   */
  public float[] averageView() {
    return average == null ? EMPTY : average;
  }

  /**
   * Update the averaged spectrum with a new measurement. The first measurement initializes the
   * state. If the bin count changes (e.g. FFT size changed), the state is reset.
   *
   * @param magnitudes new one-sided magnitude spectrum; must not be {@code null}
   */
  public void update(float[] magnitudes) {
    Objects.requireNonNull(magnitudes, "magnitudes");
    if (average == null || average.length != magnitudes.length) {
      average = magnitudes.clone();
      updates = 1;
      return;
    }
    float oneMinusAlpha = 1f - alpha;
    for (int i = 0; i < average.length; i++) {
      average[i] = oneMinusAlpha * average[i] + alpha * magnitudes[i];
    }
    updates++;
  }

  /**
   * Reset the averaged spectrum, discarding all accumulated state. The next {@link
   * #update(float[])} call will re-seed the average from the new measurement.
   */
  public void reset() {
    average = null;
    updates = 0;
  }
}