Skip to content

feat(7sense): real-time animal sound identification and acoustic visualization - #779

Open
ruvnet wants to merge 12 commits into
mainfrom
claude/7sense-animal-sound-id-lg01kr
Open

feat(7sense): real-time animal sound identification and acoustic visualization#779
ruvnet wants to merge 12 commits into
mainfrom
claude/7sense-animal-sound-id-lg01kr

Conversation

@ruvnet

@ruvnet ruvnet commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Context

The vibecast-7sense example advertises real-time species identification and a manifold visualization. An audit of the code found neither exists, and several documented entry points resolve to nothing:

  • No audio capture dependency anywhere; ingestion is ingest_file(&Path) decoding the whole file into a Vec<f32>. The AudioStream::new() in the crate README is not a real type.
  • /ws/stream is documented in two READMEs but no such route is registered, and the WebSocket receive task discards every inbound frame.
  • No classifier, label set, or logits head exists. Perch 2.0 as integrated emits embeddings only, so POST /api/identify could not have been served.
  • No frontend source of any kind. The sevensense-viz crate that ADR-009 specifies was never created, and the API's umap_url points at an unregistered route.
  • sevensense-api declares all seven sibling crates as dependencies and imports none of them, reimplementing each stage as a stub that returns zero vectors and empty segments.

This PR closes those gaps. It is being built incrementally; the checklist below tracks progress.

Architecture decisions

Four ADRs record the design before the code lands.

ADR-010 — Real-time streaming ingestion. An AudioSource trait keeps cpal behind a non-default feature so WASM keeps building. A lock-free ring buffer with an explicit lossy-overwrite policy, because blocking a real-time audio callback causes device glitches and stale audio is worth less than current audio. An EMA noise floor with open/close hysteresis replaces global statistics, so a segment can close before the recording ends.

ADR-011 — Acoustic feature extraction. Descriptors are computed from a linear-frequency power spectrum rather than the existing mel path, because log-scaled perceptually-spaced bins do not yield a centroid in Hz.

ADR-012 — Manifold projection. Supersedes ADR-009's technology choices. PCA via randomized SVD is the default rather than UMAP: it is deterministic, so saved viewports stay valid, and it extends to new points in one matrix multiply, which is what streaming needs. UMAP remains opt-in, implemented over the existing HNSW graph rather than via the unmaintained umap-rs.

ADR-013 — Retrieval-based identification. k-NN against a labelled reference index instead of a classifier. Open-set by construction, so unknown species are detectable rather than forced into the nearest class; extensible by adding reference recordings rather than retraining; and every result carries the neighbours that produced it, which is what makes it auditable.

Progress

  • ADR-010, ADR-011, ADR-012, ADR-013
  • sevensense-audio::features — interpretable acoustic descriptors (ADR-011)
  • sevensense-audio::streaming — live ingestion pipeline (ADR-010)
  • sevensense-vector::projection — PCA / Poincaré projection (ADR-012)
  • Retrieval-based identification (ADR-013)
  • Wire sevensense-api to the real crates; add /ws/stream, /projection, /features
  • Web UI — 3D manifold explorer with live feature panels
  • Benchmarks and optimization pass

Changes so far

Acoustic features (ADR-011)

Spectral centroid, spread, skewness, rolloff, flatness, tonality, crest, entropy, slope, and a parabolically-interpolated dominant frequency per frame, plus amplitude and frequency modulation across frames. This populates CallSegment::spectral_centroid — a field whose builder previously had no callers anywhere in the workspace, leaving it permanently None.

Details worth review: flatness uses exp(mean(ln p)) because the product of ~1000 bin powers underflows f32; summary statistics cover voiced frames only, since averaging across silence pulls the centroid toward the noise floor; modulation returns None below 32 frames rather than a fabricated rate, and its band is capped at envelope Nyquist (50 Hz at the default hop), which corrects an overstatement in the first ADR draft.

Streaming ingestion (ADR-010)

RingBuffer is a lock-free SPSC queue that overwrites its oldest samples when the consumer falls behind and reports the gap. Only the producer stores write_pos and only the consumer stores read_pos, so no CAS is needed. Samples are held as AtomicU32 bit patterns — a torn read is inherent to an overwrite policy, and going through atomics makes that a stale value rather than undefined behaviour. The crate still contains no unsafe: Send/Sync are derived, with a compile-time assertion in place of a manual unsafe impl.

