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.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
obj/
|
||||
bin/oscope-sphere
|
||||
bin/oscope-sphere_debug
|
||||
bin/oscope-sphere.app/
|
||||
*.o
|
||||
*.d
|
||||
@@ -0,0 +1,97 @@
|
||||
#include "AudioAnalyzer.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace oscope {
|
||||
|
||||
AudioAnalyzer::AudioAnalyzer() : fft_(kFftSize) {
|
||||
mono_.assign(kFftSize, 0.0f);
|
||||
}
|
||||
|
||||
void AudioAnalyzer::update(const std::vector<float>& ch1,
|
||||
const std::vector<float>& ch2,
|
||||
float scopeSampleRateHz) {
|
||||
if (ch1.size() < 16 || scopeSampleRateHz < 1.0f) {
|
||||
// Décay doux pour ne pas figer la valeur précédente
|
||||
bands_.bass *= 0.92f;
|
||||
bands_.lowMid *= 0.92f;
|
||||
bands_.mid *= 0.92f;
|
||||
bands_.treble *= 0.92f;
|
||||
bands_.kick *= 0.85f;
|
||||
bands_.snare *= 0.85f;
|
||||
bands_.full *= 0.92f;
|
||||
return;
|
||||
}
|
||||
|
||||
// Downsampling par moyenne (box filter) du SR scope vers ~48 kHz audio.
|
||||
// Décimation = scopeSr / 48000, arrondi entier ≥ 1.
|
||||
const std::size_t deci =
|
||||
static_cast<std::size_t>(std::max(1.0f, scopeSampleRateHz / kAudioSr));
|
||||
const std::size_t n = std::min(ch1.size(), ch2.size());
|
||||
|
||||
for (std::size_t i = 0; i + deci <= n; i += deci) {
|
||||
float acc = 0.0f;
|
||||
for (std::size_t j = 0; j < deci; ++j) {
|
||||
acc += 0.5f * (ch1[i + j] + ch2[i + j]);
|
||||
}
|
||||
mono_[head_] = acc / static_cast<float>(deci);
|
||||
head_ = (head_ + 1) % mono_.size();
|
||||
}
|
||||
|
||||
// Window Hann + FFT — on linéarise mono_ depuis head_.
|
||||
std::vector<float> win(kFftSize);
|
||||
for (std::size_t i = 0; i < kFftSize; ++i) {
|
||||
const std::size_t idx = (head_ + i) % kFftSize;
|
||||
const float w = 0.5f * (1.0f - std::cos(2.0f * 3.14159265f * i /
|
||||
static_cast<float>(kFftSize - 1)));
|
||||
win[i] = mono_[idx] * w;
|
||||
}
|
||||
fft_.magnitude(win, mag_);
|
||||
|
||||
// Bin width = audioSr / fftSize. Avec kAudioSr=48k, kFftSize=1024 →
|
||||
// ~46.9 Hz par bin.
|
||||
const float binHz = kAudioSr / static_cast<float>(kFftSize);
|
||||
auto sumBand = [&](float lo, float hi) -> float {
|
||||
const std::size_t i0 = static_cast<std::size_t>(lo / binHz);
|
||||
const std::size_t i1 = std::min(mag_.size(),
|
||||
static_cast<std::size_t>(hi / binHz) + 1);
|
||||
if (i1 <= i0) return 0.0f;
|
||||
float s = 0.0f;
|
||||
for (std::size_t i = i0; i < i1; ++i) s += mag_[i];
|
||||
return s / static_cast<float>(i1 - i0);
|
||||
};
|
||||
|
||||
// Bandes en log-power, normalisées 0..1 par un mapping doux.
|
||||
auto db01 = [](float v) {
|
||||
const float db = 20.0f * std::log10(std::max(v, 1e-6f));
|
||||
// -60 dB → 0, 0 dB → 1
|
||||
return std::max(0.0f, std::min(1.0f, (db + 60.0f) / 60.0f));
|
||||
};
|
||||
|
||||
const float prevBass = bands_.bass;
|
||||
const float prevMid = bands_.mid;
|
||||
|
||||
bands_.bass = db01(sumBand(20.0f, 200.0f));
|
||||
bands_.lowMid = db01(sumBand(200.0f, 800.0f));
|
||||
bands_.mid = db01(sumBand(800.0f, 3200.0f));
|
||||
bands_.treble = db01(sumBand(3200.0f, 16000.0f));
|
||||
|
||||
// Transitoire = différence positive lissée. Détecte un kick = montée
|
||||
// brusque sur la bande basse, snare = montée sur mid + treble.
|
||||
const float kickRise = std::max(0.0f, bands_.bass - prevBass) * 1.6f;
|
||||
const float snareRise = std::max(0.0f,
|
||||
(bands_.mid + bands_.treble) * 0.5f - prevMid) * 1.6f;
|
||||
bands_.kick = std::max(bands_.kick * 0.85f, kickRise);
|
||||
bands_.snare = std::max(bands_.snare * 0.85f, snareRise);
|
||||
|
||||
// Full RMS
|
||||
float rms = 0.0f;
|
||||
for (auto v : mono_) rms += v * v;
|
||||
bands_.full = std::sqrt(rms / mono_.size());
|
||||
|
||||
prevBass_ = prevBass;
|
||||
prevMid_ = prevMid;
|
||||
}
|
||||
|
||||
} // namespace oscope
|
||||
@@ -0,0 +1,53 @@
|
||||
#pragma once
|
||||
|
||||
// Extracteur de bandes audio depuis le signal Hantek brut.
|
||||
// Downsample (8-48 MS/s scope → ~48 kHz audio) puis FFT pour obtenir
|
||||
// bass / lowMid / mid / treble + détecteurs de transitoire kick/snare.
|
||||
// Source d'inspiration : besoin de piloter les visualizers sur LE signal
|
||||
// qui passe physiquement dans le scope, pas sur les métadonnées OSC.
|
||||
|
||||
#include "FFT.h"
|
||||
|
||||
#include <vector>
|
||||
|
||||
namespace oscope {
|
||||
|
||||
struct AudioBands {
|
||||
float bass = 0.0f; // 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<float>& ch1,
|
||||
const std::vector<float>& ch2,
|
||||
float scopeSampleRateHz);
|
||||
const AudioBands& bands() const { return bands_; }
|
||||
/// Buffer mono downsamplé vers ~48 kHz (avant FFT). Ring de kFftSize.
|
||||
const std::vector<float>& monoDown() const { return mono_; }
|
||||
/// Magnitudes FFT sur monoDown (taille = kFftSize/2). Bin width ≈ 47 Hz.
|
||||
const std::vector<float>& magDown() const { return mag_; }
|
||||
std::size_t monoHead() const { return head_; }
|
||||
static constexpr float audioSr() { return kAudioSr; }
|
||||
|
||||
private:
|
||||
static constexpr std::size_t kFftSize = 2048; // 23.4 Hz/bin a 48k
|
||||
static constexpr float kAudioSr = 48000.0f;
|
||||
|
||||
FFT fft_;
|
||||
std::vector<float> mono_; // ring downsamplé, kFftSize floats
|
||||
std::size_t head_ = 0;
|
||||
std::vector<float> mag_;
|
||||
AudioBands bands_;
|
||||
float prevBass_ = 0.0f;
|
||||
float prevMid_ = 0.0f;
|
||||
};
|
||||
|
||||
} // namespace oscope
|
||||
@@ -0,0 +1,60 @@
|
||||
#include "FFT.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace oscope {
|
||||
|
||||
FFT::FFT(std::size_t size) : size_(size), window_(size), work_(size) {
|
||||
// Fenêtre de Hann pré-calculée.
|
||||
for (std::size_t i = 0; i < size_; ++i) {
|
||||
window_[i] = 0.5f * (1.0f - std::cos(2.0f * M_PI * i / (size_ - 1)));
|
||||
}
|
||||
}
|
||||
|
||||
void FFT::hannWindow(std::vector<float>& buf) const {
|
||||
for (std::size_t i = 0; i < size_; ++i) buf[i] *= window_[i];
|
||||
}
|
||||
|
||||
void FFT::fftInPlace(std::vector<std::complex<float>>& x) const {
|
||||
const std::size_t N = x.size();
|
||||
// Bit-reversal permutation.
|
||||
std::size_t j = 0;
|
||||
for (std::size_t i = 1; i < N; ++i) {
|
||||
std::size_t bit = N >> 1;
|
||||
for (; j & bit; bit >>= 1) j ^= bit;
|
||||
j ^= bit;
|
||||
if (i < j) std::swap(x[i], x[j]);
|
||||
}
|
||||
// Butterflies.
|
||||
for (std::size_t len = 2; len <= N; len <<= 1) {
|
||||
const float ang = -2.0f * M_PI / static_cast<float>(len);
|
||||
const std::complex<float> wlen(std::cos(ang), std::sin(ang));
|
||||
for (std::size_t i = 0; i < N; i += len) {
|
||||
std::complex<float> w(1.0f, 0.0f);
|
||||
for (std::size_t k = 0; k < len / 2; ++k) {
|
||||
const auto u = x[i + k];
|
||||
const auto v = x[i + k + len / 2] * w;
|
||||
x[i + k] = u + v;
|
||||
x[i + k + len / 2] = u - v;
|
||||
w *= wlen;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void FFT::magnitude(const std::vector<float>& in, std::vector<float>& outMag) {
|
||||
const std::size_t N = size_;
|
||||
std::vector<float> windowed(N, 0.0f);
|
||||
const std::size_t copy = std::min(N, in.size());
|
||||
for (std::size_t i = 0; i < copy; ++i) windowed[i] = in[i];
|
||||
hannWindow(windowed);
|
||||
for (std::size_t i = 0; i < N; ++i) work_[i] = std::complex<float>(windowed[i], 0.0f);
|
||||
fftInPlace(work_);
|
||||
outMag.resize(N / 2);
|
||||
const float invN = 1.0f / static_cast<float>(N);
|
||||
for (std::size_t i = 0; i < N / 2; ++i) {
|
||||
outMag[i] = std::abs(work_[i]) * invN;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace oscope
|
||||
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
// FFT Cooley-Tukey radix-2 in-place, sans dépendance externe.
|
||||
// Format : entrée temporelle réelle (N samples, N puissance de 2),
|
||||
// sortie magnitude (N/2 bins, dernier sample = Nyquist).
|
||||
|
||||
#include <complex>
|
||||
#include <cstddef>
|
||||
#include <vector>
|
||||
|
||||
namespace oscope {
|
||||
|
||||
class FFT {
|
||||
public:
|
||||
explicit FFT(std::size_t size);
|
||||
|
||||
/// Calcule la FFT du signal `in` (taille = size_) et écrit les magnitudes
|
||||
/// dans `outMag` (taille = size_/2).
|
||||
void magnitude(const std::vector<float>& in, std::vector<float>& outMag);
|
||||
|
||||
std::size_t size() const { return size_; }
|
||||
|
||||
private:
|
||||
void hannWindow(std::vector<float>& buf) const;
|
||||
void fftInPlace(std::vector<std::complex<float>>& x) const;
|
||||
|
||||
std::size_t size_;
|
||||
std::vector<float> window_;
|
||||
std::vector<std::complex<float>> work_;
|
||||
};
|
||||
|
||||
} // namespace oscope
|
||||
@@ -0,0 +1,284 @@
|
||||
#include "HantekDevice.h"
|
||||
|
||||
#include <libusb.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "ofLog.h"
|
||||
|
||||
namespace oscope {
|
||||
|
||||
namespace {
|
||||
|
||||
// Identifiants USB du Hantek 6022BL.
|
||||
// 0x04B5 / 0x6022 : appareil avec firmware OEM, ou aucun firmware (énumération
|
||||
// minimale, pas de bulk endpoint actif).
|
||||
// 0x04B5 / 0x602A : appareil avec firmware OpenHantek6022 chargé.
|
||||
constexpr uint16_t kVendorId = 0x04B5;
|
||||
constexpr uint16_t kProductIdRaw = 0x6022;
|
||||
constexpr uint16_t kProductIdFirmware = 0x602A;
|
||||
|
||||
constexpr int kInterface = 0;
|
||||
constexpr int kAltSetting = 0; // alt 0 = bulk EP 0x86 (firmware OpenHantek6022)
|
||||
constexpr unsigned char kEpIn = 0x86;
|
||||
|
||||
// Vendor requests OpenHantek6022.
|
||||
constexpr uint8_t kReqSetSampleRate = 0xE2;
|
||||
constexpr uint8_t kReqSetCh1Gain = 0xE0;
|
||||
constexpr uint8_t kReqSetCh2Gain = 0xE1;
|
||||
constexpr uint8_t kReqSetNumChannels= 0xE4;
|
||||
|
||||
// Codes de gain (registre du FX2).
|
||||
uint8_t gainCode(HantekGain g) {
|
||||
switch (g) {
|
||||
case HantekGain::G_5V: return 0x01;
|
||||
case HantekGain::G_2_5V: return 0x02;
|
||||
case HantekGain::G_1V: return 0x05;
|
||||
case HantekGain::G_500mV: return 0x0a;
|
||||
case HantekGain::G_250mV: return 0x14;
|
||||
}
|
||||
return 0x05;
|
||||
}
|
||||
|
||||
// Code de sample rate (cf. firmware OpenHantek6022).
|
||||
uint8_t sampleRateCode(uint32_t hz) {
|
||||
if (hz >= 48000000) return 48;
|
||||
if (hz >= 24000000) return 30; // dual-channel max ~16 MS/s, 30=24M single
|
||||
if (hz >= 16000000) return 16;
|
||||
if (hz >= 8000000) return 8;
|
||||
if (hz >= 4000000) return 4;
|
||||
if (hz >= 2000000) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
constexpr int kBulkTimeoutMs = 200;
|
||||
constexpr int kBulkXferBytes = 8192; // 4096 samples par canal (2 canaux entrelacés)
|
||||
|
||||
} // namespace
|
||||
|
||||
HantekDevice::HantekDevice()
|
||||
: ctx_(nullptr), handle_(nullptr), running_(false),
|
||||
status_(HantekStatus::NotFound),
|
||||
sampleRateHz_(8000000),
|
||||
gainCh1_(static_cast<int>(HantekGain::G_1V)),
|
||||
gainCh2_(static_cast<int>(HantekGain::G_1V)) {}
|
||||
|
||||
HantekDevice::~HantekDevice() {
|
||||
stop();
|
||||
}
|
||||
|
||||
HantekStatus HantekDevice::start() {
|
||||
if (running_.load()) {
|
||||
status_.store(HantekStatus::AlreadyRunning);
|
||||
return HantekStatus::AlreadyRunning;
|
||||
}
|
||||
|
||||
int rc = libusb_init(&ctx_);
|
||||
if (rc != 0) {
|
||||
ofLogError("HantekDevice") << "libusb_init failed: " << libusb_error_name(rc);
|
||||
status_.store(HantekStatus::UsbError);
|
||||
return HantekStatus::UsbError;
|
||||
}
|
||||
|
||||
// Recherche prioritaire du device avec firmware chargé.
|
||||
handle_ = libusb_open_device_with_vid_pid(ctx_, kVendorId, kProductIdFirmware);
|
||||
if (handle_ == nullptr) {
|
||||
// Fallback : device sans firmware.
|
||||
handle_ = libusb_open_device_with_vid_pid(ctx_, kVendorId, kProductIdRaw);
|
||||
if (handle_ != nullptr) {
|
||||
ofLogWarning("HantekDevice")
|
||||
<< "Hantek 6022BL trouvé MAIS firmware non chargé (PID 0x6022). "
|
||||
<< "Charger le firmware OpenHantek6022 via fxload, puis relancer. "
|
||||
<< "Voir docs/HANTEK_SETUP.md.";
|
||||
libusb_close(handle_);
|
||||
handle_ = nullptr;
|
||||
libusb_exit(ctx_);
|
||||
ctx_ = nullptr;
|
||||
status_.store(HantekStatus::FirmwareNeeded);
|
||||
return HantekStatus::FirmwareNeeded;
|
||||
}
|
||||
ofLogError("HantekDevice") << "Aucun Hantek 6022BL détecté.";
|
||||
libusb_exit(ctx_);
|
||||
ctx_ = nullptr;
|
||||
status_.store(HantekStatus::NotFound);
|
||||
return HantekStatus::NotFound;
|
||||
}
|
||||
|
||||
// Sur macOS, AppleUSBHostLegacyClient claim l'interface 0 par defaut
|
||||
// pour les devices vendor-specific. On force un re-configure (0->1) pour
|
||||
// detacher le client legacy, puis reset, puis claim.
|
||||
libusb_set_auto_detach_kernel_driver(handle_, 1);
|
||||
(void)libusb_detach_kernel_driver(handle_, kInterface);
|
||||
(void)libusb_set_configuration(handle_, 0);
|
||||
|
||||
(void)libusb_set_configuration(handle_, 1);
|
||||
|
||||
rc = libusb_claim_interface(handle_, kInterface);
|
||||
if (rc != 0) {
|
||||
ofLogError("HantekDevice") << "claim_interface failed: " << libusb_error_name(rc);
|
||||
libusb_close(handle_);
|
||||
handle_ = nullptr;
|
||||
libusb_exit(ctx_);
|
||||
ctx_ = nullptr;
|
||||
status_.store(HantekStatus::InterfaceClaimFailed);
|
||||
return HantekStatus::InterfaceClaimFailed;
|
||||
}
|
||||
|
||||
rc = libusb_set_interface_alt_setting(handle_, kInterface, kAltSetting);
|
||||
ofLogNotice("HantekDevice") << "set_alt_setting(" << kInterface << ", " << kAltSetting
|
||||
<< ") = " << rc << " (" << libusb_error_name(rc) << ")";
|
||||
if (rc != 0) {
|
||||
ofLogWarning("HantekDevice") << "alt_setting failed (continuing)";
|
||||
}
|
||||
libusb_clear_halt(handle_, kEpIn);
|
||||
|
||||
if (!configureDevice()) {
|
||||
libusb_release_interface(handle_, kInterface);
|
||||
libusb_close(handle_);
|
||||
handle_ = nullptr;
|
||||
libusb_exit(ctx_);
|
||||
ctx_ = nullptr;
|
||||
status_.store(HantekStatus::UsbError);
|
||||
return HantekStatus::UsbError;
|
||||
}
|
||||
|
||||
running_.store(true);
|
||||
status_.store(HantekStatus::Ok);
|
||||
worker_ = std::thread([this] { streamLoop(); });
|
||||
return HantekStatus::Ok;
|
||||
}
|
||||
|
||||
void HantekDevice::stop() {
|
||||
running_.store(false);
|
||||
if (worker_.joinable()) worker_.join();
|
||||
if (handle_ != nullptr) {
|
||||
libusb_release_interface(handle_, kInterface);
|
||||
libusb_close(handle_);
|
||||
handle_ = nullptr;
|
||||
}
|
||||
if (ctx_ != nullptr) {
|
||||
libusb_exit(ctx_);
|
||||
ctx_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
void HantekDevice::setSampleRate(uint32_t hz) {
|
||||
sampleRateHz_.store(hz);
|
||||
if (handle_ != nullptr && running_.load()) {
|
||||
// Le FX2 (firmware OpenHantek6022) attend la séquence : stop (0xE3=0)
|
||||
// → set sample rate (0xE2) → start (0xE3=1). Sans le re-start, le
|
||||
// bulk endpoint cesse d'émettre et le stream se fige.
|
||||
const uint8_t stopTrig = 0x00;
|
||||
const uint8_t code = sampleRateCode(hz);
|
||||
const uint8_t startTrig = 0x01;
|
||||
sendVendorControl(0xE3, 0, 0, &stopTrig, 1);
|
||||
sendVendorControl(kReqSetSampleRate, 0, 0, &code, 1);
|
||||
sendVendorControl(0xE3, 0, 0, &startTrig, 1);
|
||||
}
|
||||
}
|
||||
|
||||
void HantekDevice::setGain(int channel, HantekGain gain) {
|
||||
const uint8_t code = gainCode(gain);
|
||||
if (channel == 1) {
|
||||
gainCh1_.store(static_cast<int>(gain));
|
||||
if (handle_) sendVendorControl(kReqSetCh1Gain, 0, 0, &code, 1);
|
||||
} else if (channel == 2) {
|
||||
gainCh2_.store(static_cast<int>(gain));
|
||||
if (handle_) sendVendorControl(kReqSetCh2Gain, 0, 0, &code, 1);
|
||||
}
|
||||
}
|
||||
|
||||
bool HantekDevice::sendVendorControl(uint8_t request, uint16_t value,
|
||||
uint16_t index, const uint8_t* data,
|
||||
uint16_t length) {
|
||||
const uint8_t bmRequestType =
|
||||
LIBUSB_REQUEST_TYPE_VENDOR | LIBUSB_RECIPIENT_DEVICE | LIBUSB_ENDPOINT_OUT;
|
||||
int rc = libusb_control_transfer(handle_, bmRequestType, request, value, index,
|
||||
const_cast<uint8_t*>(data), length, 1000);
|
||||
if (rc < 0) {
|
||||
ofLogError("HantekDevice") << "control_transfer 0x" << std::hex << int(request)
|
||||
<< " failed: " << libusb_error_name(rc);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool HantekDevice::configureDevice() {
|
||||
const uint8_t numChannels = 2;
|
||||
if (!sendVendorControl(kReqSetNumChannels, 0, 0, &numChannels, 1)) return false;
|
||||
const uint8_t srCode = sampleRateCode(sampleRateHz_.load());
|
||||
if (!sendVendorControl(kReqSetSampleRate, 0, 0, &srCode, 1)) return false;
|
||||
const uint8_t g1 = gainCode(static_cast<HantekGain>(gainCh1_.load()));
|
||||
if (!sendVendorControl(kReqSetCh1Gain, 0, 0, &g1, 1)) return false;
|
||||
const uint8_t g2 = gainCode(static_cast<HantekGain>(gainCh2_.load()));
|
||||
if (!sendVendorControl(kReqSetCh2Gain, 0, 0, &g2, 1)) return false;
|
||||
// E3 = trigger / start sampling (firmware fx2lafw / OpenHantek6022)
|
||||
const uint8_t startTrig = 0x01;
|
||||
if (!sendVendorControl(0xE3, 0, 0, &startTrig, 1)) {
|
||||
ofLogWarning("HantekDevice") << "vendor 0xE3 (start) failed (continuing)";
|
||||
}
|
||||
ofLogNotice("HantekDevice") << "configureDevice: numCh=2 sr=" << (int)srCode
|
||||
<< " g1=" << (int)g1 << " g2=" << (int)g2 << " trig=0x01";
|
||||
return true;
|
||||
}
|
||||
|
||||
void HantekDevice::streamLoop() {
|
||||
std::vector<uint8_t> raw(kBulkXferBytes);
|
||||
std::vector<float> ch1, ch2;
|
||||
ch1.reserve(kBulkXferBytes / 2);
|
||||
ch2.reserve(kBulkXferBytes / 2);
|
||||
|
||||
while (running_.load()) {
|
||||
int actual = 0;
|
||||
int rc = libusb_bulk_transfer(handle_, kEpIn, raw.data(),
|
||||
static_cast<int>(raw.size()),
|
||||
&actual, kBulkTimeoutMs);
|
||||
if (rc == LIBUSB_ERROR_TIMEOUT) {
|
||||
continue;
|
||||
}
|
||||
if (rc != 0) {
|
||||
ofLogWarning("HantekDevice") << "bulk_transfer: " << libusb_error_name(rc);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
||||
continue;
|
||||
}
|
||||
|
||||
static std::atomic<uint64_t> totalBytes{0};
|
||||
static auto lastLog = std::chrono::steady_clock::now();
|
||||
totalBytes += actual;
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
if (std::chrono::duration_cast<std::chrono::seconds>(now - lastLog).count() >= 2) {
|
||||
ofLogNotice("HantekDevice") << "stream " << totalBytes.load() << " bytes received";
|
||||
lastLog = now;
|
||||
}
|
||||
|
||||
// Format OpenHantek6022 : octets entrelacés [CH1, CH2, CH1, CH2, ...].
|
||||
// Chaque échantillon est un uint8_t centré autour de 128 (offset binary).
|
||||
const std::size_t pairs = static_cast<std::size_t>(actual) / 2;
|
||||
ch1.resize(pairs);
|
||||
ch2.resize(pairs);
|
||||
for (std::size_t i = 0; i < pairs; ++i) {
|
||||
const uint8_t a = raw[2 * i];
|
||||
const uint8_t b = raw[2 * i + 1];
|
||||
ch1[i] = (static_cast<float>(a) - 128.0f) / 128.0f;
|
||||
ch2[i] = (static_cast<float>(b) - 128.0f) / 128.0f;
|
||||
}
|
||||
ring_.push(ch1.data(), ch2.data(), pairs);
|
||||
}
|
||||
}
|
||||
|
||||
std::string HantekDevice::statusString() const {
|
||||
switch (status_.load()) {
|
||||
case HantekStatus::Ok: return "OK";
|
||||
case HantekStatus::NotFound: return "Aucun Hantek 6022BL detecte";
|
||||
case HantekStatus::FirmwareNeeded: return "Firmware Cypress non charge (voir docs/HANTEK_SETUP.md)";
|
||||
case HantekStatus::OpenFailed: return "libusb_open echec";
|
||||
case HantekStatus::InterfaceClaimFailed: return "claim_interface echec";
|
||||
case HantekStatus::AlreadyRunning: return "deja en cours";
|
||||
case HantekStatus::UsbError: return "erreur USB";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
} // namespace oscope
|
||||
@@ -0,0 +1,99 @@
|
||||
#pragma once
|
||||
|
||||
// Wrapper libusb-1.0 pour l'oscilloscope Hantek 6022BL (Cypress FX2-based).
|
||||
//
|
||||
// Références protocolaires :
|
||||
// - https://github.com/OpenHantek/OpenHantek6022 (code firmware open-source
|
||||
// Cypress FX2 + commandes vendor)
|
||||
// - VID 0x04B5 / PID 0x6022 (firmware non chargé) ou 0x602A (variante)
|
||||
// - Endpoint bulk IN 0x86 sur l'interface 0, alt setting 1
|
||||
//
|
||||
// Important : le 6022BL démarre en "device générique" tant que le firmware
|
||||
// Cypress n'est pas uploadé. Cette classe détecte ce cas et retourne
|
||||
// Status::FirmwareNeeded au lieu de tenter un upload (le user doit utiliser
|
||||
// fxload externe — voir docs/HANTEK_SETUP.md).
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#include "ScopeData.h"
|
||||
|
||||
struct libusb_context;
|
||||
struct libusb_device_handle;
|
||||
|
||||
namespace oscope {
|
||||
|
||||
enum class HantekStatus {
|
||||
Ok,
|
||||
NotFound,
|
||||
FirmwareNeeded,
|
||||
OpenFailed,
|
||||
InterfaceClaimFailed,
|
||||
AlreadyRunning,
|
||||
UsbError
|
||||
};
|
||||
|
||||
enum class HantekGain {
|
||||
G_5V = 0, ///< +/- 5 V (0x01)
|
||||
G_2_5V = 1, ///< +/- 2.5 V (0x02)
|
||||
G_1V = 2, ///< +/- 1 V (0x05)
|
||||
G_500mV = 3, ///< +/- 500 mV (0x0a)
|
||||
G_250mV = 4 ///< +/- 250 mV (0x14)
|
||||
};
|
||||
|
||||
class HantekDevice {
|
||||
public:
|
||||
HantekDevice();
|
||||
~HantekDevice();
|
||||
|
||||
HantekDevice(const HantekDevice&) = delete;
|
||||
HantekDevice& operator=(const HantekDevice&) = delete;
|
||||
|
||||
/// Ouvre le device, claim l'interface, configure sample rate / gains.
|
||||
/// Démarre le thread bulk-transfer et alimente le ringbuffer.
|
||||
HantekStatus start();
|
||||
|
||||
/// Stoppe le thread, libère l'interface, ferme libusb.
|
||||
void stop();
|
||||
|
||||
/// Sample rate desired (Hz). Codes valides : 1e6, 2e6, 4e6, 8e6, 16e6,
|
||||
/// 24e6, 48e6 (24 et 48 MS/s ne sont disponibles qu'avec un seul canal
|
||||
/// actif sur le firmware OpenHantek6022).
|
||||
void setSampleRate(uint32_t hz);
|
||||
|
||||
/// Gain par canal (1 ou 2).
|
||||
void setGain(int channel, HantekGain gain);
|
||||
|
||||
/// Accesseur vers le ringbuffer partagé.
|
||||
ScopeRing& ring() { return ring_; }
|
||||
|
||||
/// Statut courant (Ok ou dernier code d'erreur).
|
||||
HantekStatus status() const { return status_.load(); }
|
||||
|
||||
/// Description humaine du dernier statut.
|
||||
std::string statusString() const;
|
||||
|
||||
/// Indique si un firmware doit être chargé (renvoyé par start()).
|
||||
bool firmwareNeeded() const { return status_.load() == HantekStatus::FirmwareNeeded; }
|
||||
|
||||
private:
|
||||
void streamLoop();
|
||||
bool sendVendorControl(uint8_t request, uint16_t value, uint16_t index,
|
||||
const uint8_t* data, uint16_t length);
|
||||
bool configureDevice();
|
||||
|
||||
libusb_context* ctx_;
|
||||
libusb_device_handle* handle_;
|
||||
std::thread worker_;
|
||||
std::atomic<bool> running_;
|
||||
std::atomic<HantekStatus> status_;
|
||||
std::atomic<uint32_t> sampleRateHz_;
|
||||
std::atomic<int> gainCh1_;
|
||||
std::atomic<int> gainCh2_;
|
||||
ScopeRing ring_;
|
||||
};
|
||||
|
||||
} // namespace oscope
|
||||
@@ -0,0 +1,69 @@
|
||||
#pragma once
|
||||
|
||||
// Structure partagée entre le thread USB Hantek et le thread principal OF.
|
||||
// Ringbuffer SPSC (single-producer / single-consumer) lock-free basé sur
|
||||
// std::atomic. Le producteur est le thread bulk-transfer libusb, le consommateur
|
||||
// est ofApp::update().
|
||||
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
namespace oscope {
|
||||
|
||||
// Capacité du ringbuffer en échantillons par canal. Doit être une puissance de 2
|
||||
// pour permettre le masquage modulo via (idx & (kCapacity - 1)).
|
||||
static constexpr std::size_t kRingCapacity = 1u << 18; // 262144 samples
|
||||
|
||||
/// Buffer SPSC partagé pour les échantillons CH1/CH2 normalisés [-1, 1].
|
||||
class ScopeRing {
|
||||
public:
|
||||
ScopeRing() : write_(0), read_(0) {
|
||||
ch1_.resize(kRingCapacity, 0.0f);
|
||||
ch2_.resize(kRingCapacity, 0.0f);
|
||||
}
|
||||
|
||||
/// Producteur : écrit n échantillons. Si le buffer est plein, écrase
|
||||
/// les plus anciens (overwrite policy, on préfère perdre du passé que
|
||||
/// bloquer le thread USB).
|
||||
void push(const float* ch1, const float* ch2, std::size_t n) {
|
||||
const std::size_t mask = kRingCapacity - 1;
|
||||
std::size_t w = write_.load(std::memory_order_relaxed);
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
ch1_[(w + i) & mask] = ch1[i];
|
||||
ch2_[(w + i) & mask] = ch2[i];
|
||||
}
|
||||
write_.store(w + n, std::memory_order_release);
|
||||
}
|
||||
|
||||
/// Consommateur : lit jusqu'à `nrequested` échantillons les plus récents.
|
||||
/// Retourne le nombre effectivement copié.
|
||||
std::size_t readLatest(std::vector<float>& outCh1,
|
||||
std::vector<float>& outCh2,
|
||||
std::size_t nrequested) {
|
||||
const std::size_t mask = kRingCapacity - 1;
|
||||
const std::size_t w = write_.load(std::memory_order_acquire);
|
||||
const std::size_t available = (w >= nrequested) ? nrequested : w;
|
||||
outCh1.resize(available);
|
||||
outCh2.resize(available);
|
||||
const std::size_t start = w - available;
|
||||
for (std::size_t i = 0; i < available; ++i) {
|
||||
outCh1[i] = ch1_[(start + i) & mask];
|
||||
outCh2[i] = ch2_[(start + i) & mask];
|
||||
}
|
||||
read_.store(w, std::memory_order_release);
|
||||
return available;
|
||||
}
|
||||
|
||||
std::size_t writeIndex() const { return write_.load(std::memory_order_acquire); }
|
||||
|
||||
private:
|
||||
std::vector<float> ch1_;
|
||||
std::vector<float> ch2_;
|
||||
std::atomic<std::size_t> write_;
|
||||
std::atomic<std::size_t> read_;
|
||||
};
|
||||
|
||||
} // namespace oscope
|
||||
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
#include <cstdio>
|
||||
|
||||
inline int g_checks = 0;
|
||||
inline int g_fails = 0;
|
||||
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
++g_checks; \
|
||||
if (!(cond)) { \
|
||||
++g_fails; \
|
||||
std::printf("FAIL %s:%d %s\n", __FILE__, __LINE__, #cond); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
#define REPORT() \
|
||||
do { \
|
||||
std::printf("%d/%d checks passed\n", g_checks - g_fails, g_checks);\
|
||||
return g_fails ? 1 : 0; \
|
||||
} while (0)
|
||||
@@ -0,0 +1,59 @@
|
||||
#include "check.h"
|
||||
#include "FFT.h"
|
||||
#include "AudioAnalyzer.h"
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
|
||||
using oscope::AudioAnalyzer;
|
||||
using oscope::FFT;
|
||||
|
||||
static int argmax(const std::vector<float>& v) {
|
||||
int best = 1;
|
||||
float bv = -1.0f;
|
||||
for (int i = 1; i < static_cast<int>(v.size()); ++i)
|
||||
if (v[i] > bv) { bv = v[i]; best = i; }
|
||||
return best;
|
||||
}
|
||||
|
||||
static std::vector<float> tone(double freqHz, double srHz, std::size_t n) {
|
||||
std::vector<float> s(n);
|
||||
for (std::size_t i = 0; i < n; ++i)
|
||||
s[i] = static_cast<float>(std::sin(2.0 * M_PI * freqHz * i / srHz));
|
||||
return s;
|
||||
}
|
||||
|
||||
// FFT of a sine landing exactly on bin 64 must peak at bin 64.
|
||||
static void test_fft_peak() {
|
||||
const std::size_t N = 2048;
|
||||
std::vector<float> in(N);
|
||||
for (std::size_t i = 0; i < N; ++i)
|
||||
in[i] = static_cast<float>(std::sin(2.0 * M_PI * 64.0 * i / N));
|
||||
FFT fft(N);
|
||||
std::vector<float> mag;
|
||||
fft.magnitude(in, mag);
|
||||
CHECK(mag.size() == N / 2);
|
||||
int peak = argmax(mag);
|
||||
CHECK(peak >= 63 && peak <= 65);
|
||||
}
|
||||
|
||||
// Feeding update(chX, chX) must isolate chX — proves the dual-analyzer
|
||||
// trick: AudioAnalyzer mixes 0.5*(a+b), so 0.5*(x+x) == x.
|
||||
static void test_per_channel_isolation() {
|
||||
const double sr = 48000.0; // deci == 1, no decimation
|
||||
auto a = tone(1000.0, sr, 2048); // bin width 23.4 Hz -> bin ~43
|
||||
auto b = tone(5000.0, sr, 2048); // -> bin ~213
|
||||
AudioAnalyzer an1, an2;
|
||||
an1.update(a, a, static_cast<float>(sr));
|
||||
an2.update(b, b, static_cast<float>(sr));
|
||||
int p1 = argmax(an1.magDown());
|
||||
int p2 = argmax(an2.magDown());
|
||||
CHECK(p1 >= 41 && p1 <= 45);
|
||||
CHECK(p2 >= 211 && p2 <= 217);
|
||||
CHECK(p1 != p2);
|
||||
}
|
||||
|
||||
int main() {
|
||||
test_fft_peak();
|
||||
test_per_channel_isolation();
|
||||
REPORT();
|
||||
}
|
||||
Reference in New Issue
Block a user