From 1848368bc258b2ec4d2f5678fdc704a37d40c1cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=27=C3=A9lectron=20rare?= <108685187+electron-rare@users.noreply.github.com> Date: Mon, 18 May 2026 15:58:14 +0200 Subject: [PATCH] build: scaffold oscope-sphere project Context: oscope-sphere is a new openFrameworks app (sibling of oscope-of) that will render audio analysis on a 3D sphere. This commit establishes the project tree and reuses the mature capture and analysis layer from oscope-of. Approach: Copy four proven source files byte-for-byte from oscope-of/src/ (no modifications), create the standard oF directory layout, and add a standalone clang++ test harness that validates two design assumptions without requiring openFrameworks installed. Changes: - Create oscope-sphere/{src,tests,bin/data/shaders} directories - Copy FFT.h/cpp, AudioAnalyzer.h/cpp, ScopeData.h, HantekDevice.h/cpp from oscope-of/src (unmodified) - Add addons.make and .gitignore for oF build system - Add tests/check.h: lightweight CHECK/REPORT macros - Add tests/test_analysis.cpp: two tests (FFT peak at bin 64, per-channel isolation via dual-analyzer trick) Impact: Test harness compiles with plain clang++ -std=c++17 and passes 5/5 checks, confirming FFT correctness and AudioAnalyzer channel isolation before any oF integration work begins. --- oscope-sphere/.gitignore | 6 + oscope-sphere/addons.make | 0 oscope-sphere/src/AudioAnalyzer.cpp | 97 +++++++++ oscope-sphere/src/AudioAnalyzer.h | 53 +++++ oscope-sphere/src/FFT.cpp | 60 ++++++ oscope-sphere/src/FFT.h | 32 +++ oscope-sphere/src/HantekDevice.cpp | 284 ++++++++++++++++++++++++++ oscope-sphere/src/HantekDevice.h | 99 +++++++++ oscope-sphere/src/ScopeData.h | 69 +++++++ oscope-sphere/tests/check.h | 20 ++ oscope-sphere/tests/test_analysis.cpp | 59 ++++++ 11 files changed, 779 insertions(+) create mode 100644 oscope-sphere/.gitignore create mode 100644 oscope-sphere/addons.make create mode 100644 oscope-sphere/src/AudioAnalyzer.cpp create mode 100644 oscope-sphere/src/AudioAnalyzer.h create mode 100644 oscope-sphere/src/FFT.cpp create mode 100644 oscope-sphere/src/FFT.h create mode 100644 oscope-sphere/src/HantekDevice.cpp create mode 100644 oscope-sphere/src/HantekDevice.h create mode 100644 oscope-sphere/src/ScopeData.h create mode 100644 oscope-sphere/tests/check.h create mode 100644 oscope-sphere/tests/test_analysis.cpp diff --git a/oscope-sphere/.gitignore b/oscope-sphere/.gitignore new file mode 100644 index 0000000..a784d0c --- /dev/null +++ b/oscope-sphere/.gitignore @@ -0,0 +1,6 @@ +obj/ +bin/oscope-sphere +bin/oscope-sphere_debug +bin/oscope-sphere.app/ +*.o +*.d diff --git a/oscope-sphere/addons.make b/oscope-sphere/addons.make new file mode 100644 index 0000000..e69de29 diff --git a/oscope-sphere/src/AudioAnalyzer.cpp b/oscope-sphere/src/AudioAnalyzer.cpp new file mode 100644 index 0000000..f79055a --- /dev/null +++ b/oscope-sphere/src/AudioAnalyzer.cpp @@ -0,0 +1,97 @@ +#include "AudioAnalyzer.h" + +#include +#include + +namespace oscope { + +AudioAnalyzer::AudioAnalyzer() : fft_(kFftSize) { + mono_.assign(kFftSize, 0.0f); +} + +void AudioAnalyzer::update(const std::vector& ch1, + const std::vector& ch2, + float scopeSampleRateHz) { + if (ch1.size() < 16 || scopeSampleRateHz < 1.0f) { + // Décay doux pour ne pas figer la valeur précédente + bands_.bass *= 0.92f; + bands_.lowMid *= 0.92f; + bands_.mid *= 0.92f; + bands_.treble *= 0.92f; + bands_.kick *= 0.85f; + bands_.snare *= 0.85f; + bands_.full *= 0.92f; + return; + } + + // Downsampling par moyenne (box filter) du SR scope vers ~48 kHz audio. + // Décimation = scopeSr / 48000, arrondi entier ≥ 1. + const std::size_t deci = + static_cast(std::max(1.0f, scopeSampleRateHz / kAudioSr)); + const std::size_t n = std::min(ch1.size(), ch2.size()); + + for (std::size_t i = 0; i + deci <= n; i += deci) { + float acc = 0.0f; + for (std::size_t j = 0; j < deci; ++j) { + acc += 0.5f * (ch1[i + j] + ch2[i + j]); + } + mono_[head_] = acc / static_cast(deci); + head_ = (head_ + 1) % mono_.size(); + } + + // Window Hann + FFT — on linéarise mono_ depuis head_. + std::vector win(kFftSize); + for (std::size_t i = 0; i < kFftSize; ++i) { + const std::size_t idx = (head_ + i) % kFftSize; + const float w = 0.5f * (1.0f - std::cos(2.0f * 3.14159265f * i / + static_cast(kFftSize - 1))); + win[i] = mono_[idx] * w; + } + fft_.magnitude(win, mag_); + + // Bin width = audioSr / fftSize. Avec kAudioSr=48k, kFftSize=1024 → + // ~46.9 Hz par bin. + const float binHz = kAudioSr / static_cast(kFftSize); + auto sumBand = [&](float lo, float hi) -> float { + const std::size_t i0 = static_cast(lo / binHz); + const std::size_t i1 = std::min(mag_.size(), + static_cast(hi / binHz) + 1); + if (i1 <= i0) return 0.0f; + float s = 0.0f; + for (std::size_t i = i0; i < i1; ++i) s += mag_[i]; + return s / static_cast(i1 - i0); + }; + + // Bandes en log-power, normalisées 0..1 par un mapping doux. + auto db01 = [](float v) { + const float db = 20.0f * std::log10(std::max(v, 1e-6f)); + // -60 dB → 0, 0 dB → 1 + return std::max(0.0f, std::min(1.0f, (db + 60.0f) / 60.0f)); + }; + + const float prevBass = bands_.bass; + const float prevMid = bands_.mid; + + bands_.bass = db01(sumBand(20.0f, 200.0f)); + bands_.lowMid = db01(sumBand(200.0f, 800.0f)); + bands_.mid = db01(sumBand(800.0f, 3200.0f)); + bands_.treble = db01(sumBand(3200.0f, 16000.0f)); + + // Transitoire = différence positive lissée. Détecte un kick = montée + // brusque sur la bande basse, snare = montée sur mid + treble. + const float kickRise = std::max(0.0f, bands_.bass - prevBass) * 1.6f; + const float snareRise = std::max(0.0f, + (bands_.mid + bands_.treble) * 0.5f - prevMid) * 1.6f; + bands_.kick = std::max(bands_.kick * 0.85f, kickRise); + bands_.snare = std::max(bands_.snare * 0.85f, snareRise); + + // Full RMS + float rms = 0.0f; + for (auto v : mono_) rms += v * v; + bands_.full = std::sqrt(rms / mono_.size()); + + prevBass_ = prevBass; + prevMid_ = prevMid; +} + +} // namespace oscope diff --git a/oscope-sphere/src/AudioAnalyzer.h b/oscope-sphere/src/AudioAnalyzer.h new file mode 100644 index 0000000..d040538 --- /dev/null +++ b/oscope-sphere/src/AudioAnalyzer.h @@ -0,0 +1,53 @@ +#pragma once + +// Extracteur de bandes audio depuis le signal Hantek brut. +// Downsample (8-48 MS/s scope → ~48 kHz audio) puis FFT pour obtenir +// bass / lowMid / mid / treble + détecteurs de transitoire kick/snare. +// Source d'inspiration : besoin de piloter les visualizers sur LE signal +// qui passe physiquement dans le scope, pas sur les métadonnées OSC. + +#include "FFT.h" + +#include + +namespace oscope { + +struct AudioBands { + float bass = 0.0f; // 20–200 Hz + float lowMid = 0.0f; // 200–800 Hz + float mid = 0.0f; // 800–3200 Hz + float treble = 0.0f; // 3200 Hz+ + float kick = 0.0f; // transient sur bass + float snare = 0.0f; // transient sur mid+treble + float full = 0.0f; // RMS global +}; + +class AudioAnalyzer { +public: + AudioAnalyzer(); + /// Alimenter avec les samples Hantek bruts + sample rate scope. + void update(const std::vector& ch1, + const std::vector& ch2, + float scopeSampleRateHz); + const AudioBands& bands() const { return bands_; } + /// Buffer mono downsamplé vers ~48 kHz (avant FFT). Ring de kFftSize. + const std::vector& monoDown() const { return mono_; } + /// Magnitudes FFT sur monoDown (taille = kFftSize/2). Bin width ≈ 47 Hz. + const std::vector& magDown() const { return mag_; } + std::size_t monoHead() const { return head_; } + static constexpr float audioSr() { return kAudioSr; } + +private: + static constexpr std::size_t kFftSize = 2048; // 23.4 Hz/bin a 48k + static constexpr float kAudioSr = 48000.0f; + + FFT fft_; + std::vector mono_; // ring downsamplé, kFftSize floats + std::size_t head_ = 0; + std::vector mag_; + AudioBands bands_; + float prevBass_ = 0.0f; + float prevMid_ = 0.0f; +}; + +} // namespace oscope diff --git a/oscope-sphere/src/FFT.cpp b/oscope-sphere/src/FFT.cpp new file mode 100644 index 0000000..5db58b5 --- /dev/null +++ b/oscope-sphere/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-sphere/src/FFT.h b/oscope-sphere/src/FFT.h new file mode 100644 index 0000000..88ceff8 --- /dev/null +++ b/oscope-sphere/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-sphere/src/HantekDevice.cpp b/oscope-sphere/src/HantekDevice.cpp new file mode 100644 index 0000000..afbb51c --- /dev/null +++ b/oscope-sphere/src/HantekDevice.cpp @@ -0,0 +1,284 @@ +#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()) { + // Le FX2 (firmware OpenHantek6022) attend la séquence : stop (0xE3=0) + // → set sample rate (0xE2) → start (0xE3=1). Sans le re-start, le + // bulk endpoint cesse d'émettre et le stream se fige. + const uint8_t stopTrig = 0x00; + const uint8_t code = sampleRateCode(hz); + const uint8_t startTrig = 0x01; + sendVendorControl(0xE3, 0, 0, &stopTrig, 1); + sendVendorControl(kReqSetSampleRate, 0, 0, &code, 1); + sendVendorControl(0xE3, 0, 0, &startTrig, 1); + } +} + +void HantekDevice::setGain(int channel, HantekGain gain) { + const uint8_t code = gainCode(gain); + if (channel == 1) { + gainCh1_.store(static_cast(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-sphere/src/HantekDevice.h b/oscope-sphere/src/HantekDevice.h new file mode 100644 index 0000000..6342fcc --- /dev/null +++ b/oscope-sphere/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-sphere/src/ScopeData.h b/oscope-sphere/src/ScopeData.h new file mode 100644 index 0000000..b5dda5c --- /dev/null +++ b/oscope-sphere/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-sphere/tests/check.h b/oscope-sphere/tests/check.h new file mode 100644 index 0000000..1c811e3 --- /dev/null +++ b/oscope-sphere/tests/check.h @@ -0,0 +1,20 @@ +#pragma once +#include + +inline int g_checks = 0; +inline int g_fails = 0; + +#define CHECK(cond) \ + do { \ + ++g_checks; \ + if (!(cond)) { \ + ++g_fails; \ + std::printf("FAIL %s:%d %s\n", __FILE__, __LINE__, #cond); \ + } \ + } while (0) + +#define REPORT() \ + do { \ + std::printf("%d/%d checks passed\n", g_checks - g_fails, g_checks);\ + return g_fails ? 1 : 0; \ + } while (0) diff --git a/oscope-sphere/tests/test_analysis.cpp b/oscope-sphere/tests/test_analysis.cpp new file mode 100644 index 0000000..f91e6d4 --- /dev/null +++ b/oscope-sphere/tests/test_analysis.cpp @@ -0,0 +1,59 @@ +#include "check.h" +#include "FFT.h" +#include "AudioAnalyzer.h" +#include +#include + +using oscope::AudioAnalyzer; +using oscope::FFT; + +static int argmax(const std::vector& v) { + int best = 1; + float bv = -1.0f; + for (int i = 1; i < static_cast(v.size()); ++i) + if (v[i] > bv) { bv = v[i]; best = i; } + return best; +} + +static std::vector tone(double freqHz, double srHz, std::size_t n) { + std::vector s(n); + for (std::size_t i = 0; i < n; ++i) + s[i] = static_cast(std::sin(2.0 * M_PI * freqHz * i / srHz)); + return s; +} + +// FFT of a sine landing exactly on bin 64 must peak at bin 64. +static void test_fft_peak() { + const std::size_t N = 2048; + std::vector in(N); + for (std::size_t i = 0; i < N; ++i) + in[i] = static_cast(std::sin(2.0 * M_PI * 64.0 * i / N)); + FFT fft(N); + std::vector mag; + fft.magnitude(in, mag); + CHECK(mag.size() == N / 2); + int peak = argmax(mag); + CHECK(peak >= 63 && peak <= 65); +} + +// Feeding update(chX, chX) must isolate chX — proves the dual-analyzer +// trick: AudioAnalyzer mixes 0.5*(a+b), so 0.5*(x+x) == x. +static void test_per_channel_isolation() { + const double sr = 48000.0; // deci == 1, no decimation + auto a = tone(1000.0, sr, 2048); // bin width 23.4 Hz -> bin ~43 + auto b = tone(5000.0, sr, 2048); // -> bin ~213 + AudioAnalyzer an1, an2; + an1.update(a, a, static_cast(sr)); + an2.update(b, b, static_cast(sr)); + int p1 = argmax(an1.magDown()); + int p2 = argmax(an2.magDown()); + CHECK(p1 >= 41 && p1 <= 45); + CHECK(p2 >= 211 && p2 <= 217); + CHECK(p1 != p2); +} + +int main() { + test_fft_peak(); + test_per_channel_isolation(); + REPORT(); +}