SpectrogramHistory.java
package org.hammer.audio.spectrogram;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
/**
* Bounded rolling history of {@link SpectrogramFrame}s used to back the waterfall display and drift
* / burst analyses.
*
* <p>Internally backed by a fixed-size ring buffer. Once {@link #capacity()} frames are stored,
* appending a new frame evicts the oldest. The history rejects frames whose {@link
* SpectrogramFrame#binCount()} differs from the established bin count and starts fresh after a
* {@link #clear()} (a clear is also performed automatically when the FFT size or sample rate of an
* appended frame changes).
*
* <p>Instances are <strong>not thread-safe</strong>; callers must externally synchronize if shared.
*/
public final class SpectrogramHistory {
private final int capacity;
private final SpectrogramFrame[] frames;
private int head;
private int size;
private int activeBinCount = -1;
private int activeFftSize = -1;
private float activeSampleRate = -1f;
/**
* Create a history with the given maximum number of retained frames.
*
* @param capacity maximum number of retained frames; must be {@code >= 1}
*/
public SpectrogramHistory(int capacity) {
if (capacity < 1) {
throw new IllegalArgumentException("capacity must be >= 1, was " + capacity);
}
this.capacity = capacity;
this.frames = new SpectrogramFrame[capacity];
}
/**
* @return maximum number of retained frames
*/
public int capacity() {
return capacity;
}
/**
* @return current number of stored frames (between 0 and {@link #capacity()})
*/
public int size() {
return size;
}
/**
* @return true if no frames are currently stored
*/
public boolean isEmpty() {
return size == 0;
}
/**
* @return number of one-sided frequency bins of the stored frames, or {@code -1} if empty
*/
public int binCount() {
return activeBinCount;
}
/**
* @return FFT size of the stored frames, or {@code -1} if empty
*/
public int fftSize() {
return activeFftSize;
}
/**
* @return sample rate of the stored frames in Hz, or {@code -1f} if empty
*/
public float sampleRate() {
return activeSampleRate;
}
/**
* Append a frame to the history. If the frame's bin count, FFT size or sample rate differs from
* the established values, the history is cleared first so the new geometry can take over.
*
* @param frame frame to append; must not be {@code null}
*/
public void append(SpectrogramFrame frame) {
Objects.requireNonNull(frame, "frame");
if (activeBinCount != -1
&& (frame.binCount() != activeBinCount
|| frame.fftSize() != activeFftSize
|| Math.abs(frame.sampleRate() - activeSampleRate) > 0.0001f)) {
clear();
}
if (activeBinCount == -1) {
activeBinCount = frame.binCount();
activeFftSize = frame.fftSize();
activeSampleRate = frame.sampleRate();
}
int writeIndex;
if (size < capacity) {
writeIndex = (head + size) % capacity;
size++;
} else {
writeIndex = head;
head = (head + 1) % capacity;
}
frames[writeIndex] = frame;
}
/** Reset the history to empty, releasing references to retained frames. */
public void clear() {
Arrays.fill(frames, null);
head = 0;
size = 0;
activeBinCount = -1;
activeFftSize = -1;
activeSampleRate = -1f;
}
/**
* @return the most recently appended frame, or {@code null} if empty
*/
public SpectrogramFrame latest() {
if (size == 0) {
return null;
}
int idx = (head + size - 1) % capacity;
return frames[idx];
}
/**
* @param indexFromOldest zero-based index from the oldest stored frame
* @return the frame at that position
* @throws IndexOutOfBoundsException if the index is out of range
*/
public SpectrogramFrame frameAt(int indexFromOldest) {
if (indexFromOldest < 0 || indexFromOldest >= size) {
throw new IndexOutOfBoundsException("indexFromOldest=" + indexFromOldest + ", size=" + size);
}
return frames[(head + indexFromOldest) % capacity];
}
/**
* @return an immutable copy of the stored frames in order from oldest to newest
*/
public List<SpectrogramFrame> snapshot() {
List<SpectrogramFrame> out = new ArrayList<>(size);
for (int i = 0; i < size; i++) {
out.add(frames[(head + i) % capacity]);
}
return List.copyOf(out);
}
}