PeakHoldSpectrum.java
package org.hammer.audio.analysis;
import java.util.Arrays;
import java.util.Objects;
/**
* Peak-hold trace over a one-sided FFT magnitude spectrum.
*
* <p>For each frequency bin the stored value is the maximum magnitude observed across all updates
* since the last {@link #reset()}, optionally decayed each update by an exponential factor so old
* peaks slowly fade rather than persist forever.
*
* <p>Instances are <strong>not thread-safe</strong>; callers must synchronize externally if shared.
*/
public final class PeakHoldSpectrum {
/** Default per-update multiplicative decay factor (no decay). */
public static final float DEFAULT_DECAY_FACTOR = 1.0f;
private static final float[] EMPTY = new float[0];
private float[] peaks;
private float decayFactor;
private int updates;
/** Create a peak-hold trace with no decay (sticky peaks). */
public PeakHoldSpectrum() {
this(DEFAULT_DECAY_FACTOR);
}
/**
* Create a peak-hold trace with the given per-update decay factor.
*
* @param decayFactor multiplicative factor applied to each held peak before max-merging the new
* spectrum, in {@code [0, 1]}. {@code 1.0} disables decay.
*/
public PeakHoldSpectrum(float decayFactor) {
setDecayFactor(decayFactor);
}
/**
* @return current per-update decay factor in {@code [0, 1]}
*/
public float decayFactor() {
return decayFactor;
}
/**
* Update the per-update decay factor.
*
* @param decayFactor in {@code [0, 1]}, where {@code 1.0} disables decay
*/
public void setDecayFactor(float decayFactor) {
if (!(decayFactor >= 0f) || !(decayFactor <= 1f) || Float.isNaN(decayFactor)) {
throw new IllegalArgumentException("decayFactor must be in [0,1], was " + decayFactor);
}
this.decayFactor = decayFactor;
}
/**
* @return number of accepted updates since the last {@link #reset()}
*/
public int updates() {
return updates;
}
/**
* @return number of bins currently tracked, or {@code 0} if empty
*/
public int binCount() {
return peaks == null ? 0 : peaks.length;
}
/**
* @return defensive copy of the held peak magnitudes, or an empty array if no updates yet
*/
public float[] peaks() {
return peaks == null ? EMPTY : peaks.clone();
}
/**
* Read-only access to the internal peak magnitudes array. Callers must not mutate the returned
* array. Intended for hot rendering paths that need to avoid per-frame allocations.
*
* @return the internal peak magnitudes array (do not mutate), or an empty array if no updates yet
*/
public float[] peaksView() {
return peaks == null ? EMPTY : peaks;
}
/**
* Update the peak-hold trace from a new magnitude spectrum. If the bin count changes (e.g. FFT
* size changed), the existing peaks are reset.
*
* @param magnitudes new one-sided magnitude spectrum; must not be {@code null}
*/
public void update(float[] magnitudes) {
Objects.requireNonNull(magnitudes, "magnitudes");
if (peaks == null || peaks.length != magnitudes.length) {
peaks = magnitudes.clone();
updates = 1;
return;
}
if (decayFactor < 1f) {
for (int i = 0; i < peaks.length; i++) {
peaks[i] *= decayFactor;
if (magnitudes[i] > peaks[i]) {
peaks[i] = magnitudes[i];
}
}
} else {
for (int i = 0; i < peaks.length; i++) {
if (magnitudes[i] > peaks[i]) {
peaks[i] = magnitudes[i];
}
}
}
updates++;
}
/** Reset the peak-hold trace, discarding all stored peaks. */
public void reset() {
if (peaks != null) {
Arrays.fill(peaks, 0f);
}
updates = 0;
}
}