#include "VisualizerWidget.h" #include #include #include #include #include #include #ifndef M_PI #define M_PI 3.14159265358979323846 #endif static QShader loadShader(const QString &name) { QFile f(name); if (f.open(QIODevice::ReadOnly)) return QShader::fromSerialized(f.readAll()); qWarning() << "Failed to load shader:" << name; return {}; } VisualizerWidget::VisualizerWidget(QWidget *parent) : QRhiWidget(parent) { setSampleCount(4); setNumBins(26); } void VisualizerWidget::mouseReleaseEvent(QMouseEvent *event) { if (event->button() == Qt::LeftButton) emit tapDetected(); QRhiWidget::mouseReleaseEvent(event); } void VisualizerWidget::setNumBins(int n) { m_customBins.clear(); float minFreq = 40.0f; float maxFreq = 11000.0f; for (int i = 0; i <= n; ++i) { float f = minFreq * std::pow(maxFreq / minFreq, (float)i / n); m_customBins.push_back(f); } m_channels.clear(); } void VisualizerWidget::setTargetFps(int fps) { m_targetFps = std::max(15, std::min(120, fps)); } void VisualizerWidget::setParams(bool glass, bool albumColors, bool mirrored, bool inverted, float hue, float contrast, float brightness, float entropy) { m_glass = glass; m_useAlbumColors = albumColors; m_mirrored = mirrored; m_inverted = inverted; m_hueFactor = hue; m_contrast = contrast; m_brightness = brightness; m_entropyStrength = entropy; update(); } void VisualizerWidget::setAlbumPalette(const std::vector &palette) { m_albumPalette.clear(); int targetLen = static_cast(m_customBins.size()) - 1; if (palette.empty()) return; for (int i = 0; i < targetLen; ++i) { int idx = (i * static_cast(palette.size() - 1)) / (targetLen - 1); m_albumPalette.push_back(palette[idx]); } } float VisualizerWidget::getX(float freq) { float logMin = std::log10(20.0f); float logMax = std::log10(20000.0f); if (freq <= 0) return 0; return (std::log10(std::max(freq, 1e-9f)) - logMin) / (logMax - logMin); } QColor VisualizerWidget::applyModifiers(QColor c) { float h, s, l; c.getHslF(&h, &s, &l); s = std::clamp(s * m_hueFactor, 0.0f, 1.0f); float v = c.valueF(); v = std::clamp(v * (m_contrast * 0.5f + 0.5f), 0.0f, 1.0f); return QColor::fromHsvF(c.hsvHueF(), s, v); } float VisualizerWidget::calculateEntropy(const std::deque &history) { if (history.size() < 4) return 0.0f; int N = static_cast(history.size()); // Forward DFT (O(N²) for N≈30 is ~900 ops — trivial, no FFTW needed) std::vector> X(N); for (int k = 0; k < N; ++k) { double re = 0, im = 0; for (int n = 0; n < N; ++n) { double angle = -2.0 * M_PI * k * n / N; re += history[n] * std::cos(angle); im += history[n] * std::sin(angle); } X[k] = {re, im}; } // Analytic signal: zero negative freqs, double positive freqs for (int k = N / 2 + 1; k < N; ++k) X[k] = 0; for (int k = 1; k < (N + 1) / 2; ++k) X[k] *= 2.0; // Inverse DFT — imaginary part only (zero-centered, phase-aligned) float sqSum = 0.0f; for (int n = 0; n < N; ++n) { double im = 0; for (int k = 0; k < N; ++k) { double angle = 2.0 * M_PI * k * n / N; im += X[k].real() * std::sin(angle) + X[k].imag() * std::cos(angle); } im /= N; sqSum += static_cast(im * im); } // RMS of zero-centered signal, normalized: 10dB RMS = max chaos return std::clamp(std::sqrt(sqSum / N) / 10.0f, 0.0f, 1.0f); } // ===== Spectrum Processing ===== void VisualizerWidget::updateData( const std::vector &data) { if (!isVisible()) return; m_data = data; m_dataDirty = true; qint64 now = QDateTime::currentMSecsSinceEpoch(); if (now - m_lastFrameTime < (1000 / m_targetFps)) return; m_lastFrameTime = now; if (m_channels.size() != data.size()) m_channels.resize(data.size()); // --- 1. Unified Glass Color --- if (m_glass && !m_data.empty()) { size_t midIdx = m_data[0].freqs.size() / 2; float frameMidFreq = (midIdx < m_data[0].freqs.size()) ? m_data[0].freqs[midIdx] : 1000.0f; float sumDb = 0; for (float v : m_data[0].db) sumDb += v; float frameMeanDb = m_data[0].db.empty() ? -80.0f : sumDb / m_data[0].db.size(); float logMin = std::log10(20.0f); float logMax = std::log10(20000.0f); float frameFreqNorm = (std::log10(std::max(frameMidFreq, 1e-9f)) - logMin) / (logMax - logMin); float frameAmpNorm = std::clamp((frameMeanDb + 80.0f) / 80.0f, 0.0f, 1.0f); float frameAmpWeight = 1.0f / std::pow(frameFreqNorm + 1e-4f, 5.0f); frameAmpWeight = std::clamp(frameAmpWeight * 2.0f, 0.5f, 6.0f); float frameHue = std::fmod( frameFreqNorm + frameAmpNorm * frameAmpWeight * m_hueFactor, 1.0f); if (m_mirrored) frameHue = 1.0f - frameHue; if (frameHue < 0) frameHue += 1.0f; float angle = frameHue * 2.0f * M_PI; float cosVal = std::cos(angle); float sinVal = std::sin(angle); m_hueHistory.push_back({cosVal, sinVal}); m_hueSumCos += cosVal; m_hueSumSin += sinVal; if (m_hueHistory.size() > 40) { auto old = m_hueHistory.front(); m_hueSumCos -= old.first; m_hueSumSin -= old.second; m_hueHistory.pop_front(); } float smoothedAngle = std::atan2(m_hueSumSin, m_hueSumCos); float smoothedHue = smoothedAngle / (2.0f * M_PI); if (smoothedHue < 0.0f) smoothedHue += 1.0f; m_unifiedColor = QColor::fromHsvF(smoothedHue, 1.0, 1.0); } else { m_unifiedColor = Qt::white; } // --- 2. Process Channels & Bins --- for (size_t ch = 0; ch < data.size(); ++ch) { const auto &db = data[ch].db; const auto &primaryDb = data[ch].primaryDb; size_t numBins = db.size(); auto &bins = m_channels[ch].bins; if (bins.size() != numBins) bins.resize(numBins); std::vector vertexEnergy(numBins); float globalMax = 0.001f; bool useEntropy = m_entropyStrength > -2.0f; // Pass 1: compute per-bin entropy from output history (self-referencing) std::vector binEntropy(numBins, 0.0f); if (useEntropy) { for (size_t i = 0; i < numBins; ++i) binEntropy[i] = calculateEntropy(bins[i].history); } // Find the midpoint — median entropy across all bins float medianEntropy = 0.0f; if (useEntropy) { std::vector sorted = binEntropy; std::nth_element(sorted.begin(), sorted.begin() + numBins / 2, sorted.end()); medianEntropy = sorted[numBins / 2]; } // Pass 2: apply entropy-relative multiplier and update visual state for (size_t i = 0; i < numBins; ++i) { auto &bin = bins[i]; float rawVal = db[i]; float primaryVal = (i < primaryDb.size()) ? primaryDb[i] : rawVal; float change = rawVal - bin.visualDb; if (useEntropy) { // Position relative to midpoint: >0 = more stable, <0 = more chaotic float relative = medianEntropy - binEntropy[i]; // Slider controls ratio of reward (stable bins) to penalty (chaotic bins) // At 0: equal gains → balanced/neutral. +1.5: all reward. -1.5: all penalty. float base = 1.5f; float rewardGain = base + m_entropyStrength; // 0..3 float penaltyGain = base - m_entropyStrength; // 3..0 float gain = (relative >= 0.0f) ? rewardGain : penaltyGain; float multiplier = 1.0f + relative * gain * 2.0f; multiplier = std::clamp(multiplier, 0.05f, 4.0f); bin.visualDb += change * multiplier; // Feed output back into history — the compressor sees its own work bin.history.push_back(bin.visualDb); if (bin.history.size() > 30) bin.history.pop_front(); } else { float responsiveness = 0.2f; bin.visualDb = (bin.visualDb * (1.0f - responsiveness)) + (rawVal * responsiveness); } float patternResp = 0.1f; bin.primaryVisualDb = (bin.primaryVisualDb * (1.0f - patternResp)) + (primaryVal * patternResp); vertexEnergy[i] = std::clamp((bin.primaryVisualDb + 80.0f) / 80.0f, 0.0f, 1.0f); } size_t splitIdx = numBins / 2; float maxLow = 0.01f; float maxHigh = 0.01f; for (size_t j = 0; j < splitIdx; ++j) maxLow = std::max(maxLow, vertexEnergy[j]); for (size_t j = splitIdx; j < numBins; ++j) maxHigh = std::max(maxHigh, vertexEnergy[j]); float trebleBoost = std::clamp(maxLow / maxHigh, 1.0f, 40.0f); for (size_t j = 0; j < numBins; ++j) { if (j >= splitIdx) { float t = (float)(j - splitIdx) / (numBins - splitIdx); vertexEnergy[j] *= 1.0f + (trebleBoost - 1.0f) * t; } float compressed = std::tanh(vertexEnergy[j]); vertexEnergy[j] = compressed; if (compressed > globalMax) globalMax = compressed; } for (float &v : vertexEnergy) v = std::clamp(v / globalMax, 0.0f, 1.0f); // --- 3. Procedural Pattern --- for (auto &b : bins) { b.brightMod = 0.0f; b.alphaMod = 0.0f; } for (size_t i = 1; i < vertexEnergy.size() - 1; ++i) { float curr = vertexEnergy[i]; float prev = vertexEnergy[i - 1]; float next = vertexEnergy[i + 1]; if (curr > prev && curr > next) { bool leftDominant = (prev > next); float sharpness = std::min(curr - prev, curr - next); float entropyFactor = useEntropy ? std::max(0.1f, std::abs(m_entropyStrength)) : 1.0f; float peakIntensity = std::clamp(std::pow(sharpness * 10.0f * entropyFactor, 0.3f), 0.0f, 1.0f); float decayBase = 0.65f - std::clamp(sharpness * 3.0f, 0.0f, 0.35f); auto applyPattern = [&](int dist, bool isBrightSide, int direction) { int segIdx = (direction == -1) ? (static_cast(i) - dist) : (static_cast(i) + dist - 1); if (segIdx < 0 || segIdx >= static_cast(bins.size())) return; int cycle = (dist - 1) / 3; int step = (dist - 1) % 3; float decay = std::pow(decayBase, cycle); float intensity = peakIntensity * decay; if (intensity < 0.01f) return; int type = step; if (isBrightSide) type = (type + 2) % 3; switch (type) { case 0: bins[segIdx].brightMod += 0.8f * intensity; bins[segIdx].alphaMod -= 0.8f * intensity; break; case 1: bins[segIdx].brightMod -= 0.8f * intensity; bins[segIdx].alphaMod += 0.2f * intensity; break; case 2: bins[segIdx].brightMod += 0.8f * intensity; bins[segIdx].alphaMod += 0.2f * intensity; break; } }; for (int d = 1; d <= 12; ++d) { applyPattern(d, leftDominant, -1); applyPattern(d, !leftDominant, 1); } } } // --- 4. Pre-calculate Colors --- for (size_t i = 0; i < numBins; ++i) { auto &b = bins[i]; QColor binColor; if (m_useAlbumColors && !m_albumPalette.empty()) { int palIdx = static_cast(i); if (m_mirrored) palIdx = static_cast(m_albumPalette.size()) - 1 - static_cast(i); palIdx = std::clamp(palIdx, 0, static_cast(m_albumPalette.size()) - 1); binColor = m_albumPalette[palIdx]; binColor = applyModifiers(binColor); } else { float hue = (float)i / (numBins - 1); if (m_mirrored) hue = 1.0f - hue; binColor = QColor::fromHsvF(hue, 1.0f, 1.0f); } b.cachedColor = binColor; } } // --- 5. Cepstral Thread Smoothing (mirrored mode only) --- if (m_mirrored && !data.empty() && !data[0].cepstrum.empty()) { const auto &raw = data[0].cepstrum; if (m_smoothedCepstrum.size() != raw.size()) m_smoothedCepstrum.assign(raw.size(), 0.0f); for (size_t i = 0; i < raw.size(); ++i) m_smoothedCepstrum[i] = 0.15f * raw[i] + 0.85f * m_smoothedCepstrum[i]; } update(); } // ===== QRhiWidget GPU Rendering ===== void VisualizerWidget::initialize(QRhiCommandBuffer *cb) { if (m_rhi != rhi()) { m_fillPipeline.reset(); m_rhi = rhi(); } if (!m_fillPipeline) { m_ubufAlign = m_rhi->ubufAlignment(); // Vertex buffer: dynamic, sized for worst case m_vbuf.reset(m_rhi->newBuffer(QRhiBuffer::Dynamic, QRhiBuffer::VertexBuffer, 2048 * 6 * sizeof(float))); m_vbuf->create(); // Uniform buffer: single MVP matrix (mirroring baked into vertices) m_ubuf.reset(m_rhi->newBuffer(QRhiBuffer::Dynamic, QRhiBuffer::UniformBuffer, m_ubufAlign * 5)); m_ubuf->create(); // Shader resource bindings with dynamic UBO offset m_srb.reset(m_rhi->newShaderResourceBindings()); m_srb->setBindings({QRhiShaderResourceBinding::uniformBufferWithDynamicOffset( 0, QRhiShaderResourceBinding::VertexStage, m_ubuf.get(), 64)}); m_srb->create(); // Load compiled shaders QShader vs = loadShader(QStringLiteral(":/shaders/visualizer.vert.qsb")); QShader fs = loadShader(QStringLiteral(":/shaders/visualizer.frag.qsb")); // Vertex layout: [x, y, r, g, b, a] = 6 floats, 24 bytes stride QRhiVertexInputLayout inputLayout; inputLayout.setBindings({{6 * sizeof(float)}}); inputLayout.setAttributes( {{0, 0, QRhiVertexInputAttribute::Float2, 0}, {0, 1, QRhiVertexInputAttribute::Float4, 2 * sizeof(float)}}); // Alpha blending QRhiGraphicsPipeline::TargetBlend blend; blend.enable = true; blend.srcColor = QRhiGraphicsPipeline::SrcAlpha; blend.dstColor = QRhiGraphicsPipeline::OneMinusSrcAlpha; blend.srcAlpha = QRhiGraphicsPipeline::One; blend.dstAlpha = QRhiGraphicsPipeline::OneMinusSrcAlpha; // Fill pipeline (triangles) m_fillPipeline.reset(m_rhi->newGraphicsPipeline()); m_fillPipeline->setShaderStages( {{QRhiShaderStage::Vertex, vs}, {QRhiShaderStage::Fragment, fs}}); m_fillPipeline->setVertexInputLayout(inputLayout); m_fillPipeline->setShaderResourceBindings(m_srb.get()); m_fillPipeline->setRenderPassDescriptor(renderTarget()->renderPassDescriptor()); m_fillPipeline->setTopology(QRhiGraphicsPipeline::Triangles); m_fillPipeline->setTargetBlends({blend}); m_fillPipeline->setSampleCount(sampleCount()); m_fillPipeline->create(); // Line pipeline (same shader, line topology) m_linePipeline.reset(m_rhi->newGraphicsPipeline()); m_linePipeline->setShaderStages( {{QRhiShaderStage::Vertex, vs}, {QRhiShaderStage::Fragment, fs}}); m_linePipeline->setVertexInputLayout(inputLayout); m_linePipeline->setShaderResourceBindings(m_srb.get()); m_linePipeline->setRenderPassDescriptor(renderTarget()->renderPassDescriptor()); m_linePipeline->setTopology(QRhiGraphicsPipeline::Lines); m_linePipeline->setTargetBlends({blend}); m_linePipeline->setSampleCount(sampleCount()); m_linePipeline->create(); } } void VisualizerWidget::render(QRhiCommandBuffer *cb) { if (!m_fillPipeline) return; const QSize outputSize = renderTarget()->pixelSize(); int w = width(); int h = height(); // Rebuild vertices when new data arrives OR when the widget has been resized bool sizeChanged = (w != m_lastBuildW || h != m_lastBuildH); if (m_dataDirty || sizeChanged) { m_dataDirty = false; m_lastBuildW = w; m_lastBuildH = h; if (m_mirrored) { buildVertices(w * 0.55f, h / 2); // Mirror into 4 quadrants directly (avoids multi-pass dynamic UBO // issues on Android GPU drivers) { int fillFloats = m_fillVertexCount * 6; std::vector baseFill(m_vertices.begin(), m_vertices.begin() + fillFloats); std::vector baseLine(m_vertices.begin() + fillFloats, m_vertices.end()); m_vertices.clear(); auto mir = [](const std::vector &src, std::vector &dst, float sx, float sy, float tx, float ty) { for (size_t j = 0; j < src.size(); j += 6) { dst.push_back(src[j] * sx + tx); dst.push_back(src[j + 1] * sy + ty); dst.insert(dst.end(), src.begin() + j + 2, src.begin() + j + 6); } }; mir(baseFill, m_vertices, 1, 1, 0, 0); mir(baseFill, m_vertices, -1, 1, (float)w, 0); mir(baseFill, m_vertices, 1, -1, 0, (float)h); mir(baseFill, m_vertices, -1, -1, (float)w, (float)h); m_fillVertexCount *= 4; mir(baseLine, m_vertices, 1, 1, 0, 0); mir(baseLine, m_vertices, -1, 1, (float)w, 0); mir(baseLine, m_vertices, 1, -1, 0, (float)h); mir(baseLine, m_vertices, -1, -1, (float)w, (float)h); m_lineVertexCount *= 4; } buildCepstrumVertices(w, h); } else { buildVertices(w, h); m_cepstrumVertexCount = 0; } } // Prepare resource updates QRhiResourceUpdateBatch *u = m_rhi->nextResourceUpdateBatch(); // Upload vertex data (main + cepstrum appended) { int mainSize = static_cast(m_vertices.size() * sizeof(float)); int cepSize = static_cast(m_cepstrumVerts.size() * sizeof(float)); int totalSize = mainSize + cepSize; if (totalSize > 0) { if (totalSize > m_vbuf->size()) { m_vbuf->setSize(totalSize); m_vbuf->create(); } if (mainSize > 0) u->updateDynamicBuffer(m_vbuf.get(), 0, mainSize, m_vertices.data()); if (cepSize > 0) u->updateDynamicBuffer(m_vbuf.get(), mainSize, cepSize, m_cepstrumVerts.data()); } } // Single full-screen ortho MVP — mirroring is baked into vertex positions QMatrix4x4 correction = m_rhi->clipSpaceCorrMatrix(); QMatrix4x4 proj; proj.ortho(0, (float)w, (float)h, 0, -1, 1); QMatrix4x4 mvp = correction * proj; u->updateDynamicBuffer(m_ubuf.get(), 0, 64, mvp.constData()); // Begin render pass cb->beginPass(renderTarget(), QColor(0, 0, 0, 255), {1.0f, 0}, u); cb->setViewport({0, 0, (float)outputSize.width(), (float)outputSize.height()}); const QRhiCommandBuffer::VertexInput vbufBinding(m_vbuf.get(), 0); QRhiCommandBuffer::DynamicOffset dynOfs(0, 0); if (m_fillVertexCount > 0) { cb->setGraphicsPipeline(m_fillPipeline.get()); cb->setShaderResources(m_srb.get(), 1, &dynOfs); cb->setVertexInput(0, 1, &vbufBinding); cb->draw(m_fillVertexCount); } if (m_lineVertexCount > 0) { cb->setGraphicsPipeline(m_linePipeline.get()); cb->setShaderResources(m_srb.get(), 1, &dynOfs); cb->setVertexInput(0, 1, &vbufBinding); cb->draw(m_lineVertexCount, 1, m_fillVertexCount, 0); } if (m_cepstrumVertexCount > 0) { cb->setGraphicsPipeline(m_linePipeline.get()); cb->setShaderResources(m_srb.get(), 1, &dynOfs); cb->setVertexInput(0, 1, &vbufBinding); cb->draw(m_cepstrumVertexCount, 1, m_fillVertexCount + m_lineVertexCount, 0); } cb->endPass(); update(); } void VisualizerWidget::releaseResources() { m_linePipeline.reset(); m_fillPipeline.reset(); m_srb.reset(); m_ubuf.reset(); m_vbuf.reset(); } // ===== Cepstral Thread Vertex Building ===== void VisualizerWidget::buildCepstrumVertices(int w, int h) { m_cepstrumVerts.clear(); m_cepstrumVertexCount = 0; if (m_smoothedCepstrum.empty()) return; // Quefrency range: indices 12-600 (~80Hz to ~4000Hz pitch at 48kHz) int qStart = 12; int qEnd = std::min(600, (int)m_smoothedCepstrum.size()); if (qEnd <= qStart) return; // Find peak magnitude for normalization float peak = 0.0f; for (int i = qStart; i < qEnd; ++i) peak = std::max(peak, std::abs(m_smoothedCepstrum[i])); if (peak < 1e-7f) return; // silence — don't draw float invPeak = 1.0f / peak; float maxDisp = w * 0.06f; float cx = w * 0.5f; // Color: unified color desaturated slightly, alpha ~0.45 float cr, cg, cb; { float ch, cs, cv; m_unifiedColor.getHsvF(&ch, &cs, &cv); cs *= 0.7f; // desaturate QColor c = QColor::fromHsvF(ch, cs, cv); cr = c.redF(); cg = c.greenF(); cb = c.blueF(); } float ca = 0.45f; // Build line segments with top/bottom edge fade float fadeMargin = 0.08f; // fade over 8% of height at each end float prevX = cx + m_smoothedCepstrum[qStart] * invPeak * maxDisp; float prevY = 0.0f; float prevT = 0.0f; for (int i = qStart + 1; i < qEnd; ++i) { float t = (float)(i - qStart) / (qEnd - qStart); float y = t * h; float x = cx + m_smoothedCepstrum[i] * invPeak * maxDisp; // Fade alpha near top and bottom edges auto edgeFade = [&](float tt) -> float { if (tt < fadeMargin) return tt / fadeMargin; if (tt > 1.0f - fadeMargin) return (1.0f - tt) / fadeMargin; return 1.0f; }; float a0 = ca * edgeFade(prevT); float a1 = ca * edgeFade(t); m_cepstrumVerts.insert(m_cepstrumVerts.end(), {prevX, prevY, cr, cg, cb, a0, x, y, cr, cg, cb, a1}); prevX = x; prevY = y; prevT = t; } m_cepstrumVertexCount = (int)m_cepstrumVerts.size() / 6; } // ===== Vertex Building (identical logic to old drawContent) ===== void VisualizerWidget::buildVertices(int w, int h) { m_vertices.clear(); m_fillVertexCount = 0; m_lineVertexCount = 0; std::vector lineVerts; for (size_t ch = 0; ch < m_channels.size(); ++ch) { if (ch >= m_data.size()) break; const auto &freqs = m_data[ch].freqs; const auto &bins = m_channels[ch].bins; if (bins.empty() || freqs.size() < 2) continue; float xOffset = (ch == 1 && m_data.size() > 1) ? 1.005f : 1.0f; size_t numBins = std::min(freqs.size(), bins.size()); for (size_t i = 0; i + 1 < numBins; ++i) { // When inverted, read bin data in reverse order (highs left, lows right) size_t di = m_inverted ? (numBins - 1 - i) : i; size_t diN = m_inverted ? (numBins - 2 - i) : (i + 1); const auto &b = bins[di]; const auto &bNext = bins[diN]; // --- Brightness --- float avgEnergy = std::clamp((b.primaryVisualDb + 80.0f) / 80.0f, 0.0f, 1.0f); float baseBrightness = std::pow(avgEnergy, 0.5f); float bMod = b.brightMod; float bMult = (bMod >= 0) ? (1.0f + bMod) : (1.0f / (1.0f - bMod * 2.0f)); float finalBrightness = std::clamp(baseBrightness * bMult * m_brightness, 0.0f, 1.0f); // --- Color --- QColor dynamicBinColor = b.cachedColor; float h_val, s, v, a; dynamicBinColor.getHsvF(&h_val, &s, &v, &a); dynamicBinColor = QColor::fromHsvF( h_val, s, std::clamp(v * finalBrightness, 0.0f, 1.0f)); QColor fillColor, lineColor; if (m_glass) { float uh, us, uv, ua; m_unifiedColor.getHsvF(&uh, &us, &uv, &ua); fillColor = QColor::fromHsvF( uh, us, std::clamp(uv * finalBrightness, 0.0f, 1.0f)); lineColor = dynamicBinColor; } else { fillColor = dynamicBinColor; lineColor = dynamicBinColor; } // --- Alpha --- float aMod = b.alphaMod; float aMult = (aMod >= 0) ? (1.0f + aMod * 0.5f) : (1.0f + aMod); if (aMult < 0.1f) aMult = 0.1f; float alpha = 0.4f + (avgEnergy - 0.5f) * m_contrast; alpha = std::clamp(alpha * aMult, 0.0f, 1.0f); // --- Edge fade: taper last bins to transparent near center gap --- if (m_mirrored) { int fadeBins = 4; int fromEnd = (int)(numBins - 2) - (int)i; if (fromEnd < fadeBins) { float fade = (float)(fromEnd + 1) / (float)(fadeBins + 1); fade = fade * fade; // ease-in for smoother taper alpha *= fade; } } fillColor.setAlphaF(alpha); lineColor.setAlphaF(std::min(0.9f, alpha)); // --- Channel 1 tint --- if (ch == 1 && m_data.size() > 1) { int r, g, b_val, a_val; fillColor.getRgb(&r, &g, &b_val, &a_val); fillColor.setRgb(std::max(0, r - 40), std::max(0, g - 40), std::min(255, b_val + 40), a_val); lineColor.getRgb(&r, &g, &b_val, &a_val); lineColor.setRgb(std::max(0, r - 40), std::max(0, g - 40), std::min(255, b_val + 40), a_val); } // --- Geometry --- float x1 = getX(freqs[i] * xOffset) * w; float x2 = getX(freqs[i + 1] * xOffset) * w; float barH1 = std::clamp((b.visualDb + 80.0f) / 80.0f * h, 0.0f, (float)h); float barH2 = std::clamp((bNext.visualDb + 80.0f) / 80.0f * h, 0.0f, (float)h); float anchorY = h; float y1 = h - barH1; float y2 = h - barH2; float fr = fillColor.redF(), fg = fillColor.greenF(), fb = fillColor.blueF(), fa = fillColor.alphaF(); // Triangle 1 m_vertices.insert(m_vertices.end(), {x1, anchorY, fr, fg, fb, fa, x1, y1, fr, fg, fb, fa, x2, y2, fr, fg, fb, fa}); // Triangle 2 m_vertices.insert(m_vertices.end(), {x1, anchorY, fr, fg, fb, fa, x2, y2, fr, fg, fb, fa, x2, anchorY, fr, fg, fb, fa}); m_fillVertexCount += 6; float lr = lineColor.redF(), lg = lineColor.greenF(), lb = lineColor.blueF(), la = lineColor.alphaF(); // Left edge lineVerts.insert(lineVerts.end(), {x1, anchorY, lr, lg, lb, la, x1, y1, lr, lg, lb, la}); // Right edge (last bin only) if (i + 2 == freqs.size()) { lineVerts.insert( lineVerts.end(), {x2, anchorY, lr, lg, lb, la, x2, y2, lr, lg, lb, la}); } } } m_lineVertexCount = static_cast(lineVerts.size()) / 6; m_vertices.insert(m_vertices.end(), lineVerts.begin(), lineVerts.end()); }