From ad4acba6e69bc77dca83095bed0d4ba1c9b1524a Mon Sep 17 00:00:00 2001 From: Benjiboy Date: Mon, 14 Sep 2026 15:00:42 +0200 Subject: [PATCH 01/14] feat(dsp): add noise gate and per-source mic volume to audio pipeline Add NoiseGateSampleProvider to mute the mic branch between phrases, gated after switching and both mic volumes so the threshold reflects the level actually sent. Add a primary-only volume ramp to SwitchingSampleProvider for the normal mic, and raise the processed voice volume ceiling from unity to a 2x boost to match. --- src/MicMixer/Audio/AudioRouter.cs | 48 ++++- src/MicMixer/Audio/NoiseGateSampleProvider.cs | 136 +++++++++++++ src/MicMixer/Audio/SwitchingSampleProvider.cs | 42 +++- .../Audio/VoiceProcessorSamplePair.cs | 2 +- .../NoiseGateSampleProviderTests.cs | 180 ++++++++++++++++++ .../SwitchingSampleProviderTests.cs | 80 ++++++++ 6 files changed, 477 insertions(+), 11 deletions(-) create mode 100644 src/MicMixer/Audio/NoiseGateSampleProvider.cs create mode 100644 tests/MicMixer.Tests/NoiseGateSampleProviderTests.cs create mode 100644 tests/MicMixer.Tests/SwitchingSampleProviderTests.cs diff --git a/src/MicMixer/Audio/AudioRouter.cs b/src/MicMixer/Audio/AudioRouter.cs index e189c40..b0994b4 100644 --- a/src/MicMixer/Audio/AudioRouter.cs +++ b/src/MicMixer/Audio/AudioRouter.cs @@ -30,9 +30,13 @@ public sealed class AudioRouter : IDisposable private InputRoute? _normalRoute; private InputRoute? _moddedRoute; private SwitchingSampleProvider? _micSource; + private NoiseGateSampleProvider? _noiseGate; private VoiceProcessorSamplePair? _voiceProcessorPair; private bool _useModdedInput; private float _processedVoiceVolume = 1f; + private float _normalMicVolume = 1f; + private bool _noiseGateEnabled; + private float _noiseGateThresholdDb = -45f; private bool _outputGateOpen = true; private bool _musicIgnoresPushToTalk; private bool _musicMonitorOnly; @@ -44,14 +48,42 @@ public sealed class AudioRouter : IDisposable private float _musicRms; private bool _disposed; - /// Applied after the built-in effect, before switching and output fan-out. + /// Applied after the built-in effect, before switching and output fan-out. 1 is unity, up to 2 boosts. public float ProcessedVoiceVolume { get => Volatile.Read(ref _processedVoiceVolume); set => Volatile.Write(ref _processedVoiceVolume, - float.IsFinite(value) ? Math.Clamp(value, 0f, 1f) : 1f); + float.IsFinite(value) ? Math.Clamp(value, 0f, 2f) : 1f); } + /// Applied to the normal mic only, before switching. 1 is unity; up to 2 boosts a quiet mic. + public float NormalMicVolume + { + get => Volatile.Read(ref _normalMicVolume); + set => Volatile.Write(ref _normalMicVolume, + float.IsFinite(value) ? Math.Clamp(value, 0f, 2f) : 1f); + } + + /// Mutes the mic branch between phrases; see . + public bool NoiseGateEnabled + { + get => Volatile.Read(ref _noiseGateEnabled); + set => Volatile.Write(ref _noiseGateEnabled, value); + } + + public float NoiseGateThresholdDb + { + get => Volatile.Read(ref _noiseGateThresholdDb); + set => Volatile.Write(ref _noiseGateThresholdDb, + float.IsFinite(value) ? Math.Clamp(value, -70f, -10f) : -45f); + } + + /// Whether the noise gate currently lets the mic through; false while routing is stopped. + public bool NoiseGateOpen => _noiseGate?.IsOpen ?? false; + + /// Highest mic-branch peak seen by the noise gate since the last call, for placing the threshold. + public float ReadAndResetNoiseGateInputPeak() => _noiseGate?.ReadAndResetInputPeak() ?? 0f; + public bool IsRouting => _player?.PlaybackState == PlaybackState.Playing; public bool UseModdedInput => Volatile.Read(ref _useModdedInput); public bool OutputGateOpen => Volatile.Read(ref _outputGateOpen); @@ -104,6 +136,7 @@ public void Start( InputRoute? normalRoute = null; InputRoute? moddedRoute = null; SwitchingSampleProvider? micSource = null; + NoiseGateSampleProvider? noiseGate = null; VoiceProcessorSamplePair? voiceProcessorPair = null; WasapiPlayer? player = null; @@ -158,7 +191,10 @@ public void Start( samplePair = new IndependentSamplePair(normalRoute, moddedRoute); } - micSource = new SwitchingSampleProvider(samplePair, () => UseModdedInput); + micSource = new SwitchingSampleProvider(samplePair, () => UseModdedInput, primaryVolume: () => NormalMicVolume); + // Gated after switching and both mic volumes, so the threshold refers + // to the level actually sent and covers every mic source alike. + noiseGate = new NoiseGateSampleProvider(micSource, () => NoiseGateEnabled, () => NoiseGateThresholdDb); ISampleProvider? musicSource = MusicSourceFactory?.Invoke(targetFormat); // A secondary start failure only skips that branch — the cable @@ -186,7 +222,7 @@ public void Start( // (monitor-only preview). Upstream sources keep advancing regardless, // so music never rewinds while a gate is closed. ISampleProvider source = new MixFanoutSampleProvider( - micSource, + noiseGate, musicSource, micGateOpen: () => OutputGateOpen, musicGateOpen: () => MusicRouteOpen, @@ -221,6 +257,7 @@ public void Start( _normalRoute = normalRoute; _moddedRoute = moddedRoute; _micSource = micSource; + _noiseGate = noiseGate; _voiceProcessorPair = voiceProcessorPair; _player = player; @@ -236,6 +273,7 @@ public void Start( _normalRoute = null; _moddedRoute = null; _micSource = null; + _noiseGate = null; _voiceProcessorPair = null; _player = null; throw; @@ -414,6 +452,7 @@ public void Stop() _normalRoute = null; _moddedRoute = null; _micSource = null; + _noiseGate = null; _voiceProcessorPair = null; } @@ -496,6 +535,7 @@ private void OnPlaybackStopped(object? sender, StoppedEventArgs e) _normalRoute = null; _moddedRoute = null; _micSource = null; + _noiseGate = null; _voiceProcessorPair = null; // Keep the secondary branch from running after its master clock has diff --git a/src/MicMixer/Audio/NoiseGateSampleProvider.cs b/src/MicMixer/Audio/NoiseGateSampleProvider.cs new file mode 100644 index 0000000..e250981 --- /dev/null +++ b/src/MicMixer/Audio/NoiseGateSampleProvider.cs @@ -0,0 +1,136 @@ +using NAudio.Wave; + +namespace MicMixer.Audio; + +/// +/// Mutes the mic while nothing above the threshold is picked up, so what leaves +/// the mic branch between phrases is exact digital silence rather than room +/// noise and hum. Receiving apps that decide "talking" from the signal +/// therefore stop as soon as the speaker does. +/// +/// Opens within a couple of milliseconds, holds for a while after the level +/// drops so natural pauses do not chop words, then ramps down to zero. Disabled +/// means unity gain, reached through the same ramp so toggling never clicks. +/// +internal sealed class NoiseGateSampleProvider : ISampleProvider +{ + private const float AttackSeconds = 0.002f; + private const float HoldSeconds = 0.25f; + private const float ReleaseSeconds = 0.04f; + + private readonly ISampleProvider _source; + private readonly Func _enabled; + private readonly Func _thresholdDb; + private readonly int _channels; + private readonly int _holdFrames; + private readonly float _attackStep; + private readonly float _releaseStep; + private int _holdRemaining; + private float _gain = 1f; + private bool _isOpen = true; + private float _inputPeak; + + public NoiseGateSampleProvider(ISampleProvider source, Func enabled, Func thresholdDb) + { + _source = source; + _enabled = enabled; + _thresholdDb = thresholdDb; + _channels = source.WaveFormat.Channels; + + int sampleRate = source.WaveFormat.SampleRate; + _holdFrames = (int)(HoldSeconds * sampleRate); + _attackStep = 1f / Math.Max(1f, AttackSeconds * sampleRate); + _releaseStep = 1f / Math.Max(1f, ReleaseSeconds * sampleRate); + } + + public WaveFormat WaveFormat => _source.WaveFormat; + + /// Whether any signal currently passes; always true while disabled. + public bool IsOpen => Volatile.Read(ref _isOpen); + + /// + /// Highest pre-gate peak since the last call, then resets. Accumulated rather + /// than sampled so a meter polling every 50 ms sees every block, including + /// the short transients that would reopen the gate. + /// + public float ReadAndResetInputPeak() => Interlocked.Exchange(ref _inputPeak, 0f); + + public int Read(Span buffer) + { + int read = _source.Read(buffer); + Span samples = buffer[..read]; + + bool enabled = _enabled(); + if (!enabled && _gain == 1f) + { + Volatile.Write(ref _isOpen, true); + return read; + } + + float threshold = MathF.Pow(10f, _thresholdDb() / 20f); + float blockPeak = 0f; + + // Whole frames only; a trailing partial frame from a short read is left as is. + int frameSamples = samples.Length / _channels * _channels; + for (int offset = 0; offset < frameSamples; offset += _channels) + { + float framePeak = 0f; + for (int channel = 0; channel < _channels; channel++) + { + framePeak = Math.Max(framePeak, Math.Abs(samples[offset + channel])); + } + + blockPeak = Math.Max(blockPeak, framePeak); + + bool open; + if (!enabled) + { + _holdRemaining = 0; + open = true; + } + else if (framePeak >= threshold) + { + _holdRemaining = _holdFrames; + open = true; + } + else if (_holdRemaining > 0) + { + _holdRemaining--; + open = true; + } + else + { + open = false; + } + + if (open) + { + _gain = Math.Min(1f, _gain + _attackStep); + } + else + { + _gain = Math.Max(0f, _gain - _releaseStep); + } + + if (_gain == 1f) + { + continue; + } + + for (int channel = 0; channel < _channels; channel++) + { + samples[offset + channel] *= _gain; + } + } + + if (blockPeak > Volatile.Read(ref _inputPeak)) + { + Volatile.Write(ref _inputPeak, blockPeak); + } + + Volatile.Write(ref _isOpen, _gain > 0f); + return read; + } + + public int Read(float[] buffer, int offset, int count) => Read(buffer.AsSpan(offset, count)); +} diff --git a/src/MicMixer/Audio/SwitchingSampleProvider.cs b/src/MicMixer/Audio/SwitchingSampleProvider.cs index 91d3e39..6b727a2 100644 --- a/src/MicMixer/Audio/SwitchingSampleProvider.cs +++ b/src/MicMixer/Audio/SwitchingSampleProvider.cs @@ -5,14 +5,19 @@ namespace MicMixer.Audio; internal sealed class SwitchingSampleProvider : ISampleProvider, IDisposable { private const double HalfPi = Math.PI / 2d; + private const float VolumeRampSeconds = 0.01f; + private const float MaxPrimaryVolume = 2f; private readonly ISamplePair _pair; private readonly Func _useSecondary; + private readonly Func? _primaryVolume; private readonly float[] _fadeOut; private readonly float[] _fadeIn; + private readonly float _volumeStepPerFrame; private float[] _primaryBuffer = Array.Empty(); private float[] _secondaryBuffer = Array.Empty(); private bool _targetSecondary; private int _fadePosition; + private float _currentPrimaryVolume = 1f; private bool _initialized; public SwitchingSampleProvider( @@ -24,14 +29,17 @@ public SwitchingSampleProvider( { } + /// Gain for the primary source only, 0–2; null means unity. public SwitchingSampleProvider( ISamplePair pair, Func useSecondary, - int crossfadeMilliseconds = 8) + int crossfadeMilliseconds = 8, + Func? primaryVolume = null) { ArgumentOutOfRangeException.ThrowIfNegative(crossfadeMilliseconds); _pair = pair; _useSecondary = useSecondary; + _primaryVolume = primaryVolume; WaveFormat = pair.WaveFormat; int fadeFrames = Math.Max(1, WaveFormat.SampleRate * crossfadeMilliseconds / 1_000); @@ -43,6 +51,9 @@ public SwitchingSampleProvider( _fadeOut[frame] = (float)Math.Cos(progress * HalfPi); _fadeIn[frame] = (float)Math.Sin(progress * HalfPi); } + + // Ten milliseconds per unit of change keeps slider drags click-free. + _volumeStepPerFrame = 1f / Math.Max(1f, VolumeRampSeconds * WaveFormat.SampleRate); } public WaveFormat WaveFormat { get; } @@ -60,11 +71,13 @@ public int Read(Span buffer) _pair.Read(primary, secondary); bool requestedSecondary = _pair.HasSecondary && _useSecondary(); + float requestedVolume = RequestedPrimaryVolume(); if (!_initialized) { _initialized = true; _targetSecondary = requestedSecondary; _fadePosition = requestedSecondary ? _fadeOut.Length - 1 : 0; + _currentPrimaryVolume = requestedVolume; } else { @@ -74,15 +87,26 @@ public int Read(Span buffer) int channels = WaveFormat.Channels; for (int offset = 0; offset < buffer.Length; offset += channels) { + _currentPrimaryVolume = _currentPrimaryVolume < requestedVolume + ? Math.Min(requestedVolume, _currentPrimaryVolume + _volumeStepPerFrame) + : Math.Max(requestedVolume, _currentPrimaryVolume - _volumeStepPerFrame); + + float primaryGain; + float secondaryGain; if (_fadeOut.Length == 1) { - ReadOnlySpan selected = _targetSecondary ? secondary : primary; - selected.Slice(offset, channels).CopyTo(buffer.Slice(offset, channels)); - continue; + // A one-entry table cannot hold both ends of a crossfade, so a + // zero-length crossfade switches hard. + primaryGain = _targetSecondary ? 0f : 1f; + secondaryGain = 1f - primaryGain; + } + else + { + primaryGain = _fadeOut[_fadePosition]; + secondaryGain = _fadeIn[_fadePosition]; } - float primaryGain = _fadeOut[_fadePosition]; - float secondaryGain = _fadeIn[_fadePosition]; + primaryGain *= _currentPrimaryVolume; for (int channel = 0; channel < channels; channel++) { buffer[offset + channel] = @@ -106,6 +130,12 @@ public int Read(Span buffer) public void Dispose() => _pair.Dispose(); + private float RequestedPrimaryVolume() + { + float requested = _primaryVolume?.Invoke() ?? 1f; + return float.IsFinite(requested) ? Math.Clamp(requested, 0f, MaxPrimaryVolume) : 1f; + } + private void EnsureCapacity(int count) { if (_primaryBuffer.Length < count) diff --git a/src/MicMixer/Audio/VoiceProcessorSamplePair.cs b/src/MicMixer/Audio/VoiceProcessorSamplePair.cs index e3b6ab3..b56e95f 100644 --- a/src/MicMixer/Audio/VoiceProcessorSamplePair.cs +++ b/src/MicMixer/Audio/VoiceProcessorSamplePair.cs @@ -179,7 +179,7 @@ private void DelayDry(ReadOnlySpan input, Span output) private void ApplyOutputVolume(Span samples) { float requested = _outputVolume?.Invoke() ?? 1f; - requested = float.IsFinite(requested) ? Math.Clamp(requested, 0f, 1f) : 1f; + requested = float.IsFinite(requested) ? Math.Clamp(requested, 0f, 2f) : 1f; if (!_gainInitialized) { _currentGain = _targetGain = requested; diff --git a/tests/MicMixer.Tests/NoiseGateSampleProviderTests.cs b/tests/MicMixer.Tests/NoiseGateSampleProviderTests.cs new file mode 100644 index 0000000..4610153 --- /dev/null +++ b/tests/MicMixer.Tests/NoiseGateSampleProviderTests.cs @@ -0,0 +1,180 @@ +using AwesomeAssertions; +using MicMixer.Audio; +using NAudio.Wave; +using Xunit; + +namespace MicMixer.Tests; + +/// +/// The gate's promise: exact digital zero once the mic has been quiet past the +/// hold time, unity while speaking, and click-free ramps in between. +/// +public sealed class NoiseGateSampleProviderTests +{ + private const int SampleRate = 48_000; + private const int HoldFrames = SampleRate / 4; // 250 ms + private const int ReleaseFrames = SampleRate * 40 / 1_000; + private const int AttackFrames = SampleRate * 2 / 1_000; + private const float ThresholdDb = -40f; + private const float Loud = 0.1f; // -20 dBFS, well above the threshold + private const float Quiet = 0.001f; // -60 dBFS, well below it + + [Fact] + public void Disabled_ShouldPassTheSourceThroughUntouched() + { + var source = new LevelSampleProvider(channels: 2) { Level = Quiet }; + var gate = new NoiseGateSampleProvider(source, () => false, () => ThresholdDb); + + float[] block = ReadMilliseconds(gate, 100, channels: 2); + + block.Should().OnlyContain(sample => sample == Quiet); + gate.IsOpen.Should().BeTrue(); + } + + [Fact] + public void Enabled_ShouldReachExactSilence_WhenTheMicStaysBelowTheThreshold() + { + var source = new LevelSampleProvider(channels: 1) { Level = Quiet }; + var gate = new NoiseGateSampleProvider(source, () => true, () => ThresholdDb); + + // Hold (250 ms) plus release (40 ms) must have elapsed. + ReadMilliseconds(gate, 300, channels: 1); + float[] block = ReadMilliseconds(gate, 20, channels: 1); + + block.Should().OnlyContain(sample => sample == 0f); + gate.IsOpen.Should().BeFalse(); + gate.ReadAndResetInputPeak().Should().Be(Quiet, "the readout shows the level before gating"); + gate.ReadAndResetInputPeak().Should().Be(0f); + } + + [Fact] + public void Enabled_ShouldOpen_WhenOnlyOneChannelExceedsTheThreshold() + { + var source = new LevelSampleProvider(channels: 2); + source.SetLevels(Quiet, Loud); + var gate = new NoiseGateSampleProvider(source, () => true, () => ThresholdDb); + + ReadMilliseconds(gate, 5, channels: 2); + float[] block = ReadMilliseconds(gate, 10, channels: 2); + + block.Where((_, i) => i % 2 == 0).Should().OnlyContain(sample => sample == Quiet); + block.Where((_, i) => i % 2 == 1).Should().OnlyContain(sample => sample == Loud); + gate.IsOpen.Should().BeTrue(); + } + + [Fact] + public void InputPeak_ShouldAccumulateAcrossBlocksUntilRead() + { + var source = new LevelSampleProvider(channels: 1) { Level = Loud }; + var gate = new NoiseGateSampleProvider(source, () => true, () => ThresholdDb); + ReadMilliseconds(gate, 10, channels: 1); + source.Level = Quiet; + ReadMilliseconds(gate, 10, channels: 1); + + gate.ReadAndResetInputPeak().Should().Be(Loud, "a transient in an earlier block must still reach the meter"); + } + + [Fact] + public void Enabled_ShouldPassSpeechAtUnity_AfterTheAttack() + { + var source = new LevelSampleProvider(channels: 2) { Level = Quiet }; + var gate = new NoiseGateSampleProvider(source, () => true, () => ThresholdDb); + ReadMilliseconds(gate, 300, channels: 2); + + source.Level = Loud; + ReadMilliseconds(gate, 5, channels: 2); + float[] block = ReadMilliseconds(gate, 20, channels: 2); + + block.Should().OnlyContain(sample => sample == Loud); + gate.IsOpen.Should().BeTrue(); + } + + [Fact] + public void Enabled_ShouldHoldOpenThroughAShortPause_ThenCloseAfterTheHoldTime() + { + var source = new LevelSampleProvider(channels: 1) { Level = Loud }; + var gate = new NoiseGateSampleProvider(source, () => true, () => ThresholdDb); + ReadMilliseconds(gate, 50, channels: 1); + + source.Level = Quiet; + float[] afterSpeech = ReadMilliseconds(gate, 300, channels: 1); + + int firstAttenuated = Array.FindIndex(afterSpeech, sample => sample < Quiet); + int firstSilent = Array.FindIndex(afterSpeech, sample => sample == 0f); + firstAttenuated.Should().Be(HoldFrames, "the gate holds for exactly 250 ms after the level drops"); + firstSilent.Should().BeInRange(HoldFrames + ReleaseFrames, HoldFrames + ReleaseFrames + 2, "then releases over 40 ms"); + afterSpeech[firstSilent..].Should().OnlyContain(sample => sample == 0f); + } + + [Fact] + public void Transitions_ShouldRampWithoutSteps() + { + var source = new LevelSampleProvider(channels: 1) { Level = Loud }; + var gate = new NoiseGateSampleProvider(source, () => true, () => ThresholdDb); + ReadMilliseconds(gate, 50, channels: 1); + + source.Level = Quiet; + float[] closing = ReadMilliseconds(gate, 300, channels: 1); + source.Level = Loud; + float[] opening = ReadMilliseconds(gate, 5, channels: 1); + + AssertSmooth(closing, Quiet / ReleaseFrames * 1.01f); + AssertSmooth(opening, Loud / AttackFrames * 1.01f); + opening[^1].Should().Be(Loud); + } + + [Fact] + public void DisablingWhileClosed_ShouldRampBackToUnity() + { + bool enabled = true; + var source = new LevelSampleProvider(channels: 1) { Level = Quiet }; + var gate = new NoiseGateSampleProvider(source, () => enabled, () => ThresholdDb); + ReadMilliseconds(gate, 300, channels: 1); + + enabled = false; + float[] reopening = ReadMilliseconds(gate, 5, channels: 1); + + reopening[0].Should().BeLessThan(Quiet); + reopening[^1].Should().Be(Quiet); + AssertSmooth(reopening, Quiet / AttackFrames * 1.01f); + } + + private static void AssertSmooth(float[] samples, float maxStep) + { + for (int i = 1; i < samples.Length; i++) + { + Math.Abs(samples[i] - samples[i - 1]).Should().BeLessThanOrEqualTo(maxStep, $"sample {i} must not jump"); + } + } + + private static float[] ReadMilliseconds(ISampleProvider provider, int milliseconds, int channels) + { + var buffer = new float[SampleRate * milliseconds / 1_000 * channels]; + provider.Read(buffer.AsSpan()).Should().Be(buffer.Length); + return buffer; + } + + private sealed class LevelSampleProvider(int channels) : ISampleProvider + { + private readonly float[] _levels = new float[channels]; + + public float Level + { + set => Array.Fill(_levels, value); + } + + public WaveFormat WaveFormat { get; } = WaveFormat.CreateIeeeFloatWaveFormat(SampleRate, channels); + + public void SetLevels(params float[] levels) => levels.CopyTo(_levels, 0); + + public int Read(Span buffer) + { + for (int i = 0; i < buffer.Length; i++) + { + buffer[i] = _levels[i % _levels.Length]; + } + + return buffer.Length; + } + } +} diff --git a/tests/MicMixer.Tests/SwitchingSampleProviderTests.cs b/tests/MicMixer.Tests/SwitchingSampleProviderTests.cs new file mode 100644 index 0000000..7417c73 --- /dev/null +++ b/tests/MicMixer.Tests/SwitchingSampleProviderTests.cs @@ -0,0 +1,80 @@ +using AwesomeAssertions; +using MicMixer.Audio; +using NAudio.Wave; +using Xunit; + +namespace MicMixer.Tests; + +public sealed class SwitchingSampleProviderTests +{ + private const int SampleRate = 48_000; + private const float PrimaryLevel = 0.2f; + private const float SecondaryLevel = 0.5f; + + [Fact] + public void PrimaryVolume_ShouldBeUnity_WhenNotProvided() + { + var switching = new SwitchingSampleProvider( + new ConstantSampleProvider(PrimaryLevel), new ConstantSampleProvider(SecondaryLevel), () => false); + + float[] block = Read(switching, 480); + + block.Should().OnlyContain(sample => sample == PrimaryLevel); + } + + [Theory] + [InlineData(0.5f)] + [InlineData(1.5f)] + public void PrimaryVolume_ShouldScaleOnlyThePrimarySource(float volume) + { + bool useSecondary = false; + var pair = new IndependentSamplePair(new ConstantSampleProvider(PrimaryLevel), new ConstantSampleProvider(SecondaryLevel)); + var switching = new SwitchingSampleProvider(pair, () => useSecondary, primaryVolume: () => volume); + + float[] primary = Read(switching, 480); + primary.Should().OnlyContain(sample => Math.Abs(sample - PrimaryLevel * volume) < 1e-6f); + + useSecondary = true; + Read(switching, 480); + float[] secondary = Read(switching, 480); + secondary.Should().OnlyContain(sample => sample == SecondaryLevel, "the modded source is never scaled by the normal mic volume"); + } + + [Fact] + public void PrimaryVolume_ShouldRampToTheNewValueWithinTenMilliseconds() + { + float volume = 1f; + var pair = new IndependentSamplePair(new ConstantSampleProvider(PrimaryLevel), secondary: null); + var switching = new SwitchingSampleProvider(pair, () => false, primaryVolume: () => volume); + Read(switching, 480); + + volume = 2f; + float[] ramp = Read(switching, 480); + + ramp[0].Should().BeGreaterThan(PrimaryLevel).And.BeLessThan(PrimaryLevel * 1.01f); + ramp[^1].Should().BeApproximately(PrimaryLevel * 2f, 1e-5f); + for (int i = 1; i < ramp.Length; i++) + { + (ramp[i] - ramp[i - 1]).Should().BeInRange(0f, 0.001f); + } + } + + private static float[] Read(ISampleProvider provider, int count) + { + var buffer = new float[count]; + provider.Read(buffer.AsSpan()).Should().Be(count); + return buffer; + } + + private sealed class ConstantSampleProvider(float value) : ISampleProvider + { + public WaveFormat WaveFormat { get; } = WaveFormat.CreateIeeeFloatWaveFormat(SampleRate, 1); + + public int Read(Span buffer) + { + buffer.Fill(value); + return buffer.Length; + } + + } +} From f9f838e5f138a9fe9bf46d20c378cd4674c3c6f6 Mon Sep 17 00:00:00 2001 From: Benjiboy Date: Mon, 14 Sep 2026 15:00:46 +0200 Subject: [PATCH 02/14] feat(settings): persist noise gate and mic volume settings Add NormalMicVolume, NoiseGateEnabled and NoiseGateThresholdDb to AppSettings, and clamp them (and the now-2x ProcessedVoiceVolume ceiling) on load like the other saved levels. --- src/MicMixer/Settings/AppSettings.cs | 10 ++++++++- src/MicMixer/Settings/SettingsStore.cs | 6 +++++- tests/MicMixer.Tests/SettingsStoreTests.cs | 25 +++++++++++++++++++++- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/MicMixer/Settings/AppSettings.cs b/src/MicMixer/Settings/AppSettings.cs index 0fee5d5..35aeb37 100644 --- a/src/MicMixer/Settings/AppSettings.cs +++ b/src/MicMixer/Settings/AppSettings.cs @@ -8,13 +8,21 @@ public sealed class AppSettings public string? ModdedInputDeviceId { get; set; } + /// Gain for the normal mic only. 1 sends it exactly as captured; up to 2 boosts a quiet mic. + public float NormalMicVolume { get; set; } = 1f; + + /// Mutes the mic between phrases so only signal above the threshold is sent. + public bool NoiseGateEnabled { get; set; } + + public float NoiseGateThresholdDb { get; set; } = -45f; + public ModifiedVoiceMode ModifiedVoiceMode { get; set; } public string? SelectedVoiceProfileId { get; set; } public bool LongerAnalysisWindow { get; set; } - /// Post-effect gain for processed voice only, from silence to unity. + /// Post-effect gain for processed voice only. 1 is unity; up to 2 boosts, same scale as . public float ProcessedVoiceVolume { get; set; } = 1f; /// Legacy mirror retained so older MicMixer builds still understand a saved settings file. diff --git a/src/MicMixer/Settings/SettingsStore.cs b/src/MicMixer/Settings/SettingsStore.cs index bb18efc..6dae058 100644 --- a/src/MicMixer/Settings/SettingsStore.cs +++ b/src/MicMixer/Settings/SettingsStore.cs @@ -68,7 +68,11 @@ public AppSettings Load() } } settings.ProcessedVoiceVolume = float.IsFinite(settings.ProcessedVoiceVolume) - ? Math.Clamp(settings.ProcessedVoiceVolume, 0f, 1f) : 1f; + ? Math.Clamp(settings.ProcessedVoiceVolume, 0f, 2f) : 1f; + settings.NormalMicVolume = float.IsFinite(settings.NormalMicVolume) + ? Math.Clamp(settings.NormalMicVolume, 0f, 2f) : 1f; + settings.NoiseGateThresholdDb = float.IsFinite(settings.NoiseGateThresholdDb) + ? Math.Clamp(settings.NoiseGateThresholdDb, -70f, -10f) : -45f; settings.SkipModdedMic = settings.ModifiedVoiceMode == ModifiedVoiceMode.None; return settings; } diff --git a/tests/MicMixer.Tests/SettingsStoreTests.cs b/tests/MicMixer.Tests/SettingsStoreTests.cs index 2f69bab..479ab18 100644 --- a/tests/MicMixer.Tests/SettingsStoreTests.cs +++ b/tests/MicMixer.Tests/SettingsStoreTests.cs @@ -11,7 +11,7 @@ public sealed class SettingsStoreTests : IDisposable [Theory] [InlineData("{}", 1f)] [InlineData("{\"ProcessedVoiceVolume\":-1}", 0f)] - [InlineData("{\"ProcessedVoiceVolume\":3}", 1f)] + [InlineData("{\"ProcessedVoiceVolume\":3}", 2f)] public void Load_ShouldDefaultAndClampProcessedVoiceVolume(string json, float expected) { Directory.CreateDirectory(_root); @@ -20,6 +20,23 @@ public void Load_ShouldDefaultAndClampProcessedVoiceVolume(string json, float ex new SettingsStore(path).Load().ProcessedVoiceVolume.Should().Be(expected); } + [Theory] + [InlineData("{}", 1f, -45f)] + [InlineData("{\"NormalMicVolume\":-1,\"NoiseGateThresholdDb\":-200}", 0f, -70f)] + [InlineData("{\"NormalMicVolume\":5,\"NoiseGateThresholdDb\":12}", 2f, -10f)] + public void Load_ShouldDefaultAndClampMicVolumeAndGateThreshold(string json, float volume, float thresholdDb) + { + Directory.CreateDirectory(_root); + string path = Path.Combine(_root, "settings.json"); + File.WriteAllText(path, json); + + AppSettings settings = new SettingsStore(path).Load(); + + settings.NormalMicVolume.Should().Be(volume); + settings.NoiseGateThresholdDb.Should().Be(thresholdDb); + settings.NoiseGateEnabled.Should().BeFalse(); + } + [Fact] public void SaveAndLoad_ShouldRoundTripRoutingAndMusicSettings_WhenValuesAreConfigured() { @@ -34,6 +51,9 @@ public void SaveAndLoad_ShouldRoundTripRoutingAndMusicSettings_WhenValuesAreConf DownloadFolderPath = @"D:\Music B", MusicVolume = 0.75f, ProcessedVoiceVolume = 0.63f, + NormalMicVolume = 1.4f, + NoiseGateEnabled = true, + NoiseGateThresholdDb = -38f, SecondaryOutputEnabled = true, SecondaryOutputDeviceId = "secondary-device-id", SecondaryOutputVolume = 0.6f, @@ -50,6 +70,9 @@ public void SaveAndLoad_ShouldRoundTripRoutingAndMusicSettings_WhenValuesAreConf actual.DownloadFolderPath.Should().Be(expected.DownloadFolderPath); actual.MusicVolume.Should().Be(expected.MusicVolume); actual.ProcessedVoiceVolume.Should().Be(expected.ProcessedVoiceVolume); + actual.NormalMicVolume.Should().Be(expected.NormalMicVolume); + actual.NoiseGateEnabled.Should().BeTrue(); + actual.NoiseGateThresholdDb.Should().Be(expected.NoiseGateThresholdDb); actual.SecondaryOutputEnabled.Should().BeTrue(); actual.SecondaryOutputDeviceId.Should().Be(expected.SecondaryOutputDeviceId); actual.SecondaryOutputVolume.Should().Be(expected.SecondaryOutputVolume); From 7a5014306aadf53011300ec2a312d5aaa70f04f3 Mon Sep 17 00:00:00 2001 From: Benjiboy Date: Mon, 14 Sep 2026 15:00:50 +0200 Subject: [PATCH 03/14] feat(ui): add noise gate and mic volume controls to main window Add a Volume slider under the normal mic, move the processed-voice Volume slider next to it under the modified-voice picker (same 0-200% scale), and add a Noise gate checkbox with threshold slider and live level bar under push-to-talk. --- src/MicMixer/MainWindow.xaml | 96 ++++++++++++++++++++++++++++----- src/MicMixer/MainWindow.xaml.cs | 75 ++++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 14 deletions(-) diff --git a/src/MicMixer/MainWindow.xaml b/src/MicMixer/MainWindow.xaml index eede6d7..50859fa 100644 --- a/src/MicMixer/MainWindow.xaml +++ b/src/MicMixer/MainWindow.xaml @@ -319,6 +319,20 @@ + + + + + + + + + + + + + + + + + + + + @@ -410,20 +440,6 @@ Visibility="Collapsed"> - - - - - - - - - - @@ -608,6 +624,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MicMixer/MainWindow.xaml.cs b/src/MicMixer/MainWindow.xaml.cs index 1c699fb..22be404 100644 --- a/src/MicMixer/MainWindow.xaml.cs +++ b/src/MicMixer/MainWindow.xaml.cs @@ -77,6 +77,7 @@ public partial class MainWindow : Window, IMicMixerControlHost private OverlayIndicatorWindow? _overlayIndicator; private HotkeyBinding _hotkeyBinding = HotkeyBinding.Default; private int _releaseDelayMilliseconds; + private float _noiseGatePeakHold; private bool _isCapturingHotkey; private bool _isReleaseDelayPending; private bool _isStartingRouting; @@ -185,6 +186,14 @@ public MainWindow( ProcessedVoiceVolumeSlider.Value = _settings.ProcessedVoiceVolume; _router.ProcessedVoiceVolume = _settings.ProcessedVoiceVolume; ProcessedVoiceVolumePercentText.Text = $"{Math.Round(_settings.ProcessedVoiceVolume * 100)} %"; + NormalMicVolumeSlider.Value = _settings.NormalMicVolume; + _router.NormalMicVolume = _settings.NormalMicVolume; + NormalMicVolumePercentText.Text = $"{Math.Round(_settings.NormalMicVolume * 100)} %"; + NoiseGateCheck.IsChecked = _settings.NoiseGateEnabled; + NoiseGateThresholdSlider.Value = _settings.NoiseGateThresholdDb; + _router.NoiseGateEnabled = _settings.NoiseGateEnabled; + _router.NoiseGateThresholdDb = _settings.NoiseGateThresholdDb; + NoiseGateThresholdText.Text = $"{_settings.NoiseGateThresholdDb:0} dB"; _isUpdatingUi = false; UpdateSecondaryVolumePercentText(); ApplySecondaryOutputConfig(); @@ -710,6 +719,7 @@ private void OnLevelTimerTick(object? sender, EventArgs e) UpdateExternalCaptureStatusText(); } + UpdateNoiseGateStateText(); if (_router.IsRouting) { DryLevelMeter.Value = _router.NormalPeak; @@ -1100,9 +1110,74 @@ private void OnProcessedVoiceVolumeChanged(object sender, RoutedPropertyChangedE _settings.ProcessedVoiceVolume = (float)e.NewValue; _router.ProcessedVoiceVolume = _settings.ProcessedVoiceVolume; ProcessedVoiceVolumePercentText.Text = $"{Math.Round(e.NewValue * 100)} %"; + ScheduleSettingsSave(); + } + + private void OnNormalMicVolumeChanged(object sender, RoutedPropertyChangedEventArgs e) + { + if (_isUpdatingUi || NormalMicVolumePercentText == null) + { + return; + } + + _settings.NormalMicVolume = (float)e.NewValue; + _router.NormalMicVolume = _settings.NormalMicVolume; + NormalMicVolumePercentText.Text = $"{Math.Round(e.NewValue * 100)} %"; + ScheduleSettingsSave(); + } + + private void OnNoiseGateChanged(object sender, RoutedEventArgs e) + { + if (_isUpdatingUi || NoiseGateStateText == null) + { + return; + } + + _settings.NoiseGateEnabled = NoiseGateCheck.IsChecked == true; + _router.NoiseGateEnabled = _settings.NoiseGateEnabled; + UpdateNoiseGateStateText(); SaveSettings(); } + private void OnNoiseGateThresholdChanged(object sender, RoutedPropertyChangedEventArgs e) + { + if (_isUpdatingUi || NoiseGateThresholdText == null) + { + return; + } + + _settings.NoiseGateThresholdDb = (float)e.NewValue; + _router.NoiseGateThresholdDb = _settings.NoiseGateThresholdDb; + NoiseGateThresholdText.Text = $"{_settings.NoiseGateThresholdDb:0} dB"; + ScheduleSettingsSave(); + } + + /// + /// Live gate readout: the level bar under the threshold slider plus open/closed. + /// Peak-hold with decay, like the capture meter, so the bar is readable instead + /// of flickering with every block. + /// + private void UpdateNoiseGateStateText() + { + if (!_router.IsRouting || !_settings.NoiseGateEnabled) + { + _noiseGatePeakHold = 0f; + NoiseGateLevelBar.Value = NoiseGateLevelBar.Minimum; + NoiseGateStateText.Text = string.Empty; + return; + } + + float peak = _router.ReadAndResetNoiseGateInputPeak(); + _noiseGatePeakHold = Math.Max(float.IsFinite(peak) ? peak : 0f, _noiseGatePeakHold * 0.85f); + double decibels = _noiseGatePeakHold > 0f ? 20 * Math.Log10(_noiseGatePeakHold) : double.NegativeInfinity; + NoiseGateLevelBar.Value = Math.Clamp(decibels, NoiseGateLevelBar.Minimum, NoiseGateLevelBar.Maximum); + + bool open = _router.NoiseGateOpen; + NoiseGateStateText.Text = open ? "Open" : "Closed"; + NoiseGateStateText.Foreground = open ? StatusTheme.LiveBrush : StatusTheme.StoppedInkBrush; + NoiseGateLevelBar.Foreground = open ? StatusTheme.LiveBrush : StatusTheme.StoppedInkBrush; + } + private void OnLongerAnalysisWindowChanged(object sender, RoutedEventArgs e) { if (_isUpdatingUi) From 3c56ec830e1e287b3648bac52d6dc3978ba4cfe8 Mon Sep 17 00:00:00 2001 From: Benjiboy Date: Mon, 14 Sep 2026 15:00:55 +0200 Subject: [PATCH 04/14] docs: document noise gate and mic volume boost Update README, RELEASE_NOTES, the local voice profiles doc and the FiveM guide (voice detection lingering after you stop talking) for the new noise gate and normal/processed mic volume controls. --- README.md | 24 +++++++++++++++++++++++- RELEASE_NOTES.md | 19 ++++++++----------- docs/guides/fivem-music-through-mic.md | 8 ++++++++ docs/local-voice-profiles.md | 5 +++-- 4 files changed, 42 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 4d1cdf3..40e214e 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,10 @@ The rest of this document is the detailed reference. - Supports setups without a modified microphone. - Supports a configurable release delay before switching back to the normal mic. - Provides push-to-talk for the complete outgoing mix, including music. +- Provides an optional noise gate that keeps the mic silent between phrases, so + the virtual cable carries true silence instead of room noise. +- Provides matching volume controls for the normal mic and the processed voice, + from mute up to a 2× boost. - Optionally lets music bypass push-to-talk, so the music keeps playing into the virtual cable while the voice stays gated. - Provides a monitor-only preview mode that keeps music out of the virtual cable @@ -152,7 +156,11 @@ button that should select the modified mic while routing is active: - Release delay above `0 ms`: the modified mic remains active until the delay ends. - **None**: the hotkey is disabled unless push-to-talk is enabled. - **External microphone / Voicemod**: select the existing voice-changer output device. -- **Local voice profile** (experimental): processes the physical microphone using a private local profile. Select a profile, create or edit one in the voice designer, or delete one you no longer want. **Processed voice volume** adjusts the wet output from 0–100% while routing is active. +- **Local voice profile** (experimental): processes the physical microphone using a private local profile. Select a profile, create or edit one in the voice designer, or delete one you no longer want. Its **Volume** slider (under the modified-voice picker) adjusts the processed voice only, from 0 to 200%, while routing is active. +- **Volume** under the normal mic adjusts only the normal mic, on the same 0–200% + scale. 100% sends it exactly as captured. MicMixer never lowers the normal mic + on its own; raise this if it sounds quieter than your modified voice, or lower + the modified voice next to it. Push-to-talk reverses the idle behavior: while the hotkey is not held, the virtual cable receives silence. Neither microphone audio nor music is sent. @@ -167,6 +175,20 @@ cable receives silence. Neither microphone audio nor music is sent. only the microphone: the music keeps flowing into the virtual cable as long as it plays. See [Music routing](#music-routing). +**Noise gate** mutes the mic whenever its level stays below the threshold, so +the cable carries true digital silence between phrases instead of room noise +and hum. Apps that use voice activation on the cable +then stop treating you as talking the moment you stop speaking, even while the +push-to-talk key is still held. + +- The gate opens within a few milliseconds and stays open for a quarter second + after the level drops, so short pauses do not chop words. +- The bar under the slider shows the mic level on the same scale: set the + threshold so the bar passes the knob while you talk and stays below it while + you are quiet. The label next to it tells whether the gate is open or closed. +- It applies to the normal and the modified mic alike and works together with + push-to-talk. Music is not affected. + ## Music sources MicMixer can use its built-in MP3 library or capture audio from another application. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index d09421b..86e3750 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -2,15 +2,12 @@ -- Local voice profiles replace the fixed voice configuration. Profile identity, display name and DSP settings are stored privately per user. Existing local installations migrate automatically through an explicit local binding. -- Two built-in voice starting points, Feminine and Masculine, can be used immediately or customized as local copies. They contain generic effect settings only. -- Create custom local voices in the new voice designer. Record a short sample, compare original and processed playback with optional looping, adjust voice character and advanced effects, and save a new profile in local AppData without editing JSON. Test recordings stay in memory. -- The voice designer now tunes by ear: adjusting a setting while the sample plays re-renders it and keeps playing from the same spot instead of stopping. A live input-level meter shows whether the microphone is picking you up, every slider shows its unit, and frequency and compressor timing sliders use a logarithmic scale so their low end is actually reachable. -- Voice volume now gets a full-width slider. Alternate processing quality appears only for profiles that support it. Recording and comparison playback share a panel, with clear Play / Pause / Resume buttons and a separate Stop action. -- Your own voices can be edited and deleted. Saving back to a profile you started from replaces it, **Save as copy** keeps both, and **Delete** removes one from the main window. Cancelling a draft asks before discarding it. -- The modified-voice settings moved out of the device row into their own panel below it. The three device dropdowns stay aligned, the panel is simply absent when the modified voice is **None**, and switching mode no longer shoves the cards underneath up and down. The voice designer is disabled with a reason while routing runs rather than refusing the click afterwards, and a fresh install starts on a built-in starter instead of an empty profile box. -- Processed voice volume remains after DSP with smooth, saved level changes. Missing or invalid selected profiles produce an error. -- Advanced: the voice designer now has a Pitch engine picker to switch a voice between the default spectral engine and a second, time-domain one (format 2). Choosing time-domain shows its waveform window/search controls and disables independent resonance adjustment, which this engine does not support; resetting to the starting point restores its original engine. -- The StreamDecky remote-control connection no longer logs a spurious error - when a client disconnects abruptly instead of closing cleanly. +- New **Noise gate** under push-to-talk: keeps the mic silent between phrases so + the virtual cable carries true silence instead of room noise. Apps that use + voice activation on the cable stop treating you as talking as soon as you + stop speaking, even while the push-to-talk key is still held. Off by default. +- New **Volume** slider under the normal mic, for matching a quiet mic to a + louder modified voice. 100% is the default and sends the mic exactly as + before. The processed-voice volume moved next to it, under the modified-voice + picker, and both use the same 0–200% scale. diff --git a/docs/guides/fivem-music-through-mic.md b/docs/guides/fivem-music-through-mic.md index 8612678..d940653 100644 --- a/docs/guides/fivem-music-through-mic.md +++ b/docs/guides/fivem-music-through-mic.md @@ -92,6 +92,14 @@ else hears it, then turn it off to send it. own push-to-talk key; if that gate is mandatory, this setup is unsupported. Disable noise suppression / echo cancellation in the game or voice resource — those filters often strip out music. +- **Your character keeps "talking" after you stop.** FiveM decides who is + talking from the signal on the cable, with no hold time of its own. While the + MicMixer hotkey is held, room noise alone is enough to keep its + voice detection triggered. Enable **Noise gate** under push-to-talk and set + the threshold so the level bar passes the knob while you talk and stays below + it while you are quiet; the cable then carries true silence between phrases. Also keep **Release delay** at 0 + when you use push-to-talk, and lower FiveM's **Microphone Sensitivity** if + quiet sounds still register. - **The music cuts out when you stop talking.** *Music ignores push-to-talk* is off, or push-to-talk isn't enabled. The ignore toggle only does something while push-to-talk is on. diff --git a/docs/local-voice-profiles.md b/docs/local-voice-profiles.md index c3a8b61..f88b2c8 100644 --- a/docs/local-voice-profiles.md +++ b/docs/local-voice-profiles.md @@ -154,11 +154,12 @@ The device row at the top of the routing column holds three peer dropdowns: norm mic, modified voice and virtual cable output. The modified-voice dropdown only names the *kind* of source. Its settings live in a full-width panel directly below, which is absent for **None**, holds the device picker for **External microphone / Voicemod**, -and holds the profile picker, **Create a voice**, **Delete** and the voice volume for +and holds the profile picker, **Create a voice** and **Delete** for **Local voice profile**. Keeping the settings out of the device row is what stops one column from growing several rows taller than the two beside it. -The main voice volume slider occupies its own full-width row. The old alternate +The processed-voice volume sits under the modified-voice picker, next to the +normal mic's volume and on the same 0–200% scale. The old alternate analysis-window checkbox is hidden unless the selected profile actually supplies an alternate. For a longer alternate it reads **Smoother processing (more delay)**; its tooltip explains the quality/latency tradeoff. The two starters do not need this From f421c753ef26e58bccdee508fee797560836269b Mon Sep 17 00:00:00 2001 From: Benjiboy Date: Mon, 14 Sep 2026 20:17:10 +0200 Subject: [PATCH 05/14] feat(settings): separate saved setup from live settings Add AppSettings.Clone, CopyConfigurationFrom and ConfigurationEquals so the settings window can apply changes live while only its Save button writes them. CopyConfigurationFrom is the one list of fields that belong to the settings window; music card, music folders and window size stay saved as they change. Add SetupGuideDismissed for the first-run setup guide. --- src/MicMixer/Settings/AppSettings.cs | 52 ++++++++++++++++++++++ tests/MicMixer.Tests/SettingsStoreTests.cs | 27 +++++++++++ 2 files changed, 79 insertions(+) diff --git a/src/MicMixer/Settings/AppSettings.cs b/src/MicMixer/Settings/AppSettings.cs index 35aeb37..859ea56 100644 --- a/src/MicMixer/Settings/AppSettings.cs +++ b/src/MicMixer/Settings/AppSettings.cs @@ -1,7 +1,56 @@ +using System.Text.Json; + namespace MicMixer.Settings; public sealed class AppSettings { + public AppSettings Clone() => JsonSerializer.Deserialize(JsonSerializer.Serialize(this))!; + + /// + /// Copies the settings edited in the settings window. They apply live but are + /// written to disk only by its Save button, so the saved values stay a reliable + /// reference for "is the app running the way I set it up?". Everything else + /// (music card, music folders, window size) is saved as it changes, because the + /// remote-control API changes those too. + /// + public void CopyConfigurationFrom(AppSettings source) + { + StartWithWindows = source.StartWithWindows; + NormalInputDeviceId = source.NormalInputDeviceId; + ModdedInputDeviceId = source.ModdedInputDeviceId; + NormalMicVolume = source.NormalMicVolume; + NoiseGateEnabled = source.NoiseGateEnabled; + NoiseGateThresholdDb = source.NoiseGateThresholdDb; + ModifiedVoiceMode = source.ModifiedVoiceMode; + SelectedVoiceProfileId = source.SelectedVoiceProfileId; + LongerAnalysisWindow = source.LongerAnalysisWindow; + ProcessedVoiceVolume = source.ProcessedVoiceVolume; + OutputDeviceId = source.OutputDeviceId; + HotkeyId = source.HotkeyId; + ReleaseDelayMilliseconds = source.ReleaseDelayMilliseconds; + PushToTalkMode = source.PushToTalkMode; + MusicMonitorDeviceId = source.MusicMonitorDeviceId; + SecondaryOutputEnabled = source.SecondaryOutputEnabled; + SecondaryOutputDeviceId = source.SecondaryOutputDeviceId; + SecondaryOutputVolume = source.SecondaryOutputVolume; + SecondaryOutputIgnorePushToTalk = source.SecondaryOutputIgnorePushToTalk; + OverlayIndicatorEnabled = source.OverlayIndicatorEnabled; + OverlayVolumeMeterEnabled = source.OverlayVolumeMeterEnabled; + MeterSensitivityDb = source.MeterSensitivityDb; + ObsOverlayEnabled = source.ObsOverlayEnabled; + ObsOverlayPort = source.ObsOverlayPort; + } + + /// True when has the same settings-window values. + public bool ConfigurationEquals(AppSettings other) + { + // Copying our configuration onto a clone of other changes nothing exactly + // when the two agree, which keeps the field list in one place. + AppSettings probe = other.Clone(); + probe.CopyConfigurationFrom(this); + return JsonSerializer.Serialize(probe) == JsonSerializer.Serialize(other); + } + public bool StartWithWindows { get; set; } public string? NormalInputDeviceId { get; set; } @@ -98,6 +147,9 @@ public sealed class AppSettings public string? ExternalAppName { get; set; } + /// The first-run setup guide was skipped; don't open it on its own again. + public bool SetupGuideDismissed { get; set; } + /// Last window size; 0 means never saved, so the XAML default is used. public double WindowWidth { get; set; } diff --git a/tests/MicMixer.Tests/SettingsStoreTests.cs b/tests/MicMixer.Tests/SettingsStoreTests.cs index 479ab18..7209a5e 100644 --- a/tests/MicMixer.Tests/SettingsStoreTests.cs +++ b/tests/MicMixer.Tests/SettingsStoreTests.cs @@ -126,6 +126,33 @@ public void SavingAnUnrelatedStateChange_ShouldPreserveUnavailableDevicePreferen reloaded.MusicVolume.Should().Be(0.8f); } + [Fact] + public void ConfigurationEquals_ShouldIgnoreValuesSavedOutsideTheSettingsWindow() + { + var saved = new AppSettings { OutputDeviceId = "cable", MusicVolume = 0.2f, WindowWidth = 900 }; + AppSettings live = saved.Clone(); + live.MusicVolume = 0.9f; + live.WindowWidth = 1200; + live.MusicFolderPaths = [@"D:\Music"]; + + live.ConfigurationEquals(saved).Should().BeTrue(); + } + + [Fact] + public void ConfigurationEquals_ShouldDetectAChangedSetting_AndCopyConfigurationFromShouldUndoIt() + { + var saved = new AppSettings { OutputDeviceId = "cable", PushToTalkMode = true }; + AppSettings live = saved.Clone(); + live.OutputDeviceId = "speakers"; + live.PushToTalkMode = false; + + live.ConfigurationEquals(saved).Should().BeFalse(); + + live.CopyConfigurationFrom(saved); + live.ConfigurationEquals(saved).Should().BeTrue(); + live.OutputDeviceId.Should().Be("cable"); + } + [Theory] [InlineData(ModifiedVoiceMode.None)] [InlineData(ModifiedVoiceMode.ExternalMicrophone)] From 08c547a4c3d475269915dd05b0a18abeda823f19 Mon Sep 17 00:00:00 2001 From: Benjiboy Date: Mon, 14 Sep 2026 20:17:10 +0200 Subject: [PATCH 06/14] feat(audio): share device detection and recognize more virtual cables Move endpoint enumeration, the device heuristics and default selection into AudioDevices so the main window and the setup guide agree on what a virtual cable is. Recognize Virtual Audio Cable and Voicemeeter in addition to VB-CABLE, pair a cable's playback end with its recording end, and stop guessing a cable's recording end as a microphone. --- src/MicMixer/Audio/AudioDevices.cs | 100 ++++++++++++++++++++++ tests/MicMixer.Tests/AudioDevicesTests.cs | 50 +++++++++++ 2 files changed, 150 insertions(+) create mode 100644 src/MicMixer/Audio/AudioDevices.cs create mode 100644 tests/MicMixer.Tests/AudioDevicesTests.cs diff --git a/src/MicMixer/Audio/AudioDevices.cs b/src/MicMixer/Audio/AudioDevices.cs new file mode 100644 index 0000000..97b0f0c --- /dev/null +++ b/src/MicMixer/Audio/AudioDevices.cs @@ -0,0 +1,100 @@ +using NAudio.CoreAudioApi; + +namespace MicMixer.Audio; + +internal sealed record AudioDeviceOption(string Id, string FriendlyName); + +/// +/// Active Windows audio endpoints, and how MicMixer recognizes and pre-selects them. +/// Windows has no "virtual device" flag, so recognition is by driver naming. +/// +internal static class AudioDevices +{ + public static (List Inputs, List Outputs) EnumerateActive() + { + using var enumerator = new MMDeviceEnumerator(); + return (Read(enumerator, DataFlow.Capture), Read(enumerator, DataFlow.Render)); + } + + private static List Read(MMDeviceEnumerator enumerator, DataFlow dataFlow) + { + var options = new List(); + foreach (MMDevice device in enumerator.EnumerateAudioEndPoints(dataFlow, DeviceState.Active)) + { + using (device) + { + options.Add(new AudioDeviceOption(device.ID, device.FriendlyName)); + } + } + + return options; + } + + /// + /// Either end of VB-CABLE (all variants), Virtual Audio Cable ("Line 1 (Virtual Audio Cable)") + /// or Voicemeeter. + /// + public static bool LooksLikeVirtualCable(AudioDeviceOption device) + { + string name = device.FriendlyName; + return name.Contains("vb-audio", StringComparison.OrdinalIgnoreCase) + || name.Contains("virtual cable", StringComparison.OrdinalIgnoreCase) + || name.Contains("virtual audio cable", StringComparison.OrdinalIgnoreCase) + || name.Contains("cable input", StringComparison.OrdinalIgnoreCase) + || name.Contains("cable output", StringComparison.OrdinalIgnoreCase) + || name.Contains("voicemeeter", StringComparison.OrdinalIgnoreCase); + } + + public static bool LooksLikeVoiceModDevice(AudioDeviceOption device) + { + string name = device.FriendlyName; + return name.Contains("voicemod", StringComparison.OrdinalIgnoreCase) + || name.Contains("voice mod", StringComparison.OrdinalIgnoreCase); + } + + /// A physical microphone: neither a voice changer nor the recording end of a cable. + public static bool LooksLikeNormalMic(AudioDeviceOption device) => + !LooksLikeVoiceModDevice(device) && !LooksLikeVirtualCable(device); + + /// + /// The recording device a game should use for the cable MicMixer plays into: + /// VB-CABLE pairs "CABLE Input" with "CABLE Output", Virtual Audio Cable uses + /// the same name for both ends. + /// + public static AudioDeviceOption? FindRecordingEnd(AudioDeviceOption playbackEnd, IReadOnlyList inputs) + { + string swapped = playbackEnd.FriendlyName.Replace("input", "Output", StringComparison.OrdinalIgnoreCase); + return inputs.FirstOrDefault(device => string.Equals(device.FriendlyName, swapped, StringComparison.OrdinalIgnoreCase)) + ?? inputs.FirstOrDefault(device => string.Equals(device.FriendlyName, playbackEnd.FriendlyName, StringComparison.OrdinalIgnoreCase)) + ?? inputs.FirstOrDefault(LooksLikeVirtualCable); + } + + public static AudioDeviceOption? SelectInput( + IReadOnlyList devices, + string? preferredId, + Func heuristic, + string? excludedId = null) + { + // The recording end of a cable is a microphone to Windows, but picking it as a + // source would feed the mix back into itself, so it is only a last resort. + return devices.FirstOrDefault(device => device.Id == preferredId) + ?? devices.FirstOrDefault(device => device.Id != excludedId && heuristic(device)) + ?? devices.FirstOrDefault(device => device.Id != excludedId && !LooksLikeVirtualCable(device)) + ?? devices.FirstOrDefault(device => device.Id != excludedId) + ?? devices.FirstOrDefault(); + } + + public static AudioDeviceOption? SelectCableOutput(IReadOnlyList devices, string? preferredId) + { + return devices.FirstOrDefault(device => device.Id == preferredId) + ?? devices.FirstOrDefault(LooksLikeVirtualCable) + ?? devices.FirstOrDefault(); + } + + public static AudioDeviceOption? SelectMonitor(IReadOnlyList devices, string? preferredId) + { + return devices.FirstOrDefault(device => device.Id == preferredId) + ?? devices.FirstOrDefault(device => !LooksLikeVirtualCable(device)) + ?? devices.FirstOrDefault(); + } +} diff --git a/tests/MicMixer.Tests/AudioDevicesTests.cs b/tests/MicMixer.Tests/AudioDevicesTests.cs new file mode 100644 index 0000000..f38728d --- /dev/null +++ b/tests/MicMixer.Tests/AudioDevicesTests.cs @@ -0,0 +1,50 @@ +using AwesomeAssertions; +using MicMixer.Audio; +using Xunit; + +namespace MicMixer.Tests; + +public sealed class AudioDevicesTests +{ + [Theory] + [InlineData("CABLE Input (VB-Audio Virtual Cable)", true)] + [InlineData("CABLE Output (VB-Audio Virtual Cable)", true)] + [InlineData("CABLE-A Input (VB-Audio Cable A)", true)] + [InlineData("Line 1 (Virtual Audio Cable)", true)] + [InlineData("Voicemeeter Input (VB-Audio Voicemeeter VAIO)", true)] + [InlineData("Speakers (Realtek(R) Audio)", false)] + [InlineData("Microphone (Voicemod Virtual Audio Device (WDM))", false)] + public void LooksLikeVirtualCable_ShouldRecognizeCommonCableDrivers(string name, bool expected) + { + AudioDevices.LooksLikeVirtualCable(new AudioDeviceOption("id", name)).Should().Be(expected); + } + + [Theory] + [InlineData("CABLE Input (VB-Audio Virtual Cable)", "CABLE Output (VB-Audio Virtual Cable)")] + [InlineData("Line 1 (Virtual Audio Cable)", "Line 1 (Virtual Audio Cable)")] + public void FindRecordingEnd_ShouldPairThePlaybackEndWithItsMicrophone(string playback, string expected) + { + var inputs = new List + { + new("mic", "Microphone (Realtek(R) Audio)"), + new("vb", "CABLE Output (VB-Audio Virtual Cable)"), + new("vac", "Line 1 (Virtual Audio Cable)") + }; + + AudioDevices.FindRecordingEnd(new AudioDeviceOption("out", playback), inputs)!.FriendlyName.Should().Be(expected); + } + + [Fact] + public void SelectInput_ShouldNotGuessTheCableAsAMicrophone_WhenAnotherDeviceExists() + { + var inputs = new List + { + new("cable", "CABLE Output (VB-Audio Virtual Cable)"), + new("mic", "Microphone (Realtek(R) Audio)"), + new("headset", "Headset Microphone (USB Audio)") + }; + + AudioDevices.SelectInput(inputs, null, AudioDevices.LooksLikeNormalMic)!.Id.Should().Be("mic"); + AudioDevices.SelectInput(inputs, null, AudioDevices.LooksLikeVoiceModDevice, excludedId: "mic")!.Id.Should().Be("headset"); + } +} From d67576aa206f994ea2b0d405c2a288293b30923e Mon Sep 17 00:00:00 2001 From: Benjiboy Date: Mon, 14 Sep 2026 20:17:11 +0200 Subject: [PATCH 07/14] feat(ui): add a first-run setup guide A seven-step window that explains what MicMixer does and how a virtual cable works, detects an installed cable (showing VB-CABLE install steps only when none is found), and collects the microphone, modified voice, cable, monitoring device, hotkey and push-to-talk. Nothing is written until Finish; closing without Skip keeps the guide coming back, e.g. after restarting Windows for the cable driver. --- src/MicMixer/UI/SetupGuideWindow.xaml | 382 +++++++++++++++++++++++ src/MicMixer/UI/SetupGuideWindow.xaml.cs | 286 +++++++++++++++++ 2 files changed, 668 insertions(+) create mode 100644 src/MicMixer/UI/SetupGuideWindow.xaml create mode 100644 src/MicMixer/UI/SetupGuideWindow.xaml.cs diff --git a/src/MicMixer/UI/SetupGuideWindow.xaml b/src/MicMixer/UI/SetupGuideWindow.xaml new file mode 100644 index 0000000..b682eb7 --- /dev/null +++ b/src/MicMixer/UI/SetupGuideWindow.xaml @@ -0,0 +1,382 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + - - - - - - - - - - - - + + + + + + + + + + + + + - - - - - - - - - - - - - + + + + - - + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - - - + - + + + + + + + - - + + - - + + - - - - - - - - + - - - - + + + + - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Pixlexi + + + + Run the setup guide + + + + + + + + + + + + + + + + + + + - + + + + - - - - - - - - - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + - - - - - - - - - - - - - - - - - + - - + - - - - - - - - - + + + - - - - - - - - - - - - - - - - - + + + + + - - - - - - - - - - - + + + - + + + + + + + + + + + - - - - - - - - - - + + + + - - - - - - - - - - - + + + - - + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - vb-audio.com/Cable - - - - - - - - - - - - - - - - - - - - Pixlexi - - + + + + + + + + + + + + + + + + @@ -1045,11 +1074,30 @@ + - + + + + + + + + + + +