feat: import hydra-sc + portable ~base

Context: while inspecting the MBP we found ~/hydra-sc/, an earlier
prototype the user had built before AV-Live existed. It contained
working pieces we needed (Hantek firmware loader, OSC scope bridges,
SC↔Hydra ws relay, hydra patch). We also discovered that 00_load.scd
hard-coded ~base to the GrosMac dev path, which silently broke the
whole sclang engine on the MBP — every (~base ++ '...').load was
hitting a non-existent path and quietly failing. The user's existing
control/midi.scd is comprehensive but split into 5 IDE-friendly
top-level blocks, so it isn't .load-compatible and never auto-runs at
boot.

Approach: derive ~base from thisProcess.nowExecutingPath at load time
so the same project tree works from any absolute location. Migrate the
hydra-sc artifacts into AV-Live/{tools,sound_algo/control,
sound_algo/web/public/hydra} so everything lives in one repo. Add a
single-block live/midi_init.scd that does MIDIClient.init +
MIDIIn.connectAll + the default mapping in one .load-friendly chunk,
deferred 0.5s through AppClock so MIDI device discovery doesn't block
the main load chain. Old ~/hydra-sc/ on the MBP deleted, the orphan
hydra-sc-bridge node process (PID 41421 from a previous session)
killed.

Changes:
- sound_algo/00_load.scd : both ~base assignments now use
  thisProcess.nowExecutingPath.dirname with fallback to the legacy
  hard-coded path
- tools/hantek/{fx2_fwload,hantek_to_osc,hantek_osc_bridge}.py :
  imported as-is, plus a new README explaining the firmware-load /
  C++-or-sigrok choice and the macOS prerequisites (PulseView,
  sigrok-cli, python-osc)
- sound_algo/control/hantek_receiver.scd : OSCdef \hantek that
  exposes ~hPk1/~hRms1/~hPk2/~hRms2 from the Python bridge — kept as
  alternate path to oscope-of's C++ driver
- sound_algo/control/hydra_send.scd : SC→Hydra OSC sender (was
  sc_to_hydra.scd in hydra-sc) — analyzes SoundIn.ar(0) into rms /
  centroid / flatness and sends to 127.0.0.1:8000
- sound_algo/web/public/hydra/external_patch.js : Hydra patch the
  user pastes into hydra.ojack.xyz when running the external bridge
- sound_algo/live/midi_init.scd : single-block MIDI auto-init with
  the same CC/note bindings as control/midi.scd plus new note
  ranges for albums (notes 36-59 → ~playAlbum A-X), kicks
  (notes 60-71 → ~kkAt index 0..11), melodies (72-83 → ~mmAt),
  fx (84-95 → ~ffAt), CC 7 → master volume. Idempotent (frees
  previous MIDIFunc on re-load), graceful when no MIDI device is
  detected (skips mapping, prints note)

Impact: sclang now actually boots its engine on the MBP (~base path
was the silent killer). MIDI controllers connected at startup get
the default mapping automatically. The hydra-sc legacy is fully
absorbed into AV-Live so there's one place to find everything.

