namespace TravelEar.Core; /// Which parts of the Megaphone voice are active (config Fidelity.Megaphone*). public readonly record struct MegaphoneToggles(bool Master, bool Crusher, bool HighPass, bool Compressors, bool Mixer = true) { public static MegaphoneToggles All => new(true, true, true, true, true); public static MegaphoneToggles Off => new(false, false, false, false, false); } /// /// The Megaphone voice as a listener beside the holder hears it: the game's Megaphone /// VoicePlayer chain evaluated at listener distance 0 (docs/reference/ /// big-walk-local-voice-wiring.md section 3, the remote-holder branch of VoicePlayer.Update /// with d = 0): in process, the (24 bit, 4000 Hz hold, dry/wet 0.5, /// smooth 0.5) then a 300 Hz Q 0.4 high-pass; on its mixer, a compressor (threshold -25 dB, /// make-up +6 dB) into a post-compressor (threshold -15 dB, release at FMOD's 10 ms floor), /// reverb send off, HP 0 Hz and LP 22 kHz (both open), and the megaphone channel's wet at 0 dB /// with its dry at -80 dB. The result is the megaphone's own output, which peers hear on top of /// the holder's direct voice. Exact: crusher and high-pass (ported kernels). Approximate: the /// compressors (). Mono, encoder thread. /// public sealed class MegaphoneVoice { public const int CrusherBitDepth = 24; public const int CrusherRateHz = 4000; public const float CrusherDryWet = 0.5f; public const float CrusherSmooth = 0.5f; public const float HighPassHz = 300f; public const float HighPassQ = 0.4f; public const float CompressorThresholdDb = -25f; public const float CompressorMakeupDb = 6f; public const float PostCompressorThresholdDb = -15f; /// The megaphone mixer's Duck Volume: threshold -15 dB, ratio 5, attack 0, release 0.25 s, knee 10 (catalogue 5.9). public const float PostCompressorReleaseMs = 250f; public const float PostCompressorRatio = 5f; public const float PostCompressorKneeDb = 10f; /// The megaphone mixer's Compressor: attack 10 ms, release 1000 ms (asset values, catalogue 5.9). public const float UnityCompressorAttackMs = 10f; public const float UnityCompressorReleaseMs = 1000f; /// Fixed effects on every megaphone mixer, in order: Lowpass Simple 5 kHz, ParamEQ 2500 Hz octave 0.8 gain 2.5, SFX Reverb (-10 dB wet, 2 s, full dry), Echo 100 ms decay 0.3 wet-only. public const float MixerLowPassHz = 5000f; public const float MixerEqHz = 2500f; public const float MixerEqOctaves = 0.8f; public const float MixerEqGainLinear = 2.5f; public const float MixerEchoMs = 100f; public const float MixerEchoDecay = 0.3f; /// The megaphone mixer's SFX Reverb at the Self-Ear (d = 0): DryLevel 0, Room -1000 mB, DecayTime 2 s, DecayHFRatio 0.5, Density 0, RoomLF 0, RoomHF -2000, Reflections -10000, ReflectDelay 0, Reverb 0, ReverbDelay 0.04, Diffusion 100, HFReference 3500, LFReference 1000. public static readonly EnvironmentReverbParams MixerReverb = new( DryLevelMb: 0, RoomMb: -1000, RoomHfMb: -2000, RoomLfMb: 0, DecayTimeS: 2f, DecayHfRatio: 0.5f, ReflectionsMb: -10000, ReflectDelayS: 0f, ReverbMb: 0, ReverbDelayS: 0.04f, HfReferenceHz: 3500, LfReferenceHz: 1000, DiffusionPct: 100, DensityPct: 0, MasterWetDb: 0, VoiceBusDb: 0); private readonly BitCrusher _crusher = new(CrusherBitDepth, CrusherRateHz, CrusherDryWet, CrusherSmooth); private readonly Biquad _highPass; private readonly Compressor _compressor; private readonly Compressor _postCompressor; private readonly Biquad _mixerLowPass; private readonly Biquad _mixerEq; private readonly SfxReverb _mixerReverb; private readonly float[] _wet = new float[512]; private readonly float[] _echo; private int _echoIndex; public int SampleRate { get; } public long Frames { get; private set; } public float CompressorReductionDb => _compressor.ReductionDb; public float PostCompressorReductionDb => _postCompressor.ReductionDb; public MegaphoneVoice(int sampleRate) { SampleRate = sampleRate; _highPass = new Biquad(sampleRate); _highPass.Configure(Biquad.Kind.HighPass, HighPassHz, HighPassQ, 0f, 1f, 1f); _compressor = new Compressor(sampleRate, CompressorThresholdDb, UnityCompressorAttackMs, UnityCompressorReleaseMs, CompressorMakeupDb); _postCompressor = new Compressor(sampleRate, PostCompressorThresholdDb, 0.1f, PostCompressorReleaseMs, 0f, PostCompressorRatio, PostCompressorKneeDb); _mixerLowPass = new Biquad(sampleRate); _mixerLowPass.Configure(Biquad.Kind.LowPass, MixerLowPassHz, 0.707f, 0f, 1f, 1f); _mixerEq = new Biquad(sampleRate); // Unity ParamEQ: octave range -> Q = 1 / (2 sinh(ln2 / 2 * octaves)); gain is linear. var q = 1f / (2f * MathF.Sinh(MathF.Log(2f) / 2f * MixerEqOctaves)); _mixerEq.Configure(Biquad.Kind.PeakingEq, MixerEqHz, q, 20f * MathF.Log10(MixerEqGainLinear), 1f, 1f); _mixerReverb = new SfxReverb(sampleRate); _mixerReverb.Configure(MixerReverb); _echo = new float[(int)(MixerEchoMs * sampleRate / 1000f)]; } /// Clears the filter and detector state (the megaphone was picked up again after a gap). public void Reset() { _highPass.Reset(); _compressor.Reset(); _postCompressor.Reset(); _mixerLowPass.Reset(); _mixerEq.Reset(); _mixerReverb.Reset(); Array.Clear(_echo); _echoIndex = 0; } // [impl->REQ-RENDER-MEGAPHONE] /// /// Renders the megaphone output of into /// (same length). With the master toggle off the output is silence. /// public void Process(ReadOnlySpan voice, Span megaphone, in MegaphoneToggles toggles) { Frames++; if (!toggles.Master) { megaphone.Clear(); return; } voice.CopyTo(megaphone); if (toggles.Crusher) _crusher.Process(megaphone, SampleRate); if (toggles.HighPass) _highPass.Process(megaphone); // The megaphone mixer, in the asset's effect order (catalogue 5.9): Lowpass, ParamEQ, // Compressor, SFX Reverb, Echo, then the Duck Volume. if (toggles.Mixer) { _mixerLowPass.Process(megaphone); _mixerEq.Process(megaphone); } if (toggles.Compressors) _compressor.Process(megaphone); if (toggles.Mixer) { for (var offset = 0; offset < megaphone.Length; offset += _wet.Length) { var chunk = megaphone.Slice(offset, Math.Min(_wet.Length, megaphone.Length - offset)); var wet = _wet.AsSpan(0, chunk.Length); _mixerReverb.Process(chunk, wet); for (var i = 0; i < chunk.Length; i++) chunk[i] += wet[i]; // DryLevel 0 mB: the dry copy at unity } // Echo, DryMix 0 / WetMix 1: the delay line's output only, y[n] = x[n-D] + decay * y[n-D]. for (var i = 0; i < megaphone.Length; i++) { var delayed = _echo[_echoIndex]; var y = delayed; _echo[_echoIndex] = megaphone[i] + MixerEchoDecay * delayed; if (++_echoIndex >= _echo.Length) _echoIndex = 0; megaphone[i] = y; } } if (toggles.Compressors) _postCompressor.Process(megaphone); } }