StreamSegmenter uses an EMA noise floor with open/close hysteresis. Only quiet frames update the floor — otherwise a long call drags the threshold up behind itself and closes its own segment. Pre-roll frames are retained so the opening transient is not clipped.

One design fix the tests forced: segments initially included the trailing hangover, which padded every segment with silence and let a 30 ms transient masquerade as a 300 ms call. Closing now trims the hangover, keeping one frame so natural decay survives.

Projection (ADR-012)

Randomized SVD that never forms the scatter matrix — scatter_multiply computes XᵀXv in O(n·d), so memory stays linear in the embedding dimension rather than quadratic. Two power iterations sharpen the subspace (acoustic embeddings have a slowly decaying spectrum); Gram-Schmidt reorthogonalizes twice, since one pass loses orthogonality on the nearly-dependent vectors power iteration produces.

Also puts the hyperbolic module to use — implemented and entirely unreferenced since the crate was written.

A bug the tests caught: tanh saturates to exactly 1.0 in f32 past ~9, landing distant points on the ball boundary, which is at infinite hyperbolic distance from everything. The radius is now clamped strictly inside.

Testing

186 tests passing, 0 failing across sevensense-audio and sevensense-vector.

Every test drives a signal or dataset whose correct answer is known analytically, so a failure points at the maths rather than at a fixture. The invariants that carry the most weight:

  • The ring buffer's concurrent producer/consumer test asserts consumed + dropped == produced — no sample may vanish silently or be counted twice.
  • Segmentation is asserted identical across push chunk sizes and source chunk sizes, since a live device delivers whatever it likes.
  • A signal below the adapted noise floor must not open a segment.
  • Single-point projection must agree exactly with the batch path, or streaming and batch views would disagree.
  • Fitting at the real 1536-dimensional embedding size is time-asserted, which fails loudly if anyone reintroduces the full scatter matrix.
cargo test -p sevensense-audio -p sevensense-vector

ruvnet added 12 commits August 3, 2026 01:57
The example promised real-time identification and a manifold visualization
that no code implemented. These four ADRs record how each gap is closed, and
the first of them is now built.

ADR-010 designs streaming ingestion: an AudioSource trait so cpal stays
optional and WASM keeps building, a lock-free ring buffer with an explicit
lossy-overwrite policy, and an EMA noise floor with hysteresis so segments can
close before a recording ends.

ADR-011 specifies interpretable acoustic descriptors, computed from a linear
power spectrum rather than the mel path, since log-scaled perceptual bins do
not yield a centroid in Hz.

ADR-012 supersedes ADR-009's technology choices: PCA via randomized SVD as the
default projection because it is deterministic and extends to new points in one
matrix multiply, with UMAP opt-in over the existing HNSW graph.

ADR-013 makes identification k-NN retrieval against a labelled index rather
than classification. Perch emits embeddings, not logits, and retrieval is
open-set, extensible without retraining, and evidence-backed.

Implements ADR-011 as sevensense-audio::features: centroid, spread, skewness,
rolloff, flatness, tonality, crest, entropy, slope, interpolated dominant
frequency, plus amplitude and frequency modulation across frames. This
populates CallSegment::spectral_centroid, whose builder previously had no
callers anywhere in the workspace.

Three details the tests pin down. Flatness uses exp(mean(ln p)) because the
product of ~1000 bin powers underflows f32. Summary statistics cover voiced
frames only, so silence cannot drag the centroid toward the noise floor.
Modulation is None below 32 frames rather than a fabricated rate, and its
search band is capped at envelope Nyquist -- 50 Hz at the default hop, which
corrects an overstatement in the first draft of ADR-011.

23 new tests, all driven by signals with analytically known descriptors.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_018tPo7cc6trZB1eNJ3YrC7Q
Runtime lock state written by the task scheduler when this session acquired
it. Kept in its own commit so it does not mix into the 7sense feature history.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_018tPo7cc6trZB1eNJ3YrC7Q
Live audio could not work before this: ingestion took a &Path, the decoder
materialized the whole signal into a Vec<f32>, and EnergySegmenter derived its
noise floor from global statistics over the complete buffer, so no segment
could be emitted until the recording ended.

Adds sevensense-audio::streaming with four pieces.

RingBuffer is a lock-free SPSC queue that overwrites its oldest samples when
the consumer falls behind, and reports the gap rather than hiding it. Only the
producer stores write_pos and only the consumer stores read_pos, so no CAS is
needed. Samples are held as AtomicU32 bit patterns: a torn read is inherent to
an overwrite policy, and going through atomics makes that a stale value instead
of undefined behaviour. The crate still contains no unsafe code -- Send and
Sync are derived, with a compile-time assertion standing in for the manual impl.