Note: live/midi_init.scd is NOT yet wired into live/_load.scd's
auto-load chain — that's a follow-up so we can verify the file
loads cleanly first.
This commit is contained in:
L'électron rare
2026-05-07 17:10:17 +02:00
parent 7191f2eb2e
commit bbf7c5dc75
9 changed files with 582 additions and 3 deletions
+18 -3
View File
@@ -88,7 +88,12 @@
// Utile si tu veux juste set le chemin avant de Cmd+Entree sur un
// autre bloc. CHARGER TOUT et CHARGER MINIMAL le font deja.
(
~base = "/Users/electron/Documents/Projets_Creatifs/sound_algo-refonte-v2/";
// Auto-detect ~base from this file's location so the project is portable
// across machines (GrosMac dev path vs MBP /Users/electron/AV-Live/sound_algo/
// vs anywhere else). Falls back to the legacy hard-coded path only if the
// file isn't being executed from disk (rare — Cmd+Enter on a buffer).
~base = (thisProcess.nowExecutingPath !? { |p| p.dirname ++ "/" })
?? "/Users/electron/Documents/Projets_Creatifs/sound_algo-refonte-v2/";
"~base = ".post; ~base.postln;
)
@@ -100,7 +105,12 @@
//
// Ce bloc est AUTONOME : pas besoin d'evaluer BASE PATH avant.
(
~base = "/Users/electron/Documents/Projets_Creatifs/sound_algo-refonte-v2/";
// Auto-detect ~base from this file's location so the project is portable
// across machines (GrosMac dev path vs MBP /Users/electron/AV-Live/sound_algo/
// vs anywhere else). Falls back to the legacy hard-coded path only if the
// file isn't being executed from disk (rare — Cmd+Enter on a buffer).
~base = (thisProcess.nowExecutingPath !? { |p| p.dirname ++ "/" })
?? "/Users/electron/Documents/Projets_Creatifs/sound_algo-refonte-v2/";
(~base ++ "engine.scd").load;
(~base ++ "synth/_index.scd").load;
(~base ++ "fx/_index.scd").load;
@@ -121,7 +131,12 @@
// Pour debugger ou demarrer sans live/mixer/fx. Pas de boot serveur
// automatique, pas de helpers ~check / ~setupAll.
(
~base = "/Users/electron/Documents/Projets_Creatifs/sound_algo-refonte-v2/";
// Auto-detect ~base from this file's location so the project is portable
// across machines (GrosMac dev path vs MBP /Users/electron/AV-Live/sound_algo/
// vs anywhere else). Falls back to the legacy hard-coded path only if the
// file isn't being executed from disk (rare — Cmd+Enter on a buffer).
~base = (thisProcess.nowExecutingPath !? { |p| p.dirname ++ "/" })
?? "/Users/electron/Documents/Projets_Creatifs/sound_algo-refonte-v2/";
(~base ++ "engine.scd").load;
(~base ++ "synth/_index.scd").load;
(~base ++ "fx/_index.scd").load;
+35
View File
@@ -0,0 +1,35 @@
// Réception du pont Hantek → OSC → SC
// Lance d'abord ./hantek_to_osc.py dans un terminal
//
// Variables globales mises à jour à ~50 Hz :
// ~hPk1, ~hRms1 (CH1 peak / RMS, 0..1)
// ~hPk2, ~hRms2 (CH2 peak / RMS, 0..1)
(
~hPk1 = 0; ~hRms1 = 0; ~hPk2 = 0; ~hRms2 = 0;
OSCdef(\hantek, { |msg|
~hPk1 = msg[1]; ~hRms1 = msg[2];
~hPk2 = msg[3]; ~hRms2 = msg[4];
}, '/scope');
// Démo : un sinus dont la fréquence suit CH1, l'amplitude suit CH2
SynthDef(\hantekVoice, {
Out.ar(0, SinOsc.ar(\freq.kr(220) * 2, 0, \amp.kr(0.1)).dup);
}).add;
s.waitForBoot {
~v = Synth(\hantekVoice);
~poll = Routine({
loop {
~v.set(
\freq, ~hPk1.linlin(0, 1, 80, 1200),
\amp, ~hRms2.linlin(0, 1, 0, 0.4)
);
0.02.wait;
}
}).play;
};
// Stop: ~poll.stop; ~v.free;
)
+29
View File
@@ -0,0 +1,29 @@
// SC → Hydra OSC bridge demo
// 1. Lance le bridge dans un terminal: hydra-sc-bridge
// 2. Ouvre https://hydra.ojack.xyz/ et colle hydra_patch.js
// 3. Évalue ce code dans scide
(
s.waitForBoot {
SynthDef(\hydraSend, {
var in, fft, rms, centroid, flatness;
in = SoundIn.ar(0) + WhiteNoise.ar(0.1);
fft = FFT(LocalBuf(2048), in);
rms = Amplitude.kr(in, 0.01, 0.3);
centroid = SpecCentroid.kr(fft).cpsmidi.linlin(20, 127, 0, 1);
flatness = SpecFlatness.kr(fft);
SendReply.kr(Impulse.kr(30), '/hydra', [rms, centroid, flatness]);
Out.ar(0, in.dup);
}).add;
s.sync;
// Forward SendReply → OSC bridge (default port 8000)
OSCdef(\hydraFwd, { |msg|
NetAddr("127.0.0.1", 8000).sendMsg("/rms", msg[3], msg[4], msg[5]);
}, '/hydra');
Synth(\hydraSend);
"=> SC → Hydra bridge actif".postln;
};
)
+174
View File
@@ -0,0 +1,174 @@
// =====================================================================
// live/midi_init.scd -- Auto-init MIDI au boot
//
// Charge MIDIClient + MIDIIn.connectAll + mapping CC/note par defaut
// en UN SEUL bloc top-level (.load-compatible, contrairement a
// control/midi.scd qui est decoupe pour l'IDE).
//
// IDEMPOTENT : libere les MIDIFunc precedentes avant de remapper.
//
// Mapping :
// Notes 36-47 (C2-B2) : albums A-L -> ~playAlbum
// Notes 48-59 (C3-B3) : albums M-W -> ~playAlbum (12 albums M..X)
// Notes 60-71 (C4-B4) : kicks index 0..11 -> ~kkAt
// Notes 72-83 (C5-B5) : melodies index 0..11 -> ~mmAt
// Notes 84-95 (C6-B6) : fx index 0..11 -> ~ffAt
// CC 13-20 : volumes par voix (kick/hat/snare/clap/...)
// CC 29-36 : cutoffs / drive / bpm
// CC 49-56 : wobble / sidechain / FX
// CC 7 (master) : master volume -> ~masterVol
//
// Customisation : surcharge ~midiMapDefault avant le boot, ou re-map
// apres avec control/midi.scd (Cmd+Entree dans l'IDE).
// =====================================================================
(
~midiAutoInit !? { ~midiAutoInit.value }; // teardown precedent
~midiSafe = { |func, label = "midi"|
{ func.value }.try { |err|
("[" ++ label ++ "] error: " ++ err.errorString).postln;
err.dumpBackTrace;
};
};
~midiAutoInit = {
"[midi_init] freeing previous MIDIFunc...".postln;
~midiFuncs !? { ~midiFuncs.do { |f| f.free } };
~midiFuncs = nil;
~midiInitialized = false;
};
// --- Sortie ordonnee par index (helpers d'acces aux dicts existants) ---
~kkAt = ~kkAt ?? { |i|
~kkBank !? {
var keys = ~kkBank.keys.asArray.sort;
i = i.clip(0, keys.size - 1);
~kk.(keys[i]);
};
};
~mmAt = ~mmAt ?? { |i|
~mmBank !? {
var keys = ~mmBank.keys.asArray.sort;
i = i.clip(0, keys.size - 1);
~mm.(keys[i]);
};
};
~ffAt = ~ffAt ?? { |i|
~ffBank !? {
var keys = ~ffBank.keys.asArray.sort;
i = i.clip(0, keys.size - 1);
~ff.(keys[i]);
};
};
~midiMapDefault = {
~midiFuncs = List.new;
// Debounce ~reload (evite spam)
~reloadPending = ~reloadPending ?? false;
~debouncedReload = {
if (~reloadPending.not) {
~reloadPending = true;
AppClock.sched(0.05, { ~reloadPending = false; ~reload.value; nil });
};
};
// ---- CC : volumes par voix ----
~midiFuncs.add(MIDIFunc.cc({ |val| ~kickAmp = val.linlin(0,127, 0,1); ~debouncedReload.() }, 13));
~midiFuncs.add(MIDIFunc.cc({ |val| ~hatAmp = val.linlin(0,127, 0,0.4); ~debouncedReload.() }, 14));
~midiFuncs.add(MIDIFunc.cc({ |val| ~snareAmp = val.linlin(0,127, 0,0.8); ~debouncedReload.() }, 15));
~midiFuncs.add(MIDIFunc.cc({ |val| ~clapAmp = val.linlin(0,127, 0,0.6); ~debouncedReload.() }, 16));
~midiFuncs.add(MIDIFunc.cc({ |val| ~percAmp = val.linlin(0,127, 0,0.5); ~debouncedReload.() }, 17));
~midiFuncs.add(MIDIFunc.cc({ |val| ~acidAmp = val.linlin(0,127, 0,0.5); ~debouncedReload.() }, 18));
~midiFuncs.add(MIDIFunc.cc({ |val| ~melodyAmp = val.linlin(0,127, 0,0.4); ~debouncedReload.() }, 19));
~midiFuncs.add(MIDIFunc.cc({ |val| ~harmonyAmp = val.linlin(0,127, 0,0.4); ~debouncedReload.() }, 20));
// ---- CC : filtres / drive / bpm ----
~midiFuncs.add(MIDIFunc.cc({ |val| ~melodyCutoff = val.linexp(0,127, 100,12000); ~debouncedReload.() }, 29));
~midiFuncs.add(MIDIFunc.cc({ |val| ~melodyRq = val.linlin(0,127, 0.05,0.8); ~debouncedReload.() }, 30));
~midiFuncs.add(MIDIFunc.cc({ |val| ~acidCutoff = val.linexp(0,127, 100,8000); ~debouncedReload.() }, 31));
~midiFuncs.add(MIDIFunc.cc({ |val| ~acidRq = val.linlin(0,127, 0.05,0.5); ~debouncedReload.() }, 32));
~midiFuncs.add(MIDIFunc.cc({ |val| ~melodyDrive = val.linlin(0,127, 0.5,5.0); ~debouncedReload.() }, 33));
~midiFuncs.add(MIDIFunc.cc({ |val| ~acidDrive = val.linlin(0,127, 0.5,5.0); ~debouncedReload.() }, 34));
~midiFuncs.add(MIDIFunc.cc({ |val| ~melodyBoost = val.linlin(0,127, 0.5,3.0); ~debouncedReload.() }, 35));
~midiFuncs.add(MIDIFunc.cc({ |val| TempoClock.default.tempo = val.linlin(0,127, 60,200) / 60 }, 36));
// ---- CC : wobble / sidechain / FX ----
~midiFuncs.add(MIDIFunc.cc({ |val| ~melodyWobbleRate = val.linexp(0,127, 0.5,32); ~debouncedReload.() }, 49));
~midiFuncs.add(MIDIFunc.cc({ |val| ~melodyWobbleDepth = val.linlin(0,127, 0,4500); ~debouncedReload.() }, 50));
~midiFuncs.add(MIDIFunc.cc({ |val| ~melodySubAmt = val.linlin(0,127, 0,1.5); ~debouncedReload.() }, 51));
~midiFuncs.add(MIDIFunc.cc({ |val| ~acidScAmt = val.linlin(0,127, 0,1); ~debouncedReload.() }, 52));
~midiFuncs.add(MIDIFunc.cc({ |val| ~leadScAmt = val.linlin(0,127, 0,1); ~debouncedReload.() }, 53));
~midiFuncs.add(MIDIFunc.cc({ |val| ~kickScShape = val.linlin(0,127, 0.05,0.5); ~debouncedReload.() }, 54));
~midiFuncs.add(MIDIFunc.cc({ |val|
~fxFx !? { ~fxFx[\rev] !? { |s| s.set(\mix, val.linlin(0,127, 0,1)) } };
}, 55));
~midiFuncs.add(MIDIFunc.cc({ |val|
~fxFx !? { ~fxFx[\dly] !? { |s| s.set(\feedback, val.linlin(0,127, 0,0.95)) } };
}, 56));
// ---- CC : master ----
~midiFuncs.add(MIDIFunc.cc({ |val|
~masterVol !? { ~masterVol.(val.linlin(0,127, 0, 1.5)) };
}, 7));
// ---- Notes 36-47 : albums A-L ----
12.do { |i|
var letter = "ABCDEFGHIJKL"[i].asString.asSymbol;
~midiFuncs.add(MIDIFunc.noteOn({
~playAlbum !? { ~playAlbum.(letter) };
}, 36 + i));
};
// ---- Notes 48-59 : albums M-X ----
12.do { |i|
var letter = "MNOPQRSTUVWX"[i].asString.asSymbol;
~midiFuncs.add(MIDIFunc.noteOn({
~playAlbum !? { ~playAlbum.(letter) };
}, 48 + i));
};
// ---- Notes 60-71 : kicks 0..11 ----
12.do { |i|
~midiFuncs.add(MIDIFunc.noteOn({ ~kkAt.(i) }, 60 + i));
};
// ---- Notes 72-83 : melodies 0..11 ----
12.do { |i|
~midiFuncs.add(MIDIFunc.noteOn({ ~mmAt.(i) }, 72 + i));
};
// ---- Notes 84-95 : fx 0..11 ----
12.do { |i|
~midiFuncs.add(MIDIFunc.noteOn({ ~ffAt.(i) }, 84 + i));
};
"[midi_init] mapping default applique :".postln;
(" notes 36-59 = albums A-X (~playAlbum)").postln;
(" notes 60-71 = kicks 0-11 (~kkAt)").postln;
(" notes 72-83 = melodies 0-11 (~mmAt)").postln;
(" notes 84-95 = fx 0-11 (~ffAt)").postln;
(" CC 7 = master volume").postln;
(" CC 13-20 = volumes par voix").postln;
(" CC 29-36 = cutoffs / drive / bpm").postln;
(" CC 49-56 = wobble / sidechain / FX").postln;
};
// Defer init so MIDIClient discovery doesn't block the main load
AppClock.sched(0.5, {
~midiSafe.({
MIDIClient.init;
var nSrc = MIDIClient.sources.size;
("[midi_init] " ++ nSrc ++ " MIDI source(s) detected").postln;
if (nSrc > 0) {
MIDIClient.sources.do { |src, i|
(" [" ++ i ++ "] " ++ src.device ++ " : " ++ src.name).postln;
};
MIDIIn.connectAll;
~midiMapDefault.value;
~midiInitialized = true;
"[midi_init] OK".postln;
} {
"[midi_init] no MIDI device, skipping mapping (re-run live/midi_init.scd to retry)".postln;
~midiInitialized = false;
};
}, "midi_init");
nil;
});
)
@@ -0,0 +1,14 @@
// Hydra patch — coller dans https://hydra.ojack.xyz/
// Lance hydra-sc-bridge dans un terminal AVANT, et SC envoie OSC sur 8000
// Variables exposées par le bridge : a.fft[0..2] (low/mid/high) si SuperDirt-RMS-style
a.show()
a.setBins(3)
a.setSmooth(0.85)
osc(() => 10 + (a.fft[0] || 0) * 40, 0.1, () => (a.fft[1] || 0))
.color(() => 1 - (a.fft[2] || 0), 0.5, 1)
.rotate(() => time * 0.1)
.modulate(noise(2, 0.3))
.kaleid(5)
.out()
+45
View File
@@ -0,0 +1,45 @@
# Hantek 6022BL helper tools
Standalone Python scripts that handle the parts of the Hantek 6022BL USB
oscilloscope workflow that don't belong in the C++ visualizer
(`oscope-of/src/HantekDevice`).
| Script | Role |
|---|---|
| `fx2_fwload.py` | One-shot firmware uploader. The Hantek arrives as a bare Cypress FX2LP and needs `fx2lafw-hantek-6022bl.fw` pushed via vendor request 0xA0. After upload the device renumerates with VID:PID 1d50:608e. Uses libusb via ctypes (no `pyusb` dep). |
| `hantek_to_osc.py` | Alternative scope reader using `sigrok-cli` (instead of the C++ libusb path in oscope-of). Streams CH1/CH2 chunks as float32 over OSC to `127.0.0.1:57120 /scope`. |
| `hantek_osc_bridge.py` | Variant of the above. Same end goal, different parsing strategy. |
## Prerequisites (macOS)
1. **PulseView.app** (ships libusb + the firmware blob)
```sh
brew install --cask pulseview
```
2. **sigrok-cli** (only needed for the Python scope readers)
```sh
brew install sigrok-cli
```
3. **python-osc** (only needed for the Python scope readers)
```sh
/usr/bin/python3 -m pip install --user python-osc
```
## Workflow
### Once per session : load the firmware
```sh
./fx2_fwload.py
```
This must run before either oscope-of's C++ driver or sigrok-cli can talk to
the scope.
### Then either :
- **C++ path (default)** — start `oscope-of` (or the launcher) and the C++
`HantekDevice` will open the scope directly via libusb.
- **Python path (fallback / debugging)** — start `./hantek_to_osc.py` and
open `sound_algo/control/hantek_receiver.scd` in sclang to receive
`~hPk1 / ~hRms1 / ~hPk2 / ~hRms2` updated at 50 Hz.
The two paths are mutually exclusive — only one process can hold the USB
device at a time.
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""
Cypress FX2LP firmware loader using libusb1 via ctypes (no pyusb required).
Loads fx2lafw-hantek-6022bl.fw onto the Hantek 6022BL via vendor request 0xA0.
After upload, the device renumerates with VID:PID 1d50:608e.
Usage: ./fx2_fwload.py [firmware.fw] (default: bundled in PulseView.app)
"""
import ctypes, ctypes.util, sys, time, os
LIBUSB_PATH = "/Applications/PulseView.app/Contents/Frameworks/libusb-1.0.0.dylib"
DEFAULT_FW = "/Applications/PulseView.app/Contents/share/sigrok-firmware/fx2lafw-hantek-6022bl.fw"
VID_BOOT, PID_BOOT = 0x04b4, 0x6022 # Hantek 6022BL after re-initial unconfigured
VID_LV, PID_LV = 0x0925, 0x3881 # Lakeview default (unprogrammed FX2 with EEPROM)
usb = ctypes.CDLL(LIBUSB_PATH)
usb.libusb_init.argtypes = [ctypes.c_void_p]
usb.libusb_open_device_with_vid_pid.restype = ctypes.c_void_p
usb.libusb_control_transfer.argtypes = [
ctypes.c_void_p, ctypes.c_uint8, ctypes.c_uint8,
ctypes.c_uint16, ctypes.c_uint16,
ctypes.c_char_p, ctypes.c_uint16, ctypes.c_uint
]
usb.libusb_control_transfer.restype = ctypes.c_int
CPUCS = 0xE600
A0 = 0xA0
HOST_TO_DEV = 0x40 # vendor, host→dev
def vendor_write(handle, addr, data):
return usb.libusb_control_transfer(
handle, HOST_TO_DEV, A0, addr, 0,
ctypes.c_char_p(data), len(data), 1000
)
def main():
fw_path = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_FW
if not os.path.isfile(fw_path):
sys.exit(f"firmware not found: {fw_path}")
fw = open(fw_path, "rb").read()
print(f"[fx2load] firmware {fw_path} ({len(fw)} B)")
if usb.libusb_init(None) != 0:
sys.exit("libusb_init failed")
handle = None
for vid, pid in [(VID_LV, PID_LV), (VID_BOOT, PID_BOOT)]:
h = usb.libusb_open_device_with_vid_pid(None, vid, pid)
if h:
handle = h
print(f"[fx2load] opened {vid:04x}:{pid:04x}")
break
if not handle:
sys.exit("device not found (VID:PID 0925:3881 ou 04b4:6022)")
usb.libusb_detach_kernel_driver(handle, 0)
if usb.libusb_claim_interface(handle, 0) != 0:
print("[fx2load] warning: could not claim interface 0 (continuing)")
# 1. Reset 8051
print("[fx2load] reset CPU…")
vendor_write(handle, CPUCS, b"\x01")
time.sleep(0.05)
# 2. Upload firmware in 4096-byte chunks
chunk = 4096
for i in range(0, len(fw), chunk):
block = fw[i:i+chunk]
n = vendor_write(handle, i, block)
if n != len(block):
sys.exit(f"upload failed at offset {i}: got {n}")
print(f"[fx2load] uploaded {len(fw)} bytes")
# 3. Release reset
print("[fx2load] release CPU…")
vendor_write(handle, CPUCS, b"\x00")
time.sleep(0.5)
usb.libusb_release_interface(handle, 0)
usb.libusb_close(handle)
usb.libusb_exit(None)
print("[fx2load] OK — device should renumerate as 1d50:608e (sigrok) ou 04b5:6022 (hantek)")
if __name__ == "__main__":
main()
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""
Hantek 6022BE/BL → OSC bridge (no sigrok-cli, no pyusb).
Uses libusb bundled in PulseView.app via ctypes.
Protocole post-firmware (fx2lafw hantek-6022be):
VR 0xE0 CH1 voltage range (0x01=5V, 0x02=2.5V, 0x05=1V, 0x0A=500mV)
VR 0xE1 CH2 voltage range (idem)
VR 0xE2 sample rate index (0x01=100kS/s, 0x08=1MS/s, 0x10=4MS/s, 0x18=24MS/s)
VR 0xE3 trigger / start (envoyer 0x01)
VR 0xE4 num channels (0x01=CH1 only, 0x02=CH1+CH2)
Bulk IN endpoint 0x86 payload: échantillons 8-bit non-signés, interleaved
Usage:
./hantek_osc_bridge.py [--rate 1M] [--host 127.0.0.1] [--port 57120]
"""
import ctypes, ctypes.util, sys, time, argparse, struct
from pythonosc.udp_client import SimpleUDPClient
LIBUSB_PATH = "/Applications/PulseView.app/Contents/Frameworks/libusb-1.0.0.dylib"
# Hantek post-firmware IDs
CANDIDATES = [
(0x04b5, 0x602a), # 6022BL programmé
(0x04b5, 0x6022), # 6022BE programmé
(0x1d50, 0x608e), # sigrok generic fx2lafw
]
RATES = {"100k":0x01, "200k":0x08, "500k":0x10, "1M":0x18,
"4M":0x20, "8M":0x28, "16M":0x30, "24M":0x31, "30M":0x32, "48M":0x33}
# libusb ctypes setup
usb = ctypes.CDLL(LIBUSB_PATH)
usb.libusb_init.argtypes = [ctypes.c_void_p]
usb.libusb_open_device_with_vid_pid.restype = ctypes.c_void_p
usb.libusb_control_transfer.argtypes = [
ctypes.c_void_p, ctypes.c_uint8, ctypes.c_uint8,
ctypes.c_uint16, ctypes.c_uint16,
ctypes.c_char_p, ctypes.c_uint16, ctypes.c_uint
]
usb.libusb_control_transfer.restype = ctypes.c_int
usb.libusb_bulk_transfer.argtypes = [
ctypes.c_void_p, ctypes.c_uint8,
ctypes.c_char_p, ctypes.c_int,
ctypes.POINTER(ctypes.c_int), ctypes.c_uint
]
usb.libusb_bulk_transfer.restype = ctypes.c_int
HOST_TO_DEV = 0x40
def vendor(handle, req, val_byte):
return usb.libusb_control_transfer(
handle, HOST_TO_DEV, req, 0, 0,
ctypes.c_char_p(bytes([val_byte])), 1, 1000
)
def main():
p = argparse.ArgumentParser()
p.add_argument("--rate", default="1M", choices=list(RATES))
p.add_argument("--host", default="127.0.0.1")
p.add_argument("--port", type=int, default=57120)
p.add_argument("--addr", default="/scope")
p.add_argument("--chunk", type=int, default=4096) # samples per OSC packet
args = p.parse_args()
if usb.libusb_init(None) != 0:
sys.exit("libusb_init failed")
handle = None
for vid, pid in CANDIDATES:
h = usb.libusb_open_device_with_vid_pid(None, vid, pid)
if h:
handle = h
print(f"[bridge] device {vid:04x}:{pid:04x}")
break
if not handle:
sys.exit("Hantek non trouvé. Lance OpenHantek une fois pour uploader le firmware.")
r = usb.libusb_set_configuration(handle, 1)
print(f"[bridge] set_config={r}", flush=True)
r = usb.libusb_claim_interface(handle, 0)
print(f"[bridge] claim_interface={r}", flush=True)
if r < 0:
sys.exit("claim_interface failed (try kill OpenHantek / replug)")
# Configure: 5V range CH1+CH2, 2 channels, sample rate, then start
print(f"[bridge] E0={vendor(handle, 0xE0, 0x01)}", flush=True)
print(f"[bridge] E1={vendor(handle, 0xE1, 0x01)}", flush=True)
print(f"[bridge] E4={vendor(handle, 0xE4, 0x02)}", flush=True)
print(f"[bridge] E2={vendor(handle, 0xE2, RATES[args.rate])}", flush=True)
print(f"[bridge] E3={vendor(handle, 0xE3, 0x01)}", flush=True)
print(f"[bridge] streaming @ {args.rate}S/s → osc://{args.host}:{args.port}{args.addr}", flush=True)
osc = SimpleUDPClient(args.host, args.port)
buf = ctypes.create_string_buffer(args.chunk * 2) # 2 ch * 1 byte
transferred = ctypes.c_int(0)
try:
while True:
r = usb.libusb_bulk_transfer(handle, 0x86, buf, len(buf),
ctypes.byref(transferred), 200)
if r != 0 or transferred.value == 0:
continue
data = buf.raw[:transferred.value]
# interleaved CH1, CH2, CH1, CH2, ...
n = len(data) // 2
ch1 = [(data[i*2] - 128) / 128.0 for i in range(n)]
ch2 = [(data[i*2+1] - 128) / 128.0 for i in range(n)]
pk1 = max(abs(min(ch1)), abs(max(ch1)))
pk2 = max(abs(min(ch2)), abs(max(ch2)))
rms1 = (sum(x*x for x in ch1)/n)**0.5
rms2 = (sum(x*x for x in ch2)/n)**0.5
osc.send_message(args.addr, [pk1, rms1, pk2, rms2])
except KeyboardInterrupt:
pass
finally:
vendor(handle, 0xE3, 0x00) # stop
usb.libusb_release_interface(handle, 0)
usb.libusb_close(handle)
usb.libusb_exit(None)
if __name__ == "__main__":
main()
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""
Hantek 6022BL → OSC bridge.
Lit des blocs d'échantillons via sigrok-cli et envoie chunks OSC à SC.
Usage:
./hantek_to_osc.py [--rate 100k] [--samples 1024] [--host 127.0.0.1] [--port 57120]
Prérequis:
- sigrok-cli installé (brew install sigrok-cli)
- firmware fx2lafw chargé (lance OpenHantek une fois, OU sigrok-cli scanne 2x)
- python-osc: /usr/bin/python3 -m pip install --user python-osc
"""
import argparse, subprocess, sys, struct
from pythonosc.udp_client import SimpleUDPClient
def main():
p = argparse.ArgumentParser()
p.add_argument("--rate", default="100k")
p.add_argument("--samples", type=int, default=1024)
p.add_argument("--driver", default="hantek-6xxx")
p.add_argument("--host", default="127.0.0.1")
p.add_argument("--port", type=int, default=57120)
p.add_argument("--addr", default="/scope")
args = p.parse_args()
osc = SimpleUDPClient(args.host, args.port)
cmd = [
"sigrok-cli", "-d", args.driver,
"--config", f"samplerate={args.rate}",
"-O", "binary",
"--continuous",
"-C", "CH1,CH2",
]
print(f"[bridge] {' '.join(cmd)}{args.host}:{args.port}{args.addr}")
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, bufsize=0)
chunk_bytes = args.samples * 4 * 2 # 2 channels float32
try:
while True:
buf = proc.stdout.read(chunk_bytes)
if not buf:
break
n = len(buf) // 8
ch1 = struct.unpack(f"{n}f", b"".join(buf[i*8:i*8+4] for i in range(n)))
ch2 = struct.unpack(f"{n}f", b"".join(buf[i*8+4:i*8+8] for i in range(n)))
# Send peak + RMS per channel (compact, SC-friendly)
def pk(s): return max(abs(min(s)), abs(max(s)))
def rms(s): return (sum(x*x for x in s)/len(s))**0.5
osc.send_message(args.addr, [pk(ch1), rms(ch1), pk(ch2), rms(ch2)])
except KeyboardInterrupt:
pass
finally:
proc.terminate()
if __name__ == "__main__":
main()