diff --git a/sound_algo/00_load.scd b/sound_algo/00_load.scd index 5d781af..ca776f1 100644 --- a/sound_algo/00_load.scd +++ b/sound_algo/00_load.scd @@ -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; diff --git a/sound_algo/control/hantek_receiver.scd b/sound_algo/control/hantek_receiver.scd new file mode 100644 index 0000000..c98e223 --- /dev/null +++ b/sound_algo/control/hantek_receiver.scd @@ -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; +) diff --git a/sound_algo/control/hydra_send.scd b/sound_algo/control/hydra_send.scd new file mode 100644 index 0000000..0e7bce9 --- /dev/null +++ b/sound_algo/control/hydra_send.scd @@ -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; +}; +) diff --git a/sound_algo/live/midi_init.scd b/sound_algo/live/midi_init.scd new file mode 100644 index 0000000..e8c002c --- /dev/null +++ b/sound_algo/live/midi_init.scd @@ -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; +}); +) diff --git a/sound_algo/web/public/hydra/external_patch.js b/sound_algo/web/public/hydra/external_patch.js new file mode 100644 index 0000000..f0fa772 --- /dev/null +++ b/sound_algo/web/public/hydra/external_patch.js @@ -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() diff --git a/tools/hantek/README.md b/tools/hantek/README.md new file mode 100644 index 0000000..aa32a99 --- /dev/null +++ b/tools/hantek/README.md @@ -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. diff --git a/tools/hantek/fx2_fwload.py b/tools/hantek/fx2_fwload.py new file mode 100755 index 0000000..66015fb --- /dev/null +++ b/tools/hantek/fx2_fwload.py @@ -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() diff --git a/tools/hantek/hantek_osc_bridge.py b/tools/hantek/hantek_osc_bridge.py new file mode 100755 index 0000000..19fecb5 --- /dev/null +++ b/tools/hantek/hantek_osc_bridge.py @@ -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() diff --git a/tools/hantek/hantek_to_osc.py b/tools/hantek/hantek_to_osc.py new file mode 100755 index 0000000..70e5582 --- /dev/null +++ b/tools/hantek/hantek_to_osc.py @@ -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()