AudioSource keeps device access behind a trait so the crate stays testable
without hardware and WASM targets keep building. MemorySource takes a chunk
size, which lets tests reproduce a device's arrival pattern.

StreamSegmenter replaces global statistics with an EMA noise floor plus
open/close hysteresis, so a call closes while audio keeps arriving. Only quiet
frames update the floor; otherwise a long call drags the threshold up behind
itself and closes its own segment. Pre-roll frames are retained so the opening
transient is not clipped by the frames it took to cross the threshold.

StreamPipeline wires source to ring to segmenter to fixed 5 s windows at 50%
overlap, so a call straddling one boundary lands near the centre of another.

One design fix the tests forced. Segments initially included the trailing
hangover, which padded every segment with silence and let a 30 ms transient
masquerade as a 300 ms call. Closing now trims the hangover frames, keeping one
so a natural decay is not clipped.

54 new tests. The invariants worth naming: the ring's concurrent test asserts
consumed + dropped equals produced, so no sample vanishes silently or is
counted twice; segmentation is asserted identical across push chunk sizes and
across source chunk sizes, since a live device delivers whatever it likes; and
a signal below the adapted noise floor must not open a segment.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_018tPo7cc6trZB1eNJ3YrC7Q
Adds sevensense-vector::projection, the piece the visualization needs: a way
to turn 1536-dimensional embeddings into plottable coordinates. Nothing in the
workspace did this before, and the API's umap_url pointed at a route that could
not have been served.

PCA via randomized SVD, not UMAP. ADR-012 records why: the projection is
deterministic, so the same corpus lands in the same place every time and a
saved viewport stays valid; and once fitted, a new point projects with one dot
product per component, which is what streaming needs. UMAP has no cheap exact
out-of-sample extension.

Randomized rather than full SVD because only the top two or three components
are wanted out of 1536. The implementation never forms the scatter matrix --
scatter_multiply computes X^T X v in O(n*d) per call, so memory stays linear
in the embedding dimension rather than quadratic. Two power iterations sharpen
the sampled subspace, which matters because acoustic embeddings have a slowly
decaying spectrum that leaves a single pass with the leading components mixed.
Gram-Schmidt reorthogonalizes twice; one pass loses orthogonality badly on the
nearly-dependent vectors power iteration produces.

Also exposes the hyperbolic module, which has been implemented and unused since
the crate was written. to_poincare_ball maps normalized coordinates into the
Poincare ball so hierarchical structure reads as radial depth.

One bug the tests caught: tanh saturates to exactly 1.0 in f32 once its
argument passes about 9, so a distant point landed on the ball boundary, which
is at infinite hyperbolic distance from everything. The radius is now clamped
strictly inside.

22 tests. The ones that carry weight: identical data and seed must produce
identical components; single-point projection must agree exactly with the batch
path, since streaming and batch would otherwise disagree; data genuinely lying
in a plane keeps its pairwise distances to within 5%; fitting 200 vectors at
the real 1536-dimensional embedding size is asserted to complete quickly, which
fails loudly if someone reintroduces the full scatter matrix; and Poincare
images are checked to yield finite geodesic distances under the crate's own
poincare_distance.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_018tPo7cc6trZB1eNJ3YrC7Q
The scheduler removed its lock file when the session released it. Kept separate
from the 7sense feature history, as with the earlier acquisition.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_018tPo7cc6trZB1eNJ3YrC7Q
…d UI

The visualization ADR-012 designed now exists, and it runs the real analysis
code rather than a JavaScript reimplementation of it.

ADR-010 claimed the AudioSource trait "keeps WASM building". That claim was
never tested and was false: sevensense-audio did not compile for
wasm32-unknown-unknown at all. Two causes, both fixed here.

uuid's v4 generator requires an explicit randomness source on wasm32, which
has no OS entropy API, so both sevensense-core and sevensense-vector now
declare the js feature under a target-specific dependency.

More substantially, tokio's full feature pulls mio, which refuses to compile
for the target. The heavy dependencies are now optional behind a feature --
`full` for sevensense-audio, `native` for sevensense-vector -- gating the
async services, file decoding, mel spectrograms, and HNSW indexing. What
remains without them is exactly the pure computation: features, streaming,
distance, hyperbolic, and projection. This is the split ADR-010 assumed
existed; now it does, and it is enforced by the build rather than asserted in
a document.

