namespace TravelEar.Core; /// /// An approximation of Unity's mixer Compressor effect (the FMOD compressor behind it: hard /// knee, fixed 2.5:1 ratio, exposed threshold / attack / release / make-up gain) and of its /// Duck Volume (exposed ratio and a soft knee in dB) for the megaphone's mixer dynamics and /// the master limiter (ADR-0002: Mixer Stage effects are approximations). Peak /// detector with exponential attack and release in the linear domain, gain computed above the /// threshold in dB. Parameters are clamped to FMOD's ranges (attack 0.1-500 ms, release /// 10-5000 ms, threshold -60..0 dB, make-up -30..30 dB). Mono, one instance per thread. /// public sealed class Compressor { public const float DefaultRatio = 2.5f; /// Compression ratio (2.5 for Unity's Compressor; a Duck Volume exposes its own). public float Ratio { get; private set; } = DefaultRatio; /// Soft-knee width in dB (0 = hard knee). public float KneeDb { get; private set; } public float ThresholdDb { get; private set; } public float AttackMs { get; private set; } public float ReleaseMs { get; private set; } public float MakeupDb { get; private set; } public int SampleRate { get; } private float _envelope; private float _attackCoef, _releaseCoef, _threshold, _makeup; /// The last block's gain reduction in dB (0 = none), for diagnostics. public float ReductionDb { get; private set; } public Compressor(int sampleRate, float thresholdDb, float attackMs, float releaseMs, float makeupDb, float ratio = DefaultRatio, float kneeDb = 0f) { SampleRate = sampleRate; Set(thresholdDb, attackMs, releaseMs, makeupDb, ratio, kneeDb); } public void Set(float thresholdDb, float attackMs, float releaseMs, float makeupDb, float ratio = DefaultRatio, float kneeDb = 0f) { Ratio = Math.Clamp(ratio, 1f, 50f); KneeDb = Math.Clamp(kneeDb, 0f, 50f); ThresholdDb = Math.Clamp(thresholdDb, -60f, 0f); AttackMs = Math.Clamp(attackMs, 0.1f, 500f); ReleaseMs = Math.Clamp(releaseMs, 10f, 5000f); MakeupDb = Math.Clamp(makeupDb, -30f, 30f); _threshold = MathF.Pow(10f, ThresholdDb / 20f); _makeup = MathF.Pow(10f, MakeupDb / 20f); _attackCoef = MathF.Exp(-1f / (AttackMs * 0.001f * SampleRate)); _releaseCoef = MathF.Exp(-1f / (ReleaseMs * 0.001f * SampleRate)); } public void Reset() { _envelope = 0f; ReductionDb = 0f; } // [impl->REQ-RENDER-MEGAPHONE] /// Compresses in place; no allocation. public void Process(Span mono) { var maxReduction = 0f; for (var i = 0; i < mono.Length; i++) { var x = mono[i]; var level = MathF.Abs(x); var coef = level > _envelope ? _attackCoef : _releaseCoef; _envelope = coef * _envelope + (1f - coef) * level; var gain = 1f; if (_envelope > 1e-9f) { var overDb = 20f * MathF.Log10(_envelope / _threshold); var reductionDb = ReductionFor(overDb); if (reductionDb > 0f) { if (reductionDb > maxReduction) maxReduction = reductionDb; gain = MathF.Pow(10f, -reductionDb / 20f); } } mono[i] = x * gain * _makeup; } ReductionDb = maxReduction; } /// Gain reduction in dB for a level above the threshold: hard knee, or the quadratic soft knee over +-KneeDb/2. public float ReductionFor(float overDb) { var slope = 1f - 1f / Ratio; if (KneeDb <= 0f) return overDb > 0f ? overDb * slope : 0f; var half = KneeDb / 2f; if (overDb <= -half) return 0f; if (overDb >= half) return overDb * slope; var x = overDb + half; return slope * x * x / (2f * KneeDb); } }