Merge remote-tracking branch 'origin/main'

This commit is contained in:
L'électron rare
2026-06-23 22:55:03 +02:00
34 changed files with 3811 additions and 0 deletions
+83
View File
@@ -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, ~10k40k 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.
+7
View File
@@ -0,0 +1,7 @@
obj/
bin/oscope-sphere
bin/oscope-sphere_debug
bin/oscope-sphere.app/
*.o
*.d
openFrameworks-Info.plist
+62
View File
@@ -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.
+14
View File
@@ -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
+56
View File
@@ -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.
View File
@@ -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;
}
+50
View File
@@ -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 =
+97
View File
@@ -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
+53
View File
@@ -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; // 20200 Hz
float lowMid = 0.0f; // 200800 Hz
float mid = 0.0f; // 8003200 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
+41
View File
@@ -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
+28
View File
@@ -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
+60
View File
@@ -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
+32
View File
@@ -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
+284
View File
@@ -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
+99
View File
@@ -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
+50
View File
@@ -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();
}
+21
View File
@@ -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
};
+69
View File
@@ -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
+54
View File
@@ -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
+35
View File
@@ -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
+139
View File
@@ -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();
}
+44
View File
@@ -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;
};
+17
View File
@@ -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;
}
+112
View File
@@ -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;
}
}
+47
View File
@@ -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_;
};
+20
View File
@@ -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)
+23
View File
@@ -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"
+59
View File
@@ -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();
}
+53
View File
@@ -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();
}
+47
View File
@@ -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();
}