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;
}
}