| 203 | } |
| 204 | |
| 205 | void ClientTube::process(float* interleaved, int frames, int channels) noexcept |
| 206 | { |
| 207 | if (frames <= 0) return; |
| 208 | if (channels != 1 && channels != 2) return; |
| 209 | |
| 210 | recacheIfDirty(); |
| 211 | const bool enabled = m_atomics.enabled.load(std::memory_order_acquire); |
| 212 | |
| 213 | float inPeakLin = 0.0f; |
| 214 | float outPeakLin = 0.0f; |
| 215 | float maxDriveLin = 0.0f; |
| 216 | |
| 217 | const float baseDrive = m_cached.baseDriveLin; |
| 218 | const float outputLin = m_cached.outputLin; |
| 219 | const float dryWet = m_cached.dryWet; |
| 220 | const float envAmount = m_cached.envAmount; |
| 221 | const float attackCoeff = m_cached.attackCoeff; |
| 222 | const float releaseCoeff = m_cached.releaseCoeff; |
| 223 | const float toneCoef = m_cached.toneCoeff; |
| 224 | const float tone = m_cached.tone; |
| 225 | |
| 226 | for (int f = 0; f < frames; ++f) { |
| 227 | const float dryL = interleaved[f * channels]; |
| 228 | const float dryR = (channels == 2) |
| 229 | ? interleaved[f * channels + 1] : dryL; |
| 230 | |
| 231 | const float inAbs = std::max(std::fabs(dryL), std::fabs(dryR)); |
| 232 | if (inAbs > inPeakLin) inPeakLin = inAbs; |
| 233 | |
| 234 | // Envelope follower for dynamic drive. |
| 235 | const float alpha = (inAbs > m_envLin) ? attackCoeff : releaseCoeff; |
| 236 | m_envLin += alpha * (inAbs - m_envLin); |
| 237 | |
| 238 | // Effective drive: baseDrive * (1 + envAmount * envLin). |
| 239 | // envAmount = 0 → static drive (no modulation) |
| 240 | // envAmount = +1 → at full-scale input, drive × 2 |
| 241 | // envAmount = -1 → at full-scale input, drive × 0 |
| 242 | // Clamped to [0.1, 10] so an aggressive negative envelope at |
| 243 | // a loud level doesn't zero the drive entirely. |
| 244 | const float envMod = 1.0f + envAmount * m_envLin; |
| 245 | const float drive = std::clamp(baseDrive * envMod, 0.1f, 10.0f); |
| 246 | if (drive > maxDriveLin) maxDriveLin = drive; |
| 247 | |
| 248 | // Per-channel tone pre-filter (simple one-pole LP with HP |
| 249 | // blend). m_toneState holds the LP output; the shaper sees |
| 250 | // dry blended toward HP (tone > 0) or LP (tone < 0). |
| 251 | auto shapeChan = [&](float x, float& state) { |
| 252 | state += toneCoef * (x - state); |
| 253 | const float lp = state; |
| 254 | const float hp = x - state; |
| 255 | const float shaped = (tone >= 0.0f) |
| 256 | ? x + tone * (hp - x * 0.5f) // brighten |
| 257 | : x + (-tone) * (lp - x * 0.5f); // darken |
| 258 | return shape(shaped * drive); |
| 259 | }; |
| 260 | |
| 261 | const float wetL = shapeChan(dryL, m_toneStateL); |
| 262 | const float wetR = (channels == 2) |