DEV Community

Pavel
Pavel

Posted on

Why Browser FFT Can't Label Dog Growling

You cannot label dog growling as play or warning from a browser FFT peak. getByteFrequencyData() returns energy bins, not mood. On 204 growls from 32 dogs, F0 and formants did not split those contexts—duration and pulse rate did. Measure energy and length with the mic code below; do not invent a mood enum.

Who this is for

You are shipping a pet-cam, a bark logger, or a weekend vocalization demo. The mic works. Bars bounce. Then a play growl and a food-guard growl both light the same 80–200 Hz region, and any mood badge flips wrong on most takes. That is the failure this article walks up to—and stops at.

What research already ruled out

Taylor, Reby, and McComb (Ethology, 2009) recorded 204 isolated growls across play and aggression setups with 32 dogs. Fundamental frequency and formant frequencies did not differ by context. Aggression growls ran longer. Sequence timing (how fast growls repeated) differed too. Human listeners could not sort isolated growls by context; they could rate synthetic bouts once pulse rate matched play versus aggression patterns.

Faragó et al. (Royal Society Open Science, 2017) reported the same shape: play growls came shorter and closer together than food-guarding or threat growls, often near about 0.2 seconds each when pulsed fast. Fundamental frequency still showed no significant context effect. Formant dispersion moved with context, but that needs careful spectral estimation—not a single loudest FFT bin at roughly 23 Hz resolution.

So the browser path that feels clever—max(dataArray) → Hertz → emoji mood—fails the published physics.

Step 1: Prove the UI can lie without a mic

Shipping sites sometimes animate a "listening" waveform without reading audio at all. One real React demo in this project's marketing repo (src/components/ui/DemoScreen.tsx) uses 26 bars, refreshed every 110 ms, drawn from a random envelope—not from getUserMedia.

const BAR_COUNT = 26;
const WAVE_TICK_MS = 110;

function randomBars(): number[] {
  return Array.from({ length: BAR_COUNT }, (_, i) => {
    const centre = 1 - Math.abs(i - BAR_COUNT / 2) / (BAR_COUNT / 2);
    const v = 0.18 + Math.random() * (0.35 + 0.65 * centre);
    return Math.min(1, v);
  });
}
Enter fullscreen mode Exit fullscreen mode

Paste that into any page. Bars look alive. Acoustic truth stays at zero. If a dashboard already does this, kill the random tick before you trust a growl badge.

Step 2: Wire a real AnalyserNode

Tested against the Web Audio API as documented on MDN (fftSize must be a power of 2 between 32 and 32768; default 2048). Run as one HTML file. On Chromium, revoke mic access later under Settings → Privacy and security → Site settings → Microphone.

<!doctype html>
<button id="start">Start mic</button>
<pre id="out">idle</pre>
<script type="module">
const out = document.getElementById("out");
document.getElementById("start").onclick = async () => {
  const stream = await navigator.mediaDevices.getUserMedia({
    audio: {
      echoCancellation: false,
      noiseSuppression: false,
      autoGainControl: false,
    },
  });
  const ctx = new AudioContext();
  // Safari may stay suspended until a user gesture; this click is that gesture.
  if (ctx.state === "suspended") await ctx.resume();

  const src = ctx.createMediaStreamSource(stream);
  const analyser = ctx.createAnalyser();
  analyser.fftSize = 2048;
  analyser.smoothingTimeConstant = 0.8;
  src.connect(analyser);
  // Skip ctx.destination unless you want speaker feedback howl.

  const bins = new Uint8Array(analyser.frequencyBinCount); // 1024 bins
  const hzPerBin = ctx.sampleRate / analyser.fftSize;
  // 48000 / 2048 ≈ 23.44 Hz per bin (MDN frequency mapping)

  let aboveSince = 0;
  const ENERGY_FLOOR = 28; // 0–255 from getByteFrequencyData
  const BAND_LO = Math.floor(60 / hzPerBin);
  const BAND_HI = Math.ceil(400 / hzPerBin);

  function tick(t) {
    analyser.getByteFrequencyData(bins);
    let sum = 0;
    let peakI = BAND_LO;
    for (let i = BAND_LO; i <= BAND_HI; i++) {
      const v = bins[i];
      sum += v;
      if (v > bins[peakI]) peakI = i;
    }
    const mean = sum / (BAND_HI - BAND_LO + 1);
    const peakHz = peakI * hzPerBin;
    const hot = mean >= ENERGY_FLOOR;
    if (hot && aboveSince === 0) aboveSince = t;
    if (!hot) aboveSince = 0;
    const durMs = aboveSince ? Math.round(t - aboveSince) : 0;

    out.textContent =
      `mean=${mean.toFixed(1)} peakHz≈${peakHz.toFixed(0)} ` +
      `durMs=${durMs} sampleRate=${ctx.sampleRate}`;
    requestAnimationFrame(tick);
  }
  requestAnimationFrame(tick);
};
</script>
Enter fullscreen mode Exit fullscreen mode