Adds sevensense-wasm, a cdylib exposing a raw C ABI. Not wasm-bindgen: the
module is inlined into the page as base64 because a strict CSP blocks every
external request, so binary size is a correctness constraint, and wasm-bindgen
would add a JS shim and several hundred kilobytes of glue for an interface
that is six functions over float buffers. Results are packed as flat arrays
with a fixed stride so the host reads them through one Float32Array view.
Release profile is opt-level="z" with LTO: 288 KB, 384 KB encoded.

The page synthesises five acoustic archetypes at 32 kHz, then hands each to
analyze_summary for a twelve-dimensional descriptor vector, detect_segments
for boundaries, and project_pca for placement. Point position therefore
reflects measured structure, not the label -- the archetypes separate because
they genuinely differ. Explained variance is displayed, since three components
never capture everything and the figure should say how much they miss.

The 3D scatter is hand-written canvas projection with a painter's-algorithm
depth sort. three.js was the obvious choice and is unreachable: no CDN loads
under the artifact CSP.

Three rendering bugs found by inspecting the rendered page rather than trusting
the code. Point radii were multiplied by the projection's raw pixel scale
(~80) instead of a normalised perspective factor, producing 70-pixel blobs.
Colour mapped against a nominal 0-12 kHz range, compressing every point into a
third of the viridis ramp; it now maps to the corpus's measured range. And a
string replacement silently failed to match, leaving the old fixed legend in
place while colourRange kept its default and clamped every point to one end of
the ramp -- the edit script now asserts each substitution landed.

Verified headlessly in Chromium: 110 calls analysed in under a second, one
segment recovered per call, no console errors, no horizontal overflow, and the
data-theme override confirmed to win over the prefers-color-scheme media query
in both directions.

187 native tests still pass; the feature gating changes no native behaviour.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_018tPo7cc6trZB1eNJ3YrC7Q
Testing at 390px width surfaced two problems that mattered and several that
were merely untidy.

The serious one was a scroll trap. Every canvas carried touch-action: none so
that drags would rotate the plot, which on a phone means a full-width plot
swallows vertical swipes -- a finger landing anywhere on it could not scroll
past. The manifold now declares touch-action: pan-y, so the browser keeps
vertical gestures for scrolling and hands horizontal ones to the rotation
code. Pitch is no longer draggable on touch; auto-rotation and the arrow keys
still reach it, which is a fair trade for not stranding the reader.

The second was self-inflicted. Adding a 5:4 aspect ratio for narrow screens
left the canvas backing store at its authored 880x880, so everything drawn
into it was stretched horizontally -- points rendered as ellipses and the
reference cage was skewed. Canvases now size their backing store to the
displayed box at device pixel ratio, which removes the distortion and sharpens
every plot on high-density screens as a side effect. Fonts, radii, and line
widths are scaled by the same factor so they stay constant in CSS pixels.

The rest: tap targets reach 44px under a coarse pointer, where 36px buttons
and 39px rows were cramped; the descriptor grid drops to two columns so eight
values divide evenly instead of leaving an orphan cell, with larger figures;
the build tag becomes a row rather than a stranded right-aligned column; and
the plot hint reads "swipe across to spin" on touch, since "drag to rotate"
describes a gesture that device does not have.

Verified in Chromium at 360, 390, and 768 px in both themes: no horizontal
overflow, no tap target under 44px, no console errors, scrolling over the plot
confirmed working, and horizontal drag confirmed still rotating.

Two edits in this session silently failed to match their target and were only
caught by inspecting the rendered page. The build scripts now assert every
substitution lands and write nothing if one does not.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_018tPo7cc6trZB1eNJ3YrC7Q
Six new browser capabilities, and the UI panels that use them. This also
completes ADR-013, the last unbuilt piece of the plan.

The mel spectrogram was locked behind the `full` feature because it imported
rayon, which needs threads wasm32 does not have without the atomics proposal.
rayon is now the `parallel` feature and the sequential path produces identical
output, so spectrogram joins features and streaming in the browser build. While
opening it up, the STFT stopped constructing a fresh FFT planner inside the
per-frame loop: `RealToComplex` is Sync and `process_with_scratch` takes &self,
so one plan is shared across frames instead of planning per frame.

