diff --git a/oscope-of/.gitignore b/oscope-of/.gitignore new file mode 100644 index 0000000..fec92cc --- /dev/null +++ b/oscope-of/.gitignore @@ -0,0 +1,30 @@ +# openFrameworks build artifacts +obj/ +bin/oscope-of +bin/oscope-of_debug +bin/oscope-of.app/ +bin/oscope-of.app +bin/libs/ +*.o +*.d + +# Xcode +*.xcuserdata/ +*.xcuserstate +xcuserdata/ +*.xcodeproj/project.xcworkspace/xcuserdata/ +DerivedData/ + +# macOS +.DS_Store + +# Editor +.vscode/ +.idea/ +*.swp +*~ + +# Generated project files (keep handwritten config.make/addons.make) +oscope-of.xcodeproj/ +Makefile.bak +openFrameworks-Info.plist diff --git a/oscope-of/Makefile b/oscope-of/Makefile new file mode 100644 index 0000000..bbfb31f --- /dev/null +++ b/oscope-of/Makefile @@ -0,0 +1,14 @@ +# Délègue au Makefile générique d'openFrameworks. +# Suppose que le projet est cloné dans /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 diff --git a/oscope-of/README.md b/oscope-of/README.md new file mode 100644 index 0000000..9c661da --- /dev/null +++ b/oscope-of/README.md @@ -0,0 +1,165 @@ +# oscope-of + +Visualizer openFrameworks pour le système de live coding [`sound_algo`](https://github.com/electron-rare/sound_algo) couplé à un oscilloscope USB Hantek 6022BL. + +Trois modes superposés : + +1. **Lissajous XY** — CH1=X, CH2=Y, persistance type CRT phosphor (vert `#00FF7F`), shader glow gaussien +2. **Spectrogramme FFT** — 1024 bins, scrolling horizontal, palette bleu→violet→rouge +3. **Layer réactif sound_algo** — grille hexagonale, gradient HSV, modulé par BPM/beat/`amp.*` reçus en OSC + +## Pré-requis + +- macOS Apple Silicon (M1/M2/M3/M5) — testé en cible, fonctionne aussi sur Intel macs avec ajustement de `config.make` +- [openFrameworks 0.12+](https://openframeworks.cc/download/) avec template Xcode +- Xcode Command Line Tools : `xcode-select --install` +- libusb-1.0 : `brew install libusb` +- Pour charger le firmware Hantek : `brew install fxload` ou compilation depuis [OpenHantek6022](https://github.com/OpenHantek/OpenHantek6022) + +## Installation + +### Étape 1 — Cloner dans l'arbre openFrameworks + +Le projet doit vivre dans `/apps/myApps/oscope-of` pour que le Makefile générique fonctionne : + +```bash +cd /apps/myApps/ +git clone git@github.com:electron-rare/oscope-of.git +cd oscope-of +``` + +Si vous le placez ailleurs, ajustez `OF_ROOT` dans `config.make`. + +### Étape 2 — Régénérer le projet Xcode + +```bash +/projectGenerator -u apps/myApps/oscope-of +``` + +Cela crée `oscope-of.xcodeproj/` à partir des fichiers `addons.make` et `config.make`. + +### Étape 3 — Charger le firmware Hantek + +Le 6022BL est un Cypress FX2 sans firmware persistant : il faut uploader le firmware OpenHantek6022 à chaque branchement USB. + +Voir le guide complet : [docs/HANTEK_SETUP.md](docs/HANTEK_SETUP.md). + +Résumé express : + +```bash +# Cloner et builder le firmware OpenHantek6022 une seule fois. +git clone https://github.com/OpenHantek/OpenHantek6022.git +cd OpenHantek6022/openhantek/res/firmware/ +# (lire les instructions de build du repo pour produire les .hex) + +# Charger à chaque branchement (PID 0x6022 = sans firmware) : +fxload -t fx2lp -I firmware.hex -D /dev/bus/usb// +# ou via libusb-helper sur macOS — voir docs/HANTEK_SETUP.md +``` + +Une fois le firmware chargé, le device réénumère en `0x04B5:0x602A` et `oscope-of` peut le claim. + +### Étape 4 — Lancer sound_algo et son bridge + +Depuis votre arbre `sound_algo` : + +```supercollider +// Dans SuperCollider +"web_bridge.scd".loadRelative; +~startBridge.value; +``` + +Le bridge écoute sur `127.0.0.1:57121` (SC) et `127.0.0.1:57122` (Node→SC) ; `oscope-of` se branche sur ces deux ports. + +### Étape 5 — Build et run + +Ouvrir `oscope-of.xcodeproj` dans Xcode, sélectionner la scheme `oscope-of Release`, ⌘+R. + +Ou en CLI : + +```bash +make Release +make RunRelease +``` + +## Modes et contrôles clavier + +| Touche | Action | +|--------|--------| +| `1` | Mode Lissajous (plein écran XY) | +| `2` | Mode Spectrogram (plein écran FFT) | +| `3` | Mode Reactive (layer sound_algo seul) | +| `4` | Mode Hybrid (défaut, les 3 superposés) | +| `f` | Toggle fullscreen | +| `g` | Toggle GUI ofxPanel | +| `r` | Reload shaders (utile pour l'édition live) | + +## Configuration + +`bin/data/settings.json` : + +```json +{ + "scope": { "sample_rate": 48000000, "gain_ch1": 0.5, "gain_ch2": 0.5, "buffer_size": 4096 }, + "osc": { "listen_port": 57122, "send_host": "127.0.0.1", "send_port": 57121 }, + "display": { "fullscreen": false, "width": 1920, "height": 1080, "default_mode": "hybrid" } +} +``` + +## Protocole OSC + +Voir [docs/OSC_PROTOCOL.md](docs/OSC_PROTOCOL.md) pour la liste complète des messages reçus depuis sound_algo et envoyés vers SC. + +## Troubleshooting + +### "Hantek 6022BL trouvé MAIS firmware non chargé" + +Le device énumère en `0x04B5:0x6022` au lieu de `0x602A`. Charger le firmware (voir étape 3) puis relancer. + +### "claim_interface failed: LIBUSB_ERROR_ACCESS" + +macOS bloque l'accès USB direct par défaut. Aller dans **Réglages Système → Confidentialité et sécurité → Surveillance des entrées** et autoriser le binaire `oscope-of`. Sinon, lancer en root pour test : `sudo bin/oscope-of_debug`. + +### Aucun signal en Lissajous + +- Vérifier que les sondes sont branchées sur CH1 et CH2 et que le couplage DC/AC est compatible avec votre source +- Vérifier les V/div dans la GUI (slider `CH1 V/div`, `CH2 V/div`) +- Le statut "Scope" dans la GUI doit afficher "OK" et un nombre de samples > 0 + +### Conflit avec OpenHantek lui-même + +Si OpenHantek6022 est lancé en parallèle, il claim le device et `oscope-of` ne pourra pas l'ouvrir. Quitter OpenHantek avant. + +### Pas de messages OSC + +Vérifier dans la GUI que le port d'écoute est bien `57122`, et que `sound_algo`'s bridge a démarré (`~startBridge.value` dans SC). Tester avec `oscchief` ou `OSCdef.trace`. + +## Architecture + +``` +src/ + main.cpp Entry point OF, fenêtre 1920x1080 MSAA 8x + ofApp.h/.cpp Orchestrateur, dispatch des modes + HantekDevice.h/.cpp libusb wrapper, thread bulk-transfer + OscClient.h/.cpp ofxOsc receiver/sender pour sound_algo + ScopeData.h Ringbuffer SPSC lock-free + FFT.h/.cpp Cooley-Tukey radix-2 in-place + visualizers/ + Visualizer.h Interface abstraite + LissajousVis.h/.cpp XY scope avec FBO trail + SpectrogramVis.h/.cpp FFT scrolling + ReactiveVis.h/.cpp Layer sound_algo (shader bg.frag) +bin/data/ + settings.json + shaders/ + glow.vert/frag 9-tap Gaussian + threshold + bloom.vert/frag Bloom single-pass (réservé futur) + bg.frag Layer réactif sound_algo +docs/ + HANTEK_SETUP.md Guide firmware + permissions USB macOS + OSC_PROTOCOL.md Référence des messages OSC échangés +``` + +## Licence + +MIT — voir le projet sound_algo pour la licence côté SuperCollider. diff --git a/oscope-of/addons.make b/oscope-of/addons.make new file mode 100644 index 0000000..f766294 --- /dev/null +++ b/oscope-of/addons.make @@ -0,0 +1,2 @@ +ofxOsc +ofxGui diff --git a/oscope-of/bin/data/settings.json b/oscope-of/bin/data/settings.json new file mode 100644 index 0000000..8511989 --- /dev/null +++ b/oscope-of/bin/data/settings.json @@ -0,0 +1,19 @@ +{ + "scope": { + "sample_rate": 48000000, + "gain_ch1": 0.5, + "gain_ch2": 0.5, + "buffer_size": 4096 + }, + "osc": { + "listen_port": 57123, + "send_host": "127.0.0.1", + "send_port": 57121 + }, + "display": { + "fullscreen": false, + "width": 1920, + "height": 1080, + "default_mode": "hybrid" + } +} diff --git a/oscope-of/bin/data/shaders/bg.frag b/oscope-of/bin/data/shaders/bg.frag new file mode 100644 index 0000000..5dfb20e --- /dev/null +++ b/oscope-of/bin/data/shaders/bg.frag @@ -0,0 +1,76 @@ +#version 150 + +// Layer arrière-plan réactif sound_algo. +// Grille hexagonale qui pulse sur kick, gradient HSV qui tourne au beat, +// distortion modulée par la mélodie. Subtil par défaut (alpha global ~0.6). + +uniform vec2 uResolution; +uniform float uTime; +uniform float uBpm; +uniform int uBeat; +uniform float uKick; +uniform float uHat; +uniform float uSnare; +uniform float uClap; +uniform float uPerc; +uniform float uMelody; +uniform float uAcid; +uniform float uHarmony; + +out vec4 fragColor; + +// HSV -> RGB. +vec3 hsv2rgb(vec3 c) { + vec3 p = abs(fract(c.xxx + vec3(0.0, 2.0/3.0, 1.0/3.0)) * 6.0 - 3.0); + return c.z * mix(vec3(1.0), clamp(p - 1.0, 0.0, 1.0), c.y); +} + +// Distance hexagonale (vector display style). +float hexDist(vec2 p) { + p = abs(p); + return max(p.x * 0.866 + p.y * 0.5, p.y); +} + +vec4 hexCoords(vec2 uv) { + vec2 r = vec2(1.0, 1.732); + vec2 h = r * 0.5; + vec2 a = mod(uv, r) - h; + vec2 b = mod(uv - h, r) - h; + vec2 gv = (dot(a, a) < dot(b, b)) ? a : b; + float d = hexDist(gv); + return vec4(gv, d, 1.0); +} + +void main() { + vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / min(uResolution.x, uResolution.y); + + // Distortion mélodique. + uv += 0.05 * uMelody * vec2(sin(uv.y * 6.0 + uTime * 1.3), + cos(uv.x * 5.0 + uTime * 0.9)); + + // Grille hexagonale. + float scale = 6.0 + 2.0 * uHarmony - 1.5 * uKick; + vec4 hex = hexCoords(uv * scale); + float ring = smoothstep(0.5, 0.49, hex.z); + float pulse = smoothstep(0.4 - 0.3 * uKick, 0.5, hex.z); + + // Couleur HSV qui tourne avec beat. + float hue = fract(0.55 + 0.02 * float(uBeat) + 0.05 * uAcid); + vec3 col = hsv2rgb(vec3(hue, 0.6 + 0.3 * uClap, 0.35 + 0.5 * pulse)); + + // Vignette radiale. + float r = length(uv); + col *= smoothstep(1.4, 0.2, r); + + // Boost kick. + col += 0.15 * uKick * vec3(0.6, 0.2, 1.0); + + // Trame snare (lignes horizontales). + col += 0.08 * uSnare * sin(uv.y * 200.0); + + // Hat = grain léger. + float n = fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453); + col += 0.06 * uHat * (n - 0.5); + + fragColor = vec4(col, 0.85); +} diff --git a/oscope-of/bin/data/shaders/bg.vert b/oscope-of/bin/data/shaders/bg.vert new file mode 100644 index 0000000..80090cf --- /dev/null +++ b/oscope-of/bin/data/shaders/bg.vert @@ -0,0 +1,8 @@ +#version 150 + +uniform mat4 modelViewProjectionMatrix; +in vec4 position; + +void main() { + gl_Position = modelViewProjectionMatrix * position; +} diff --git a/oscope-of/bin/data/shaders/bloom.frag b/oscope-of/bin/data/shaders/bloom.frag new file mode 100644 index 0000000..a95c291 --- /dev/null +++ b/oscope-of/bin/data/shaders/bloom.frag @@ -0,0 +1,27 @@ +#version 150 + +// Bloom single-pass simplifié : downsample 5x5 + threshold. +// Réservé pour usage futur ; LissajousVis utilise actuellement glow.frag. + +uniform sampler2DRect uTex; +uniform vec2 uTexel; +uniform float uThreshold; + +in vec2 vUV; +out vec4 fragColor; + +void main() { + vec3 acc = vec3(0.0); + float wsum = 0.0; + for (int dy = -2; dy <= 2; ++dy) { + for (int dx = -2; dx <= 2; ++dx) { + vec2 q = vUV + vec2(float(dx), float(dy)) * 1.5; + vec3 c = texture(uTex, q).rgb; + float l = dot(c, vec3(0.299, 0.587, 0.114)); + float k = smoothstep(uThreshold, uThreshold + 0.2, l); + acc += c * k; + wsum += 1.0; + } + } + fragColor = vec4(acc / wsum, 1.0); +} diff --git a/oscope-of/bin/data/shaders/bloom.vert b/oscope-of/bin/data/shaders/bloom.vert new file mode 100644 index 0000000..4484124 --- /dev/null +++ b/oscope-of/bin/data/shaders/bloom.vert @@ -0,0 +1,11 @@ +#version 150 + +uniform mat4 modelViewProjectionMatrix; +in vec4 position; +in vec2 texcoord; +out vec2 vUV; + +void main() { + vUV = texcoord; + gl_Position = modelViewProjectionMatrix * position; +} diff --git a/oscope-of/bin/data/shaders/glow.frag b/oscope-of/bin/data/shaders/glow.frag new file mode 100644 index 0000000..34de093 --- /dev/null +++ b/oscope-of/bin/data/shaders/glow.frag @@ -0,0 +1,41 @@ +#version 150 + +// Glow CRT phosphor : 9-tap Gaussian + threshold + additive. +// Préserve la couleur d'origine et ajoute un halo vert pondéré. + +uniform sampler2DRect uTex; +uniform vec2 uTexel; // 1.0 / textureSize +uniform float uIntensity; // boost global (1.0 = neutre) + +in vec2 vUV; +out vec4 fragColor; + +void main() { + // ofTexture par défaut sur of macOS = sampler2DRect (coords pixel). + vec2 p = vUV; + vec4 base = texture(uTex, p); + + // 9-tap (3x3) gaussian. + float w[9] = float[9]( + 1.0, 2.0, 1.0, + 2.0, 4.0, 2.0, + 1.0, 2.0, 1.0 + ); + vec4 sum = vec4(0.0); + int idx = 0; + for (int dy = -1; dy <= 1; ++dy) { + for (int dx = -1; dx <= 1; ++dx) { + vec2 q = p + vec2(float(dx), float(dy)); + sum += texture(uTex, q) * w[idx]; + idx++; + } + } + sum /= 16.0; + + // Threshold : seuil sur la luminance pour ne booster que le tracé. + float l = dot(sum.rgb, vec3(0.299, 0.587, 0.114)); + float bloom = smoothstep(0.05, 0.6, l); + vec3 glowCol = vec3(0.0, 1.0, 0.5) * bloom * uIntensity; + + fragColor = vec4(base.rgb + glowCol, 1.0); +} diff --git a/oscope-of/bin/data/shaders/glow.vert b/oscope-of/bin/data/shaders/glow.vert new file mode 100644 index 0000000..4484124 --- /dev/null +++ b/oscope-of/bin/data/shaders/glow.vert @@ -0,0 +1,11 @@ +#version 150 + +uniform mat4 modelViewProjectionMatrix; +in vec4 position; +in vec2 texcoord; +out vec2 vUV; + +void main() { + vUV = texcoord; + gl_Position = modelViewProjectionMatrix * position; +} diff --git a/oscope-of/config.make b/oscope-of/config.make new file mode 100644 index 0000000..3c8caf7 --- /dev/null +++ b/oscope-of/config.make @@ -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 = diff --git a/oscope-of/docs/HANTEK_SETUP.md b/oscope-of/docs/HANTEK_SETUP.md new file mode 100644 index 0000000..b22f826 --- /dev/null +++ b/oscope-of/docs/HANTEK_SETUP.md @@ -0,0 +1,135 @@ +# Hantek 6022BL — setup macOS Apple Silicon + +Le Hantek 6022BL est un oscilloscope USB 2 voies, 8-bit, basé sur le microcontrôleur Cypress FX2LP (CY7C68013A). Le FX2 n'a pas de mémoire flash : à chaque branchement USB, un firmware doit être uploadé en RAM. Tant que ce n'est pas fait, le device énumère avec un descripteur générique (PID `0x6022`) et n'a pas d'endpoint bulk fonctionnel. + +Le firmware open-source de référence est celui du projet [OpenHantek6022](https://github.com/OpenHantek/OpenHantek6022) (GPL-3.0). Une fois chargé, le device réénumère en `0x04B5:0x602A` avec un endpoint bulk IN sur `0x86`. + +## 1. Installer libusb et fxload + +```bash +brew install libusb fxload +``` + +Vérifier que `fxload` est bien sous `/opt/homebrew/bin/fxload` (Apple Silicon) ou `/usr/local/bin/fxload` (Intel). + +## 2. Récupérer ou builder le firmware + +### Option A — pré-built depuis OpenHantek6022 + +Le repo upstream fournit des `.hex` pré-buildés dans `openhantek/res/firmware/` : + +```bash +git clone https://github.com/OpenHantek/OpenHantek6022.git +ls OpenHantek6022/openhantek/res/firmware/ +# Hantek6022BE.hex Hantek6022BL.hex ... +``` + +Pour le 6022BL spécifiquement, utiliser `Hantek6022BL.hex`. + +### Option B — build from source + +Le firmware est en C8051, buildable avec `sdcc` : + +```bash +brew install sdcc +cd OpenHantek6022/openhantek/res/firmware/build +make +# produit .hex et .iic +``` + +## 3. Identifier le bus/device USB + +Brancher le scope, puis : + +```bash +system_profiler SPUSBDataType | grep -A 6 "Hantek\|6022\|04b5" +``` + +Vous devriez voir une entrée avec `Vendor ID: 0x04b5` et `Product ID: 0x6022` (avant firmware). + +Pour avoir bus/device au format requis par fxload, sur macOS la convention `/dev/bus/usb/` n'existe pas — il faut utiliser libusb directement. Une option simple : + +```bash +# Lister les devices avec libusb (depuis brew) +ioreg -p IOUSB -l -w 0 | grep -i hantek +``` + +## 4. Charger le firmware + +### Méthode A — fxload (Linux-style, peut nécessiter adaptation) + +Sur Linux : `fxload -t fx2lp -I Hantek6022BL.hex -D /dev/bus/usb/001/005`. + +Sur macOS, `fxload` Homebrew accepte `-D` au format `vid:pid` ou via libusb device path. Tester : + +```bash +sudo fxload -t fx2lp -I Hantek6022BL.hex +# (sans -D, scanne tous les FX2 et upload sur le premier) +``` + +### Méthode B — utilitaire Python avec libusb + +Plus fiable sur macOS, le repo OpenHantek6022 fournit un script ou vous pouvez utiliser `pyusb` : + +```python +# upload_firmware.py +import usb.core, time +from intelhex import IntelHex + +dev = usb.core.find(idVendor=0x04B5, idProduct=0x6022) +ih = IntelHex("Hantek6022BL.hex") + +# Stop CPU +dev.ctrl_transfer(0x40, 0xA0, 0xE600, 0, [0x01]) +# Upload +for start, end in ih.segments(): + chunk = ih.tobinarray(start=start, size=end-start) + dev.ctrl_transfer(0x40, 0xA0, start, 0, chunk.tobytes()) +# Start CPU +dev.ctrl_transfer(0x40, 0xA0, 0xE600, 0, [0x00]) +time.sleep(1) +print("Firmware uploaded.") +``` + +```bash +pip install pyusb intelhex +sudo python upload_firmware.py +``` + +### Méthode C — passer par OpenHantek6022 lui-même + +Si vous installez OpenHantek6022 en .app sur macOS, son lancement charge automatiquement le firmware. Vous pouvez le lancer une fois (qui charge le firmware), le quitter, puis lancer `oscope-of` qui trouvera le device en mode firmware. + +## 5. Vérifier le succès du chargement + +Après upload, le device doit réénumérer en quelques secondes : + +```bash +system_profiler SPUSBDataType | grep -A 6 "04b5" +# Product ID: 0x602a <-- firmware chargé +``` + +## 6. Permissions USB sur macOS + +macOS Sequoia/Sonoma demande une autorisation explicite pour qu'une application non signée Apple accède aux devices USB. + +- **Réglages Système → Confidentialité et sécurité → Périphériques USB / Surveillance des entrées** : ajouter le binaire `oscope-of` à la liste autorisée. +- En cas d'échec persistant : `sudo /Users//.../oscope-of.app/Contents/MacOS/oscope-of` pour test (lancer en root contourne les ACL). + +## 7. Diagnostic + +Le binaire `oscope-of` log les statuts au démarrage : + +- `Hantek 6022BL trouvé MAIS firmware non chargé` → revenir à l'étape 4 +- `claim_interface failed` → permissions USB ou device claimé par une autre app +- `bulk_transfer: LIBUSB_ERROR_TIMEOUT` répétés → le firmware est chargé mais ne stream pas ; vérifier sample rate et gain + +## 8. Re-uploader à chaque branchement + +Le firmware vit en RAM : débrancher = perdre le firmware. Pour automatiser, créer un service launchd ou un script `usb_arrived.sh` qui détecte le PID `0x6022` et lance l'upload. + +## Liens + +- [OpenHantek6022 firmware source](https://github.com/OpenHantek/OpenHantek6022/tree/main/openhantek/res/firmware) +- [Cypress FX2LP datasheet](https://www.infineon.com/dgdl/Infineon-CY7C68013A_CY7C68014A_CY7C68015A_CY7C68016A_EZ-USB_FX2LP_USB_Microcontroller_High_Speed_USB_Peripheral_Controller-DataSheet-v17_00-EN.pdf) +- [libusb-1.0 API doc](https://libusb.sourceforge.io/api-1.0/) diff --git a/oscope-of/docs/OSC_PROTOCOL.md b/oscope-of/docs/OSC_PROTOCOL.md new file mode 100644 index 0000000..8a86cab --- /dev/null +++ b/oscope-of/docs/OSC_PROTOCOL.md @@ -0,0 +1,124 @@ +# Protocole OSC entre oscope-of et sound_algo + +Référence des messages OSC échangés entre les deux projets. Sert aussi de spec pour toute évolution du bridge `web_bridge.scd` côté sound_algo. + +## Vue d'ensemble + +``` + +---------------------+ + | sclang (SC IDE) | + +----------+----------+ + | :57110 (réception SC interne) + | + +-----------v-----------+ :3000 ws + | web_bridge.scd | <----------------> browser (control panel) + | (Node + sclang OSC) | + +-----+-------------+---+ + :57121 | | | :57122 + (SC<-) | | | (->SC, broadcast WS) + v v v + +-----+--------------------+ + | oscope-of | + | ofxOscReceiver :57122 | + | ofxOscSender :57121 | + +--------------------------+ +``` + +`oscope-of` : + +- **Écoute** sur `127.0.0.1:57122` les `/sync/*` +- **Envoie** sur `127.0.0.1:57121` des `/control/*` interprétés par sound_algo + +## Messages reçus (sound_algo → oscope-of) + +### `/sync/bpm ` + +Tempo courant en BPM. Émis à chaque changement (`~bpm = X` dans une track ou via `~tempo.()`). + +``` +/sync/bpm 132.5 +``` + +### `/sync/beat ` + +Compteur de beats incrémenté à chaque pulsation par le clock SC. Reset à 0 sur `~jumpTo` ou changement de track. + +``` +/sync/beat 1247 +``` + +### `/sync/amp ` + +Amplitude RMS (0..1) de l'une des 8 voies. Mis à jour ~30 Hz par les RMS analyzers de sound_algo. + +Voix possibles : `kick`, `hat`, `snare`, `clap`, `perc`, `melody`, `acid`, `harmony`. + +``` +/sync/amp "kick" 0.78 +/sync/amp "melody" 0.22 +``` + +### `/sync/rms ` + +RMS du bus master (post-FX). Utile pour le ducking visuel global. + +``` +/sync/rms 0.41 +``` + +### `/sync/album ` + +Nom de l'album courant (ex `acid_journey`, `vietnam_hard`). + +### `/sync/melody ` + +Nom de la mélodie active (ex `epic_long_001`, `short_modal_007`). + +### `/sync/synthdef ` + +SynthDef courante en mélodie (ex `\fmLead`, `\acidBass`). + +## Messages envoyés (oscope-of → sound_algo) + +oscope-of est un client OSC actif : il peut piloter sound_algo, mais cette feature est minoritaire dans l'usage par défaut (la GUI principale reste le browser web). + +### `/control/kk ` + +Joue un kick via le live API `~kk.`. + +``` +/control/kk "tek" +``` + +### `/control/setMelody ` + +Change la mélodie active. + +``` +/control/setMelody "epic_long_001" +``` + +### `/control/setAlbum ` + +Change l'album / la track. + +``` +/control/setAlbum "acid_journey" +``` + +### `/control/ ` + +Tout autre paramètre exposé par sound_algo via OSCdef. La nomenclature suit `~p..()` ou `~ff..`. + +## Notes d'implémentation + +- Le bridge sound_algo broadcast les `/sync/*` à la fois en UDP direct (vers `oscope-of` et tout autre client OSC sur :57122) et en WebSocket (vers les browsers). oscope-of utilise UNIQUEMENT le canal UDP. +- Si vous lancez plusieurs visualizers en parallèle (par ex `oscope-of` + un autre client OF), augmenter le buffer UDP socket ou utiliser des ports distincts. +- Le `web_bridge.scd` côté sound_algo doit avoir oscope-of dans sa liste de subscribers UDP. Vérifier dans le bridge : + + ```supercollider + ~oscRelays = [ + NetAddr("127.0.0.1", 57122), // oscope-of + // autres clients OSC... + ]; + ``` diff --git a/oscope-of/src/FFT.cpp b/oscope-of/src/FFT.cpp new file mode 100644 index 0000000..5db58b5 --- /dev/null +++ b/oscope-of/src/FFT.cpp @@ -0,0 +1,60 @@ +#include "FFT.h" + +#include + +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& buf) const { + for (std::size_t i = 0; i < size_; ++i) buf[i] *= window_[i]; +} + +void FFT::fftInPlace(std::vector>& 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(len); + const std::complex wlen(std::cos(ang), std::sin(ang)); + for (std::size_t i = 0; i < N; i += len) { + std::complex 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& in, std::vector& outMag) { + const std::size_t N = size_; + std::vector 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(windowed[i], 0.0f); + fftInPlace(work_); + outMag.resize(N / 2); + const float invN = 1.0f / static_cast(N); + for (std::size_t i = 0; i < N / 2; ++i) { + outMag[i] = std::abs(work_[i]) * invN; + } +} + +} // namespace oscope diff --git a/oscope-of/src/FFT.h b/oscope-of/src/FFT.h new file mode 100644 index 0000000..88ceff8 --- /dev/null +++ b/oscope-of/src/FFT.h @@ -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 +#include +#include + +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& in, std::vector& outMag); + + std::size_t size() const { return size_; } + +private: + void hannWindow(std::vector& buf) const; + void fftInPlace(std::vector>& x) const; + + std::size_t size_; + std::vector window_; + std::vector> work_; +}; + +} // namespace oscope diff --git a/oscope-of/src/HantekDevice.cpp b/oscope-of/src/HantekDevice.cpp new file mode 100644 index 0000000..0064fac --- /dev/null +++ b/oscope-of/src/HantekDevice.cpp @@ -0,0 +1,277 @@ +#include "HantekDevice.h" + +#include + +#include +#include +#include + +#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(HantekGain::G_1V)), + gainCh2_(static_cast(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()) { + const uint8_t code = sampleRateCode(hz); + sendVendorControl(kReqSetSampleRate, 0, 0, &code, 1); + } +} + +void HantekDevice::setGain(int channel, HantekGain gain) { + const uint8_t code = gainCode(gain); + if (channel == 1) { + gainCh1_.store(static_cast(gain)); + if (handle_) sendVendorControl(kReqSetCh1Gain, 0, 0, &code, 1); + } else if (channel == 2) { + gainCh2_.store(static_cast(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(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(gainCh1_.load())); + if (!sendVendorControl(kReqSetCh1Gain, 0, 0, &g1, 1)) return false; + const uint8_t g2 = gainCode(static_cast(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 raw(kBulkXferBytes); + std::vector 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(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 totalBytes{0}; + static auto lastLog = std::chrono::steady_clock::now(); + totalBytes += actual; + auto now = std::chrono::steady_clock::now(); + if (std::chrono::duration_cast(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(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(a) - 128.0f) / 128.0f; + ch2[i] = (static_cast(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 diff --git a/oscope-of/src/HantekDevice.h b/oscope-of/src/HantekDevice.h new file mode 100644 index 0000000..6342fcc --- /dev/null +++ b/oscope-of/src/HantekDevice.h @@ -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 +#include +#include +#include +#include + +#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 running_; + std::atomic status_; + std::atomic sampleRateHz_; + std::atomic gainCh1_; + std::atomic gainCh2_; + ScopeRing ring_; +}; + +} // namespace oscope diff --git a/oscope-of/src/OscClient.cpp b/oscope-of/src/OscClient.cpp new file mode 100644 index 0000000..834326e --- /dev/null +++ b/oscope-of/src/OscClient.cpp @@ -0,0 +1,82 @@ +#include "OscClient.h" + +#include "ofLog.h" + +namespace oscope { + +void OscClient::setup(int listenPort, const std::string& sendHost, int sendPort) { + receiver_.setup(listenPort); + sender_.setup(sendHost, sendPort); + // Pré-init des 8 voies connues. + for (const auto& v : {"kick", "hat", "snare", "clap", "perc", + "melody", "acid", "harmony"}) { + amp_[v] = 0.0f; + } + ofLogNotice("OscClient") << "listen :" << listenPort + << " send -> " << sendHost << ":" << sendPort; +} + +void OscClient::update() { + while (receiver_.hasWaitingMessages()) { + ofxOscMessage m; + receiver_.getNextMessage(m); + const std::string& a = m.getAddress(); + + if (a == "/sync/bpm" && m.getNumArgs() >= 1) { + bpm_ = m.getArgAsFloat(0); + } else if (a == "/sync/beat" && m.getNumArgs() >= 1) { + beat_ = m.getArgAsInt32(0); + } else if (a == "/sync/amp" && m.getNumArgs() >= 2) { + amp_[m.getArgAsString(0)] = m.getArgAsFloat(1); + } else if (a == "/sync/rms" && m.getNumArgs() >= 1) { + rms_ = m.getArgAsFloat(0); + } else if (a == "/sync/album" && m.getNumArgs() >= 1) { + album_ = m.getArgAsString(0); + } else if (a == "/sync/melody" && m.getNumArgs() >= 1) { + melody_ = m.getArgAsString(0); + } else if (a == "/sync/synthdef" && m.getNumArgs() >= 1) { + synthdef_ = m.getArgAsString(0); + } + } +} + +float OscClient::amp(const std::string& voice) const { + auto it = amp_.find(voice); + return (it == amp_.end()) ? 0.0f : it->second; +} + +bool OscClient::beatPulse() { + if (beat_ != lastBeatPulsed_) { + lastBeatPulsed_ = beat_; + return true; + } + return false; +} + +void OscClient::sendKick(const std::string& name) { + sendControl("/control/kk", name); +} + +void OscClient::sendMelody(const std::string& name) { + sendControl("/control/setMelody", name); +} + +void OscClient::sendAlbum(const std::string& name) { + sendControl("/control/setAlbum", name); +} + +void OscClient::sendControl(const std::string& addr, float value) { + ofxOscMessage m; + m.setAddress(addr); + m.addFloatArg(value); + sender_.sendMessage(m, false); +} + +void OscClient::sendControl(const std::string& addr, const std::string& value) { + ofxOscMessage m; + m.setAddress(addr); + m.addStringArg(value); + sender_.sendMessage(m, false); +} + +} // namespace oscope diff --git a/oscope-of/src/OscClient.h b/oscope-of/src/OscClient.h new file mode 100644 index 0000000..c5400d8 --- /dev/null +++ b/oscope-of/src/OscClient.h @@ -0,0 +1,63 @@ +#pragma once + +// Client OSC pour le pont sound_algo. +// +// Reçoit sur 127.0.0.1:57122 les messages /sync/* émis par le bridge : +// /sync/bpm +// /sync/beat +// /sync/amp // 8 voies +// /sync/rms +// /sync/album +// /sync/melody +// /sync/synthdef +// +// Envoie sur 127.0.0.1:57121 les messages /control/* compris par sclang +// (kicks, mélodies, FX, etc.). + +#include "ofxOsc.h" + +#include +#include +#include + +namespace oscope { + +class OscClient { +public: + void setup(int listenPort, const std::string& sendHost, int sendPort); + void update(); + + // Etat courant (lu par les visualizers). + float bpm() const { return bpm_; } + int beat() const { return beat_; } + float amp(const std::string& voice) const; + float rms() const { return rms_; } + const std::string& album() const { return album_; } + const std::string& melody() const { return melody_; } + const std::string& synthdef() const { return synthdef_; } + + /// Beat impulse : retourne true une seule fois quand le beat change. + bool beatPulse(); + + /// Helpers d'envoi. + void sendKick(const std::string& name); + void sendMelody(const std::string& name); + void sendAlbum(const std::string& name); + void sendControl(const std::string& addr, float value); + void sendControl(const std::string& addr, const std::string& value); + +private: + ofxOscReceiver receiver_; + ofxOscSender sender_; + + float bpm_ = 120.0f; + int beat_ = 0; + int lastBeatPulsed_ = -1; + float rms_ = 0.0f; + std::unordered_map amp_; + std::string album_; + std::string melody_; + std::string synthdef_; +}; + +} // namespace oscope diff --git a/oscope-of/src/ScopeData.h b/oscope-of/src/ScopeData.h new file mode 100644 index 0000000..b5dda5c --- /dev/null +++ b/oscope-of/src/ScopeData.h @@ -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 +#include +#include +#include +#include + +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& outCh1, + std::vector& 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 ch1_; + std::vector ch2_; + std::atomic write_; + std::atomic read_; +}; + +} // namespace oscope diff --git a/oscope-of/src/main.cpp b/oscope-of/src/main.cpp new file mode 100644 index 0000000..ae71d8e --- /dev/null +++ b/oscope-of/src/main.cpp @@ -0,0 +1,19 @@ +// oscope-of — main entry point. +// Fenêtre 1920x1080 avec MSAA 8x, OpenGL 3.2 core profile pour les shaders modernes. + +#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-of"; + + auto window = ofCreateWindow(settings); + ofRunApp(window, std::make_shared()); + ofRunMainLoop(); + return 0; +} diff --git a/oscope-of/src/ofApp.cpp b/oscope-of/src/ofApp.cpp new file mode 100644 index 0000000..826bd23 --- /dev/null +++ b/oscope-of/src/ofApp.cpp @@ -0,0 +1,173 @@ +#include "ofApp.h" + +#include "ofxOsc.h" + +#include +#include + +namespace { +// Mini-parseur JSON minimaliste pour settings.json — suffisant pour les clés +// plates qu'on utilise (pas de vraie dépendance à ofxJSON). +std::string extractString(const std::string& src, const std::string& key, + const std::string& fallback) { + auto p = src.find("\"" + key + "\""); + if (p == std::string::npos) return fallback; + p = src.find(':', p); + if (p == std::string::npos) return fallback; + auto q = src.find('"', p); + if (q == std::string::npos) return fallback; + auto r = src.find('"', q + 1); + if (r == std::string::npos) return fallback; + return src.substr(q + 1, r - q - 1); +} + +float extractNumber(const std::string& src, const std::string& key, float fallback) { + auto p = src.find("\"" + key + "\""); + if (p == std::string::npos) return fallback; + p = src.find(':', p); + if (p == std::string::npos) return fallback; + std::size_t i = p + 1; + while (i < src.size() && (src[i] == ' ' || src[i] == '\n' || src[i] == '\t')) ++i; + std::size_t j = i; + while (j < src.size() && (std::isdigit(src[j]) || src[j] == '.' || src[j] == '-')) ++j; + if (j == i) return fallback; + try { return std::stof(src.substr(i, j - i)); } catch (...) { return fallback; } +} +} // namespace + +void ofApp::setup() { + ofSetVerticalSync(true); + ofSetFrameRate(60); + ofBackground(0); + ofSetCircleResolution(72); + + loadSettings(); + + osc_.setup(oscListenPort_, oscSendHost_, oscSendPort_); + + auto st = scope_.start(); + if (st == oscope::HantekStatus::FirmwareNeeded) { + ofLogWarning("ofApp") << "Hantek firmware non charge — fonctionnement degrade."; + } + + const int W = ofGetWidth(); + const int H = ofGetHeight(); + lissajous_ = std::make_unique(); + spectro_ = std::make_unique(); + reactive_ = std::make_unique(); + waveform_ = std::make_unique(); + lissajous_->setup(W, H); + spectro_->setup(W, H / 4); + reactive_->setup(W, H); + waveform_->setup(W, H); + + gui_.setup("oscope-of"); + gui_.add(modeLabel_.setup("Mode", "Hybrid")); + gui_.add(scopeStatusLabel_.setup("Scope", scope_.statusString())); + gui_.add(oscStatusLabel_.setup("OSC", "listening")); + gui_.add(gainCh1_.setup("CH1 V/div", 1.0f, 0.25f, 5.0f)); + gui_.add(gainCh2_.setup("CH2 V/div", 1.0f, 0.25f, 5.0f)); + gui_.add(trailFade_.setup("Trail fade", 0.07f, 0.0f, 1.0f)); + gui_.add(sampleRateHz_.setup("Sample rate", 8000000, 1000000, 48000000)); + + ch1_.reserve(bufferSize_); + ch2_.reserve(bufferSize_); +} + +void ofApp::loadSettings() { + std::ifstream f(ofToDataPath("settings.json")); + if (!f.is_open()) return; + std::stringstream ss; ss << f.rdbuf(); + const std::string s = ss.str(); + oscListenPort_ = static_cast(extractNumber(s, "listen_port", oscListenPort_)); + oscSendPort_ = static_cast(extractNumber(s, "send_port", oscSendPort_)); + oscSendHost_ = extractString(s, "send_host", oscSendHost_); + bufferSize_ = static_cast(extractNumber(s, "buffer_size", bufferSize_)); + const std::string m = extractString(s, "default_mode", "hybrid"); + if (m == "lissajous") mode_ = Mode::Lissajous; + else if (m == "spectro" || m == "spectrogram") mode_ = Mode::Spectrogram; + else if (m == "reactive") mode_ = Mode::Reactive; + else if (m == "waveform" || m == "scope") mode_ = Mode::Waveform; + else mode_ = Mode::Hybrid; +} + +void ofApp::update() { + osc_.update(); + scope_.ring().readLatest(ch1_, ch2_, static_cast(bufferSize_)); + + oscope::VisFrame frame{ch1_, ch2_, osc_}; + if (mode_ == Mode::Lissajous || mode_ == Mode::Hybrid) lissajous_->update(frame); + if (mode_ == Mode::Spectrogram || mode_ == Mode::Hybrid) spectro_->update(frame); + if (mode_ == Mode::Reactive || mode_ == Mode::Hybrid) reactive_->update(frame); + if (mode_ == Mode::Waveform || mode_ == Mode::Hybrid) waveform_->update(frame); + + scopeStatusLabel_ = "Scope: " + scope_.statusString() + " (" + + ofToString(ch1_.size()) + " s)"; + oscStatusLabel_ = "OSC bpm=" + ofToString(osc_.bpm(), 1) + + " beat=" + ofToString(osc_.beat()) + + " kick=" + ofToString(osc_.amp("kick"), 2); +} + +void ofApp::draw() { + const int W = ofGetWidth(); + const int H = ofGetHeight(); + + if (mode_ == Mode::Reactive || mode_ == Mode::Hybrid) { + reactive_->draw(0, 0, W, H); + } else { + ofBackground(0); + } + if (mode_ == Mode::Waveform) { + waveform_->draw(0, 0, W, H); + } else if (mode_ == Mode::Lissajous || mode_ == Mode::Hybrid) { + lissajous_->draw(0, 0, W, H); + } + if (mode_ == Mode::Spectrogram || mode_ == Mode::Hybrid) { + const int sh = (mode_ == Mode::Spectrogram) ? H : H / 4; + const int sy = (mode_ == Mode::Spectrogram) ? 0 : H - sh; + spectro_->draw(0, sy, W, sh); + } + + if (showGui_) { + gui_.draw(); + drawHud(); + } +} + +void ofApp::drawHud() { + ofPushStyle(); + ofSetColor(180, 220, 200, 220); + ofDrawBitmapString("FPS: " + ofToString(ofGetFrameRate(), 1), 12, ofGetHeight() - 28); + ofDrawBitmapString("Mode: " + + std::string(mode_ == Mode::Lissajous ? "Lissajous" : + mode_ == Mode::Spectrogram ? "Spectrogram" : + mode_ == Mode::Reactive ? "Reactive" : + mode_ == Mode::Waveform ? "Waveform" : "Hybrid"), + 12, ofGetHeight() - 12); + ofPopStyle(); +} + +void ofApp::exit() { + scope_.stop(); +} + +void ofApp::keyPressed(int key) { + switch (key) { + case '1': mode_ = Mode::Lissajous; modeLabel_ = "Lissajous"; break; + case '2': mode_ = Mode::Spectrogram; modeLabel_ = "Spectrogram"; break; + case '3': mode_ = Mode::Reactive; modeLabel_ = "Reactive"; break; + case '4': mode_ = Mode::Hybrid; modeLabel_ = "Hybrid"; break; + case '5': mode_ = Mode::Waveform; modeLabel_ = "Waveform"; break; + case 'f': + fullscreen_ = !fullscreen_; + ofSetFullscreen(fullscreen_); + break; + case 'g': showGui_ = !showGui_; break; + case 'r': + lissajous_->reloadShaders(); + reactive_->reloadShaders(); + ofLogNotice("ofApp") << "Shaders rechargés"; + break; + default: break; + } +} diff --git a/oscope-of/src/ofApp.h b/oscope-of/src/ofApp.h new file mode 100644 index 0000000..3a5cb2b --- /dev/null +++ b/oscope-of/src/ofApp.h @@ -0,0 +1,62 @@ +#pragma once + +// Orchestrateur principal : +// - démarre le scope Hantek + thread USB +// - démarre le client OSC +// - gère le mode courant (Lissajous / Spectrogram / Reactive / Hybrid) +// - assemble les visualizers et la GUI ofxPanel + +#include "ofMain.h" +#include "ofxGui.h" + +#include "HantekDevice.h" +#include "OscClient.h" +#include "visualizers/LissajousVis.h" +#include "visualizers/ReactiveVis.h" +#include "visualizers/SpectrogramVis.h" +#include "visualizers/WaveformVis.h" + +#include + +class ofApp : public ofBaseApp { +public: + enum class Mode { Lissajous, Spectrogram, Reactive, Waveform, Hybrid }; + + void setup() override; + void update() override; + void draw() override; + void exit() override; + void keyPressed(int key) override; + +private: + void loadSettings(); + void drawHud(); + + oscope::HantekDevice scope_; + oscope::OscClient osc_; + + std::unique_ptr lissajous_; + std::unique_ptr spectro_; + std::unique_ptr reactive_; + std::unique_ptr waveform_; + + std::vector ch1_, ch2_; + Mode mode_ = Mode::Hybrid; + bool showGui_ = true; + bool fullscreen_ = false; + + // GUI + ofxPanel gui_; + ofxFloatSlider gainCh1_, gainCh2_; + ofxFloatSlider trailFade_; + ofxIntSlider sampleRateHz_; + ofxLabel scopeStatusLabel_; + ofxLabel oscStatusLabel_; + ofxLabel modeLabel_; + + // Settings persistants. + int oscListenPort_ = 57122; + std::string oscSendHost_ = "127.0.0.1"; + int oscSendPort_ = 57121; + int bufferSize_ = 4096; +}; diff --git a/oscope-of/src/visualizers/LissajousVis.cpp b/oscope-of/src/visualizers/LissajousVis.cpp new file mode 100644 index 0000000..1790dde --- /dev/null +++ b/oscope-of/src/visualizers/LissajousVis.cpp @@ -0,0 +1,74 @@ +#include "LissajousVis.h" + +namespace oscope { + +void LissajousVis::setup(int w, int h) { + w_ = w; h_ = h; + ofFboSettings s; + s.width = w; s.height = h; + s.internalformat = GL_RGBA16F; + s.numSamples = 0; + s.useDepth = false; + trail_.allocate(s); + trail_.begin(); ofClear(0, 0, 0, 255); trail_.end(); + reloadShaders(); +} + +void LissajousVis::reloadShaders() { + glow_.load("shaders/glow"); +} + +void LissajousVis::update(const VisFrame& frame) { + // Détection beat -> flash kick (utilise l'amplitude kick). + const float k = frame.osc.amp("kick"); + if (k > kickFlash_) kickFlash_ = k; + kickFlash_ *= 0.92f; + + // Dessin dans le FBO trail avec fade. + trail_.begin(); + ofPushStyle(); + ofEnableAlphaBlending(); + // Fade : rectangle noir semi-transparent. + ofSetColor(0, 0, 0, 18); + ofDrawRectangle(0, 0, w_, h_); + + // Trace CH1/CH2 en LINE_STRIP centré dans le FBO. + const auto& ch1 = frame.ch1; + const auto& ch2 = frame.ch2; + const std::size_t n = std::min(ch1.size(), ch2.size()); + if (n >= 2) { + const float cx = w_ * 0.5f; + const float cy = h_ * 0.5f; + const float scale = 0.45f * std::min(w_, h_); + ofMesh mesh; + mesh.setMode(OF_PRIMITIVE_LINE_STRIP); + const float intensity = 0.6f + 0.4f * kickFlash_; + ofFloatColor base(0.0f, 1.0f, 0.5f, intensity); // vert phosphor #00FF7F + for (std::size_t i = 0; i < n; ++i) { + mesh.addVertex(glm::vec3(cx + ch1[i] * scale, + cy - ch2[i] * scale, 0.0f)); + mesh.addColor(base); + } + mesh.draw(); + } + ofPopStyle(); + trail_.end(); +} + +void LissajousVis::draw(int x, int y, int w, int h) { + ofPushStyle(); + ofEnableBlendMode(OF_BLENDMODE_ADD); + if (glow_.isLoaded()) { + glow_.begin(); + glow_.setUniformTexture("uTex", trail_.getTexture(), 0); + glow_.setUniform2f("uTexel", 1.0f / w_, 1.0f / h_); + glow_.setUniform1f("uIntensity", 1.2f + 0.6f * kickFlash_); + trail_.draw(x, y, w, h); + glow_.end(); + } else { + trail_.draw(x, y, w, h); + } + ofPopStyle(); +} + +} // namespace oscope diff --git a/oscope-of/src/visualizers/LissajousVis.h b/oscope-of/src/visualizers/LissajousVis.h new file mode 100644 index 0000000..0531136 --- /dev/null +++ b/oscope-of/src/visualizers/LissajousVis.h @@ -0,0 +1,29 @@ +#pragma once + +// Lissajous XY scope avec persistance type CRT phosphor. +// FBO en feedback : à chaque frame on dessine un fade noir alpha-bas par-dessus +// puis on trace les segments (CH1, CH2). Un shader glow (Gaussian blur 9-tap) +// est appliqué en sortie. + +#include "Visualizer.h" +#include "ofMain.h" + +namespace oscope { + +class LissajousVis : public Visualizer { +public: + void setup(int w, int h) override; + void update(const VisFrame& frame) override; + void draw(int x, int y, int w, int h) override; + void reloadShaders() override; + +private: + ofFbo trail_; + ofShader glow_; + int w_ = 0; + int h_ = 0; + float kickFlash_ = 0.0f; // 0..1, décrémenté à chaque frame + int lastBeat_ = -1; +}; + +} // namespace oscope diff --git a/oscope-of/src/visualizers/ReactiveVis.cpp b/oscope-of/src/visualizers/ReactiveVis.cpp new file mode 100644 index 0000000..45f647b --- /dev/null +++ b/oscope-of/src/visualizers/ReactiveVis.cpp @@ -0,0 +1,56 @@ +#include "ReactiveVis.h" + +namespace oscope { + +void ReactiveVis::setup(int w, int h) { + w_ = w; h_ = h; + reloadShaders(); +} + +void ReactiveVis::reloadShaders() { + bg_.load("shaders/bg"); +} + +void ReactiveVis::update(const VisFrame& frame) { + time_ += ofGetLastFrameTime(); + auto& osc = frame.osc; + bpm_ = osc.bpm(); + beat_ = osc.beat(); + // Lissage exponentiel pour éviter les jumps brutaux. + auto smooth = [](float& s, float target) { s += (target - s) * 0.18f; }; + smooth(kick_, osc.amp("kick")); + smooth(hat_, osc.amp("hat")); + smooth(snare_, osc.amp("snare")); + smooth(clap_, osc.amp("clap")); + smooth(perc_, osc.amp("perc")); + smooth(melody_, osc.amp("melody")); + smooth(acid_, osc.amp("acid")); + smooth(harmony_, osc.amp("harmony")); +} + +void ReactiveVis::draw(int x, int y, int w, int h) { + if (!bg_.isLoaded()) { + ofPushStyle(); + ofSetColor(8, 6, 16); + ofDrawRectangle(x, y, w, h); + ofPopStyle(); + return; + } + bg_.begin(); + bg_.setUniform2f("uResolution", w, h); + bg_.setUniform1f("uTime", time_); + bg_.setUniform1f("uBpm", bpm_); + bg_.setUniform1i("uBeat", beat_); + bg_.setUniform1f("uKick", kick_); + bg_.setUniform1f("uHat", hat_); + bg_.setUniform1f("uSnare", snare_); + bg_.setUniform1f("uClap", clap_); + bg_.setUniform1f("uPerc", perc_); + bg_.setUniform1f("uMelody", melody_); + bg_.setUniform1f("uAcid", acid_); + bg_.setUniform1f("uHarmony", harmony_); + ofDrawRectangle(x, y, w, h); + bg_.end(); +} + +} // namespace oscope diff --git a/oscope-of/src/visualizers/ReactiveVis.h b/oscope-of/src/visualizers/ReactiveVis.h new file mode 100644 index 0000000..bc3acd3 --- /dev/null +++ b/oscope-of/src/visualizers/ReactiveVis.h @@ -0,0 +1,29 @@ +#pragma once + +// Layer arrière-plan piloté par sound_algo : grille hexagonale qui pulse au +// kick, gradient HSV qui tourne au beat, distortion modulée par la mélodie. +// Tout est calculé dans bg.frag, on n'envoie que des uniforms. + +#include "Visualizer.h" +#include "ofMain.h" + +namespace oscope { + +class ReactiveVis : public Visualizer { +public: + void setup(int w, int h) override; + void update(const VisFrame& frame) override; + void draw(int x, int y, int w, int h) override; + void reloadShaders() override; + +private: + ofShader bg_; + int w_ = 0, h_ = 0; + float time_ = 0.0f; + float kick_ = 0.0f, hat_ = 0.0f, snare_ = 0.0f, clap_ = 0.0f; + float perc_ = 0.0f, melody_ = 0.0f, acid_ = 0.0f, harmony_ = 0.0f; + float bpm_ = 120.0f; + int beat_ = 0; +}; + +} // namespace oscope diff --git a/oscope-of/src/visualizers/SpectrogramVis.cpp b/oscope-of/src/visualizers/SpectrogramVis.cpp new file mode 100644 index 0000000..4236978 --- /dev/null +++ b/oscope-of/src/visualizers/SpectrogramVis.cpp @@ -0,0 +1,71 @@ +#include "SpectrogramVis.h" + +#include +#include + +namespace oscope { + +namespace { +constexpr std::size_t kFftSize = 1024; +} + +SpectrogramVis::SpectrogramVis() : fft_(kFftSize) {} + +void SpectrogramVis::setup(int w, int h) { + w_ = w; h_ = h; + ofFboSettings s; + s.width = w; s.height = h; + s.internalformat = GL_RGBA8; + s.useDepth = false; + fbo_.allocate(s); + scratch_.allocate(s); + fbo_.begin(); ofClear(0, 0, 0, 255); fbo_.end(); + scratch_.begin(); ofClear(0, 0, 0, 255); scratch_.end(); + mono_.resize(kFftSize, 0.0f); +} + +void SpectrogramVis::update(const VisFrame& frame) { + const auto& ch1 = frame.ch1; + const auto& ch2 = frame.ch2; + const std::size_t n = std::min({ch1.size(), ch2.size(), kFftSize}); + if (n == 0) return; + // Mixe mono = (CH1 + CH2) / 2, prend les n derniers samples, padde avec 0. + std::fill(mono_.begin(), mono_.end(), 0.0f); + for (std::size_t i = 0; i < n; ++i) mono_[i] = 0.5f * (ch1[i] + ch2[i]); + fft_.magnitude(mono_, mag_); + + // Décale fbo_ d'1 px vers la gauche en blittant dans scratch_, puis dessine + // la nouvelle colonne tout à droite. + scratch_.begin(); + ofClear(0, 0, 0, 255); + fbo_.draw(-1, 0); + // Nouvelle colonne : magnitude log-mappée -> hue bleu→violet→rouge. + const std::size_t bins = mag_.size(); + for (int py = 0; py < h_; ++py) { + // Mapping log-fréquence : bins du bas = basses, haut = aigus. + const float t = 1.0f - static_cast(py) / h_; + const std::size_t bin = static_cast(std::pow(t, 3.0f) * (bins - 1)); + const float magdb = 20.0f * std::log10(std::max(mag_[bin], 1e-6f)) + 60.0f; + const float v = ofClamp(magdb / 60.0f, 0.0f, 1.0f); + const float hue = 170.0f - 170.0f * v; // bleu (170) -> rouge (0) + ofColor c; c.setHsb(hue, 200.0f * (0.4f + 0.6f * v), 255.0f * v); + ofSetColor(c); + ofDrawRectangle(w_ - 1, py, 1, 1); + } + scratch_.end(); + + // Swap. + fbo_.begin(); + ofClear(0, 0, 0, 255); + scratch_.draw(0, 0); + fbo_.end(); +} + +void SpectrogramVis::draw(int x, int y, int w, int h) { + ofPushStyle(); + ofSetColor(255, 255, 255, 200); + fbo_.draw(x, y, w, h); + ofPopStyle(); +} + +} // namespace oscope diff --git a/oscope-of/src/visualizers/SpectrogramVis.h b/oscope-of/src/visualizers/SpectrogramVis.h new file mode 100644 index 0000000..ec1d55f --- /dev/null +++ b/oscope-of/src/visualizers/SpectrogramVis.h @@ -0,0 +1,29 @@ +#pragma once + +// Spectrogramme FFT scrollant. Une nouvelle colonne est ajoutée à droite à +// chaque update, le contenu de la texture se décale d'1 pixel vers la gauche. + +#include "../FFT.h" +#include "Visualizer.h" +#include "ofMain.h" + +namespace oscope { + +class SpectrogramVis : public Visualizer { +public: + SpectrogramVis(); + void setup(int w, int h) override; + void update(const VisFrame& frame) override; + void draw(int x, int y, int w, int h) override; + +private: + ofFbo fbo_; + ofFbo scratch_; + int w_ = 0; + int h_ = 0; + FFT fft_; + std::vector mono_; + std::vector mag_; +}; + +} // namespace oscope diff --git a/oscope-of/src/visualizers/Visualizer.h b/oscope-of/src/visualizers/Visualizer.h new file mode 100644 index 0000000..65847b3 --- /dev/null +++ b/oscope-of/src/visualizers/Visualizer.h @@ -0,0 +1,27 @@ +#pragma once + +// Interface abstraite pour les 3 modes de visualisation. + +#include "../OscClient.h" +#include "../ScopeData.h" + +#include + +namespace oscope { + +struct VisFrame { + const std::vector& ch1; + const std::vector& ch2; + OscClient& osc; +}; + +class Visualizer { +public: + virtual ~Visualizer() = default; + virtual void setup(int w, int h) = 0; + virtual void update(const VisFrame& frame) = 0; + virtual void draw(int x, int y, int w, int h) = 0; + virtual void reloadShaders() {} +}; + +} // namespace oscope diff --git a/oscope-of/src/visualizers/WaveformVis.cpp b/oscope-of/src/visualizers/WaveformVis.cpp new file mode 100644 index 0000000..2ab0639 --- /dev/null +++ b/oscope-of/src/visualizers/WaveformVis.cpp @@ -0,0 +1,132 @@ +#include "WaveformVis.h" +#include +#include + +namespace oscope { + +void WaveformVis::setup(int w, int h) { + w_ = w; + h_ = h; + trace1_.assign(syntheticSize_, 0.0f); + trace2_.assign(syntheticSize_, 0.0f); +} + +void WaveformVis::update(const VisFrame& frame) { + const auto& osc = frame.osc; + + // Pulse de beat : déclenche un envelope decay quand /sync/beat tick + int b = osc.beat(); + if (b != prevBeat_) { + beatPhase_ = 1.0f; + prevBeat_ = b; + } + beatPhase_ *= 0.92f; + + // Si Hantek a fourni des samples, on les copie tels quels + if (frame.ch1.size() > 16) { + std::size_t n = std::min(frame.ch1.size(), trace1_.size()); + for (std::size_t i = 0; i < n; ++i) { + trace1_[i] = frame.ch1[i]; + trace2_[i] = (i < frame.ch2.size()) ? frame.ch2[i] : 0.0f; + } + if (n < trace1_.size()) { + std::fill(trace1_.begin() + n, trace1_.end(), 0.0f); + std::fill(trace2_.begin() + n, trace2_.end(), 0.0f); + } + return; + } + + // Sinon : synthèse à partir des audio meters reçus en OSC + const float kick = osc.amp("kick"); + const float snare = osc.amp("snare"); + const float melody = osc.amp("melody"); + const float hat = osc.amp("hat"); + const float bpm = std::max(60.0f, std::min(240.0f, osc.bpm())); + + // Fréquence porteuse modulée par melody + const float carrierHz = 220.0f + melody * 1800.0f; + const float sampleRate = 48000.0f; + const float dphi = ofWrap(2.0f * PI * carrierHz / sampleRate, 0.0f, 1e9f); + + // Sub-bass kick + const float subHz = 60.0f; + const float dsub = 2.0f * PI * subHz / sampleRate; + + for (int i = 0; i < syntheticSize_; ++i) { + phase_ += dphi; + if (phase_ > 1e6f) phase_ = std::fmod(phase_, 2.0f * PI); + const float t = static_cast(i) / static_cast(syntheticSize_); + + const float carrier = std::sin(phase_) * (0.3f + melody * 0.6f); + const float sub = std::sin(t * dsub * syntheticSize_) * kick * (0.4f + beatPhase_ * 0.6f); + const float noise = (ofRandom(-1.0f, 1.0f)) * (snare * 0.4f + hat * 0.25f); + const float ch1 = (carrier + sub + noise) * (0.6f + beatPhase_ * 0.4f); + + const float carrier2 = std::sin(phase_ * 1.5f + 0.7f) * (0.25f + melody * 0.5f); + const float ch2 = carrier2 + sub * 0.7f + noise * 0.6f; + + trace1_[i] = std::tanh(ch1); + trace2_[i] = std::tanh(ch2 * 0.9f); + } +} + +void WaveformVis::draw(int x, int y, int w, int h) { + ofPushStyle(); + ofPushMatrix(); + + // Fond noir + grille verte CRT + ofFill(); + ofSetColor(8, 12, 8); + ofDrawRectangle(x, y, w, h); + + ofNoFill(); + ofSetLineWidth(1); + ofSetColor(0, 80, 50, 80); + for (int i = 0; i <= divX_; ++i) { + const float gx = x + (w / divX_) * i; + ofDrawLine(gx, y, gx, y + h); + } + for (int j = 0; j <= divY_; ++j) { + const float gy = y + (h / divY_) * j; + ofDrawLine(x, gy, x + w, gy); + } + // Centre lines, plus marqués + ofSetColor(0, 140, 80, 130); + ofDrawLine(x, y + h * 0.5f, x + w, y + h * 0.5f); + ofDrawLine(x + w * 0.5f, y, x + w * 0.5f, y + h); + + auto plot = [&](const std::vector& trace, ofColor color, float yOff) { + ofSetColor(color); + ofSetLineWidth(2); + ofBeginShape(); + const int n = static_cast(trace.size()); + for (int i = 0; i < n; ++i) { + const float px = x + (static_cast(i) / (n - 1)) * w; + const float py = y + yOff + trace[i] * (h * 0.22f); + ofVertex(px, py); + } + ofEndShape(false); + }; + + // Glow phosphor : 3 passes décalées en alpha + for (int pass = 3; pass >= 1; --pass) { + const float a = 30.0f * pass; + plot(trace1_, ofColor(0, 255, 140, static_cast(a)), h * 0.30f); + plot(trace2_, ofColor(255, 200, 60, static_cast(a)), h * 0.70f); + } + + // HUD + ofSetColor(160, 220, 180); + const float vdiv1 = 0.5f; + const float vdiv2 = 0.5f; + const float tdiv = 5.0f; // ms/div approximatif + ofDrawBitmapString("CH1 " + ofToString(vdiv1, 2) + " V/div", x + 10, y + 16); + ofDrawBitmapString("CH2 " + ofToString(vdiv2, 2) + " V/div", x + 10, y + 32); + ofDrawBitmapString("Time " + ofToString(tdiv, 1) + " ms/div", x + 10, y + 48); + ofDrawBitmapString("Trig auto", x + 10, y + 64); + + ofPopMatrix(); + ofPopStyle(); +} + +} // namespace oscope diff --git a/oscope-of/src/visualizers/WaveformVis.h b/oscope-of/src/visualizers/WaveformVis.h new file mode 100644 index 0000000..82679dc --- /dev/null +++ b/oscope-of/src/visualizers/WaveformVis.h @@ -0,0 +1,30 @@ +#pragma once + +// Time-domain oscilloscope view (CRT-style) for ch1/ch2 buffers. +// Falls back to synthesized signal driven by OscClient telemetry when +// the Hantek scope buffer is empty (Big Sur libusb limitation). + +#include "Visualizer.h" +#include "ofMain.h" + +namespace oscope { + +class WaveformVis : public Visualizer { +public: + void setup(int w, int h) override; + void update(const VisFrame& frame) override; + void draw(int x, int y, int w, int h) override; + +private: + int w_ = 0, h_ = 0; + float phase_ = 0.0f; + float beatPhase_ = 0.0f; + int prevBeat_ = 0; + std::vector trace1_; + std::vector trace2_; + int syntheticSize_ = 2048; + float divX_ = 8.0f; // grid divisions + float divY_ = 8.0f; +}; + +} // namespace oscope