Add log-mel flux bird receipts

This commit is contained in:
Meta Vens 2026-05-24 22:14:47 +01:00
commit 24c1362ec8
6 changed files with 3994 additions and 3963 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 MiB

View file

@ -313,9 +313,11 @@ Decoded WAVs remain ignored scratch artifacts. Committed receipts live under
`artifacts/spectra/real-birds-ffmpeg/`:
- `lesser-redpoll/Acanthis_cabaret_-_Lesser_Redpoll_XC482789-log-mel.png`
- `lesser-redpoll/Acanthis_cabaret_-_Lesser_Redpoll_XC482789-log-mel-flux.png`
- `lesser-redpoll/Acanthis_cabaret_-_Lesser_Redpoll_XC482789-cepstrum.png`
- `lesser-redpoll/Acanthis_cabaret_-_Lesser_Redpoll_XC482789-mel-cepstral-features.csv`
- `wattled-guan/Aburria_aburri_-_Wattled_Guan_XC250442-log-mel.png`
- `wattled-guan/Aburria_aburri_-_Wattled_Guan_XC250442-log-mel-flux.png`
- `wattled-guan/Aburria_aburri_-_Wattled_Guan_XC250442-cepstrum.png`
- `wattled-guan/Aburria_aburri_-_Wattled_Guan_XC250442-mel-cepstral-features.csv`
@ -328,18 +330,26 @@ Invalid data found when processing input
The mel-cepstral receipt tool chooses the loudest active window when no start
time is supplied, then writes log-mel, cepstrum, and per-frame feature CSV. The
first real-bird readings:
feature CSV now includes positive log-mel spectral flux: frame-to-frame
new-energy motion in mel space. This is a better timing-anchor surface than the
plain cepstrum because sustained bands recede and attacks/glides light up.
The first real-bird readings:
```text
Lesser Redpoll active window: start=2.000s, duration=4.000s
centroid median=2440 Hz, p90=3041 Hz, max=3981 Hz
bandwidth median=2427 Hz, p90=2683 Hz
peak-energy frame near 1.732s, centroid=3224 Hz, bandwidth=1768 Hz
log-mel flux median=0.0870, p90=0.1166, p99=0.2079, max=0.3437
top flux frames around 1.716-1.720s and 1.478-1.482s
Wattled Guan active window: start=137.000s, duration=4.000s
centroid median=2217 Hz, p90=4229 Hz, max=4445 Hz
bandwidth median=1072 Hz, p90=1854 Hz
peak-energy frame near 1.722s, centroid=2021 Hz, bandwidth=766 Hz
log-mel flux median=0.0749, p90=0.1034, p99=0.1818, max=0.2370
top flux frames around 0.034-0.036s and the 3.248-3.744s high-band shifts
```
Read against the current `canary-packet-trill`, the lesson is blunt:
@ -354,6 +364,11 @@ Read against the current `canary-packet-trill`, the lesson is blunt:
deliberately sharp, but it is still too ladder-like. Real birds are carrying
identity through redundant contour, roughness, onset statistics, and cepstral
envelope motion at the same time. That is the next scoring surface.
- The log-mel flux receipts are the clearest current clue. They expose repeated
curved strokes in Redpoll and slower phrase-boundary/harmonic-motion anchors
in Guan. The next decoder should treat flux ridges as anchor proposals, then
classify the surrounding log-mel/cepstral word shape instead of asking the
cepstrum to do all the work alone.
## Next Cut

View file

@ -104,6 +104,16 @@ def colorize(surface: np.ndarray, floor: float | None = None, ceiling: float | N
return Image.fromarray(np.stack([r, g, b], axis=2).astype(np.uint8), mode="RGB")
def log_mel_flux(log_mel: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
if log_mel.shape[1] == 0:
return log_mel.copy(), np.empty((0,), dtype=np.float32)
delta = np.diff(log_mel, axis=1, prepend=log_mel[:, :1])
positive_delta = np.maximum(delta, 0.0)
flux = np.sqrt(np.mean(positive_delta * positive_delta, axis=0)).astype(np.float32)
return positive_delta.astype(np.float32), flux
def select_active_window(samples: np.ndarray, sample_rate: int, duration: float) -> int:
window = max(1, int(round(duration * sample_rate)))
if len(samples) <= window:
@ -124,6 +134,7 @@ def write_features(
path: Path,
log_mel: np.ndarray,
cepstrum: np.ndarray,
spectral_flux: np.ndarray,
centers_hz: np.ndarray,
sample_rate: int,
hop: int,
@ -135,7 +146,7 @@ def write_features(
with path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.writer(handle)
header = ["frame", "time_seconds", "log_energy", "centroid_hz", "bandwidth_hz"]
header = ["frame", "time_seconds", "log_energy", "centroid_hz", "bandwidth_hz", "log_mel_spectral_flux"]
header.extend(f"cepstrum_{index}" for index in range(cepstrum.shape[0]))
writer.writerow(header)
for frame in range(log_mel.shape[1]):
@ -145,6 +156,7 @@ def write_features(
f"{math.log(max(float(energy[frame]), 1.0e-12)):.6f}",
f"{float(centroid[frame]):.3f}",
f"{float(bandwidth[frame]):.3f}",
f"{float(spectral_flux[frame]):.6f}",
]
row.extend(f"{float(value):.6f}" for value in cepstrum[:, frame])
writer.writerow(row)
@ -178,20 +190,24 @@ def main() -> int:
bank, centers = mel_filterbank(sample_rate, args.fft, args.mel_bands, args.min_hz, min(args.max_hz, sample_rate * 0.5))
mel_power = np.maximum(bank @ power, 1.0e-18)
log_mel = np.log(mel_power)
flux_surface, spectral_flux = log_mel_flux(log_mel)
cepstrum = dct_matrix(args.cepstra, args.mel_bands) @ log_mel
stem = args.input.stem
log_mel_png = args.output_dir / f"{stem}-log-mel.png"
flux_png = args.output_dir / f"{stem}-log-mel-flux.png"
cepstrum_png = args.output_dir / f"{stem}-cepstrum.png"
features_csv = args.output_dir / f"{stem}-mel-cepstral-features.csv"
width = max(900, log_mel.shape[1] * 4)
colorize(log_mel).resize((width, 640)).save(log_mel_png)
colorize(flux_surface, floor=0.0).resize((width, 640)).save(flux_png)
colorize(cepstrum).resize((width, 420)).save(cepstrum_png)
write_features(features_csv, log_mel, cepstrum, centers, sample_rate, args.hop)
write_features(features_csv, log_mel, cepstrum, spectral_flux, centers, sample_rate, args.hop)
print(f"mel-cepstral input={args.input} sampleRate={sample_rate} startSeconds={start / sample_rate:.6f} samples={len(samples)}")
print(f"logMel={log_mel_png}")
print(f"logMelFlux={flux_png}")
print(f"cepstrum={cepstrum_png}")
print(f"features={features_csv}")
return 0