Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
# AGENTS.md
|
||||
|
||||
Guidance for AI coding agents (Claude Code, Aider, Cursor, etc.) working in this repo.
|
||||
|
||||
## Project
|
||||
|
||||
`AV-Live` — live-coding audio-visual performance system: SuperCollider sound engine, openFrameworks visualiser driven by a Hantek 6022BL oscilloscope, and a SwiftUI menubar launcher orchestrating everything. Public, GPL-3. Repo `electron-rare/AV-Live`, branch `main`. Multi-host: GrosMac (source), macm1 (sink / Multi-HMR + Apple Vision ANE), iPhone 16 Pro (ARKit/LiDAR pub).
|
||||
|
||||
## Tech stack (per sub-project)
|
||||
|
||||
| Sub-project | Stack |
|
||||
|-------------|-------|
|
||||
| `sound_algo/` | SuperCollider (sclang + scsynth), 1099 SynthDefs, 345 tracks |
|
||||
| `oscope-of/` | openFrameworks C++, libusb (Hantek bulk), GLSL 150 / GL 3.2 core |
|
||||
| `launcher/` | SwiftUI menubar app, Swift Package Manager |
|
||||
| `data_only_viz/` | Python 3.11+ via `uv`, native Metal (pyobjc), multi-backend pose |
|
||||
| `data_feeds/` | Python data ingestion |
|
||||
| `web_realart/` | Node.js, Express, OSC bridge |
|
||||
| `avlivebody-mac/` | SwiftUI body-tracking client (ARKit/SMPL-X mesh, ad-hoc signed for local dev) |
|
||||
| `iphone-arbody/` | iOS app, ARBodyTracker, publishes `/body3d/kp` via OSC |
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Python sub-projects (uv only)
|
||||
cd data_only_viz && uv sync && uv run python -m data_only_viz
|
||||
cd data_feeds && uv sync
|
||||
|
||||
# openFrameworks
|
||||
cd oscope-of && make -j
|
||||
|
||||
# Web bridge
|
||||
cd web_realart && npm install && npm start
|
||||
|
||||
# Swift
|
||||
open launcher/Package.swift # or xcodebuild from CLI
|
||||
open avlivebody-mac/avlivebody.xcodeproj
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- Commits: subject ≤ 50 chars, body ≤ 72, no underscore in scope, no AI attribution, never `--no-verify` (hooks enforce).
|
||||
- Branches: `feat/<name>`, `fix/<name>`, `docs/<name>`, `refactor/<name>`, `chore/<name>`.
|
||||
- Language: French to the user, English in code/comments/commits.
|
||||
- No emojis in code/docs/commits unless explicitly requested.
|
||||
- Python: **always `uv`** (never pip/poetry/conda directly).
|
||||
- `.gitignore` already excludes `*.pt`, `*.ckpt`, `*.safetensors`, `*.mlpackage` at root — don't commit weights.
|
||||
- License: GPL-3 (whole repo) — keep new files under a compatible license header when adding third-party code.
|
||||
|
||||
## File layout
|
||||
|
||||
- `sound_algo/` — SC sound engine (own `CLAUDE.md`)
|
||||
- `oscope-of/` — visualiser
|
||||
- `launcher/` — macOS menubar
|
||||
- `data_only_viz/` — pose / mesh / body tracking pipeline (Metal)
|
||||
- `data_feeds/` — data ingestion
|
||||
- `web_realart/` — web UI + OSC bridge
|
||||
- `avlivebody-mac/`, `iphone-arbody/` — body-tracking clients
|
||||
- `shared/` — cross-sub-project assets
|
||||
- `third_party/` — vendored deps (CHECK before adding to root deps)
|
||||
- `tools/` — helper scripts
|
||||
- `docs/superpowers/plans/` — in-flight plans/specs
|
||||
- `AV-Live-corrupted-20260514/` — quarantined corrupted snapshot, do not touch
|
||||
|
||||
## Domain-specific gotchas
|
||||
|
||||
- **mDNS hostnames are required** (`grosmac.local`, `supra-m1.local`) for `AVBODY_HOST` / `MULTIHMR_REMOTE_HOST`. They resist DHCP changes (iPhone hotspot reassigns 172.20.10.x routinely).
|
||||
- **`POSE_FILTER` chain ordering is load-bearing**: default is `median+kalman+lookahead+ik`. Extras must be inserted at the right stage — `one_euro_joints` BEFORE kalman, `one_euro_bones` AFTER SMPL-X fusion in `multi.py`. `arkit_fuse` overrides 14 body slots with ARKit ARSkeleton3D from iOS app via `/body3d/kp` on `:57128` (always-on listener).
|
||||
- **`ICP_FUSION=1`** requires `ICP_LIDAR_HOST` (iPhone IP), `ICP_LIDAR_PORT` (default 5500, iPhone ARMesh TCP), and an extrinsic JSON at `~/.config/av-live/lidar_extrinsic.json`. See `docs/ICP_FUSION.md`.
|
||||
- **iPhone OSC port `57128`** is hardcoded as the publish target for `/body3d/kp` — don't reassign.
|
||||
- **`avlivebody-mac` requires ad-hoc signing for local dev** (fixed in `85589f2`). Don't strip the signing identity.
|
||||
- **`onVideoFrame` retain cycle in avlivebody** was fixed in `3b5f29e` — when adding new frame callbacks, mind the strong-self capture.
|
||||
- **AVLive-Body legacy** has been archived (`9e1482e`); the canonical client is `avlivebody-mac`. Don't reintroduce paths to the old project.
|
||||
- **macm1 = sink** (Multi-HMR CoreML + Apple Vision ANE + SMPL-X TCP); GrosMac = source. Mind the direction when wiring new OSC topics.
|
||||
- **Each major sub-project has its own `CLAUDE.md`** — closest wins. Put cross-cutting rules here, sub-project specifics in the nested file.
|
||||
|
||||
## When in doubt
|
||||
|
||||
- Read root `CLAUDE.md` and the nested `CLAUDE.md` of the sub-project you're editing.
|
||||
- Recent commits: `git log --oneline -20`.
|
||||
- Plans: `docs/superpowers/plans/`.
|
||||
- Cluster context: `~/CLAUDE.md` (GrosMac / macm1 / iPhone topology).
|
||||
- For sound: read `sound_algo/CLAUDE.md` before touching SynthDefs.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,225 @@
|
||||
# oscope-sphere — Design Spec
|
||||
|
||||
- **Date:** 2026-05-18
|
||||
- **Status:** design approved, pending implementation plan
|
||||
- **Repo:** `electron-rare/AV-Live` (monorepo) — new sibling project `oscope-sphere/`
|
||||
- **Origin:** brainstorming session, derived from `oscope-of`
|
||||
|
||||
## 1. Goal
|
||||
|
||||
A minimal openFrameworks app that captures the Hantek 6022 oscilloscope
|
||||
(both channels) and renders a single 3D sphere combining a spectrogram
|
||||
and a waveform. No demoparty content — just the sphere.
|
||||
|
||||
It reuses `oscope-of`'s hardware-capture and signal-analysis layer and
|
||||
discards its ~80 KB demoparty renderer.
|
||||
|
||||
## 2. Scope
|
||||
|
||||
In scope:
|
||||
|
||||
- Hantek 6022 dual-channel USB capture (reused from `oscope-of`)
|
||||
- Per-channel FFT and waveform extraction (reused)
|
||||
- A single sphere rendered with three composable visual layers (A+B+C)
|
||||
- Demo-mode fallback when no scope is connected
|
||||
|
||||
Out of scope (YAGNI):
|
||||
|
||||
- OSC send/receive (`oscope-of`'s `OscClient`)
|
||||
- Post-FX chain, the 41 backgrounds, the 15 demoparties
|
||||
- GUI panels — keyboard and mouse only
|
||||
- Recording or frame export
|
||||
|
||||
## 3. Reused from oscope-of
|
||||
|
||||
Copied verbatim into `oscope-sphere/src/`. These files are small, stable,
|
||||
and self-contained; copying (rather than sharing across an openFrameworks
|
||||
Makefile boundary) keeps the project buildable in isolation. Drift risk is
|
||||
low because the capture layer is mature.
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `ScopeData.h` | SPSC lock-free ring buffer of CH1/CH2 samples, `[-1,1]` |
|
||||
| `HantekDevice.{h,cpp}` | libusb capture thread for Hantek 6022, feeds the ring |
|
||||
| `AudioAnalyzer.{h,cpp}` | downsample (scope rate → 48 kHz) + FFT bands |
|
||||
| `FFT.{h,cpp}` | Cooley-Tukey radix-2, no external dependency |
|
||||
|
||||
Public interfaces used:
|
||||
|
||||
- `ScopeRing::readLatest(outCh1, outCh2, n)` — latest `n` samples, per channel
|
||||
- `HantekDevice::start()/stop()/status()`, `HantekDevice::ring()`
|
||||
- `AudioAnalyzer::update(ch1, ch2, sr)`, `magDown()` (1024 bins, ~23.4 Hz/bin)
|
||||
|
||||
**Per-channel spectrum trick.** `AudioAnalyzer::update(ch1, ch2, sr)` mixes
|
||||
its two arguments to mono before the FFT. To get a per-channel spectrum
|
||||
without editing the file, instantiate two analyzers and feed each
|
||||
`update(chX, chX, sr)` — mixing a signal with itself yields that signal.
|
||||
`analyzerCh1` is fed `(buf1, buf1)`, `analyzerCh2` is fed `(buf2, buf2)`.
|
||||
|
||||
**Hardware constraint.** 24/48 MS/s are single-channel-only on the
|
||||
OpenHantek6022 firmware. Because both channels are required, capture is
|
||||
configured at **16 MS/s**.
|
||||
|
||||
## 4. Channel convention
|
||||
|
||||
The sphere is split at the equator: **northern hemisphere = CH1**,
|
||||
**southern hemisphere = CH2**. This convention holds across all three
|
||||
layers, so the object always reads as one coherent stereo body.
|
||||
|
||||
## 5. The three layers (A + B + C)
|
||||
|
||||
All three target the same sphere subject. Keys `1`/`2`/`3` toggle layers
|
||||
A/B/C independently; default is all three on.
|
||||
|
||||
### Layer A — Core sphere: spectrogram skin + waveform displacement
|
||||
|
||||
- **Geometry:** one icosphere VBO (subdivided, ~10k–40k vertices).
|
||||
- **Skin (spectrogram):** a scrolling 2D texture. X = time (longitude),
|
||||
Y = frequency on a log scale (latitude). Northern rows = CH1 `magDown`
|
||||
log-resampled, southern rows = CH2. Each frame writes one new column at
|
||||
the ring write index. The fragment shader samples it and applies a
|
||||
colormap (magma / inferno).
|
||||
- **Displacement (waveform):** the vertex shader displaces each vertex
|
||||
along its normal by the live waveform amplitude. Northern vertices read
|
||||
the CH1 waveform texture, southern vertices read CH2. The sphere
|
||||
geometry "breathes" in stereo.
|
||||
|
||||
### Layer B — Waveform orbit rings
|
||||
|
||||
Two 3D line-loop rings orbiting the sphere in perpendicular planes.
|
||||
Ring 1 = CH1, ring 2 = CH2. Each ring is a circle of `M` points whose
|
||||
radius is `baseOrbitRadius + channelWaveformSample(angle)`. The rings
|
||||
ripple with the live signal — a stereo Lissajous-flavoured cage.
|
||||
|
||||
### Layer C — Point-cloud shell
|
||||
|
||||
The same sphere rendered as `GL_POINTS` particles. Each particle's radial
|
||||
position uses the waveform displacement (as in layer A); its color is the
|
||||
spectrogram value at its `(latitude, longitude)`. Northern particles = CH1,
|
||||
southern = CH2. Layer C composites additively over (or instead of) the
|
||||
solid skin.
|
||||
|
||||
### Compositing and controls
|
||||
|
||||
- `1`/`2`/`3` — toggle layers A/B/C; `c` — cycle colormap; `space` — freeze
|
||||
- Mouse drag — orbit camera; slow automatic rotation otherwise
|
||||
- The spectrogram scroll is decoupled from camera rotation
|
||||
|
||||
## 6. Architecture and components
|
||||
|
||||
| Unit | Purpose | Depends on |
|
||||
|------|---------|-----------|
|
||||
| `HantekDevice` (reused) | USB capture thread → `ScopeRing` | libusb |
|
||||
| `AudioAnalyzer` ×2 (reused) | per-channel downsample + FFT | `FFT` |
|
||||
| `SphereViz` (new) | owns icosphere VBO + spectrogram texture ring; draws layers A and C | oF GL |
|
||||
| `OrbitRings` (new) | owns the two ring meshes; draws layer B | oF GL |
|
||||
| `ofApp` (new) | wires capture → analysis → viz; camera; layer toggles; HUD; demo mode | all of the above |
|
||||
|
||||
New unit interfaces:
|
||||
|
||||
```cpp
|
||||
class SphereViz {
|
||||
void setup(int subdivisions);
|
||||
// log-resamples each channel's magnitudes, advances the texture ring
|
||||
void pushSpectrogramColumn(const std::vector<float>& magCh1,
|
||||
const std::vector<float>& magCh2);
|
||||
// uploads the per-channel waveform displacement textures
|
||||
void setWaveform(const std::vector<float>& ch1,
|
||||
const std::vector<float>& ch2);
|
||||
void drawSkin(); // layer A
|
||||
void drawPoints(); // layer C
|
||||
void setColormap(int id);
|
||||
};
|
||||
|
||||
class OrbitRings {
|
||||
void setup(int pointsPerRing);
|
||||
void setWaveform(const std::vector<float>& ch1,
|
||||
const std::vector<float>& ch2);
|
||||
void draw(); // layer B
|
||||
};
|
||||
```
|
||||
|
||||
## 7. Data flow
|
||||
|
||||
```
|
||||
HantekDevice thread ── libusb bulk ──> ScopeRing (ch1, ch2 floats [-1,1])
|
||||
|
||||
ofApp::update():
|
||||
ring.readLatest(buf1, buf2, N)
|
||||
analyzerCh1.update(buf1, buf1, sr) -> magDown ch1
|
||||
analyzerCh2.update(buf2, buf2, sr) -> magDown ch2
|
||||
sphereViz.pushSpectrogramColumn(magCh1, magCh2) // advance texture
|
||||
sphereViz.setWaveform(buf1, buf2) // displacement textures
|
||||
orbitRings.setWaveform(buf1, buf2)
|
||||
|
||||
ofApp::draw():
|
||||
cam.begin()
|
||||
if layerA: sphereViz.drawSkin()
|
||||
if layerC: sphereViz.drawPoints()
|
||||
if layerB: orbitRings.draw()
|
||||
cam.end()
|
||||
drawHud() // capture status
|
||||
```
|
||||
|
||||
## 8. File layout
|
||||
|
||||
```
|
||||
oscope-sphere/
|
||||
Makefile config.make addons.make .gitignore CLAUDE.md README.md
|
||||
src/
|
||||
main.cpp
|
||||
ofApp.{h,cpp}
|
||||
HantekDevice.{h,cpp} (copied from oscope-of)
|
||||
AudioAnalyzer.{h,cpp} (copied)
|
||||
FFT.{h,cpp} (copied)
|
||||
ScopeData.h (copied)
|
||||
SphereViz.{h,cpp} (new — layers A and C)
|
||||
OrbitRings.{h,cpp} (new — layer B)
|
||||
bin/data/shaders/
|
||||
sphere.vert sphere.frag (layer A skin + layer C points)
|
||||
```
|
||||
|
||||
- `addons.make` is empty — no `ofxOsc`, `ofxGui`, or `ofxOpenCv`.
|
||||
- `Makefile` / `config.make` are copied from `oscope-of`, with `APPNAME`
|
||||
set to `oscope-sphere`.
|
||||
- Window: GL 3.2 core, GLSL 150, 1920×1080, MSAA 8× — same as
|
||||
`oscope-of/src/main.cpp`.
|
||||
- Requires an openFrameworks install (same prerequisite as `oscope-of`).
|
||||
|
||||
## 9. Error handling and demo mode
|
||||
|
||||
`HantekDevice::start()` returns a `HantekStatus`. On `NotFound`,
|
||||
`FirmwareNeeded`, or `UsbError`, `ofApp` enters demo mode: it synthesizes
|
||||
CH1 (a sine sweep) and CH2 (a distinct sine plus noise) into the same
|
||||
pipeline, so all three layers stay alive without hardware. The HUD shows:
|
||||
|
||||
- `SCOPE OK`
|
||||
- `DEMO — scope not found`
|
||||
- `DEMO — firmware needed (see docs/HANTEK_SETUP.md)`
|
||||
|
||||
Unplugging the scope mid-run must not crash (graceful fallback, per the
|
||||
`oscope-of` convention). No heap allocations in `update()` / `draw()` —
|
||||
FFT buffers, VBOs, and textures are preallocated in `setup()`.
|
||||
|
||||
## 10. Testing
|
||||
|
||||
- **FFT / AudioAnalyzer:** a known sine in → expected peak bin. Verify the
|
||||
per-channel trick: `update(chX, chX, sr)` ⇒ `monoDown` equals the
|
||||
downsampled `chX`.
|
||||
- **SphereViz:** spectrogram ring-buffer wrap index after `K > W` column
|
||||
pushes; log-resample mapping is monotonic.
|
||||
- **OrbitRings:** point count and radius-modulation bounds.
|
||||
- **GL rendering:** verified manually with a connected scope plus a signal
|
||||
generator / audio output. This layer cannot be unit-tested; results will
|
||||
be reported as observed, never claimed as "passing" without a visual check.
|
||||
|
||||
## 11. Assumptions made during brainstorming
|
||||
|
||||
Override any of these if wrong:
|
||||
|
||||
- New sibling folder `oscope-sphere/`; `oscope-of` is left untouched.
|
||||
- Capture files are copied, not shared — accepted minor duplication.
|
||||
- Capture default is 16 MS/s (dual-channel hardware constraint).
|
||||
- Channel convention: northern hemisphere = CH1, southern = CH2.
|
||||
- Work happens in `/tmp/AV-Live` because `~/Documents/Projets/AV-Live`
|
||||
is TCC-locked on this machine; the result must be moved or pushed.
|
||||
@@ -0,0 +1,7 @@
|
||||
obj/
|
||||
bin/oscope-sphere
|
||||
bin/oscope-sphere_debug
|
||||
bin/oscope-sphere.app/
|
||||
*.o
|
||||
*.d
|
||||
openFrameworks-Info.plist
|
||||
@@ -0,0 +1,62 @@
|
||||
# oscope-sphere
|
||||
|
||||
Visualiseur openFrameworks C++ : capture Hantek 6022 (2 canaux) via
|
||||
libusb -> FFT -> une sphere 3D stereo avec 3 couches composables.
|
||||
Derive de `oscope-of` (couche capture reutilisee, demoparty jetee).
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
cd oscope-sphere
|
||||
make # debug
|
||||
make Release # release
|
||||
make Run # lance bin/oscope-sphere
|
||||
|
||||
./tests/run_tests.sh # tests C++ purs (clang++, sans openFrameworks)
|
||||
```
|
||||
|
||||
Cible macOS principale (Hantek via libusb). `config.make` detecte
|
||||
libusb (pkg-config, puis brew, puis chemins par defaut).
|
||||
|
||||
## Architecture
|
||||
|
||||
| Composant | Fichier |
|
||||
|-----------|---------|
|
||||
| Capture USB Hantek 6022 (16 MS/s, 2 ch) | `HantekDevice.{h,cpp}` (copie) |
|
||||
| Downsampling + FFT 2048 | `AudioAnalyzer.{h,cpp}`, `FFT.{h,cpp}` (copie) |
|
||||
| Ring buffer SPSC CH1/CH2 | `ScopeData.h` (copie) |
|
||||
| Spectrogramme roulant log-freq | `SpectrogramBuffer.{h,cpp}` |
|
||||
| Signal de demo (pas de scope) | `DemoSignal.{h,cpp}` |
|
||||
| Sphere : peau spectro + relief waveform + points | `SphereViz.{h,cpp}` |
|
||||
| Anneaux waveform orbitaux | `OrbitRings.{h,cpp}` |
|
||||
| Cycle de vie, camera, HUD | `ofApp.{h,cpp}`, `main.cpp` |
|
||||
| Shaders sphere | `bin/data/shaders/sphere.{vert,frag}` |
|
||||
|
||||
## Convention canaux
|
||||
|
||||
La sphere est coupee a l'equateur : hemisphere Nord = CH1, Sud = CH2.
|
||||
Cette convention tient sur les 3 couches.
|
||||
|
||||
## Couches (touches 1 / 2 / 3)
|
||||
|
||||
- A (`1`) : sphere — peau spectrogramme + deplacement waveform
|
||||
- B (`2`) : deux anneaux waveform orbitaux (un par canal)
|
||||
- C (`3`) : nuage de points
|
||||
- `c` : cycle colormap (magma / viridis) ; `space` : fige le defilement
|
||||
|
||||
## Conventions
|
||||
|
||||
- GLSL 150 GL 3.2 core uniquement. Shaders dans `bin/data/shaders/`.
|
||||
- Pas d'allocations dans `update()` / `draw()` — buffers preallouees.
|
||||
- AudioAnalyzer mixe ses 2 arguments : `update(chX, chX)` isole chX.
|
||||
Deux analyzers, un par canal.
|
||||
- 24/48 MS/s sont mono-canal sur le firmware OpenHantek6022 — la
|
||||
capture deux-canaux tourne donc a 16 MS/s.
|
||||
- Scope absent -> mode demo automatique (signal synthetique).
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Ne pas committer `bin/oscope-sphere*` (binaires).
|
||||
- Ne pas faire d'I/O fichier ni de `new` dans la hot loop.
|
||||
- Ne pas modifier les 4 fichiers copies de `oscope-of` — les
|
||||
resynchroniser depuis `oscope-of/src/` si besoin.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Délègue au Makefile générique d'openFrameworks.
|
||||
# Suppose que le projet est cloné dans <OF_ROOT>/apps/myApps/oscope-of/.
|
||||
|
||||
ifndef PROJECT_ROOT
|
||||
PROJECT_ROOT := $(realpath ./)
|
||||
endif
|
||||
|
||||
include $(PROJECT_ROOT)/config.make
|
||||
|
||||
ifndef OF_ROOT
|
||||
OF_ROOT = ../../..
|
||||
endif
|
||||
|
||||
include $(OF_ROOT)/libs/openFrameworksCompiled/project/makefileCommon/compile.project.mk
|
||||
@@ -0,0 +1,56 @@
|
||||
# oscope-sphere
|
||||
|
||||
A minimal openFrameworks visualiser: it captures both channels of a
|
||||
Hantek 6022 oscilloscope and renders them as a single 3D sphere.
|
||||
|
||||
The sphere is split at the equator — **northern hemisphere = CH1,
|
||||
southern hemisphere = CH2** — across three composable layers:
|
||||
|
||||
- **Layer A** — the sphere itself: its skin is a scrolling spectrogram
|
||||
(latitude = log frequency, longitude = time) and its geometry is
|
||||
displaced radially by the live waveform.
|
||||
- **Layer B** — two waveform rings orbiting the sphere in perpendicular
|
||||
planes, one per channel.
|
||||
- **Layer C** — the sphere rendered as a point cloud.
|
||||
|
||||
It reuses the capture and analysis layer of the sibling `oscope-of`
|
||||
project.
|
||||
|
||||
## Build and run
|
||||
|
||||
```bash
|
||||
cd oscope-sphere
|
||||
make && make Run
|
||||
```
|
||||
|
||||
Requires openFrameworks (GL 3.2 core) and libusb-1.0. Without a scope
|
||||
connected the app runs in demo mode on a synthetic signal.
|
||||
|
||||
## Controls
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `1` / `2` / `3` | toggle layers A / B / C |
|
||||
| `c` | cycle colormap (magma / viridis) |
|
||||
| `space` | freeze the spectrogram scroll |
|
||||
| mouse drag | orbit the camera |
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
./tests/run_tests.sh
|
||||
```
|
||||
|
||||
Runs the pure-C++ unit tests (FFT, per-channel split, spectrogram
|
||||
buffer, demo signal) — no openFrameworks needed.
|
||||
|
||||
## Hardware note
|
||||
|
||||
24/48 MS/s are single-channel-only on the OpenHantek6022 firmware;
|
||||
because both channels are used, capture runs at 16 MS/s. Only one
|
||||
process may own the scope at a time — close OpenHantek6022 before
|
||||
running this app.
|
||||
|
||||
## License
|
||||
|
||||
GPL-3.0, as part of the AV-Live monorepo.
|
||||
@@ -0,0 +1,67 @@
|
||||
#version 150
|
||||
|
||||
uniform sampler2D spectroTex; // width = time, height = freq, R32F [0,1]
|
||||
uniform float scrollOffset;
|
||||
uniform int colormapId;
|
||||
uniform int renderMode; // 0 = skin, 1 = points, 2 = wireframe
|
||||
uniform vec4 shellTint; // rgb tint + alpha for wireframe / shells
|
||||
|
||||
in vec2 vSphereUV;
|
||||
in vec3 vViewPos;
|
||||
in float vWave;
|
||||
out vec4 fragColor;
|
||||
|
||||
// Polynomial colormap fits (public domain, Matt Zucker).
|
||||
vec3 magma(float t) {
|
||||
const vec3 c0 = vec3(-0.002136485053939,-0.000749655052795,-0.005386127855323);
|
||||
const vec3 c1 = vec3( 0.251660540737164, 0.677523243683767, 2.494026599312351);
|
||||
const vec3 c2 = vec3( 8.353717279216625,-3.577719514958484, 0.314467903013257);
|
||||
const vec3 c3 = vec3(-27.66873308576866, 14.26473078096533,-13.64921318813922);
|
||||
const vec3 c4 = vec3( 52.17613981234068,-27.94360607168351, 12.94416944238394);
|
||||
const vec3 c5 = vec3(-50.76852536473588, 29.04658282127291, 4.234152993845980);
|
||||
const vec3 c6 = vec3( 18.65570506591883,-11.48977351997711,-5.601961508734096);
|
||||
return c0+t*(c1+t*(c2+t*(c3+t*(c4+t*(c5+t*c6)))));
|
||||
}
|
||||
vec3 viridis(float t) {
|
||||
const vec3 c0 = vec3( 0.277727327223418, 0.005407344544967, 0.334099805335306);
|
||||
const vec3 c1 = vec3( 0.105093043108577, 1.404613529898575, 1.384590162594685);
|
||||
const vec3 c2 = vec3(-0.330861828725556, 0.214847559468213, 0.095095163028237);
|
||||
const vec3 c3 = vec3(-4.634230498983486,-5.799100973351585,-19.33244095627987);
|
||||
const vec3 c4 = vec3( 6.228269936347081,14.17993336680509, 56.69055260068105);
|
||||
const vec3 c5 = vec3( 4.776384997670288,-13.74514537774601,-65.35303263337234);
|
||||
const vec3 c6 = vec3(-5.435455855934631, 4.645852612178535, 26.3124352495832);
|
||||
return c0+t*(c1+t*(c2+t*(c3+t*(c4+t*(c5+t*c6)))));
|
||||
}
|
||||
|
||||
void main() {
|
||||
float u = fract(vSphereUV.x - scrollOffset);
|
||||
float mag = clamp(texture(spectroTex, vec2(u, vSphereUV.y)).r, 0.0, 1.0);
|
||||
vec3 col = (colormapId == 0) ? magma(mag) : viridis(mag);
|
||||
|
||||
if (renderMode == 1) {
|
||||
// round point sprites + a touch of waveform sheen
|
||||
vec2 d = gl_PointCoord - vec2(0.5);
|
||||
if (dot(d, d) > 0.25) discard;
|
||||
col += 0.20 * abs(vWave);
|
||||
fragColor = vec4(col, 1.0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (renderMode == 2) {
|
||||
// wireframe / concentric shells: bright lines, tinted per draw
|
||||
vec3 wc = (col * 1.4 + vec3(0.06)) * shellTint.rgb;
|
||||
fragColor = vec4(wc, shellTint.a);
|
||||
return;
|
||||
}
|
||||
|
||||
// skin: light the displaced relief with a screen-space face normal
|
||||
vec3 N = normalize(cross(dFdx(vViewPos), dFdy(vViewPos)));
|
||||
vec3 V = normalize(-vViewPos);
|
||||
if (dot(N, V) < 0.0) N = -N;
|
||||
vec3 L = normalize(vec3(0.45, 0.65, 0.75));
|
||||
float diff = max(dot(N, L), 0.0);
|
||||
float rim = pow(1.0 - max(dot(N, V), 0.0), 2.5);
|
||||
|
||||
vec3 lit = col * (0.35 + 0.85 * diff) + rim * vec3(0.35, 0.45, 0.65);
|
||||
fragColor = vec4(lit, 1.0);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
#version 150
|
||||
|
||||
uniform mat4 modelViewProjectionMatrix;
|
||||
uniform mat4 modelViewMatrix;
|
||||
uniform sampler2D waveformTex;
|
||||
uniform sampler2D spectroTex;
|
||||
uniform float displaceAmount;
|
||||
uniform float spectroAmount;
|
||||
uniform float scrollOffset;
|
||||
uniform float baseRadius;
|
||||
uniform int renderMode; // 0 = skin, 1 = points
|
||||
|
||||
in vec4 position;
|
||||
|
||||
out vec2 vSphereUV;
|
||||
out vec3 vViewPos;
|
||||
out float vWave;
|
||||
|
||||
const float PI = 3.14159265359;
|
||||
|
||||
void main() {
|
||||
vec3 dir = normalize(position.xyz);
|
||||
float lon = atan(dir.z, dir.x) / (2.0 * PI) + 0.5;
|
||||
float lat = asin(clamp(dir.y, -1.0, 1.0)) / PI + 0.5;
|
||||
|
||||
// waveform ripple (per hemisphere) + scrolling spectrogram relief
|
||||
float row = (dir.y >= 0.0) ? 0.25 : 0.75; // CH1 north, CH2 south
|
||||
float wave = texture(waveformTex, vec2(lon, row)).r; // [-1,1]
|
||||
float spec = texture(spectroTex,
|
||||
vec2(fract(lon - scrollOffset), lat)).r; // [0,1]
|
||||
|
||||
float r = baseRadius * (1.0 + displaceAmount * wave
|
||||
+ spectroAmount * spec);
|
||||
if (renderMode == 1) {
|
||||
r *= 1.06; // float the point cloud outside the skin
|
||||
gl_PointSize = 6.0;
|
||||
} else if (renderMode == 2) {
|
||||
r *= 1.01; // wireframe sits just above the lit skin
|
||||
}
|
||||
|
||||
vec4 viewPos = modelViewMatrix * vec4(dir * r, 1.0);
|
||||
gl_Position = modelViewProjectionMatrix * vec4(dir * r, 1.0);
|
||||
vViewPos = viewPos.xyz;
|
||||
vSphereUV = vec2(lon, lat);
|
||||
vWave = wave;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
################################################################################
|
||||
# CONFIGURE PROJECT MAKEFILE (optional)
|
||||
# This file is where we make project specific configurations.
|
||||
################################################################################
|
||||
|
||||
################################################################################
|
||||
# OF ROOT
|
||||
################################################################################
|
||||
OF_ROOT = ../../..
|
||||
|
||||
################################################################################
|
||||
# PROJECT EXCLUSIONS
|
||||
################################################################################
|
||||
# PROJECT_EXCLUSIONS =
|
||||
|
||||
################################################################################
|
||||
# LIBUSB AUTO-DETECT
|
||||
# Préfère pkg-config (Apple Silicon + Intel + Linux), fallback à
|
||||
# `brew --prefix libusb` si pkg-config n'est pas installé.
|
||||
################################################################################
|
||||
HAS_PKGCONFIG := $(shell command -v pkg-config 2>/dev/null)
|
||||
ifneq ($(HAS_PKGCONFIG),)
|
||||
LIBUSB_CFLAGS := $(shell pkg-config --cflags libusb-1.0)
|
||||
LIBUSB_LDFLAGS := $(shell pkg-config --libs libusb-1.0)
|
||||
else
|
||||
HAS_BREW := $(shell command -v brew 2>/dev/null)
|
||||
ifneq ($(HAS_BREW),)
|
||||
LIBUSB_PREFIX := $(shell brew --prefix libusb)
|
||||
LIBUSB_CFLAGS := -I$(LIBUSB_PREFIX)/include/libusb-1.0
|
||||
LIBUSB_LDFLAGS := -L$(LIBUSB_PREFIX)/lib -lusb-1.0
|
||||
else
|
||||
# Fallback générique
|
||||
LIBUSB_CFLAGS := -I/usr/local/include/libusb-1.0 -I/opt/homebrew/include/libusb-1.0
|
||||
LIBUSB_LDFLAGS := -L/usr/local/lib -L/opt/homebrew/lib -lusb-1.0
|
||||
endif
|
||||
endif
|
||||
|
||||
PROJECT_CFLAGS = $(LIBUSB_CFLAGS)
|
||||
PROJECT_LDFLAGS = $(LIBUSB_LDFLAGS)
|
||||
|
||||
################################################################################
|
||||
# PROJECT CPPFLAGS
|
||||
################################################################################
|
||||
PROJECT_CPPFLAGS = -std=c++17
|
||||
|
||||
################################################################################
|
||||
# PROJECT OPTIMIZATION CFLAGS
|
||||
################################################################################
|
||||
# PROJECT_OPTIMIZATION_CFLAGS_RELEASE =
|
||||
# PROJECT_OPTIMIZATION_CFLAGS_DEBUG =
|
||||
@@ -0,0 +1,97 @@
|
||||
#include "AudioAnalyzer.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace oscope {
|
||||
|
||||
AudioAnalyzer::AudioAnalyzer() : fft_(kFftSize) {
|
||||
mono_.assign(kFftSize, 0.0f);
|
||||
}
|
||||
|
||||
void AudioAnalyzer::update(const std::vector<float>& ch1,
|
||||
const std::vector<float>& ch2,
|
||||
float scopeSampleRateHz) {
|
||||
if (ch1.size() < 16 || scopeSampleRateHz < 1.0f) {
|
||||
// Décay doux pour ne pas figer la valeur précédente
|
||||
bands_.bass *= 0.92f;
|
||||
bands_.lowMid *= 0.92f;
|
||||
bands_.mid *= 0.92f;
|
||||
bands_.treble *= 0.92f;
|
||||
bands_.kick *= 0.85f;
|
||||
bands_.snare *= 0.85f;
|
||||
bands_.full *= 0.92f;
|
||||
return;
|
||||
}
|
||||
|
||||
// Downsampling par moyenne (box filter) du SR scope vers ~48 kHz audio.
|
||||
// Décimation = scopeSr / 48000, arrondi entier ≥ 1.
|
||||
const std::size_t deci =
|
||||
static_cast<std::size_t>(std::max(1.0f, scopeSampleRateHz / kAudioSr));
|
||||
const std::size_t n = std::min(ch1.size(), ch2.size());
|
||||
|
||||
for (std::size_t i = 0; i + deci <= n; i += deci) {
|
||||
float acc = 0.0f;
|
||||
for (std::size_t j = 0; j < deci; ++j) {
|
||||
acc += 0.5f * (ch1[i + j] + ch2[i + j]);
|
||||
}
|
||||
mono_[head_] = acc / static_cast<float>(deci);
|
||||
head_ = (head_ + 1) % mono_.size();
|
||||
}
|
||||
|
||||
// Window Hann + FFT — on linéarise mono_ depuis head_.
|
||||
std::vector<float> win(kFftSize);
|
||||
for (std::size_t i = 0; i < kFftSize; ++i) {
|
||||
const std::size_t idx = (head_ + i) % kFftSize;
|
||||
const float w = 0.5f * (1.0f - std::cos(2.0f * 3.14159265f * i /
|
||||
static_cast<float>(kFftSize - 1)));
|
||||
win[i] = mono_[idx] * w;
|
||||
}
|
||||
fft_.magnitude(win, mag_);
|
||||
|
||||
// Bin width = audioSr / fftSize. Avec kAudioSr=48k, kFftSize=1024 →
|
||||
// ~46.9 Hz par bin.
|
||||
const float binHz = kAudioSr / static_cast<float>(kFftSize);
|
||||
auto sumBand = [&](float lo, float hi) -> float {
|
||||
const std::size_t i0 = static_cast<std::size_t>(lo / binHz);
|
||||
const std::size_t i1 = std::min(mag_.size(),
|
||||
static_cast<std::size_t>(hi / binHz) + 1);
|
||||
if (i1 <= i0) return 0.0f;
|
||||
float s = 0.0f;
|
||||
for (std::size_t i = i0; i < i1; ++i) s += mag_[i];
|
||||
return s / static_cast<float>(i1 - i0);
|
||||
};
|
||||
|
||||
// Bandes en log-power, normalisées 0..1 par un mapping doux.
|
||||
auto db01 = [](float v) {
|
||||
const float db = 20.0f * std::log10(std::max(v, 1e-6f));
|
||||
// -60 dB → 0, 0 dB → 1
|
||||
return std::max(0.0f, std::min(1.0f, (db + 60.0f) / 60.0f));
|
||||
};
|
||||
|
||||
const float prevBass = bands_.bass;
|
||||
const float prevMid = bands_.mid;
|
||||
|
||||
bands_.bass = db01(sumBand(20.0f, 200.0f));
|
||||
bands_.lowMid = db01(sumBand(200.0f, 800.0f));
|
||||
bands_.mid = db01(sumBand(800.0f, 3200.0f));
|
||||
bands_.treble = db01(sumBand(3200.0f, 16000.0f));
|
||||
|
||||
// Transitoire = différence positive lissée. Détecte un kick = montée
|
||||
// brusque sur la bande basse, snare = montée sur mid + treble.
|
||||
const float kickRise = std::max(0.0f, bands_.bass - prevBass) * 1.6f;
|
||||
const float snareRise = std::max(0.0f,
|
||||
(bands_.mid + bands_.treble) * 0.5f - prevMid) * 1.6f;
|
||||
bands_.kick = std::max(bands_.kick * 0.85f, kickRise);
|
||||
bands_.snare = std::max(bands_.snare * 0.85f, snareRise);
|
||||
|
||||
// Full RMS
|
||||
float rms = 0.0f;
|
||||
for (auto v : mono_) rms += v * v;
|
||||
bands_.full = std::sqrt(rms / mono_.size());
|
||||
|
||||
prevBass_ = prevBass;
|
||||
prevMid_ = prevMid;
|
||||
}
|
||||
|
||||
} // namespace oscope
|
||||
@@ -0,0 +1,53 @@
|
||||
#pragma once
|
||||
|
||||
// Extracteur de bandes audio depuis le signal Hantek brut.
|
||||
// Downsample (8-48 MS/s scope → ~48 kHz audio) puis FFT pour obtenir
|
||||
// bass / lowMid / mid / treble + détecteurs de transitoire kick/snare.
|
||||
// Source d'inspiration : besoin de piloter les visualizers sur LE signal
|
||||
// qui passe physiquement dans le scope, pas sur les métadonnées OSC.
|
||||
|
||||
#include "FFT.h"
|
||||
|
||||
#include <vector>
|
||||
|
||||
namespace oscope {
|
||||
|
||||
struct AudioBands {
|
||||
float bass = 0.0f; // 20–200 Hz
|
||||
float lowMid = 0.0f; // 200–800 Hz
|
||||
float mid = 0.0f; // 800–3200 Hz
|
||||
float treble = 0.0f; // 3200 Hz+
|
||||
float kick = 0.0f; // transient sur bass
|
||||
float snare = 0.0f; // transient sur mid+treble
|
||||
float full = 0.0f; // RMS global
|
||||
};
|
||||
|
||||
class AudioAnalyzer {
|
||||
public:
|
||||
AudioAnalyzer();
|
||||
/// Alimenter avec les samples Hantek bruts + sample rate scope.
|
||||
void update(const std::vector<float>& ch1,
|
||||
const std::vector<float>& ch2,
|
||||
float scopeSampleRateHz);
|
||||
const AudioBands& bands() const { return bands_; }
|
||||
/// Buffer mono downsamplé vers ~48 kHz (avant FFT). Ring de kFftSize.
|
||||
const std::vector<float>& monoDown() const { return mono_; }
|
||||
/// Magnitudes FFT sur monoDown (taille = kFftSize/2). Bin width ≈ 47 Hz.
|
||||
const std::vector<float>& magDown() const { return mag_; }
|
||||
std::size_t monoHead() const { return head_; }
|
||||
static constexpr float audioSr() { return kAudioSr; }
|
||||
|
||||
private:
|
||||
static constexpr std::size_t kFftSize = 2048; // 23.4 Hz/bin a 48k
|
||||
static constexpr float kAudioSr = 48000.0f;
|
||||
|
||||
FFT fft_;
|
||||
std::vector<float> mono_; // ring downsamplé, kFftSize floats
|
||||
std::size_t head_ = 0;
|
||||
std::vector<float> mag_;
|
||||
AudioBands bands_;
|
||||
float prevBass_ = 0.0f;
|
||||
float prevMid_ = 0.0f;
|
||||
};
|
||||
|
||||
} // namespace oscope
|
||||
@@ -0,0 +1,41 @@
|
||||
#include "DemoSignal.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace oscope {
|
||||
|
||||
namespace {
|
||||
constexpr double kTwoPi = 6.283185307179586;
|
||||
}
|
||||
|
||||
DemoSignal::DemoSignal(float sampleRateHz)
|
||||
: sr_(sampleRateHz > 1.0f ? sampleRateHz : 48000.0f) {}
|
||||
|
||||
float DemoSignal::frand() {
|
||||
rng_ = rng_ * 1664525u + 1013904223u;
|
||||
return (static_cast<float>(rng_ >> 8) / 8388608.0f) - 1.0f; // [-1,1)
|
||||
}
|
||||
|
||||
void DemoSignal::next(std::vector<float>& ch1, std::vector<float>& ch2,
|
||||
std::size_t n) {
|
||||
ch1.resize(n);
|
||||
ch2.resize(n);
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
// CH1: sweep 80 Hz .. 2000 Hz, sweep period ~6 s.
|
||||
sweep_ += 1.0 / sr_;
|
||||
const double sweepHz =
|
||||
80.0 + 960.0 * (1.0 + std::sin(kTwoPi * sweep_ / 6.0));
|
||||
phase1_ += kTwoPi * sweepHz / sr_;
|
||||
ch1[i] = 0.85f * static_cast<float>(std::sin(phase1_));
|
||||
|
||||
// CH2: steady 440 Hz tone + light noise.
|
||||
phase2_ += kTwoPi * 440.0 / sr_;
|
||||
const float v =
|
||||
0.7f * static_cast<float>(std::sin(phase2_)) + 0.15f * frand();
|
||||
ch2[i] = std::clamp(v, -1.0f, 1.0f);
|
||||
}
|
||||
if (phase1_ > kTwoPi * 1e6) phase1_ -= kTwoPi * 1e6;
|
||||
if (phase2_ > kTwoPi * 1e6) phase2_ -= kTwoPi * 1e6;
|
||||
}
|
||||
|
||||
} // namespace oscope
|
||||
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
namespace oscope {
|
||||
|
||||
// Pure-C++ synthetic two-channel signal for when no scope is connected.
|
||||
// CH1: slow frequency sweep. CH2: steady 440 Hz tone plus light noise.
|
||||
// Stateful: phase advances across calls so the output stays continuous.
|
||||
class DemoSignal {
|
||||
public:
|
||||
explicit DemoSignal(float sampleRateHz);
|
||||
|
||||
// Fills ch1 and ch2 with n freshly generated samples each.
|
||||
void next(std::vector<float>& ch1, std::vector<float>& ch2,
|
||||
std::size_t n);
|
||||
|
||||
private:
|
||||
float frand(); // cheap LCG noise in [-1, 1)
|
||||
|
||||
float sr_;
|
||||
double phase1_ = 0.0;
|
||||
double phase2_ = 0.0;
|
||||
double sweep_ = 0.0;
|
||||
uint32_t rng_ = 0x9E3779B9u;
|
||||
};
|
||||
|
||||
} // namespace oscope
|
||||
@@ -0,0 +1,60 @@
|
||||
#include "FFT.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace oscope {
|
||||
|
||||
FFT::FFT(std::size_t size) : size_(size), window_(size), work_(size) {
|
||||
// Fenêtre de Hann pré-calculée.
|
||||
for (std::size_t i = 0; i < size_; ++i) {
|
||||
window_[i] = 0.5f * (1.0f - std::cos(2.0f * M_PI * i / (size_ - 1)));
|
||||
}
|
||||
}
|
||||
|
||||
void FFT::hannWindow(std::vector<float>& buf) const {
|
||||
for (std::size_t i = 0; i < size_; ++i) buf[i] *= window_[i];
|
||||
}
|
||||
|
||||
void FFT::fftInPlace(std::vector<std::complex<float>>& x) const {
|
||||
const std::size_t N = x.size();
|
||||
// Bit-reversal permutation.
|
||||
std::size_t j = 0;
|
||||
for (std::size_t i = 1; i < N; ++i) {
|
||||
std::size_t bit = N >> 1;
|
||||
for (; j & bit; bit >>= 1) j ^= bit;
|
||||
j ^= bit;
|
||||
if (i < j) std::swap(x[i], x[j]);
|
||||
}
|
||||
// Butterflies.
|
||||
for (std::size_t len = 2; len <= N; len <<= 1) {
|
||||
const float ang = -2.0f * M_PI / static_cast<float>(len);
|
||||
const std::complex<float> wlen(std::cos(ang), std::sin(ang));
|
||||
for (std::size_t i = 0; i < N; i += len) {
|
||||
std::complex<float> w(1.0f, 0.0f);
|
||||
for (std::size_t k = 0; k < len / 2; ++k) {
|
||||
const auto u = x[i + k];
|
||||
const auto v = x[i + k + len / 2] * w;
|
||||
x[i + k] = u + v;
|
||||
x[i + k + len / 2] = u - v;
|
||||
w *= wlen;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void FFT::magnitude(const std::vector<float>& in, std::vector<float>& outMag) {
|
||||
const std::size_t N = size_;
|
||||
std::vector<float> windowed(N, 0.0f);
|
||||
const std::size_t copy = std::min(N, in.size());
|
||||
for (std::size_t i = 0; i < copy; ++i) windowed[i] = in[i];
|
||||
hannWindow(windowed);
|
||||
for (std::size_t i = 0; i < N; ++i) work_[i] = std::complex<float>(windowed[i], 0.0f);
|
||||
fftInPlace(work_);
|
||||
outMag.resize(N / 2);
|
||||
const float invN = 1.0f / static_cast<float>(N);
|
||||
for (std::size_t i = 0; i < N / 2; ++i) {
|
||||
outMag[i] = std::abs(work_[i]) * invN;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace oscope
|
||||
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
// FFT Cooley-Tukey radix-2 in-place, sans dépendance externe.
|
||||
// Format : entrée temporelle réelle (N samples, N puissance de 2),
|
||||
// sortie magnitude (N/2 bins, dernier sample = Nyquist).
|
||||
|
||||
#include <complex>
|
||||
#include <cstddef>
|
||||
#include <vector>
|
||||
|
||||
namespace oscope {
|
||||
|
||||
class FFT {
|
||||
public:
|
||||
explicit FFT(std::size_t size);
|
||||
|
||||
/// Calcule la FFT du signal `in` (taille = size_) et écrit les magnitudes
|
||||
/// dans `outMag` (taille = size_/2).
|
||||
void magnitude(const std::vector<float>& in, std::vector<float>& outMag);
|
||||
|
||||
std::size_t size() const { return size_; }
|
||||
|
||||
private:
|
||||
void hannWindow(std::vector<float>& buf) const;
|
||||
void fftInPlace(std::vector<std::complex<float>>& x) const;
|
||||
|
||||
std::size_t size_;
|
||||
std::vector<float> window_;
|
||||
std::vector<std::complex<float>> work_;
|
||||
};
|
||||
|
||||
} // namespace oscope
|
||||
@@ -0,0 +1,284 @@
|
||||
#include "HantekDevice.h"
|
||||
|
||||
#include <libusb.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "ofLog.h"
|
||||
|
||||
namespace oscope {
|
||||
|
||||
namespace {
|
||||
|
||||
// Identifiants USB du Hantek 6022BL.
|
||||
// 0x04B5 / 0x6022 : appareil avec firmware OEM, ou aucun firmware (énumération
|
||||
// minimale, pas de bulk endpoint actif).
|
||||
// 0x04B5 / 0x602A : appareil avec firmware OpenHantek6022 chargé.
|
||||
constexpr uint16_t kVendorId = 0x04B5;
|
||||
constexpr uint16_t kProductIdRaw = 0x6022;
|
||||
constexpr uint16_t kProductIdFirmware = 0x602A;
|
||||
|
||||
constexpr int kInterface = 0;
|
||||
constexpr int kAltSetting = 0; // alt 0 = bulk EP 0x86 (firmware OpenHantek6022)
|
||||
constexpr unsigned char kEpIn = 0x86;
|
||||
|
||||
// Vendor requests OpenHantek6022.
|
||||
constexpr uint8_t kReqSetSampleRate = 0xE2;
|
||||
constexpr uint8_t kReqSetCh1Gain = 0xE0;
|
||||
constexpr uint8_t kReqSetCh2Gain = 0xE1;
|
||||
constexpr uint8_t kReqSetNumChannels= 0xE4;
|
||||
|
||||
// Codes de gain (registre du FX2).
|
||||
uint8_t gainCode(HantekGain g) {
|
||||
switch (g) {
|
||||
case HantekGain::G_5V: return 0x01;
|
||||
case HantekGain::G_2_5V: return 0x02;
|
||||
case HantekGain::G_1V: return 0x05;
|
||||
case HantekGain::G_500mV: return 0x0a;
|
||||
case HantekGain::G_250mV: return 0x14;
|
||||
}
|
||||
return 0x05;
|
||||
}
|
||||
|
||||
// Code de sample rate (cf. firmware OpenHantek6022).
|
||||
uint8_t sampleRateCode(uint32_t hz) {
|
||||
if (hz >= 48000000) return 48;
|
||||
if (hz >= 24000000) return 30; // dual-channel max ~16 MS/s, 30=24M single
|
||||
if (hz >= 16000000) return 16;
|
||||
if (hz >= 8000000) return 8;
|
||||
if (hz >= 4000000) return 4;
|
||||
if (hz >= 2000000) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
constexpr int kBulkTimeoutMs = 200;
|
||||
constexpr int kBulkXferBytes = 8192; // 4096 samples par canal (2 canaux entrelacés)
|
||||
|
||||
} // namespace
|
||||
|
||||
HantekDevice::HantekDevice()
|
||||
: ctx_(nullptr), handle_(nullptr), running_(false),
|
||||
status_(HantekStatus::NotFound),
|
||||
sampleRateHz_(8000000),
|
||||
gainCh1_(static_cast<int>(HantekGain::G_1V)),
|
||||
gainCh2_(static_cast<int>(HantekGain::G_1V)) {}
|
||||
|
||||
HantekDevice::~HantekDevice() {
|
||||
stop();
|
||||
}
|
||||
|
||||
HantekStatus HantekDevice::start() {
|
||||
if (running_.load()) {
|
||||
status_.store(HantekStatus::AlreadyRunning);
|
||||
return HantekStatus::AlreadyRunning;
|
||||
}
|
||||
|
||||
int rc = libusb_init(&ctx_);
|
||||
if (rc != 0) {
|
||||
ofLogError("HantekDevice") << "libusb_init failed: " << libusb_error_name(rc);
|
||||
status_.store(HantekStatus::UsbError);
|
||||
return HantekStatus::UsbError;
|
||||
}
|
||||
|
||||
// Recherche prioritaire du device avec firmware chargé.
|
||||
handle_ = libusb_open_device_with_vid_pid(ctx_, kVendorId, kProductIdFirmware);
|
||||
if (handle_ == nullptr) {
|
||||
// Fallback : device sans firmware.
|
||||
handle_ = libusb_open_device_with_vid_pid(ctx_, kVendorId, kProductIdRaw);
|
||||
if (handle_ != nullptr) {
|
||||
ofLogWarning("HantekDevice")
|
||||
<< "Hantek 6022BL trouvé MAIS firmware non chargé (PID 0x6022). "
|
||||
<< "Charger le firmware OpenHantek6022 via fxload, puis relancer. "
|
||||
<< "Voir docs/HANTEK_SETUP.md.";
|
||||
libusb_close(handle_);
|
||||
handle_ = nullptr;
|
||||
libusb_exit(ctx_);
|
||||
ctx_ = nullptr;
|
||||
status_.store(HantekStatus::FirmwareNeeded);
|
||||
return HantekStatus::FirmwareNeeded;
|
||||
}
|
||||
ofLogError("HantekDevice") << "Aucun Hantek 6022BL détecté.";
|
||||
libusb_exit(ctx_);
|
||||
ctx_ = nullptr;
|
||||
status_.store(HantekStatus::NotFound);
|
||||
return HantekStatus::NotFound;
|
||||
}
|
||||
|
||||
// Sur macOS, AppleUSBHostLegacyClient claim l'interface 0 par defaut
|
||||
// pour les devices vendor-specific. On force un re-configure (0->1) pour
|
||||
// detacher le client legacy, puis reset, puis claim.
|
||||
libusb_set_auto_detach_kernel_driver(handle_, 1);
|
||||
(void)libusb_detach_kernel_driver(handle_, kInterface);
|
||||
(void)libusb_set_configuration(handle_, 0);
|
||||
|
||||
(void)libusb_set_configuration(handle_, 1);
|
||||
|
||||
rc = libusb_claim_interface(handle_, kInterface);
|
||||
if (rc != 0) {
|
||||
ofLogError("HantekDevice") << "claim_interface failed: " << libusb_error_name(rc);
|
||||
libusb_close(handle_);
|
||||
handle_ = nullptr;
|
||||
libusb_exit(ctx_);
|
||||
ctx_ = nullptr;
|
||||
status_.store(HantekStatus::InterfaceClaimFailed);
|
||||
return HantekStatus::InterfaceClaimFailed;
|
||||
}
|
||||
|
||||
rc = libusb_set_interface_alt_setting(handle_, kInterface, kAltSetting);
|
||||
ofLogNotice("HantekDevice") << "set_alt_setting(" << kInterface << ", " << kAltSetting
|
||||
<< ") = " << rc << " (" << libusb_error_name(rc) << ")";
|
||||
if (rc != 0) {
|
||||
ofLogWarning("HantekDevice") << "alt_setting failed (continuing)";
|
||||
}
|
||||
libusb_clear_halt(handle_, kEpIn);
|
||||
|
||||
if (!configureDevice()) {
|
||||
libusb_release_interface(handle_, kInterface);
|
||||
libusb_close(handle_);
|
||||
handle_ = nullptr;
|
||||
libusb_exit(ctx_);
|
||||
ctx_ = nullptr;
|
||||
status_.store(HantekStatus::UsbError);
|
||||
return HantekStatus::UsbError;
|
||||
}
|
||||
|
||||
running_.store(true);
|
||||
status_.store(HantekStatus::Ok);
|
||||
worker_ = std::thread([this] { streamLoop(); });
|
||||
return HantekStatus::Ok;
|
||||
}
|
||||
|
||||
void HantekDevice::stop() {
|
||||
running_.store(false);
|
||||
if (worker_.joinable()) worker_.join();
|
||||
if (handle_ != nullptr) {
|
||||
libusb_release_interface(handle_, kInterface);
|
||||
libusb_close(handle_);
|
||||
handle_ = nullptr;
|
||||
}
|
||||
if (ctx_ != nullptr) {
|
||||
libusb_exit(ctx_);
|
||||
ctx_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
void HantekDevice::setSampleRate(uint32_t hz) {
|
||||
sampleRateHz_.store(hz);
|
||||
if (handle_ != nullptr && running_.load()) {
|
||||
// Le FX2 (firmware OpenHantek6022) attend la séquence : stop (0xE3=0)
|
||||
// → set sample rate (0xE2) → start (0xE3=1). Sans le re-start, le
|
||||
// bulk endpoint cesse d'émettre et le stream se fige.
|
||||
const uint8_t stopTrig = 0x00;
|
||||
const uint8_t code = sampleRateCode(hz);
|
||||
const uint8_t startTrig = 0x01;
|
||||
sendVendorControl(0xE3, 0, 0, &stopTrig, 1);
|
||||
sendVendorControl(kReqSetSampleRate, 0, 0, &code, 1);
|
||||
sendVendorControl(0xE3, 0, 0, &startTrig, 1);
|
||||
}
|
||||
}
|
||||
|
||||
void HantekDevice::setGain(int channel, HantekGain gain) {
|
||||
const uint8_t code = gainCode(gain);
|
||||
if (channel == 1) {
|
||||
gainCh1_.store(static_cast<int>(gain));
|
||||
if (handle_) sendVendorControl(kReqSetCh1Gain, 0, 0, &code, 1);
|
||||
} else if (channel == 2) {
|
||||
gainCh2_.store(static_cast<int>(gain));
|
||||
if (handle_) sendVendorControl(kReqSetCh2Gain, 0, 0, &code, 1);
|
||||
}
|
||||
}
|
||||
|
||||
bool HantekDevice::sendVendorControl(uint8_t request, uint16_t value,
|
||||
uint16_t index, const uint8_t* data,
|
||||
uint16_t length) {
|
||||
const uint8_t bmRequestType =
|
||||
LIBUSB_REQUEST_TYPE_VENDOR | LIBUSB_RECIPIENT_DEVICE | LIBUSB_ENDPOINT_OUT;
|
||||
int rc = libusb_control_transfer(handle_, bmRequestType, request, value, index,
|
||||
const_cast<uint8_t*>(data), length, 1000);
|
||||
if (rc < 0) {
|
||||
ofLogError("HantekDevice") << "control_transfer 0x" << std::hex << int(request)
|
||||
<< " failed: " << libusb_error_name(rc);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool HantekDevice::configureDevice() {
|
||||
const uint8_t numChannels = 2;
|
||||
if (!sendVendorControl(kReqSetNumChannels, 0, 0, &numChannels, 1)) return false;
|
||||
const uint8_t srCode = sampleRateCode(sampleRateHz_.load());
|
||||
if (!sendVendorControl(kReqSetSampleRate, 0, 0, &srCode, 1)) return false;
|
||||
const uint8_t g1 = gainCode(static_cast<HantekGain>(gainCh1_.load()));
|
||||
if (!sendVendorControl(kReqSetCh1Gain, 0, 0, &g1, 1)) return false;
|
||||
const uint8_t g2 = gainCode(static_cast<HantekGain>(gainCh2_.load()));
|
||||
if (!sendVendorControl(kReqSetCh2Gain, 0, 0, &g2, 1)) return false;
|
||||
// E3 = trigger / start sampling (firmware fx2lafw / OpenHantek6022)
|
||||
const uint8_t startTrig = 0x01;
|
||||
if (!sendVendorControl(0xE3, 0, 0, &startTrig, 1)) {
|
||||
ofLogWarning("HantekDevice") << "vendor 0xE3 (start) failed (continuing)";
|
||||
}
|
||||
ofLogNotice("HantekDevice") << "configureDevice: numCh=2 sr=" << (int)srCode
|
||||
<< " g1=" << (int)g1 << " g2=" << (int)g2 << " trig=0x01";
|
||||
return true;
|
||||
}
|
||||
|
||||
void HantekDevice::streamLoop() {
|
||||
std::vector<uint8_t> raw(kBulkXferBytes);
|
||||
std::vector<float> ch1, ch2;
|
||||
ch1.reserve(kBulkXferBytes / 2);
|
||||
ch2.reserve(kBulkXferBytes / 2);
|
||||
|
||||
while (running_.load()) {
|
||||
int actual = 0;
|
||||
int rc = libusb_bulk_transfer(handle_, kEpIn, raw.data(),
|
||||
static_cast<int>(raw.size()),
|
||||
&actual, kBulkTimeoutMs);
|
||||
if (rc == LIBUSB_ERROR_TIMEOUT) {
|
||||
continue;
|
||||
}
|
||||
if (rc != 0) {
|
||||
ofLogWarning("HantekDevice") << "bulk_transfer: " << libusb_error_name(rc);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
||||
continue;
|
||||
}
|
||||
|
||||
static std::atomic<uint64_t> totalBytes{0};
|
||||
static auto lastLog = std::chrono::steady_clock::now();
|
||||
totalBytes += actual;
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
if (std::chrono::duration_cast<std::chrono::seconds>(now - lastLog).count() >= 2) {
|
||||
ofLogNotice("HantekDevice") << "stream " << totalBytes.load() << " bytes received";
|
||||
lastLog = now;
|
||||
}
|
||||
|
||||
// Format OpenHantek6022 : octets entrelacés [CH1, CH2, CH1, CH2, ...].
|
||||
// Chaque échantillon est un uint8_t centré autour de 128 (offset binary).
|
||||
const std::size_t pairs = static_cast<std::size_t>(actual) / 2;
|
||||
ch1.resize(pairs);
|
||||
ch2.resize(pairs);
|
||||
for (std::size_t i = 0; i < pairs; ++i) {
|
||||
const uint8_t a = raw[2 * i];
|
||||
const uint8_t b = raw[2 * i + 1];
|
||||
ch1[i] = (static_cast<float>(a) - 128.0f) / 128.0f;
|
||||
ch2[i] = (static_cast<float>(b) - 128.0f) / 128.0f;
|
||||
}
|
||||
ring_.push(ch1.data(), ch2.data(), pairs);
|
||||
}
|
||||
}
|
||||
|
||||
std::string HantekDevice::statusString() const {
|
||||
switch (status_.load()) {
|
||||
case HantekStatus::Ok: return "OK";
|
||||
case HantekStatus::NotFound: return "Aucun Hantek 6022BL detecte";
|
||||
case HantekStatus::FirmwareNeeded: return "Firmware Cypress non charge (voir docs/HANTEK_SETUP.md)";
|
||||
case HantekStatus::OpenFailed: return "libusb_open echec";
|
||||
case HantekStatus::InterfaceClaimFailed: return "claim_interface echec";
|
||||
case HantekStatus::AlreadyRunning: return "deja en cours";
|
||||
case HantekStatus::UsbError: return "erreur USB";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
} // namespace oscope
|
||||
@@ -0,0 +1,99 @@
|
||||
#pragma once
|
||||
|
||||
// Wrapper libusb-1.0 pour l'oscilloscope Hantek 6022BL (Cypress FX2-based).
|
||||
//
|
||||
// Références protocolaires :
|
||||
// - https://github.com/OpenHantek/OpenHantek6022 (code firmware open-source
|
||||
// Cypress FX2 + commandes vendor)
|
||||
// - VID 0x04B5 / PID 0x6022 (firmware non chargé) ou 0x602A (variante)
|
||||
// - Endpoint bulk IN 0x86 sur l'interface 0, alt setting 1
|
||||
//
|
||||
// Important : le 6022BL démarre en "device générique" tant que le firmware
|
||||
// Cypress n'est pas uploadé. Cette classe détecte ce cas et retourne
|
||||
// Status::FirmwareNeeded au lieu de tenter un upload (le user doit utiliser
|
||||
// fxload externe — voir docs/HANTEK_SETUP.md).
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#include "ScopeData.h"
|
||||
|
||||
struct libusb_context;
|
||||
struct libusb_device_handle;
|
||||
|
||||
namespace oscope {
|
||||
|
||||
enum class HantekStatus {
|
||||
Ok,
|
||||
NotFound,
|
||||
FirmwareNeeded,
|
||||
OpenFailed,
|
||||
InterfaceClaimFailed,
|
||||
AlreadyRunning,
|
||||
UsbError
|
||||
};
|
||||
|
||||
enum class HantekGain {
|
||||
G_5V = 0, ///< +/- 5 V (0x01)
|
||||
G_2_5V = 1, ///< +/- 2.5 V (0x02)
|
||||
G_1V = 2, ///< +/- 1 V (0x05)
|
||||
G_500mV = 3, ///< +/- 500 mV (0x0a)
|
||||
G_250mV = 4 ///< +/- 250 mV (0x14)
|
||||
};
|
||||
|
||||
class HantekDevice {
|
||||
public:
|
||||
HantekDevice();
|
||||
~HantekDevice();
|
||||
|
||||
HantekDevice(const HantekDevice&) = delete;
|
||||
HantekDevice& operator=(const HantekDevice&) = delete;
|
||||
|
||||
/// Ouvre le device, claim l'interface, configure sample rate / gains.
|
||||
/// Démarre le thread bulk-transfer et alimente le ringbuffer.
|
||||
HantekStatus start();
|
||||
|
||||
/// Stoppe le thread, libère l'interface, ferme libusb.
|
||||
void stop();
|
||||
|
||||
/// Sample rate desired (Hz). Codes valides : 1e6, 2e6, 4e6, 8e6, 16e6,
|
||||
/// 24e6, 48e6 (24 et 48 MS/s ne sont disponibles qu'avec un seul canal
|
||||
/// actif sur le firmware OpenHantek6022).
|
||||
void setSampleRate(uint32_t hz);
|
||||
|
||||
/// Gain par canal (1 ou 2).
|
||||
void setGain(int channel, HantekGain gain);
|
||||
|
||||
/// Accesseur vers le ringbuffer partagé.
|
||||
ScopeRing& ring() { return ring_; }
|
||||
|
||||
/// Statut courant (Ok ou dernier code d'erreur).
|
||||
HantekStatus status() const { return status_.load(); }
|
||||
|
||||
/// Description humaine du dernier statut.
|
||||
std::string statusString() const;
|
||||
|
||||
/// Indique si un firmware doit être chargé (renvoyé par start()).
|
||||
bool firmwareNeeded() const { return status_.load() == HantekStatus::FirmwareNeeded; }
|
||||
|
||||
private:
|
||||
void streamLoop();
|
||||
bool sendVendorControl(uint8_t request, uint16_t value, uint16_t index,
|
||||
const uint8_t* data, uint16_t length);
|
||||
bool configureDevice();
|
||||
|
||||
libusb_context* ctx_;
|
||||
libusb_device_handle* handle_;
|
||||
std::thread worker_;
|
||||
std::atomic<bool> running_;
|
||||
std::atomic<HantekStatus> status_;
|
||||
std::atomic<uint32_t> sampleRateHz_;
|
||||
std::atomic<int> gainCh1_;
|
||||
std::atomic<int> gainCh2_;
|
||||
ScopeRing ring_;
|
||||
};
|
||||
|
||||
} // namespace oscope
|
||||
@@ -0,0 +1,50 @@
|
||||
#include "OrbitRings.h"
|
||||
#include <cmath>
|
||||
|
||||
void OrbitRings::setup(int pointsPerRing) {
|
||||
n_ = pointsPerRing;
|
||||
ring1_.clear();
|
||||
ring2_.clear();
|
||||
ring1_.setMode(OF_PRIMITIVE_LINE_LOOP);
|
||||
ring2_.setMode(OF_PRIMITIVE_LINE_LOOP);
|
||||
for (int i = 0; i < n_; ++i) {
|
||||
ring1_.addVertex(glm::vec3(0.0f));
|
||||
ring2_.addVertex(glm::vec3(0.0f));
|
||||
}
|
||||
}
|
||||
|
||||
void OrbitRings::setWaveform(const std::vector<float>& ch1,
|
||||
const std::vector<float>& ch2) {
|
||||
const float twoPi = 6.28318530718f;
|
||||
auto rebuild = [&](ofVboMesh& ring, const std::vector<float>& src,
|
||||
bool xzPlane) {
|
||||
const int n = static_cast<int>(src.size());
|
||||
for (int i = 0; i < n_; ++i) {
|
||||
const float theta = twoPi * static_cast<float>(i) / n_;
|
||||
float s = 0.0f;
|
||||
if (n > 0) {
|
||||
int idx = static_cast<int>(
|
||||
static_cast<long long>(i) * n / n_);
|
||||
if (idx >= n) idx = n - 1;
|
||||
s = src[idx];
|
||||
}
|
||||
const float r = baseRadius_ + amp_ * s;
|
||||
const glm::vec3 p = xzPlane
|
||||
? glm::vec3(r * std::cos(theta), 0.0f, r * std::sin(theta))
|
||||
: glm::vec3(r * std::cos(theta), r * std::sin(theta), 0.0f);
|
||||
ring.setVertex(i, p);
|
||||
}
|
||||
};
|
||||
rebuild(ring1_, ch1, true);
|
||||
rebuild(ring2_, ch2, false);
|
||||
}
|
||||
|
||||
void OrbitRings::draw() {
|
||||
ofPushStyle();
|
||||
ofSetLineWidth(2.0f);
|
||||
ofSetColor(80, 200, 255);
|
||||
ring1_.draw();
|
||||
ofSetColor(255, 140, 80);
|
||||
ring2_.draw();
|
||||
ofPopStyle();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
#pragma once
|
||||
#include "ofMain.h"
|
||||
#include <vector>
|
||||
|
||||
// GL visual unit: two line-loop rings orbiting the sphere in
|
||||
// perpendicular planes. Ring 1 follows CH1, ring 2 follows CH2; each
|
||||
// point's orbit radius is modulated by the live waveform.
|
||||
class OrbitRings {
|
||||
public:
|
||||
void setup(int pointsPerRing);
|
||||
void setWaveform(const std::vector<float>& ch1,
|
||||
const std::vector<float>& ch2);
|
||||
void draw();
|
||||
|
||||
private:
|
||||
int n_ = 0;
|
||||
float baseRadius_ = 300.0f;
|
||||
float amp_ = 70.0f;
|
||||
ofVboMesh ring1_; // CH1, XZ plane
|
||||
ofVboMesh ring2_; // CH2, XY plane
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
#pragma once
|
||||
|
||||
// Structure partagée entre le thread USB Hantek et le thread principal OF.
|
||||
// Ringbuffer SPSC (single-producer / single-consumer) lock-free basé sur
|
||||
// std::atomic. Le producteur est le thread bulk-transfer libusb, le consommateur
|
||||
// est ofApp::update().
|
||||
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
namespace oscope {
|
||||
|
||||
// Capacité du ringbuffer en échantillons par canal. Doit être une puissance de 2
|
||||
// pour permettre le masquage modulo via (idx & (kCapacity - 1)).
|
||||
static constexpr std::size_t kRingCapacity = 1u << 18; // 262144 samples
|
||||
|
||||
/// Buffer SPSC partagé pour les échantillons CH1/CH2 normalisés [-1, 1].
|
||||
class ScopeRing {
|
||||
public:
|
||||
ScopeRing() : write_(0), read_(0) {
|
||||
ch1_.resize(kRingCapacity, 0.0f);
|
||||
ch2_.resize(kRingCapacity, 0.0f);
|
||||
}
|
||||
|
||||
/// Producteur : écrit n échantillons. Si le buffer est plein, écrase
|
||||
/// les plus anciens (overwrite policy, on préfère perdre du passé que
|
||||
/// bloquer le thread USB).
|
||||
void push(const float* ch1, const float* ch2, std::size_t n) {
|
||||
const std::size_t mask = kRingCapacity - 1;
|
||||
std::size_t w = write_.load(std::memory_order_relaxed);
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
ch1_[(w + i) & mask] = ch1[i];
|
||||
ch2_[(w + i) & mask] = ch2[i];
|
||||
}
|
||||
write_.store(w + n, std::memory_order_release);
|
||||
}
|
||||
|
||||
/// Consommateur : lit jusqu'à `nrequested` échantillons les plus récents.
|
||||
/// Retourne le nombre effectivement copié.
|
||||
std::size_t readLatest(std::vector<float>& outCh1,
|
||||
std::vector<float>& outCh2,
|
||||
std::size_t nrequested) {
|
||||
const std::size_t mask = kRingCapacity - 1;
|
||||
const std::size_t w = write_.load(std::memory_order_acquire);
|
||||
const std::size_t available = (w >= nrequested) ? nrequested : w;
|
||||
outCh1.resize(available);
|
||||
outCh2.resize(available);
|
||||
const std::size_t start = w - available;
|
||||
for (std::size_t i = 0; i < available; ++i) {
|
||||
outCh1[i] = ch1_[(start + i) & mask];
|
||||
outCh2[i] = ch2_[(start + i) & mask];
|
||||
}
|
||||
read_.store(w, std::memory_order_release);
|
||||
return available;
|
||||
}
|
||||
|
||||
std::size_t writeIndex() const { return write_.load(std::memory_order_acquire); }
|
||||
|
||||
private:
|
||||
std::vector<float> ch1_;
|
||||
std::vector<float> ch2_;
|
||||
std::atomic<std::size_t> write_;
|
||||
std::atomic<std::size_t> read_;
|
||||
};
|
||||
|
||||
} // namespace oscope
|
||||
@@ -0,0 +1,54 @@
|
||||
#include "SpectrogramBuffer.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace oscope {
|
||||
|
||||
SpectrogramBuffer::SpectrogramBuffer(int width, int height)
|
||||
: width_(std::max(1, width)),
|
||||
height_(std::max(2, height - (height % 2))) {
|
||||
data_.assign(static_cast<std::size_t>(width_) * height_, 0.0f);
|
||||
}
|
||||
|
||||
float SpectrogramBuffer::logResample(const std::vector<float>& mag,
|
||||
float frac01) {
|
||||
if (mag.size() < 2) return 0.0f;
|
||||
const float lo = 1.0f;
|
||||
const float hi = static_cast<float>(mag.size() - 1);
|
||||
const float f = std::clamp(frac01, 0.0f, 1.0f);
|
||||
const float bin = lo * std::pow(hi / lo, f);
|
||||
const int i0 = static_cast<int>(bin);
|
||||
const int i1 = std::min(i0 + 1, static_cast<int>(mag.size()) - 1);
|
||||
const float t = bin - static_cast<float>(i0);
|
||||
return mag[i0] * (1.0f - t) + mag[i1] * t;
|
||||
}
|
||||
|
||||
float SpectrogramBuffer::norm01(float magnitude) {
|
||||
const float db = 20.0f * std::log10(std::max(magnitude, 1e-6f));
|
||||
return std::clamp((db + 60.0f) / 60.0f, 0.0f, 1.0f);
|
||||
}
|
||||
|
||||
void SpectrogramBuffer::pushColumn(const std::vector<float>& magCh1,
|
||||
const std::vector<float>& magCh2) {
|
||||
const int col = writeIndex_;
|
||||
const int half = height_ / 2;
|
||||
const float denom = static_cast<float>(std::max(1, half - 1));
|
||||
|
||||
// CH1 -> northern rows [half, height_): row half = equator (low freq),
|
||||
// row height_-1 = north pole (high freq).
|
||||
for (int r = half; r < height_; ++r) {
|
||||
const float frac = static_cast<float>(r - half) / denom;
|
||||
data_[static_cast<std::size_t>(r) * width_ + col] =
|
||||
norm01(logResample(magCh1, frac));
|
||||
}
|
||||
// CH2 -> southern rows [0, half): row half-1 = equator (low freq),
|
||||
// row 0 = south pole (high freq).
|
||||
for (int r = 0; r < half; ++r) {
|
||||
const float frac = static_cast<float>(half - 1 - r) / denom;
|
||||
data_[static_cast<std::size_t>(r) * width_ + col] =
|
||||
norm01(logResample(magCh2, frac));
|
||||
}
|
||||
writeIndex_ = (writeIndex_ + 1) % width_;
|
||||
}
|
||||
|
||||
} // namespace oscope
|
||||
@@ -0,0 +1,35 @@
|
||||
#pragma once
|
||||
#include <vector>
|
||||
|
||||
namespace oscope {
|
||||
|
||||
// Pure-C++ rolling spectrogram column store. No GL dependency.
|
||||
// Layout: row-major, data()[row * width + col].
|
||||
// Height is forced even. Rows [H/2, H) hold CH1 (equator -> north pole),
|
||||
// rows [0, H/2) hold CH2 (equator -> south pole). Frequency is log-scaled
|
||||
// along the rows; magnitudes are normalised to [0,1] via a -60..0 dB map.
|
||||
class SpectrogramBuffer {
|
||||
public:
|
||||
SpectrogramBuffer(int width, int height);
|
||||
|
||||
// magCh1 / magCh2: raw FFT magnitudes. Log-resampled into one column
|
||||
// at the current write index, which then advances modulo width.
|
||||
void pushColumn(const std::vector<float>& magCh1,
|
||||
const std::vector<float>& magCh2);
|
||||
|
||||
int width() const { return width_; }
|
||||
int height() const { return height_; }
|
||||
int writeIndex() const { return writeIndex_; }
|
||||
const std::vector<float>& data() const { return data_; }
|
||||
|
||||
private:
|
||||
static float logResample(const std::vector<float>& mag, float frac01);
|
||||
static float norm01(float magnitude);
|
||||
|
||||
int width_;
|
||||
int height_;
|
||||
int writeIndex_ = 0;
|
||||
std::vector<float> data_;
|
||||
};
|
||||
|
||||
} // namespace oscope
|
||||
@@ -0,0 +1,139 @@
|
||||
#include "SphereViz.h"
|
||||
#include <algorithm>
|
||||
|
||||
void SphereViz::setup(int icoIterations, int spectroWidth, int spectroHeight,
|
||||
int waveformLen) {
|
||||
waveformLen_ = waveformLen;
|
||||
spectro_ = std::make_unique<oscope::SpectrogramBuffer>(spectroWidth,
|
||||
spectroHeight);
|
||||
|
||||
ofIcoSpherePrimitive ico(baseRadius_, icoIterations);
|
||||
ofMesh src = ico.getMesh();
|
||||
mesh_.clear();
|
||||
mesh_.addVertices(src.getVertices());
|
||||
mesh_.addNormals(src.getNormals());
|
||||
mesh_.addIndices(src.getIndices());
|
||||
|
||||
// A separate GL_POINTS-primitive mesh for layer C. drawVertices() on the
|
||||
// indexed skin mesh does not yield a proper point primitive (gl_PointCoord
|
||||
// ends up degenerate), so the point cloud needs its own points mesh.
|
||||
pointsMesh_.clear();
|
||||
pointsMesh_.setMode(OF_PRIMITIVE_POINTS);
|
||||
pointsMesh_.addVertices(src.getVertices());
|
||||
|
||||
// ofDisableArbTex() is a global, app-wide GL state change: it makes all
|
||||
// textures use normalized [0,1] coordinates. oscope-sphere has a single
|
||||
// SphereViz so this is safe; revisit if other ARB-texture components are
|
||||
// ever added.
|
||||
ofDisableArbTex();
|
||||
spectroPix_.allocate(spectroWidth, spectroHeight, OF_PIXELS_GRAY);
|
||||
spectroPix_.set(0.0f);
|
||||
spectroTex_.allocate(spectroPix_);
|
||||
spectroTex_.setTextureWrap(GL_REPEAT, GL_CLAMP_TO_EDGE);
|
||||
spectroTex_.setTextureMinMagFilter(GL_LINEAR, GL_LINEAR);
|
||||
|
||||
wavePix_.allocate(waveformLen, 2, OF_PIXELS_GRAY);
|
||||
wavePix_.set(0.0f);
|
||||
waveTex_.allocate(wavePix_);
|
||||
waveTex_.setTextureWrap(GL_REPEAT, GL_CLAMP_TO_EDGE);
|
||||
waveTex_.setTextureMinMagFilter(GL_LINEAR, GL_LINEAR);
|
||||
|
||||
if (!shader_.load("shaders/sphere"))
|
||||
ofLogError("SphereViz") << "failed to load shaders/sphere";
|
||||
}
|
||||
|
||||
void SphereViz::pushSpectrogramColumn(const std::vector<float>& magCh1,
|
||||
const std::vector<float>& magCh2) {
|
||||
spectro_->pushColumn(magCh1, magCh2);
|
||||
const std::vector<float>& d = spectro_->data();
|
||||
std::copy(d.begin(), d.end(), spectroPix_.getData());
|
||||
spectroTex_.loadData(spectroPix_);
|
||||
scrollOffset_ = static_cast<float>(spectro_->writeIndex()) /
|
||||
static_cast<float>(spectro_->width());
|
||||
}
|
||||
|
||||
void SphereViz::setWaveform(const std::vector<float>& ch1,
|
||||
const std::vector<float>& ch2) {
|
||||
float* px = wavePix_.getData();
|
||||
const int L = waveformLen_;
|
||||
auto fillRow = [&](const std::vector<float>& src, int row) {
|
||||
const int n = static_cast<int>(src.size());
|
||||
for (int i = 0; i < L; ++i) {
|
||||
float v = 0.0f;
|
||||
if (n > 0) {
|
||||
int idx = n - L + i; // newest L samples
|
||||
if (idx < 0) idx = 0;
|
||||
v = src[idx];
|
||||
}
|
||||
px[row * L + i] = v;
|
||||
}
|
||||
};
|
||||
fillRow(ch1, 0);
|
||||
fillRow(ch2, 1);
|
||||
waveTex_.loadData(wavePix_);
|
||||
}
|
||||
|
||||
void SphereViz::bindUniforms() {
|
||||
shader_.setUniformTexture("spectroTex", spectroTex_, 0);
|
||||
shader_.setUniformTexture("waveformTex", waveTex_, 1);
|
||||
shader_.setUniform1f("scrollOffset", scrollOffset_);
|
||||
shader_.setUniform1f("displaceAmount", displace_);
|
||||
shader_.setUniform1f("spectroAmount", spectroAmount_);
|
||||
shader_.setUniform1f("baseRadius", baseRadius_);
|
||||
shader_.setUniform1i("colormapId", colormapId_);
|
||||
}
|
||||
|
||||
void SphereViz::drawSkin() {
|
||||
shader_.begin();
|
||||
bindUniforms();
|
||||
shader_.setUniform1i("renderMode", 0);
|
||||
mesh_.draw();
|
||||
shader_.end();
|
||||
}
|
||||
|
||||
void SphereViz::drawPoints() {
|
||||
shader_.begin();
|
||||
bindUniforms();
|
||||
shader_.setUniform1i("renderMode", 1);
|
||||
pointsMesh_.draw();
|
||||
shader_.end();
|
||||
}
|
||||
|
||||
void SphereViz::drawWireMesh(const ofFloatColor& tint) {
|
||||
shader_.begin();
|
||||
bindUniforms();
|
||||
shader_.setUniform1i("renderMode", 2);
|
||||
shader_.setUniform4f("shellTint", tint.r, tint.g, tint.b, tint.a);
|
||||
mesh_.drawWireframe();
|
||||
shader_.end();
|
||||
}
|
||||
|
||||
void SphereViz::drawWireframe() {
|
||||
drawWireMesh(ofFloatColor(1.0f, 1.0f, 1.0f, 1.0f));
|
||||
}
|
||||
|
||||
void SphereViz::drawShells(float t, float bass, float kick) {
|
||||
ofDisableDepthTest();
|
||||
ofEnableBlendMode(OF_BLENDMODE_ADD);
|
||||
|
||||
// outer shell: larger, counter-spinning, blue, breathes with bass
|
||||
ofPushMatrix();
|
||||
ofRotateYDeg(-t * 23.0f);
|
||||
ofRotateXDeg( t * 14.0f);
|
||||
const float so = 1.40f + bass * 0.25f;
|
||||
ofScale(so, so, so);
|
||||
drawWireMesh(ofFloatColor(0.28f, 0.62f, 1.00f, 0.55f));
|
||||
ofPopMatrix();
|
||||
|
||||
// inner shell: smaller, opposite spin, magenta, pulses with kick
|
||||
ofPushMatrix();
|
||||
ofRotateYDeg( t * 34.0f);
|
||||
ofRotateXDeg(-t * 23.0f);
|
||||
const float si = 0.62f + kick * 0.30f;
|
||||
ofScale(si, si, si);
|
||||
drawWireMesh(ofFloatColor(1.00f, 0.42f, 0.80f, 0.60f));
|
||||
ofPopMatrix();
|
||||
|
||||
ofDisableBlendMode();
|
||||
ofEnableDepthTest();
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
#pragma once
|
||||
#include "ofMain.h"
|
||||
#include "SpectrogramBuffer.h"
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
// GL visual unit: an icosphere skinned by the scrolling spectrogram and
|
||||
// displaced radially by the live waveform. Northern hemisphere = CH1,
|
||||
// southern = CH2.
|
||||
class SphereViz {
|
||||
public:
|
||||
void setup(int icoIterations, int spectroWidth, int spectroHeight,
|
||||
int waveformLen);
|
||||
|
||||
void pushSpectrogramColumn(const std::vector<float>& magCh1,
|
||||
const std::vector<float>& magCh2);
|
||||
void setWaveform(const std::vector<float>& ch1,
|
||||
const std::vector<float>& ch2);
|
||||
|
||||
void drawSkin();
|
||||
void drawPoints();
|
||||
void drawWireframe();
|
||||
void drawShells(float time, float bass, float kick);
|
||||
void setColormap(int id) { colormapId_ = id; }
|
||||
|
||||
private:
|
||||
void bindUniforms();
|
||||
void drawWireMesh(const ofFloatColor& tint);
|
||||
|
||||
std::unique_ptr<oscope::SpectrogramBuffer> spectro_;
|
||||
ofVboMesh mesh_;
|
||||
ofVboMesh pointsMesh_;
|
||||
ofShader shader_;
|
||||
ofTexture spectroTex_;
|
||||
ofTexture waveTex_;
|
||||
ofFloatPixels spectroPix_;
|
||||
ofFloatPixels wavePix_;
|
||||
int waveformLen_ = 0;
|
||||
float baseRadius_ = 200.0f;
|
||||
float scrollOffset_ = 0.0f;
|
||||
float displace_ = 0.28f;
|
||||
float spectroAmount_ = 0.40f;
|
||||
int colormapId_ = 0;
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
// oscope-sphere — window bootstrap. GL 3.2 core profile, MSAA 8x.
|
||||
#include "ofMain.h"
|
||||
#include "ofApp.h"
|
||||
|
||||
int main() {
|
||||
ofGLFWWindowSettings settings;
|
||||
settings.setGLVersion(3, 2);
|
||||
settings.setSize(1920, 1080);
|
||||
settings.numSamples = 8;
|
||||
settings.windowMode = OF_WINDOW;
|
||||
settings.title = "oscope-sphere";
|
||||
|
||||
auto window = ofCreateWindow(settings);
|
||||
ofRunApp(window, std::make_shared<ofApp>());
|
||||
ofRunMainLoop();
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
#include "ofApp.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
void ofApp::setup() {
|
||||
ofSetFrameRate(60);
|
||||
ofSetVerticalSync(true);
|
||||
ofBackground(6, 6, 10);
|
||||
ofEnableDepthTest();
|
||||
glEnable(GL_PROGRAM_POINT_SIZE);
|
||||
|
||||
cam_.setDistance(750.0f);
|
||||
cam_.setNearClip(1.0f);
|
||||
cam_.setFarClip(5000.0f);
|
||||
|
||||
sphere_.setup(5, 512, 256, 1024);
|
||||
rings_.setup(512);
|
||||
|
||||
hantek_.setSampleRate(16000000u);
|
||||
const oscope::HantekStatus st = hantek_.start();
|
||||
if (st == oscope::HantekStatus::Ok) {
|
||||
demoMode_ = false;
|
||||
statusText_ = "SCOPE OK";
|
||||
} else {
|
||||
demoMode_ = true;
|
||||
statusText_ = (st == oscope::HantekStatus::FirmwareNeeded)
|
||||
? "DEMO - firmware needed (see docs/HANTEK_SETUP.md)"
|
||||
: "DEMO - scope not found";
|
||||
}
|
||||
}
|
||||
|
||||
void ofApp::update() {
|
||||
if (frozen_) return;
|
||||
|
||||
if (demoMode_) {
|
||||
demo_.next(buf1_, buf2_, 8192);
|
||||
} else {
|
||||
hantek_.ring().readLatest(buf1_, buf2_, 8192);
|
||||
if (hantek_.status() != oscope::HantekStatus::Ok) {
|
||||
demoMode_ = true;
|
||||
statusText_ = "DEMO - scope lost";
|
||||
}
|
||||
}
|
||||
|
||||
const float sr = demoMode_ ? 48000.0f : scopeSr_;
|
||||
analyzerCh1_.update(buf1_, buf1_, sr);
|
||||
analyzerCh2_.update(buf2_, buf2_, sr);
|
||||
|
||||
// Audio-reactive motion: rotation speed tracks signal energy, the whole
|
||||
// sphere pulses with the kick transient.
|
||||
const oscope::AudioBands& b1 = analyzerCh1_.bands();
|
||||
const oscope::AudioBands& b2 = analyzerCh2_.bands();
|
||||
const float energy = 0.5f * (b1.full + b2.full);
|
||||
const float kick = std::max(b1.kick, b2.kick);
|
||||
const float dt = static_cast<float>(ofGetLastFrameTime());
|
||||
if (!ofGetMousePressed())
|
||||
spin_ += (8.0f + 80.0f * energy) * dt;
|
||||
pulse_ += (1.0f + 0.20f * kick - pulse_) * 0.25f;
|
||||
bass_ = 0.5f * (b1.bass + b2.bass);
|
||||
kick_ = kick;
|
||||
|
||||
sphere_.setColormap(colormap_);
|
||||
sphere_.pushSpectrogramColumn(analyzerCh1_.magDown(),
|
||||
analyzerCh2_.magDown());
|
||||
sphere_.setWaveform(buf1_, buf2_);
|
||||
rings_.setWaveform(buf1_, buf2_);
|
||||
}
|
||||
|
||||
void ofApp::draw() {
|
||||
const float t = ofGetElapsedTimef();
|
||||
cam_.begin();
|
||||
ofPushMatrix();
|
||||
ofRotateYDeg(spin_);
|
||||
ofRotateXDeg(16.0f * std::sin(t * 0.27f)); // slow tumble
|
||||
ofScale(pulse_, pulse_, pulse_); // audio pulse
|
||||
if (layerA_) sphere_.drawSkin();
|
||||
if (layerD_) sphere_.drawWireframe();
|
||||
if (layerC_) sphere_.drawPoints();
|
||||
if (layerE_) sphere_.drawShells(t, bass_, kick_);
|
||||
if (layerB_) rings_.draw();
|
||||
ofPopMatrix();
|
||||
cam_.end();
|
||||
drawHud();
|
||||
}
|
||||
|
||||
void ofApp::drawHud() {
|
||||
ofDisableDepthTest();
|
||||
ofSetColor(230);
|
||||
std::string hud = statusText_ + "\n";
|
||||
hud += std::string("[1] skin ") + (layerA_ ? "on" : "off") + "\n";
|
||||
hud += std::string("[2] rings ") + (layerB_ ? "on" : "off") + "\n";
|
||||
hud += std::string("[3] points ") + (layerC_ ? "on" : "off") + "\n";
|
||||
hud += std::string("[4] wire ") + (layerD_ ? "on" : "off") + "\n";
|
||||
hud += std::string("[5] shells ") + (layerE_ ? "on" : "off") + "\n";
|
||||
hud += std::string("[c] colormap [space] ") +
|
||||
(frozen_ ? "frozen" : "live");
|
||||
ofDrawBitmapString(hud, 16, 24);
|
||||
ofEnableDepthTest();
|
||||
}
|
||||
|
||||
void ofApp::keyPressed(int key) {
|
||||
switch (key) {
|
||||
case '1': layerA_ = !layerA_; break;
|
||||
case '2': layerB_ = !layerB_; break;
|
||||
case '3': layerC_ = !layerC_; break;
|
||||
case '4': layerD_ = !layerD_; break;
|
||||
case '5': layerE_ = !layerE_; break;
|
||||
case 'c':
|
||||
case 'C': colormap_ = (colormap_ + 1) % 2; break;
|
||||
case ' ': frozen_ = !frozen_; break;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
#pragma once
|
||||
#include "ofMain.h"
|
||||
#include "HantekDevice.h"
|
||||
#include "AudioAnalyzer.h"
|
||||
#include "DemoSignal.h"
|
||||
#include "SphereViz.h"
|
||||
#include "OrbitRings.h"
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
class ofApp : public ofBaseApp {
|
||||
public:
|
||||
void setup() override;
|
||||
void update() override;
|
||||
void draw() override;
|
||||
void keyPressed(int key) override;
|
||||
|
||||
private:
|
||||
void drawHud();
|
||||
|
||||
oscope::HantekDevice hantek_;
|
||||
oscope::AudioAnalyzer analyzerCh1_;
|
||||
oscope::AudioAnalyzer analyzerCh2_;
|
||||
oscope::DemoSignal demo_{48000.0f};
|
||||
|
||||
SphereViz sphere_;
|
||||
OrbitRings rings_;
|
||||
ofEasyCam cam_;
|
||||
|
||||
std::vector<float> buf1_;
|
||||
std::vector<float> buf2_;
|
||||
|
||||
bool demoMode_ = false;
|
||||
bool frozen_ = false;
|
||||
bool layerA_ = true;
|
||||
bool layerB_ = true;
|
||||
bool layerC_ = true;
|
||||
bool layerD_ = true;
|
||||
bool layerE_ = true;
|
||||
int colormap_ = 0;
|
||||
float scopeSr_ = 16.0e6f;
|
||||
float spin_ = 0.0f;
|
||||
float pulse_ = 1.0f;
|
||||
float bass_ = 0.0f;
|
||||
float kick_ = 0.0f;
|
||||
std::string statusText_;
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
#include <cstdio>
|
||||
|
||||
inline int g_checks = 0;
|
||||
inline int g_fails = 0;
|
||||
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
++g_checks; \
|
||||
if (!(cond)) { \
|
||||
++g_fails; \
|
||||
std::printf("FAIL %s:%d %s\n", __FILE__, __LINE__, #cond); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
#define REPORT() \
|
||||
do { \
|
||||
std::printf("%d/%d checks passed\n", g_checks - g_fails, g_checks);\
|
||||
return g_fails ? 1 : 0; \
|
||||
} while (0)
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
# Compiles and runs every pure-C++ unit test. No openFrameworks needed.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
CXX="${CXX:-clang++}"
|
||||
FLAGS=(-std=c++17 -O0 -g -Isrc)
|
||||
|
||||
echo "== analysis =="
|
||||
"$CXX" "${FLAGS[@]}" tests/test_analysis.cpp src/FFT.cpp src/AudioAnalyzer.cpp \
|
||||
-o /tmp/osph-test-analysis
|
||||
/tmp/osph-test-analysis
|
||||
|
||||
echo "== spectrogram =="
|
||||
"$CXX" "${FLAGS[@]}" tests/test_spectrogram.cpp src/SpectrogramBuffer.cpp \
|
||||
-o /tmp/osph-test-spectro
|
||||
/tmp/osph-test-spectro
|
||||
|
||||
echo "== demo =="
|
||||
"$CXX" "${FLAGS[@]}" tests/test_demo.cpp src/DemoSignal.cpp \
|
||||
-o /tmp/osph-test-demo
|
||||
/tmp/osph-test-demo
|
||||
|
||||
echo "ALL TESTS PASSED"
|
||||
@@ -0,0 +1,59 @@
|
||||
#include "check.h"
|
||||
#include "FFT.h"
|
||||
#include "AudioAnalyzer.h"
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
|
||||
using oscope::AudioAnalyzer;
|
||||
using oscope::FFT;
|
||||
|
||||
static int argmax(const std::vector<float>& v) {
|
||||
int best = 1;
|
||||
float bv = -1.0f;
|
||||
for (int i = 1; i < static_cast<int>(v.size()); ++i)
|
||||
if (v[i] > bv) { bv = v[i]; best = i; }
|
||||
return best;
|
||||
}
|
||||
|
||||
static std::vector<float> tone(double freqHz, double srHz, std::size_t n) {
|
||||
std::vector<float> s(n);
|
||||
for (std::size_t i = 0; i < n; ++i)
|
||||
s[i] = static_cast<float>(std::sin(2.0 * M_PI * freqHz * i / srHz));
|
||||
return s;
|
||||
}
|
||||
|
||||
// FFT of a sine landing exactly on bin 64 must peak at bin 64.
|
||||
static void test_fft_peak() {
|
||||
const std::size_t N = 2048;
|
||||
std::vector<float> in(N);
|
||||
for (std::size_t i = 0; i < N; ++i)
|
||||
in[i] = static_cast<float>(std::sin(2.0 * M_PI * 64.0 * i / N));
|
||||
FFT fft(N);
|
||||
std::vector<float> mag;
|
||||
fft.magnitude(in, mag);
|
||||
CHECK(mag.size() == N / 2);
|
||||
int peak = argmax(mag);
|
||||
CHECK(peak >= 63 && peak <= 65);
|
||||
}
|
||||
|
||||
// Feeding update(chX, chX) must isolate chX — proves the dual-analyzer
|
||||
// trick: AudioAnalyzer mixes 0.5*(a+b), so 0.5*(x+x) == x.
|
||||
static void test_per_channel_isolation() {
|
||||
const double sr = 48000.0; // deci == 1, no decimation
|
||||
auto a = tone(1000.0, sr, 2048); // bin width 23.4 Hz -> bin ~43
|
||||
auto b = tone(5000.0, sr, 2048); // -> bin ~213
|
||||
AudioAnalyzer an1, an2;
|
||||
an1.update(a, a, static_cast<float>(sr));
|
||||
an2.update(b, b, static_cast<float>(sr));
|
||||
int p1 = argmax(an1.magDown());
|
||||
int p2 = argmax(an2.magDown());
|
||||
CHECK(p1 >= 41 && p1 <= 45);
|
||||
CHECK(p2 >= 211 && p2 <= 217);
|
||||
CHECK(p1 != p2);
|
||||
}
|
||||
|
||||
int main() {
|
||||
test_fft_peak();
|
||||
test_per_channel_isolation();
|
||||
REPORT();
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
#include "check.h"
|
||||
#include "DemoSignal.h"
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
|
||||
using oscope::DemoSignal;
|
||||
|
||||
// Output has the requested length, stays in [-1,1], and the two
|
||||
// channels differ.
|
||||
static void test_shape_and_bounds() {
|
||||
DemoSignal demo(48000.0f);
|
||||
std::vector<float> a, b;
|
||||
demo.next(a, b, 256);
|
||||
CHECK(a.size() == 256);
|
||||
CHECK(b.size() == 256);
|
||||
bool inRange = true, differ = false;
|
||||
for (std::size_t i = 0; i < a.size(); ++i) {
|
||||
if (std::fabs(a[i]) > 1.0f || std::fabs(b[i]) > 1.0f) inRange = false;
|
||||
if (std::fabs(a[i] - b[i]) > 1e-4f) differ = true;
|
||||
}
|
||||
CHECK(inRange);
|
||||
CHECK(differ);
|
||||
}
|
||||
|
||||
// Splitting next() into two calls must equal one combined call:
|
||||
// proves the generator is deterministic AND phase-continuous across
|
||||
// call boundaries (a restart or glitch would break the identity).
|
||||
static void test_phase_continuity() {
|
||||
DemoSignal whole(48000.0f);
|
||||
std::vector<float> aw, bw;
|
||||
whole.next(aw, bw, 128);
|
||||
|
||||
DemoSignal split(48000.0f);
|
||||
std::vector<float> a1, b1, a2, b2;
|
||||
split.next(a1, b1, 64);
|
||||
split.next(a2, b2, 64);
|
||||
|
||||
bool ch1Match = true, ch2Match = true;
|
||||
for (std::size_t i = 0; i < 64; ++i) {
|
||||
if (aw[i] != a1[i]) ch1Match = false;
|
||||
if (aw[i + 64] != a2[i]) ch1Match = false;
|
||||
if (bw[i] != b1[i]) ch2Match = false;
|
||||
if (bw[i + 64] != b2[i]) ch2Match = false;
|
||||
}
|
||||
CHECK(ch1Match);
|
||||
CHECK(ch2Match);
|
||||
}
|
||||
|
||||
int main() {
|
||||
test_shape_and_bounds();
|
||||
test_phase_continuity();
|
||||
REPORT();
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
#include "check.h"
|
||||
#include "SpectrogramBuffer.h"
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
|
||||
using oscope::SpectrogramBuffer;
|
||||
|
||||
// writeIndex advances modulo width.
|
||||
static void test_write_index_wraps() {
|
||||
SpectrogramBuffer buf(8, 4);
|
||||
std::vector<float> z(1024, 0.0f);
|
||||
for (int i = 0; i < 10; ++i) buf.pushColumn(z, z);
|
||||
CHECK(buf.writeIndex() == 2); // 10 % 8
|
||||
CHECK(static_cast<int>(buf.data().size()) == 8 * 4);
|
||||
}
|
||||
|
||||
// High-frequency energy must land near the poles for both channels.
|
||||
static void test_high_freq_maps_to_poles() {
|
||||
SpectrogramBuffer buf(4, 8); // H=8 -> CH1 rows 4..7, CH2 rows 0..3
|
||||
std::vector<float> hi(1024, 0.0f);
|
||||
hi[1023] = 100.0f; // energy at the top FFT bin
|
||||
buf.pushColumn(hi, hi); // both channels: high-frequency content
|
||||
const std::vector<float>& d = buf.data();
|
||||
const int W = buf.width();
|
||||
CHECK(d[7 * W + 0] > d[4 * W + 0]); // CH1: north pole > equator
|
||||
CHECK(d[0 * W + 0] > d[3 * W + 0]); // CH2: south pole > equator
|
||||
}
|
||||
|
||||
// A 2-row buffer (half == 1) must not divide by zero / emit NaN.
|
||||
static void test_height_two_is_finite() {
|
||||
SpectrogramBuffer buf(4, 2);
|
||||
std::vector<float> m(1024, 0.0f);
|
||||
m[500] = 50.0f;
|
||||
buf.pushColumn(m, m);
|
||||
const std::vector<float>& d = buf.data();
|
||||
bool allFinite = true;
|
||||
for (float v : d)
|
||||
if (!std::isfinite(v)) allFinite = false;
|
||||
CHECK(allFinite);
|
||||
}
|
||||
|
||||
int main() {
|
||||
test_write_index_wraps();
|
||||
test_high_freq_maps_to_poles();
|
||||
test_height_two_is_finite();
|
||||
REPORT();
|
||||
}
|
||||
Reference in New Issue
Block a user