using BepInEx; using BepInEx.Configuration; using BepInEx.Logging; using BepInEx.Unity.IL2CPP; using HarmonyLib; using Il2CppInterop.Runtime.Injection; using TravelEar.Core; using UnityEngine; using Object = UnityEngine.Object; namespace TravelEar; [BepInPlugin(Guid, Name, VersionString)] public sealed class Plugin : BasePlugin { public const string Guid = "com.sabermage.travelear"; public const string Name = "TravelEar"; public const string VersionString = "0.1.0"; internal static ManualLogSource Logger; internal static PluginConfig Settings; private Harmony _harmony; private CalibrationCapture _capture; private SinkPump _pump; private OffsetMonitor _offset; private LocalVoiceRenderer _renderer; private GameObject _driver; public override void Load() { Logger = Log; Settings = new PluginConfig(Config); Logger.LogInfo($"{Name} {VersionString} loaded. Enabled={Settings.Enabled.Value}"); SessionLog.Start(Logger); if (!Settings.Enabled.Value) { Logger.LogInfo("Disabled by config; no hooks installed."); return; } // Hard-fail bind: any missing game symbol means no hooks at all (docs/KNOWN-HAZARDS.md). if (!GameSymbols.Bind(Logger)) return; try { _harmony = new Harmony(Guid); _harmony.PatchAll(typeof(OutboundVoiceTap)); Logger.LogInfo("Outbound Voice tap installed on OpusEncoder.Encode."); _harmony.PatchAll(typeof(RoundTripProvider)); _harmony.PatchAll(typeof(TapFilter)); Logger.LogInfo("Round-trip provider guard and Tap installed."); _harmony.PatchAll(typeof(TransmitSignal)); _harmony.PatchAll(typeof(MixerFloats)); Logger.LogInfo("Transmit signal probe installed on VoiceBroadcastTrigger.Start."); } catch (Exception e) { Logger.LogError($"TravelEar disabled: failed to install hooks: {e}"); _harmony?.UnpatchSelf(); _harmony = null; return; } // Sink side first: the pump only ever waits for a Helper, so it can never block the game. // The feed point (ADR-0005) picks the ring: the encoder thread's (mono, decoder rate) or the Tap's. var feed = Settings.SinkFeed.Value; _pump = feed == SinkFeedPoint.Encoder ? new SinkPump(SinkFeed.Ring, SinkFeed.Stamps, () => SinkFeed.Channels, () => LocalVoiceDecoder.SampleRate, () => Settings.Downmix.Value) : new SinkPump(TapFilter.Ring, TapFilter.SinkStamps, () => TapFilter.Channels, () => LocalVoiceRenderer.SampleRate, () => Settings.Downmix.Value); _pump.Start(); Logger.LogInfo($"Sink feed point: {feed}."); _offset = new OffsetMonitor(Logger, HelperOptions.Default.BackPipe); _offset.Start(); HelperLauncher.TryLaunch(Settings, Paths.BepInExRootPath); // T4a reference capture (M3-PLAN): off unless the operator is measuring. if (Settings.CalibrationCapture.Value) { try { var notes = $"EnvironmentReverb {Settings.EnvironmentReverb.Value} DryCopy {Settings.EnvironmentReverbDryCopy.Value} BusGains {Settings.EnvironmentReverbBusGains.Value} VoiceSlider {Settings.EnvironmentReverbVoiceSlider.Value}\n" + $"MixerStage {Settings.MixerStage.Value} ReverbFall {Settings.MixerReverbFall.Value} MegaphoneVoice {Settings.MegaphoneVoice.Value} MegaphoneMix {Settings.MegaphoneMix.Value}\n" + $"TransmitGate {Settings.TransmitGate.Value} TransmitHoldMs {Settings.TransmitHoldMs.Value} TransmitFadeOutMs {Settings.TransmitFadeOutMs.Value} OutputTrimDb {Settings.OutputTrimDb.Value} SelfEarEqDryWet {Settings.SelfEarEqDryWet.Value}"; _capture = new CalibrationCapture(Logger, notes); CalibrationCapture.Instance = _capture; var device = Settings.CalibrationCaptureDevice.Value; if (!string.IsNullOrWhiteSpace(device)) HelperLauncher.TryLaunchCapture(Settings, Paths.BepInExRootPath, device, System.IO.Path.Combine(_capture.Directory, "peer.wav")); else Logger.LogInfo("Calibration capture: no Calibration.CaptureDevice set; record the other machine's output by hand (TravelEar.Helper.exe --capture --out ) or with OBS."); } catch (Exception e) { Logger.LogWarning($"Calibration capture: not started ({e.Message})."); } } // Renderer: built lazily from the main thread once the game's audio system exists. var mixer = new MixerStageToggles(Settings.MixerStage.Value, Settings.MixerDry.Value, Settings.MixerHigh.Value, Settings.MixerReverbFall.Value, Settings.MixerReverbBoost.Value); var megaphone = new MegaphoneToggles(Settings.MegaphoneVoice.Value, Settings.MegaphoneCrusher.Value, Settings.MegaphoneHighPass.Value, Settings.MegaphoneCompressors.Value, Settings.MegaphoneMixer.Value); var listener = new ListenerToggles(Settings.IndoorAttenuation.Value, Settings.SpeechlessVolume.Value, Settings.MasterLimiter.Value, Settings.SpeechlessPitch.Value, Settings.SpeechlessBloom.Value); mixer = mixer with { Pitch = Settings.SpeechlessPitch.Value }; var environment = new EnvironmentReverbToggles(Settings.EnvironmentReverb.Value, Settings.EnvironmentReverbDryCopy.Value, Settings.EnvironmentReverbBusGains.Value, Settings.EnvironmentReverbVoiceSlider.Value); _renderer = new LocalVoiceRenderer(Logger, _pump, feed, Settings.SelfEarForwardMeters.Value, Settings.TransmitGate.Value, Settings.TransmitFadeOutMs.Value, Settings.TransmitHoldMs.Value, Settings.OutputTrimDb.Value, Settings.ReadHeadMarginFrames.Value, Settings.SelfEarEqDryWet.Value, mixer, Settings.ReverbDecaySeconds.Value, megaphone, Settings.MegaphoneMix.Value, environment, listener); ClassInjector.RegisterTypeInIl2Cpp(); _driver = new GameObject("TravelEar") { hideFlags = HideFlags.HideAndDontSave }; Object.DontDestroyOnLoad(_driver); _driver.AddComponent(); Logger.LogInfo("Local Voice renderer armed; waiting for the audio system."); } public override bool Unload() { CalibrationCapture.Instance = null; _capture?.Dispose(); SessionLog.Stop(); _offset?.Dispose(); _pump?.Dispose(); _harmony?.UnpatchSelf(); _harmony = null; return true; } } /// Where the Sink is fed from (ADR-0005). public enum SinkFeedPoint { /// The encoder thread: decoded Outbound Voice, remote-path processing, then straight to the Sink. Default. Encoder, /// The Tap on Unity's audio thread behind a game VoicePlayer (ADR-0003; M1-M2 path, kept for A/B). VoicePlayer, } /// How the Megaphone voice combines with the direct voice (config Fidelity.MegaphoneMix). public enum MegaphoneMix { /// Megaphone output added to the direct voice: what a listener beside the holder hears. Add, /// Only the megaphone output while broadcasting. Replace, } /// User-facing settings. Every entry is surfaced automatically by ModSettingsMenu if installed. internal sealed class PluginConfig { public ConfigEntry SinkFeed { get; } public ConfigEntry Enabled { get; } public ConfigEntry SpawnHelper { get; } public ConfigEntry HelperPath { get; } public ConfigEntry SinkEndpoint { get; } public ConfigEntry MixerStage { get; } public ConfigEntry MixerDry { get; } public ConfigEntry MixerHigh { get; } public ConfigEntry MixerReverbFall { get; } public ConfigEntry MixerReverbBoost { get; } public ConfigEntry ReverbDecaySeconds { get; } public ConfigEntry MegaphoneVoice { get; } public ConfigEntry MegaphoneCrusher { get; } public ConfigEntry MegaphoneHighPass { get; } public ConfigEntry MegaphoneCompressors { get; } public ConfigEntry MegaphoneMix { get; } public ConfigEntry EnvironmentReverb { get; } public ConfigEntry EnvironmentReverbDryCopy { get; } public ConfigEntry EnvironmentReverbBusGains { get; } public ConfigEntry EnvironmentReverbVoiceSlider { get; } public ConfigEntry IndoorAttenuation { get; } public ConfigEntry SpeechlessVolume { get; } public ConfigEntry SpeechlessPitch { get; } public ConfigEntry SpeechlessBloom { get; } public ConfigEntry MasterLimiter { get; } public ConfigEntry MegaphoneMixer { get; } public ConfigEntry TransmitGate { get; } public ConfigEntry TransmitFadeOutMs { get; } public ConfigEntry TransmitHoldMs { get; } public ConfigEntry OutputTrimDb { get; } public ConfigEntry Downmix { get; } public ConfigEntry ReadHeadMarginFrames { get; } public ConfigEntry SelfEarForwardMeters { get; } public ConfigEntry SelfEarEqDryWet { get; } public ConfigEntry CalibrationCapture { get; } public ConfigEntry CalibrationCaptureDevice { get; } // [impl->REQ-CONFIG-BEPINEX] public PluginConfig(ConfigFile file) { Enabled = file.Bind("General", "Enabled", true, "Render Local Voice and stream it to the Sink."); SpawnHelper = file.Bind("Sink", "SpawnHelper", true, "Launch the TravelEar Helper process automatically when the game starts."); HelperPath = file.Bind("Sink", "HelperPath", "", @"Full path to TravelEar.Helper.exe. Empty = BepInEx\TravelEar.Helper\TravelEar.Helper.exe."); SinkEndpoint = file.Bind("Sink", "SinkEndpoint", "", "Substring of the Windows playback device the Helper renders to. Empty = system default device."); SinkFeed = file.Bind("Fidelity", "SinkFeed", SinkFeedPoint.Encoder, "Where Local Voice is taken from. Encoder = the decoded outbound voice on the game's mic thread, processed by the mod and sent straight to the Sink (lowest offset, immune to the game's audio-thread stalls). VoicePlayer = the M1-M2 path through an in-game VoicePlayer and the Tap on Unity's audio thread (for A/B only)."); // [impl->REQ-MIXER-RESYNTH] MixerStage = file.Bind("Fidelity", "MixerStage", true, "Re-synthesize the game's mixer-stage effects (reverb sends, dry/high trims, megaphone character). Master switch for the Mixer* toggles below."); MixerDry = file.Bind("Fidelity", "MixerDry", true, "Apply the game's per-voice dry level (Dry{n}). At your own ears it is 0 dB, so this only matters for A/B."); MixerHigh = file.Bind("Fidelity", "MixerHigh", true, "Apply the game's occlusion high cut (High{n}) as a 3 kHz high shelf. 0 dB at your own ears (nothing between you and yourself)."); MixerReverbFall = file.Bind("Fidelity", "MixerReverbFall", false, "Apply the fall reverb send (ReverbFallWet{n}) to Local Voice. Off by default (operator ruling, M3 run 5): the send belongs to a voice receding from the listener, and the Self-Ear never recedes. Kept for comparison. Approximate reverb."); MixerReverbBoost = file.Bind("Fidelity", "MixerReverbBoost", true, "Apply the reverb boost send (ReverbBoostWet{n}): zero at your own ears by the game's formula; kept for A/B."); ReverbDecaySeconds = file.Bind("Fidelity", "ReverbDecaySeconds", 1.5f, "Decay time (RT60) of the approximate reverb behind the sends, in seconds. Tune by ear against a second-client recording."); // [impl->REQ-RENDER-MEGAPHONE] MegaphoneVoice = file.Bind("Fidelity", "MegaphoneVoice", true, "Render the megaphone's output while you hold and use one: the game's bit-crusher and 300 Hz high-pass, then its two mixer compressors, as a listener beside you hears it. Master switch for the Megaphone* toggles."); MegaphoneCrusher = file.Bind("Fidelity", "MegaphoneCrusher", true, "The megaphone's sample-hold crusher (4000 Hz, half wet, half smoothed). Exact port."); MegaphoneHighPass = file.Bind("Fidelity", "MegaphoneHighPass", true, "The megaphone's 300 Hz high-pass. Exact port."); MegaphoneCompressors = file.Bind("Fidelity", "MegaphoneCompressors", true, "The megaphone mixer's compressor (-25 dB, +6 dB make-up) and post-compressor (-15 dB). Approximate."); MegaphoneMix = file.Bind("Fidelity", "MegaphoneMix", TravelEar.MegaphoneMix.Add, "Add = the megaphone output on top of your direct voice, as a listener beside you hears both. Replace = only the megaphone output while broadcasting."); // [impl->REQ-MIXER-RESYNTH] EnvironmentReverb = file.Bind("Fidelity", "EnvironmentReverb", true, "Apply the room reverb a listener beside you hears on your voice (the game's dynamic reverb: hallways, caves, outdoors), from the same live parameters the game writes each frame. Master switch for the EnvironmentReverb* toggles. Approximate reverb, exact levels and decay."); EnvironmentReverbDryCopy = file.Bind("Fidelity", "EnvironmentReverbDryCopy", true, "The reverb's own un-reverbed copy of the voice (its DryLevel), which the game mixes on top of the direct path. It is what makes a nearby voice sit in the room rather than beside it."); EnvironmentReverbBusGains = file.Bind("Fidelity", "EnvironmentReverbBusGains", true, "Apply the fixed bus trims a voice meets on a listener's machine (-3 dB voice group, -6 dB dry bus). Off = both at 0 dB, louder than the game."); EnvironmentReverbVoiceSlider = file.Bind("Fidelity", "EnvironmentReverbVoiceSlider", false, "Multiply by the listener's voice volume slider as the game does. Off by default: the Sink level convention already follows your own slider."); IndoorAttenuation = file.Bind("Fidelity", "IndoorAttenuation", true, "The game's indoor voice attenuation: a listener hears every voice at Outdoorness * 0.5 + 0.5, i.e. 6 dB down fully indoors (their own outdoorness; yours at the Self-Ear)."); SpeechlessVolume = file.Bind("Fidelity", "SpeechlessVolume", true, "The red bells' voice fade: a speaker inside a speechless zone is heard at 1 - speechlessness, silent at the centre."); SpeechlessPitch = file.Bind("Fidelity", "SpeechlessPitch", true, "The red bells' pitch drop: the voice mixer's pitch shifter at the VoicePitch the game writes for the listener (1 - speechlessness * the zone's deduction). Pitch without tempo, as in the game; adds 16 ms of latency inside Local Voice. Off = no shifter in the chain."); SpeechlessBloom = file.Bind("Fidelity", "SpeechlessBloom", true, "The red bells' super-wet bloom: the 6.8 s dark reverb and chorus that opens as the listener nears a zone (SuperWet_Speechlessness, pitched by SuperWetPitch, both read from the game). Approximate reverb and chorus."); MasterLimiter = file.Bind("Fidelity", "MasterLimiter", true, "The game's master limiter (Duck Volume on the main mixer's Master: -3 dB threshold, 10:1, 0.125 s release, 20 dB knee), the last thing a listener's mix goes through. Approximate detector."); MegaphoneMixer = file.Bind("Fidelity", "MegaphoneMixer", true, "The megaphone mixer's fixed effects beside its dynamics: 5 kHz low-pass, 2.5 kHz +8 dB EQ, the 2 s -10 dB reverb and the 100 ms echo. Approximate reverb."); TransmitGate = file.Bind("Fidelity", "TransmitGate", true, "Render Local Voice only while peers receive it (a voice-activation or push-to-talk channel is open); silence otherwise. Off = render everything the mic encodes, noise floor included."); TransmitFadeOutMs = file.Bind("Fidelity", "TransmitFadeOutMs", 0f, "Fade-out of Local Voice when the game's voice activation stops hearing you, in ms. 0 = the game's own channel fade (read from its voice-activation trigger, logged as 'Transmit fade:'). Raise it if speech still chops between words."); TransmitHoldMs = file.Bind("Fidelity", "TransmitHoldMs", 0f, "How long Local Voice keeps rendering after the game's voice activation stops hearing you, before the fade-out starts, in ms. 0 = automatic (the fade-out plus 60 ms, at least 100; logged as 'gate hold'). Raise it if the quiet ends of words get cut."); OutputTrimDb = file.Bind("Fidelity", "OutputTrimDb", 0f, "Gain applied to Local Voice last, just before the Helper, in dB. Calibration only: the chain's level is the game's (a listener beside you gets the -6 dB dry bus plus the reverb's own dry copy, about +1 dB together). Negative = quieter."); Downmix = file.Bind("Sink", "Downmix", false, "Downmix Local Voice to mono before sending it to the Sink."); // [impl->REQ-OFFSET-MEASURE] ReadHeadMarginFrames = file.Bind("Fidelity", "ReadHeadMarginFrames", 1.5f, "How far behind the provider's write head the Local Voice read head is placed at each talk burst, in 60 ms frames. Lower = less Offset but more read-head resyncs (see the 'Local Voice stats' log line); raise it if resyncs climb."); SelfEarForwardMeters = file.Bind("Ear", "SelfEarForwardMeters", 0.0762f, "How far in front of the listener the Local Voice emitter sits, in metres (0.0762 = 3 in). 0 puts it on the listener, which pans oddly."); CalibrationCapture = file.Bind("Calibration", "Capture", false, "Reference capture for calibrating the mod against a real listener: records the decoded outbound voice, the Local Voice output and every mixer float the game writes under %LOCALAPPDATA%\\TravelEar\\calibration\\. Off unless you are measuring; see M3-PLAN T4a."); CalibrationCaptureDevice = file.Bind("Calibration", "CaptureDevice", "", "Part of the name of the Windows capture device carrying the other machine's audio output (an HDMI capture card, line-in). When set with Capture on, a second Helper records it to peer.wav beside the capture. Empty = record it yourself (OBS or TravelEar.Helper.exe --capture)."); // [impl->REQ-EAR-SELF] SelfEarEqDryWet = file.Bind("Fidelity", "SelfEarEqDryWet", 0f, "Wet mix (0-1) of the game's 400 Hz voice EQ on Local Voice. Remote voices fade it in with distance and angle; at the Self-Ear both are zero, so 0 = the dry voice a listener next to you hears. Raise it to hear the through-a-wall character."); } }