feat(of): AudioAnalyzer FFT du ring Hantek

Les visualizers ne dépendaient que de l'OSC depuis sound_algo
pour les bandes (kick/bass/lead/snare). User veut que ce soit
le signal qui passe physiquement par le scope qui pilote le
rendu.

Approach:
- Nouveau AudioAnalyzer qui downsample le ring Hantek (1-48
  MS/s) vers ~48 kHz audio, fenêtre Hann, FFT 1024.
- Bandes en log-power normalisé (-60dB→0, 0dB→1) :
  bass 20-200 Hz / lowMid 200-800 / mid 800-3200 /
  treble 3200-16000.
- Détecteurs de transitoire (kick = montée bass, snare =
  montée mid+treble) avec décay 0.85.
- Exposé via VisFrame::bands. TunnelVis et PolarVis lisent
  désormais frame.bands.* au lieu de frame.osc.amp(*).
- bpm + pad restent OSC (timing métadonnées non déductibles).
This commit is contained in:
L'électron rare
2026-05-07 19:43:33 +02:00
parent 5de30a9a8c
commit 1f0acfb8f2
7 changed files with 163 additions and 8 deletions
+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
+47
View File
@@ -0,0 +1,47 @@
#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_; }
private:
static constexpr std::size_t kFftSize = 1024;
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
+6 -1
View File
@@ -217,7 +217,12 @@ void ofApp::update() {
waveform_->setSampleRate(static_cast<float>(lastSampleRateApplied_));
scope_.ring().readLatest(ch1_, ch2_, static_cast<std::size_t>(bufferSize_));
oscope::VisFrame frame{ch1_, ch2_, osc_};
// FFT audio depuis le ring Hantek (downsampled vers 48 kHz) pour les
// bandes bass/lowMid/mid/treble + transitoires kick/snare. Pilote
// les visualizers (Tunnel, Polar) sans dépendre de l'OSC.
audio_.update(ch1_, ch2_, static_cast<float>(lastSampleRateApplied_));
oscope::VisFrame frame{ch1_, ch2_, osc_, audio_.bands()};
lissajous_->update(frame);
spectro_->update(frame);
reactive_->update(frame);
+1
View File
@@ -55,6 +55,7 @@ private:
oscope::HantekDevice scope_;
oscope::OscClient osc_;
oscope::PostFx postfx_;
oscope::AudioAnalyzer audio_;
std::unique_ptr<oscope::LissajousVis> lissajous_;
std::unique_ptr<oscope::SpectrogramVis> spectro_;
+3 -3
View File
@@ -11,9 +11,9 @@ void PolarVis::setup(int w, int h) {
void PolarVis::update(const VisFrame& frame) {
bpm_ = frame.osc.bpm();
kick_ = frame.osc.amp("kick");
bass_ = frame.osc.amp("bass");
lead_ = frame.osc.amp("lead");
kick_ = frame.bands.kick;
bass_ = frame.bands.bass;
lead_ = frame.bands.mid + frame.bands.treble * 0.5f;
// Roll the trace forward, push new samples
const std::size_t step = std::max<std::size_t>(1, trace1_.size() / 90);
+7 -4
View File
@@ -7,12 +7,15 @@ void TunnelVis::setup(int w, int h) { w_ = w; h_ = h; reloadShaders(); }
void TunnelVis::reloadShaders() { shader_.load("shaders/tunnel"); }
void TunnelVis::update(const VisFrame& frame) {
// Fréquences extraites du signal Hantek (FFT du ring downsamplé).
// bpm/pad restent OSC car ce sont des métadonnées de timing/pad pas
// déductibles d'une FFT brute.
bpm_ = frame.osc.bpm();
kick_ = frame.osc.amp("kick");
bass_ = frame.osc.amp("bass");
lead_ = frame.osc.amp("lead");
pad_ = frame.osc.amp("pad");
snare_ = frame.osc.amp("snare");
kick_ = frame.bands.kick;
bass_ = frame.bands.bass;
lead_ = frame.bands.mid + frame.bands.treble * 0.5f;
snare_ = frame.bands.snare;
// Direction : lerp lente vers +1 si la balance HF (lead) > LF (bass),
// -1 sinon. Le signe contrôle le sens de défilement et le sens du twist.
+2
View File
@@ -2,6 +2,7 @@
// Interface abstraite pour les 3 modes de visualisation.
#include "../AudioAnalyzer.h"
#include "../OscClient.h"
#include "../ScopeData.h"
@@ -13,6 +14,7 @@ struct VisFrame {
const std::vector<float>& ch1;
const std::vector<float>& ch2;
OscClient& osc;
const AudioBands& bands; // FFT-derived bands depuis le signal Hantek
};
class Visualizer {