feat(7sense): real-time animal sound identification and acoustic visualization - #779
Open
ruvnet wants to merge 12 commits into
Open
feat(7sense): real-time animal sound identification and acoustic visualization#779ruvnet wants to merge 12 commits into
ruvnet wants to merge 12 commits into
Conversation
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
marked this pull request as ready for review
August 8, 2026 22:39
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Context
The
vibecast-7senseexample 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:ingest_file(&Path)decoding the whole file into aVec<f32>. TheAudioStream::new()in the crate README is not a real type./ws/streamis documented in two READMEs but no such route is registered, and the WebSocket receive task discards every inbound frame.POST /api/identifycould not have been served.sevensense-vizcrate that ADR-009 specifies was never created, and the API'sumap_urlpoints at an unregistered route.sevensense-apideclares 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
AudioSourcetrait keepscpalbehind 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
sevensense-audio::features— interpretable acoustic descriptors (ADR-011)sevensense-audio::streaming— live ingestion pipeline (ADR-010)sevensense-vector::projection— PCA / Poincaré projection (ADR-012)sevensense-apito the real crates; add/ws/stream,/projection,/featuresChanges 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 permanentlyNone.Details worth review: flatness uses
exp(mean(ln p))because the product of ~1000 bin powers underflowsf32; summary statistics cover voiced frames only, since averaging across silence pulls the centroid toward the noise floor; modulation returnsNonebelow 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)
RingBufferis a lock-free SPSC queue that overwrites its oldest samples when the consumer falls behind and reports the gap. Only the producer storeswrite_posand only the consumer storesread_pos, so no CAS is needed. Samples are held asAtomicU32bit 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 nounsafe:Send/Syncare derived, with a compile-time assertion in place of a manualunsafe impl.StreamSegmenteruses 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_multiplycomputesXᵀXvin 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
hyperbolicmodule to use — implemented and entirely unreferenced since the crate was written.A bug the tests caught:
tanhsaturates to exactly 1.0 inf32past ~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-audioandsevensense-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:
consumed + dropped == produced— no sample may vanish silently or be counted twice.pushchunk sizes and source chunk sizes, since a live device delivers whatever it likes.