WingbeatSignalParameters.java
package org.hammer.audio.experimental.acoustic.simulation;
import java.util.Collections;
import java.util.List;
import org.hammer.audio.experimental.acoustic.scenario.AcousticGroundTruth;
/**
* Immutable signal-model parameters for a synthetic wingbeat-like source.
*
* <p>The model is a sum of sinusoidal partials (fundamental + harmonics) with optional amplitude
* modulation, linear frequency drift, per-sample frequency jitter and additive white noise:
*
* <pre>
* f(t) = fundamentalFrequencyHz + driftHzPerSecond * t + jitterHz * noise(t)
* am(t) = 1 + modulationDepth * sin(2π * modulationHz * t) (if modulationHz > 0)
* signal = am(t) * Σ_{k=0}^{harmonicCount-1} A_k * sin((k+1) * φ(t))
* + noiseAmplitude * white_noise(t)
* </pre>
*
* <p>where {@code A_k} is {@link #harmonicAmplitudes()}{@code .get(k)} when that list is provided,
* or {@code 1.0} otherwise, and {@code φ(t)} is the phase accumulated by integrating {@code f(t)}.
*
* <p>This model does not reproduce the aerodynamics of a real wing. Its purpose is to produce
* controlled, parameterised test signals with known ground truth so that feature-extraction and
* classification algorithms can be verified before real labelled recordings are available.
*
* @param fundamentalFrequencyHz emitted fundamental frequency in Hz; must be finite and positive
* @param harmonicCount number of partials (1 = fundamental only); must be >= 1
* @param harmonicAmplitudes per-partial amplitude coefficients for indices {@code 0 ..
* harmonicCount-1}; {@code null} means all coefficients equal {@code 1.0}; when non-null its
* size must equal {@link #harmonicCount()}. TODO: Replace this flat list with a HarmonicProfile
* abstraction once synthetic fixtures need harmonic phases or rolloff metadata.
* @param modulationHz amplitude-modulation frequency in Hz; {@code 0} disables AM
* @param modulationDepth AM depth in {@code [0, 1]}; {@code 0} disables AM
* @param driftHzPerSecond linear frequency drift per second; {@code 0} for no drift
* @param jitterHz maximum signed per-sample frequency deviation from the instantaneous fundamental,
* so the peak-to-peak jitter span is {@code 2 * jitterHz}; {@code 0} for no jitter
* @param noiseAmplitude additive white-noise amplitude in {@code [0, 1]}; {@code 0} for no noise
*/
public record WingbeatSignalParameters(
double fundamentalFrequencyHz,
int harmonicCount,
List<Double> harmonicAmplitudes,
double modulationHz,
double modulationDepth,
double driftHzPerSecond,
double jitterHz,
double noiseAmplitude) {
public WingbeatSignalParameters {
if (!Double.isFinite(fundamentalFrequencyHz) || fundamentalFrequencyHz <= 0.0) {
throw new IllegalArgumentException("fundamentalFrequencyHz must be finite and > 0");
}
if (harmonicCount < 1) {
throw new IllegalArgumentException("harmonicCount must be >= 1");
}
if (harmonicAmplitudes != null) {
if (harmonicAmplitudes.size() != harmonicCount) {
throw new IllegalArgumentException(
"harmonicAmplitudes.size() must equal harmonicCount when provided");
}
for (int i = 0; i < harmonicAmplitudes.size(); i++) {
Double amplitude = harmonicAmplitudes.get(i);
if (amplitude == null) {
throw new IllegalArgumentException(
"harmonicAmplitudes[%d] must not be null".formatted(i));
}
if (Double.isNaN(amplitude)) {
throw new IllegalArgumentException("harmonicAmplitudes[%d] must not be NaN".formatted(i));
}
if (Double.isInfinite(amplitude)) {
throw new IllegalArgumentException("harmonicAmplitudes[%d] must be finite".formatted(i));
}
}
harmonicAmplitudes = List.copyOf(harmonicAmplitudes);
}
if (!Double.isFinite(modulationHz) || modulationHz < 0.0) {
throw new IllegalArgumentException("modulationHz must be finite and >= 0");
}
if (!Double.isFinite(modulationDepth) || modulationDepth < 0.0 || modulationDepth > 1.0) {
throw new IllegalArgumentException("modulationDepth must be in [0, 1]");
}
if (!Double.isFinite(driftHzPerSecond)) {
throw new IllegalArgumentException("driftHzPerSecond must be finite");
}
if (!Double.isFinite(jitterHz) || jitterHz < 0.0) {
throw new IllegalArgumentException("jitterHz must be finite and >= 0");
}
if (!Double.isFinite(noiseAmplitude) || noiseAmplitude < 0.0 || noiseAmplitude > 1.0) {
throw new IllegalArgumentException("noiseAmplitude must be in [0, 1]");
}
}
@Override
public List<Double> harmonicAmplitudes() {
return harmonicAmplitudes == null ? null : List.copyOf(harmonicAmplitudes);
}
/**
* Return the fully resolved harmonic amplitude profile used by the generator and ground truth.
*/
public List<Double> resolvedHarmonicAmplitudes() {
if (harmonicAmplitudes == null) {
return Collections.nCopies(harmonicCount, 1.0);
}
return harmonicAmplitudes();
}
/**
* Create a minimal single-tone parameter set (fundamental only, no modulation, no noise).
*
* @param fundamentalFrequencyHz fundamental frequency in Hz; must be finite and positive
*/
public static WingbeatSignalParameters of(double fundamentalFrequencyHz) {
return new WingbeatSignalParameters(fundamentalFrequencyHz, 1, null, 0.0, 0.0, 0.0, 0.0, 0.0);
}
/**
* Create a parameter set that approximates a mosquito-like wingbeat signal with four harmonics, a
* small frequency drift, light jitter and a low noise floor.
*
* <p>The amplitudes follow a {@code 1 / (k+1)} profile (fundamental loudest). This is a rough
* phenomenological approximation; it does not represent any validated biological model.
*
* @param fundamentalFrequencyHz fundamental frequency in Hz; must be finite and positive
*/
public static WingbeatSignalParameters mosquitoLike(double fundamentalFrequencyHz) {
return new WingbeatSignalParameters(
fundamentalFrequencyHz, 4, List.of(1.0, 0.5, 0.25, 0.125), 0.0, 0.0, 0.5, 1.0, 0.02);
}
/**
* Convert these parameters to an {@link AcousticGroundTruth} record suitable for benchmark
* comparison.
*
* <p>The mapping is:
*
* <ul>
* <li>{@link #fundamentalFrequencyHz()} → {@link AcousticGroundTruth#fundamentalFrequencyHz()}
* <li>{@link #harmonicCount()} → {@link AcousticGroundTruth#harmonicCount()}
* <li>{@link #resolvedHarmonicAmplitudes()} → {@link AcousticGroundTruth#harmonics()}
* <li>{@link #modulationHz()} → {@link AcousticGroundTruth#modulationFrequencyHz()}
* <li>{@link #modulationDepth()} → {@link AcousticGroundTruth#modulationDepth()}
* <li>{@link #jitterHz()} → {@link AcousticGroundTruth#jitter()} as a maximum signed deviation
* in Hz
* <li>{@link #driftHzPerSecond()} → {@link AcousticGroundTruth#drift()}
* <li>{@link #noiseAmplitude()} → {@link AcousticGroundTruth#noiseAmplitude()}
* </ul>
*/
public AcousticGroundTruth toGroundTruth() {
return new AcousticGroundTruth(
fundamentalFrequencyHz,
harmonicCount,
resolvedHarmonicAmplitudes(),
modulationHz,
modulationDepth,
jitterHz,
driftHzPerSecond,
noiseAmplitude,
null);
}
}