New exports: compute_spectrogram, knn_search, quality_flags, project_poincare,
cosine_distance_of, and unknown_threshold. Module grows 288 KB -> 349 KB.

knn_search is ADR-013's identification path -- retrieval against a labelled
index rather than classification, since Perch emits embeddings and retrieval is
open set. The UI aggregates neighbours by taxon with the ADR's distance
weighting at k=25, so a few close matches outrank many mediocre ones: 21
neighbours at d=0.012 yield 93% while 4 at d=0.449 contribute 7%. The verdict
lists the neighbours that produced it, because that is what makes it checkable.

project_poincare finally puts the hyperbolic module to work behind a geometry
toggle. quality_flags surfaces the noise and clipping gates as pills.

One correctness fix in the analysis itself. The twelve descriptors carry
different units -- centroid is thousands of hertz, tonality is a ratio -- so
raw vectors let frequency dominate every distance and every principal
component. They are now standardised before both projection and retrieval,
which moves the variance split from 64/34/1 to a better-balanced 53/23/11.

Two bugs caught by checking output rather than reading code. compute_spectrogram
returned the mel-band count while its docs promised the frame count, because
`publish` returns len/stride and I passed the wrong stride; the reader then
strided wrongly and read garbage. And the spectrogram's frequency axis labelled
band index linearly, but mel bands are logarithmically spaced -- it drew the
pipit's 6 kHz trill on the 12 kHz gridline, contradicting the frequency track
beside it. Ticks are now placed at their mel fraction.

Verified in Chromium at 360, 390, 768, and 1280 px: spectrogram ridge agrees
with the frequency track, identification returns the right archetype with its
evidence, hyperbolic toggle works, no console errors, no horizontal overflow.
187 native tests pass under default features and 79 under --no-default-features,
which is what proves the wasm subset is genuinely self-contained.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_018tPo7cc6trZB1eNJ3YrC7Q
The branch is named for real-time identification, and until now the page only
analysed synthesised audio in batch. detect_segments consumes a whole
recording, so ADR-010's incremental segmenter had no way to be driven from a
microphone, where the recording never ends.

Adds a stateful handle API: segmenter_create, segmenter_push, segmenter_flush,
segmenter_destroy, plus segmenter_noise_floor, segmenter_in_segment, and
segmenter_elapsed_ms so a caller can show why a quiet call is not opening a
segment rather than leaving the reader to guess. Handles are index-plus-one so
zero is never valid, and slots are reused after destroy. Also exposes
poincare_distance_of and modulation_of. Twenty-eight exports, 352 KB.

Streaming is asserted to agree with the one-shot path: pushing the same signal
in ragged chunks of 1000, 333, 4096, 77, 20000, and 512 samples produces
segment boundaries identical to detect_segments over the whole buffer.

The UI gains a live panel. The microphone feeds the same segmenter the corpus
uses, a fifteen-second ring buffer lets a closed segment be re-read for
analysis, and each detection is identified against the corpus by k-NN. A
blocked microphone -- the common case in an embedded frame -- reports plainly
and suggests opening the page directly.

Two correctness bugs, both found by playing known audio through the real
capture path rather than by reading code.

Several descriptors are normalised over the FFT bin count, so they shift with
the sample rate: the same 4 kHz tone measures 18 Hz spread at 32 kHz and 27 Hz
at 48 kHz. A capture at the device's native rate is therefore not comparable
to a 32 kHz reference index. The AudioContext now requests the corpus rate and
the panel says so when the browser refuses.

More seriously, corpus summaries covered the whole padded clip while a live
query is a detected segment. Voiced fraction has a corpus spread of about 0.04,
so a query measured over a segment (voiced ~1.0) rather than a clip (~0.66)
landed nine standard deviations out and swamped every other dimension --
a steady 4 kHz tone was confidently identified as a descending pipit trill.
The corpus is now summarised over its detected segment, so reference and query
are measured over the same kind of extent. The tone now reads as Pure whistle
at 99%, and a descending sweep lacking the pipit's trill sits at a suitably
uncertain 80%. Explained variance also improves from 53/23/11 to 49/25/20,
since the components no longer partly encode how much padding each clip had.

Verified with a synthesised WAV played through Chromium's fake capture device:
four calls detected at the right offsets, each identified correctly. 187 native
tests pass, and the page is clean at 360, 390, 768, and 1280 px.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_018tPo7cc6trZB1eNJ3YrC7Q
@ruvnet
ruvnet marked this pull request as ready for review August 8, 2026 22:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant