feat: render spectrogram sphere skin

Context: Task 5 of oscope-sphere - add the SphereViz layer A skin
and full ofApp wiring so the app produces a visual output.

Approach: Build an icosphere (5 subdivisions) skinned by a scrolling
2-channel spectrogram texture uploaded each frame via ofFloatPixels.
GLSL 150 shaders compute spherical UV from vertex direction so the
texture wraps seamlessly; scroll is handled by a fract() offset in
the fragment shader to avoid re-uploading geometry.

Changes:
- bin/data/shaders/sphere.vert: spherical UV projection from position
- bin/data/shaders/sphere.frag: magma/viridis colormaps + scroll
- src/SphereViz.h/.cpp: icosphere mesh, shader, texture management,
  pushSpectrogramColumn() wraps SpectrogramBuffer for dual-channel
- src/ofApp.h: full member declarations (camera, analyzers, flags)
- src/ofApp.cpp: setup/update/draw/HUD/keyPressed wired end-to-end;
  HantekDevice probed at startup, falls back to DemoSignal on failure

Impact: app renders a rotating spectrogram globe in demo mode with
orbit camera, colormap toggle, freeze, and per-layer visibility keys.
This commit is contained in:
L'électron rare
2026-05-18 17:12:19 +02:00
parent 04fb49aae3
commit fddb31e3ee
6 changed files with 227 additions and 3 deletions
@@ -0,0 +1,37 @@
#version 150
uniform sampler2D spectroTex; // width = time, height = freq, R32F [0,1]
uniform float scrollOffset;
uniform int colormapId;
in vec2 vSphereUV;
out vec4 fragColor;
// Polynomial colormap fits (public domain, Matt Zucker).
vec3 magma(float t) {
const vec3 c0 = vec3(-0.002136485053939,-0.000749655052795,-0.005386127855323);
const vec3 c1 = vec3( 0.251660540737164, 0.677523243683767, 2.494026599312351);
const vec3 c2 = vec3( 8.353717279216625,-3.577719514958484, 0.314467903013257);
const vec3 c3 = vec3(-27.66873308576866, 14.26473078096533,-13.64921318813922);
const vec3 c4 = vec3( 52.17613981234068,-27.94360607168351, 12.94416944238394);
const vec3 c5 = vec3(-50.76852536473588, 29.04658282127291, 4.234152993845980);
const vec3 c6 = vec3( 18.65570506591883,-11.48977351997711,-5.601961508734096);
return c0+t*(c1+t*(c2+t*(c3+t*(c4+t*(c5+t*c6)))));
}
vec3 viridis(float t) {
const vec3 c0 = vec3( 0.277727327223418, 0.005407344544967, 0.334099805335306);
const vec3 c1 = vec3( 0.105093043108577, 1.404613529898575, 1.384590162594685);
const vec3 c2 = vec3(-0.330861828725556, 0.214847559468213, 0.095095163028237);
const vec3 c3 = vec3(-4.634230498983486,-5.799100973351585,-19.33244095627987);
const vec3 c4 = vec3( 6.228269936347081,14.17993336680509, 56.69055260068105);
const vec3 c5 = vec3( 4.776384997670288,-13.74514537774601,-65.35303263337234);
const vec3 c6 = vec3(-5.435455855934631, 4.645852612178535, 26.3124352495832);
return c0+t*(c1+t*(c2+t*(c3+t*(c4+t*(c5+t*c6)))));
}
void main() {
float u = fract(vSphereUV.x - scrollOffset);
float mag = clamp(texture(spectroTex, vec2(u, vSphereUV.y)).r, 0.0, 1.0);
vec3 col = (colormapId == 0) ? magma(mag) : viridis(mag);
fragColor = vec4(col, 1.0);
}
@@ -0,0 +1,16 @@
#version 150
uniform mat4 modelViewProjectionMatrix;
in vec4 position;
out vec2 vSphereUV; // x = longitude [0,1], y = latitude [0,1]
const float PI = 3.14159265359;
void main() {
vec3 dir = normalize(position.xyz);
float lon = atan(dir.z, dir.x) / (2.0 * PI) + 0.5;
float lat = asin(clamp(dir.y, -1.0, 1.0)) / PI + 0.5;
gl_Position = modelViewProjectionMatrix * position;
vSphereUV = vec2(lon, lat);
}
+42
View File
@@ -0,0 +1,42 @@
#include "SphereViz.h"
#include <algorithm>
void SphereViz::setup(int icoIterations, int spectroWidth, int spectroHeight) {
spectro_ = std::make_unique<oscope::SpectrogramBuffer>(spectroWidth,
spectroHeight);
ofIcoSpherePrimitive ico(baseRadius_, icoIterations);
ofMesh src = ico.getMesh();
mesh_.clear();
mesh_.addVertices(src.getVertices());
mesh_.addNormals(src.getNormals());
mesh_.addIndices(src.getIndices());
ofDisableArbTex();
spectroPix_.allocate(spectroWidth, spectroHeight, OF_PIXELS_GRAY);
spectroPix_.set(0.0f);
spectroTex_.allocate(spectroPix_);
spectroTex_.setTextureWrap(GL_REPEAT, GL_CLAMP_TO_EDGE);
spectroTex_.setTextureMinMagFilter(GL_LINEAR, GL_LINEAR);
shader_.load("shaders/sphere");
}
void SphereViz::pushSpectrogramColumn(const std::vector<float>& magCh1,
const std::vector<float>& magCh2) {
spectro_->pushColumn(magCh1, magCh2);
const std::vector<float>& d = spectro_->data();
std::copy(d.begin(), d.end(), spectroPix_.getData());
spectroTex_.loadData(spectroPix_);
scrollOffset_ = static_cast<float>(spectro_->writeIndex()) /
static_cast<float>(spectro_->width());
}
void SphereViz::drawSkin() {
shader_.begin();
shader_.setUniformTexture("spectroTex", spectroTex_, 0);
shader_.setUniform1f("scrollOffset", scrollOffset_);
shader_.setUniform1i("colormapId", colormapId_);
mesh_.draw();
shader_.end();
}
+29
View File
@@ -0,0 +1,29 @@
#pragma once
#include "ofMain.h"
#include "SpectrogramBuffer.h"
#include <memory>
#include <vector>
// GL visual unit: an icosphere whose skin is the scrolling spectrogram.
// The northern hemisphere shows CH1, the southern shows CH2.
class SphereViz {
public:
void setup(int icoIterations, int spectroWidth, int spectroHeight);
// Advances the spectrogram by one column and uploads it.
void pushSpectrogramColumn(const std::vector<float>& magCh1,
const std::vector<float>& magCh2);
void drawSkin();
void setColormap(int id) { colormapId_ = id; }
private:
std::unique_ptr<oscope::SpectrogramBuffer> spectro_;
ofVboMesh mesh_;
ofShader shader_;
ofTexture spectroTex_;
ofFloatPixels spectroPix_;
float baseRadius_ = 200.0f;
float scrollOffset_ = 0.0f;
int colormapId_ = 0;
};
+73 -3
View File
@@ -2,12 +2,82 @@
void ofApp::setup() {
ofSetFrameRate(60);
ofSetVerticalSync(true);
ofBackground(6, 6, 10);
ofEnableDepthTest();
glEnable(GL_PROGRAM_POINT_SIZE);
cam_.setDistance(750.0f);
cam_.setNearClip(1.0f);
cam_.setFarClip(5000.0f);
sphere_.setup(5, 512, 256);
hantek_.setSampleRate(16000000u);
const oscope::HantekStatus st = hantek_.start();
if (st == oscope::HantekStatus::Ok) {
demoMode_ = false;
statusText_ = "SCOPE OK";
} else {
demoMode_ = true;
statusText_ = (st == oscope::HantekStatus::FirmwareNeeded)
? "DEMO - firmware needed (see docs/HANTEK_SETUP.md)"
: "DEMO - scope not found";
}
}
void ofApp::update() {}
void ofApp::update() {
if (frozen_) return;
if (demoMode_) {
demo_.next(buf1_, buf2_, 8192);
} else {
hantek_.ring().readLatest(buf1_, buf2_, 8192);
if (hantek_.status() != oscope::HantekStatus::Ok) {
demoMode_ = true;
statusText_ = "DEMO - scope lost";
}
}
const float sr = demoMode_ ? 48000.0f : scopeSr_;
analyzerCh1_.update(buf1_, buf1_, sr);
analyzerCh2_.update(buf2_, buf2_, sr);
sphere_.setColormap(colormap_);
sphere_.pushSpectrogramColumn(analyzerCh1_.magDown(),
analyzerCh2_.magDown());
}
void ofApp::draw() {
ofSetColor(230);
ofDrawBitmapString("oscope-sphere skeleton", 16, 24);
cam_.begin();
ofPushMatrix();
ofRotateYDeg(ofGetElapsedTimef() * 6.0f);
if (layerA_) sphere_.drawSkin();
ofPopMatrix();
cam_.end();
drawHud();
}
void ofApp::drawHud() {
ofDisableDepthTest();
ofSetColor(230);
std::string hud = statusText_ + "\n";
hud += std::string("[1] skin ") + (layerA_ ? "on" : "off") + "\n";
hud += std::string("[2] rings ") + (layerB_ ? "on" : "off") + "\n";
hud += std::string("[3] points ") + (layerC_ ? "on" : "off") + "\n";
hud += std::string("[c] colormap [space] ") +
(frozen_ ? "frozen" : "live");
ofDrawBitmapString(hud, 16, 24);
ofEnableDepthTest();
}
void ofApp::keyPressed(int key) {
switch (key) {
case '1': layerA_ = !layerA_; break;
case '2': layerB_ = !layerB_; break;
case '3': layerC_ = !layerC_; break;
case 'c':
case 'C': colormap_ = (colormap_ + 1) % 2; break;
case ' ': frozen_ = !frozen_; break;
}
}
+30
View File
@@ -1,9 +1,39 @@
#pragma once
#include "ofMain.h"
#include "HantekDevice.h"
#include "AudioAnalyzer.h"
#include "DemoSignal.h"
#include "SphereViz.h"
#include <string>
#include <vector>
class ofApp : public ofBaseApp {
public:
void setup() override;
void update() override;
void draw() override;
void keyPressed(int key) override;
private:
void drawHud();
oscope::HantekDevice hantek_;
oscope::AudioAnalyzer analyzerCh1_;
oscope::AudioAnalyzer analyzerCh2_;
oscope::DemoSignal demo_{48000.0f};
SphereViz sphere_;
ofEasyCam cam_;
std::vector<float> buf1_;
std::vector<float> buf2_;
bool demoMode_ = false;
bool frozen_ = false;
bool layerA_ = true;
bool layerB_ = true;
bool layerC_ = true;
int colormap_ = 0;
float scopeSr_ = 16.0e6f;
std::string statusText_;
};