Within 5 seconds of a loud room sound you should see mean climb past 28 and peakHz jump around 80–350. Dog vocalizations land there. So do speech, HVAC, and TV. That overlap is the lesson.

Step 3: Log duration pulses, not mood words

Play contexts in the Faragó work favored short, tightly spaced growls. Aggression contexts favored longer calls and slower repetition. Your script already exposes durMs. Add a rising-edge / falling-edge log:

const events = [];
let wasHot = false;
// inside tick(), after computing hot / durMs:
if (hot && !wasHot) events.push({ type: "start", t });
if (!hot && wasHot) events.push({ type: "end", t, durMs });
wasHot = hot;
Enter fullscreen mode Exit fullscreen mode

After 60 seconds, print inter-onset intervals. You now have a crude rhythm sketch. You still do not have "play" versus "warning." Mic distance (0.5 m vs 3 m), breed size, and room noise move energy harder than context does.

Step 4: Pause work when the tab is hidden

A live FFT loop on a background tab burns CPU for nothing. The shared observer in this repo's src/components/ui/viewport.ts sets data-paused when the element leaves the viewport (rootMargin: "0px", threshold: 0) and clears it on re-entry. Pair the same idea with visibility:

document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "hidden") ctx.suspend();
  else ctx.resume();
});
Enter fullscreen mode Exit fullscreen mode

Exact paths: watchViewport in src/components/ui/viewport.ts; the demo state machine that already freezes while off-screen is in src/components/ui/DemoScreen.tsx (idle 1500 ms, listen 2200 ms, think 900 ms, type 18 ms per character).

Checklist before you ship a "growl label"

  • [ ] Fake random bars removed from any screen that claims live analysis
  • [ ] fftSize is a power of 2; you printed hzPerBin once at startup
  • [ ] Band limited (example: 60–400 Hz), not the full 0–Nyquist array
  • [ ] You log mean, peakHz, durMs—no enum like happy|angry
  • [ ] At least 20 labeled takes from your dog before any threshold tuning
  • [ ] Body posture checked in the same 2 seconds (stiff vs loose) by a human
  • [ ] Never punish a real-world growl while testing—the AKC warns that hiding the warning raises bite risk

Honest limit of this approach

getByteFrequencyData values are 0–255 integers after the node’s dB scaling. Formant dispersion (a cue Faragó tied to context) is not a single peak index. Autocorrelation can track F0, and Taylor 2009 already showed F0 failed as a context splitter on isolated growls. A living-room tab will not beat that lab result with a louder threshold.

Free alternative: skip live classification. Open Audacity 3.x (or any offline spectrogram), measure duration and gaps after the fact, then compare a loose play bow to a frozen hard stare. No model weights. Slower. Closer to how ethologists actually separate the contexts.

What this stack cannot do: turn a rumble into English, decide veterinary pain, or replace watching the whole body for 2 seconds. Peak-frequency detectors stall at the same wall; rhythm helps a little, meaning still lives outside the FFT.

FAQ

Does a higher peakHz mean a play growl?

No. Taylor 2009 found F0 and formants did not separate play from aggression on isolated growls. Peak bin ≠ emotion.

Why set echoCancellation to false?

Browser voice processing can flatten the low band you are measuring. For a detector experiment, raw input is clearer. Turn the flags back on for VoIP later.

Is fftSize 4096 better?

It halves bin width (≈11.7 Hz at 48 kHz) and raises latency. Fine for plots. Still will not invent a reliable mood class from one snapshot.

Can on-device ML fix this?

Only with a large, labeled, multi-breed corpus and temporal features. A 40-line FFT sketch is not that corpus. Budget weeks of data work, not an afternoon.

My dog growls during tug—should software alert?

Usually no. Soft body + open play face + bounce is play. Stiff body + hard stare + low steady rumble is a stop signal. Sound alone is the wrong primary sensor.

Top comments (0)