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:
L'électron rare
2026-05-18 15:58:14 +02:00
parent d7f3e3a2c1
commit 1848368bc2
11 changed files with 779 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
obj/
bin/oscope-sphere
bin/oscope-sphere_debug
bin/oscope-sphere.app/
*.o
*.d
View File
+97
View File
@@ -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
+53
View File
@@ -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; // 20200 Hz
float lowMid = 0.0f; // 200800 Hz
float mid = 0.0f; // 8003200 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
+60
View File
@@ -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
+32
View File
@@ -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
+284
View File
@@ -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
+99
View File
@@ -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
+69
View File
@@ -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
+20
View File
@@ -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)
+59
View File
@@ -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();
}