namespace TravelEar.Core; /// /// The red bells' super-wet bloom (docs/reference/big-walk-voice-effects-catalog.md rows 10, /// section 5.5 item 4): on a listener's machine the main mixer's Voice group always sends /// into VOICE SUPER WET BUS (Voice_SuperWet, 0 dB) whose Speechlessness /// return, SuperWet_Speechlessness = (1 - sp^0.4) * -80 dB, opens as the listener nears a /// zone; the return runs through Master Super Wet: group pitch SuperWetPitch, a /// fixed 6.8 s SFX Reverb (wet only, RoomHF -2000 mB, HF ratio 0.15, Density 25) and the fixed /// chorus. At the Self-Ear the listener stands in the speaker's zone, and the game writes those /// three floats for the local listener every frame, so the mod reads them as they are. /// Approximations: the group pitch is a resample (pitch and tempo) in the mixer and a /// (pitch only) here; and /// as documented on them. Wet only; the caller sums it into the voice. Encoder thread. /// public sealed class SpeechlessBloom { public const float FloorDb = -80f; /// Below this the return is treated as closed (the asset's -80 dB "off"). public const float ClosedDb = -79f; /// How long the chain keeps rendering after the return closes, so the 6.8 s tail rings out. public const float TailSeconds = 7f; /// The fixed Master Super Wet SFX Reverb: DryLevel -10000, Room 0, RoomHF -2000, RoomLF 0, DecayTime 6.8 s, DecayHFRatio 0.15, Reflections -10000, ReflectDelay 0.01, Reverb 0, ReverbDelay 0.1, HFReference 5000, LFReference 250, Diffusion 100, Density 25. public static readonly EnvironmentReverbParams Reverb = new( DryLevelMb: -10000, RoomMb: 0, RoomHfMb: -2000, RoomLfMb: 0, DecayTimeS: 6.8f, DecayHfRatio: 0.15f, ReflectionsMb: -10000, ReflectDelayS: 0.01f, ReverbMb: 0, ReverbDelayS: 0.1f, HfReferenceHz: 5000, LfReferenceHz: 250, DiffusionPct: 100, DensityPct: 25, MasterWetDb: 0, VoiceBusDb: 0); private readonly PitchShifter _pitch; private readonly SfxReverb _reverb; private readonly Chorus _chorus; private readonly float[] _send = new float[512]; private readonly float[] _wet = new float[512]; private int _tailSamplesLeft; private bool _pitchActive; public int SampleRate { get; } public long Frames { get; private set; } /// True while the return is open or its tail is still ringing. public bool Active => _tailSamplesLeft > 0; /// The return gain the last frame was rendered with (0 = closed). public float ReturnGain { get; private set; } public float BusGain { get; private set; } = 1f; public float Pitch { get; private set; } = 1f; public SpeechlessBloom(int sampleRate) { SampleRate = sampleRate; _pitch = new PitchShifter(sampleRate); _reverb = new SfxReverb(sampleRate); _reverb.Configure(Reverb); _chorus = Chorus.SuperWet(sampleRate); } public void Reset() { _pitch.Reset(); _reverb.Reset(); _chorus.Reset(); _tailSamplesLeft = 0; _pitchActive = false; ReturnGain = 0f; } // [impl->REQ-MIXER-RESYNTH] /// /// Renders the bloom of into (same length): /// Voice_SuperWet bus gain, SuperWetPitch, the fixed reverb and chorus, then /// the SuperWet_Speechlessness return. Silence (and no work) while the return has been /// closed for longer than the tail; a frame of zeros keeps the tail ringing after it closes. /// True when anything was written. /// public bool Process(ReadOnlySpan voice, Span bloom, float busDb, float pitch, float returnDb) { Frames++; var open = !float.IsNaN(returnDb) && returnDb > ClosedDb; if (open) _tailSamplesLeft = (int)(TailSeconds * SampleRate); else if (_tailSamplesLeft <= 0) { ReturnGain = 0f; bloom.Clear(); return false; } else _tailSamplesLeft -= bloom.Length; BusGain = MixerStageModel.Gain(float.IsNaN(busDb) ? 0f : busDb); Pitch = float.IsNaN(pitch) ? 1f : Math.Clamp(pitch, PitchShifter.MinRatio, PitchShifter.MaxRatio); ReturnGain = open ? MixerStageModel.Gain(returnDb) : 0f; var shift = Pitch < 0.999f || Pitch > 1.001f; if (shift && !_pitchActive) { _pitch.Reset(); _pitchActive = true; } else if (!shift && _pitchActive) _pitchActive = false; // The return volume sits on the Speechlessness child group, i.e. on the SEND into // Master Super Wet, so the reverb and chorus ring out after the return closes and the // level follows sp smoothly on the way in. var sendGain = BusGain * ReturnGain; for (var offset = 0; offset < bloom.Length; offset += _send.Length) { var count = Math.Min(_send.Length, bloom.Length - offset); var send = _send.AsSpan(0, count); var wet = _wet.AsSpan(0, count); for (var i = 0; i < count; i++) send[i] = voice[offset + i] * sendGain; if (shift) _pitch.Process(send, Pitch); _reverb.Process(send, wet); _chorus.Process(wet); wet.CopyTo(bloom.Slice(offset, count)); } return true; } }