Fix audio clock drift and adaptive jitter buffering
This commit is contained in:
@@ -139,7 +139,7 @@ internal sealed class MacAudioBackend : IAudioDeviceBackend
|
||||
|
||||
private sealed class Playback : IAudioPlayback
|
||||
{
|
||||
private readonly PcmRing pcm = new(32_768);
|
||||
private readonly AdaptivePcmBuffer pcm = new(2);
|
||||
private readonly short[] renderScratch = new short[8_192];
|
||||
private readonly AVAudioEngine engine = new();
|
||||
private readonly AVAudioFormat format;
|
||||
@@ -187,6 +187,7 @@ internal sealed class MacAudioBackend : IAudioDeviceBackend
|
||||
}
|
||||
|
||||
public void Write(ReadOnlySpan<short> stereoPcm) => pcm.TryWrite(stereoPcm);
|
||||
public int BufferMilliseconds { get => pcm.BufferMilliseconds; set => pcm.BufferMilliseconds = value; }
|
||||
|
||||
private unsafe int Render(IntPtr isSilence, IntPtr _, uint frameCount, IntPtr outputData)
|
||||
{
|
||||
@@ -199,8 +200,6 @@ internal sealed class MacAudioBackend : IAudioDeviceBackend
|
||||
int frames = checked((int)frameCount);
|
||||
int requested = checked(frames * 2);
|
||||
if (bufferCount != 2 || requested > renderScratch.Length) return Fail("Core Audio returned an invalid planar stereo playback buffer.");
|
||||
Span<short> discard = stackalloc short[1_920];
|
||||
while (pcm.Count > 11_520) pcm.Read(discard[..Math.Min(discard.Length, pcm.Count - 11_520)]);
|
||||
Span<short> source = renderScratch.AsSpan(0, requested);
|
||||
int read = pcm.Read(source);
|
||||
source[read..].Clear();
|
||||
|
||||
@@ -19,6 +19,7 @@ internal sealed class MacSettings
|
||||
internal bool AuxiliaryEnabled { get; set; }
|
||||
internal string? AuxiliaryDeviceId { get; set; }
|
||||
internal float AuxiliaryGain { get; set; } = 1f;
|
||||
internal int AudioBufferMilliseconds { get; set; } = 40;
|
||||
internal bool EventSounds { get; set; } = true;
|
||||
internal bool SpokenEvents { get; set; }
|
||||
internal float EventVolume { get; set; } = 1f;
|
||||
@@ -28,9 +29,9 @@ internal sealed class MacSettings
|
||||
internal void Load()
|
||||
{
|
||||
NSString[] keys = [(NSString)"voice.inputMode", (NSString)"voice.vadThreshold", (NSString)"voice.inputGain", (NSString)"voice.outputGain",
|
||||
(NSString)"voice.pttKeyCode", (NSString)"voice.auxGain", (NSString)"feedback.sounds", (NSString)"feedback.volume"];
|
||||
(NSString)"voice.pttKeyCode", (NSString)"voice.auxGain", (NSString)"voice.audioBufferMs", (NSString)"feedback.sounds", (NSString)"feedback.volume"];
|
||||
NSObject[] defaults = [NSNumber.FromInt32((int)AudioInputMode.VoiceActivation), NSNumber.FromFloat(0.05f),
|
||||
NSNumber.FromFloat(1f), NSNumber.FromFloat(0.8f), NSNumber.FromInt32(0x60), NSNumber.FromFloat(1f),
|
||||
NSNumber.FromFloat(1f), NSNumber.FromFloat(0.8f), NSNumber.FromInt32(0x60), NSNumber.FromFloat(1f), NSNumber.FromInt32(40),
|
||||
NSNumber.FromBoolean(true), NSNumber.FromFloat(1f)];
|
||||
values.RegisterDefaults(new NSDictionary<NSString, NSObject>(keys, defaults));
|
||||
InputMode = Enum.IsDefined(typeof(AudioInputMode), (int)values.IntForKey("voice.inputMode"))
|
||||
@@ -46,6 +47,7 @@ internal sealed class MacSettings
|
||||
AuxiliaryEnabled = values.BoolForKey("voice.auxEnabled");
|
||||
AuxiliaryDeviceId = EmptyToNull(values.StringForKey("voice.auxDeviceUID"));
|
||||
AuxiliaryGain = Math.Clamp(values.FloatForKey("voice.auxGain"), 0f, 4f);
|
||||
int audioBuffer = checked((int)values.IntForKey("voice.audioBufferMs")); AudioBufferMilliseconds = audioBuffer is 20 or 40 or 60 ? audioBuffer : 40;
|
||||
EventSounds = values.BoolForKey("feedback.sounds");
|
||||
SpokenEvents = values.BoolForKey("feedback.speech");
|
||||
EventVolume = Math.Clamp(values.FloatForKey("feedback.volume"), 0f, 1f);
|
||||
@@ -59,7 +61,7 @@ internal sealed class MacSettings
|
||||
values.SetFloat(InputGain, "voice.inputGain"); values.SetFloat(OutputGain, "voice.outputGain");
|
||||
values.SetBool(InputNoiseReduction, "voice.inputNoiseReduction"); values.SetBool(StereoMicrophone, "voice.stereoMic");
|
||||
values.SetInt(PushToTalkKeyCode, "voice.pttKeyCode"); Set("voice.inputDevice", InputDeviceId); Set("voice.outputDevice", OutputDeviceId);
|
||||
values.SetBool(AuxiliaryEnabled, "voice.auxEnabled"); Set("voice.auxDeviceUID", AuxiliaryDeviceId); values.SetFloat(AuxiliaryGain, "voice.auxGain");
|
||||
values.SetBool(AuxiliaryEnabled, "voice.auxEnabled"); Set("voice.auxDeviceUID", AuxiliaryDeviceId); values.SetFloat(AuxiliaryGain, "voice.auxGain"); values.SetInt(AudioBufferMilliseconds, "voice.audioBufferMs");
|
||||
values.SetBool(EventSounds, "feedback.sounds"); values.SetBool(SpokenEvents, "feedback.speech"); values.SetFloat(EventVolume, "feedback.volume");
|
||||
values.SetBool(SelfTalkSounds, "feedback.selfTalk"); values.SetBool(PushToTalkSound, "feedback.ptt"); values.Synchronize();
|
||||
}
|
||||
|
||||
@@ -210,6 +210,8 @@ internal sealed class MainWindowController : NSWindowController
|
||||
if (settings.InputMode != AudioInputMode.PushToTalk) { client.Audio.PushToTalk = false; pushToTalkEngaged = false; }
|
||||
client.Audio.InputGain = settings.InputGain; client.Audio.OutputGain = settings.OutputGain;
|
||||
client.Audio.InputNoiseReduction = settings.InputNoiseReduction;
|
||||
client.Audio.DeviceBufferMilliseconds = settings.AudioBufferMilliseconds;
|
||||
if (playback is not null) playback.BufferMilliseconds = settings.AudioBufferMilliseconds;
|
||||
if (auxiliaryStreamId != 0) client.Audio.SetLocalGain(auxiliaryStreamId, settings.AuxiliaryGain);
|
||||
}
|
||||
|
||||
@@ -367,6 +369,7 @@ internal sealed class MainWindowController : NSWindowController
|
||||
activeOutputDevice = settings.OutputDeviceId ?? SelectedDevice(outputDevice);
|
||||
activeStereo = settings.StereoMicrophone;
|
||||
playback = audioBackend.OpenPlayback(activeOutputDevice);
|
||||
playback.BufferMilliseconds = settings.AudioBufferMilliseconds;
|
||||
client.Audio.MixedPcm += PlayMixedPcm;
|
||||
StreamInfo stream = await client.StartStreamAsync(StreamKind.StreamMic, "Microphone", settings.StereoMicrophone ? 2 : 1);
|
||||
microphoneStreamId = stream.StreamId;
|
||||
|
||||
@@ -26,15 +26,18 @@ internal sealed class SettingsWindowController : NSWindowController
|
||||
private readonly NSButton pttSound = NSButton.CreateCheckbox("Push-to-talk cue", () => { });
|
||||
private readonly NSButton pttKey = new(new CGRect(220, 12, 200, 32)) { Title = "Change PTT key…" };
|
||||
private readonly NSSlider eventVolume = new(new CGRect(160, 505, 260, 24)) { MinValue = 0, MaxValue = 100 };
|
||||
private readonly NSPopUpButton audioBuffer = new(new CGRect(160, 545, 260, 28), false);
|
||||
|
||||
internal SettingsWindowController(MainWindowController owner, MacSettings settings, MacAudioBackend audio) : base(
|
||||
new NSWindow(new CGRect(0, 0, 460, 620), NSWindowStyle.Titled | NSWindowStyle.Closable,
|
||||
new NSWindow(new CGRect(0, 0, 460, 660), NSWindowStyle.Titled | NSWindowStyle.Closable,
|
||||
NSBackingStore.Buffered, false))
|
||||
{
|
||||
this.owner = owner; this.settings = settings; this.audio = audio;
|
||||
Window!.Title = "VoiceCat Settings"; Window.Center();
|
||||
NSView view = Window.ContentView!;
|
||||
mode.AddItems(["Voice activation", "Push to talk", "Always on"]);
|
||||
audioBuffer.AddItems(["Low latency (20 ms)", "Balanced (40 ms)", "Stable (60 ms)"]);
|
||||
Add(view, "Audio buffering", audioBuffer, 555);
|
||||
Add(view, "Sound volume", eventVolume, 515);
|
||||
Add(view, "Input mode", mode, 465); Add(view, "VAD sensitivity", vad, 425); Add(view, "Microphone volume", inputGain, 385);
|
||||
noiseReduction.Frame = new CGRect(160, 340, 260, 24); stereo.Frame = new CGRect(160, 315, 260, 24);
|
||||
@@ -46,7 +49,7 @@ internal sealed class SettingsWindowController : NSWindowController
|
||||
selfTalk.Frame = new CGRect(265, 45, 180, 24); pttSound.Frame = new CGRect(25, 18, 180, 24);
|
||||
view.AddSubview(sounds); view.AddSubview(speech); view.AddSubview(selfTalk); view.AddSubview(pttSound); view.AddSubview(pttKey);
|
||||
foreach (NSControl control in new NSControl[] { mode, vad, inputGain, noiseReduction, stereo, input, output,
|
||||
outputGain, auxiliary, auxiliaryDevice, auxiliaryGain, sounds, speech, selfTalk, pttSound, eventVolume }) control.Activated += Changed;
|
||||
outputGain, auxiliary, auxiliaryDevice, auxiliaryGain, sounds, speech, selfTalk, pttSound, eventVolume, audioBuffer }) control.Activated += Changed;
|
||||
pttKey.Activated += CapturePushToTalkKey;
|
||||
Populate(input, true, settings.InputDeviceId); Populate(output, false, settings.OutputDeviceId); Populate(auxiliaryDevice, true, settings.AuxiliaryDeviceId);
|
||||
LoadValues(); SetAccessibility();
|
||||
@@ -76,6 +79,7 @@ internal sealed class SettingsWindowController : NSWindowController
|
||||
eventVolume.DoubleValue = settings.EventVolume * 100;
|
||||
selfTalk.State = State(settings.SelfTalkSounds); pttSound.State = State(settings.PushToTalkSound);
|
||||
pttKey.Title = $"PTT key code: {settings.PushToTalkKeyCode}";
|
||||
audioBuffer.SelectItem(settings.AudioBufferMilliseconds switch { 20 => 0, 60 => 2, _ => 1 });
|
||||
UpdateEnabled();
|
||||
}
|
||||
|
||||
@@ -102,6 +106,7 @@ internal sealed class SettingsWindowController : NSWindowController
|
||||
settings.AuxiliaryGain = (float)auxiliaryGain.DoubleValue / 100; settings.EventSounds = On(sounds);
|
||||
settings.EventVolume = (float)eventVolume.DoubleValue / 100; settings.SpokenEvents = On(speech);
|
||||
settings.SelfTalkSounds = On(selfTalk); settings.PushToTalkSound = On(pttSound);
|
||||
settings.AudioBufferMilliseconds = audioBuffer.IndexOfSelectedItem switch { 0 => 20, 2 => 60, _ => 40 };
|
||||
settings.Save(); UpdateEnabled(); await owner.ApplySettingsAsync();
|
||||
}
|
||||
|
||||
@@ -113,6 +118,7 @@ internal sealed class SettingsWindowController : NSWindowController
|
||||
((INSAccessibility)output).AccessibilityLabel = "Output device"; ((INSAccessibility)outputGain).AccessibilityLabel = "Output volume";
|
||||
((INSAccessibility)auxiliaryDevice).AccessibilityLabel = "Auxiliary input device"; ((INSAccessibility)auxiliaryGain).AccessibilityLabel = "Auxiliary input volume";
|
||||
((INSAccessibility)eventVolume).AccessibilityLabel = "Event sound volume";
|
||||
((INSAccessibility)audioBuffer).AccessibilityLabel = "Audio buffering";
|
||||
}
|
||||
private static bool On(NSButton button) => button.State == NSCellStateValue.On;
|
||||
private static NSCellStateValue State(bool value) => value ? NSCellStateValue.On : NSCellStateValue.Off;
|
||||
|
||||
@@ -266,6 +266,8 @@ internal sealed class AppModel
|
||||
owner.Audio.InputMode = settings.InputMode; owner.Audio.VadThreshold = settings.VadThreshold;
|
||||
owner.Audio.InputGain = settings.InputGain; owner.Audio.OutputGain = settings.OutputGain;
|
||||
owner.Audio.InputNoiseReduction = settings.InputNoiseReduction;
|
||||
owner.Audio.DeviceBufferMilliseconds = settings.AudioBufferMilliseconds;
|
||||
IosAudioEngine.Shared.BufferMilliseconds = settings.AudioBufferMilliseconds;
|
||||
}
|
||||
|
||||
private void PollAudio()
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace VoiceCat.iOS;
|
||||
internal sealed class IosAudioEngine
|
||||
{
|
||||
internal static IosAudioEngine Shared { get; } = new();
|
||||
private readonly PcmRing playbackRing = new(131_072);
|
||||
private readonly AdaptivePcmBuffer playbackRing = new(2, capacityFrames: 65_536);
|
||||
private readonly short[] renderScratch = new short[16_384];
|
||||
private AVAudioEngine? engine;
|
||||
private AVAudioSourceNode? source;
|
||||
@@ -28,6 +28,7 @@ internal sealed class IosAudioEngine
|
||||
private bool inputProvided;
|
||||
private bool tapInstalled;
|
||||
internal bool IsConnected { get; private set; }
|
||||
internal int BufferMilliseconds { get => playbackRing.BufferMilliseconds; set => playbackRing.BufferMilliseconds = value; }
|
||||
|
||||
private IosAudioEngine() { microphoneWorker = PumpMicrophoneAsync(microphoneStop.Token); }
|
||||
|
||||
|
||||
@@ -17,13 +17,14 @@ internal sealed class IosSettings
|
||||
internal float EventVolume { get; set; } = 1f;
|
||||
internal bool SelfTalkSounds { get; set; }
|
||||
internal bool PushToTalkSound { get; set; }
|
||||
internal int AudioBufferMilliseconds { get; set; } = 40;
|
||||
|
||||
internal void Load()
|
||||
{
|
||||
NSString[] keys = [(NSString)"voice.inputMode", (NSString)"voice.vadThreshold", (NSString)"voice.inputGain",
|
||||
(NSString)"voice.outputGain", (NSString)"feedback.sounds", (NSString)"feedback.volume"];
|
||||
(NSString)"voice.outputGain", (NSString)"voice.audioBufferMs", (NSString)"feedback.sounds", (NSString)"feedback.volume"];
|
||||
NSObject[] defaults = [NSNumber.FromInt32((int)AudioInputMode.VoiceActivation), NSNumber.FromFloat(0.025f),
|
||||
NSNumber.FromFloat(1f), NSNumber.FromFloat(1f), NSNumber.FromBoolean(true), NSNumber.FromFloat(1f)];
|
||||
NSNumber.FromFloat(1f), NSNumber.FromFloat(1f), NSNumber.FromInt32(40), NSNumber.FromBoolean(true), NSNumber.FromFloat(1f)];
|
||||
values.RegisterDefaults(new NSDictionary<NSString, NSObject>(keys, defaults));
|
||||
int inputMode = checked((int)values.IntForKey("voice.inputMode"));
|
||||
InputMode = Enum.IsDefined(typeof(AudioInputMode), inputMode) ? (AudioInputMode)inputMode : AudioInputMode.VoiceActivation;
|
||||
@@ -31,6 +32,7 @@ internal sealed class IosSettings
|
||||
InputGain = Math.Clamp(values.FloatForKey("voice.inputGain"), 0f, 4f);
|
||||
OutputGain = Math.Clamp(values.FloatForKey("voice.outputGain"), 0f, 1f);
|
||||
InputNoiseReduction = values.BoolForKey("voice.inputNoiseReduction");
|
||||
int audioBuffer = checked((int)values.IntForKey("voice.audioBufferMs")); AudioBufferMilliseconds = audioBuffer is 20 or 40 or 60 ? audioBuffer : 40;
|
||||
EventSounds = values.BoolForKey("feedback.sounds");
|
||||
SpokenEvents = values.BoolForKey("feedback.speech");
|
||||
EventVolume = Math.Clamp(values.FloatForKey("feedback.volume"), 0f, 1f);
|
||||
@@ -42,6 +44,7 @@ internal sealed class IosSettings
|
||||
{
|
||||
values.SetInt((int)InputMode, "voice.inputMode"); values.SetFloat(VadThreshold, "voice.vadThreshold");
|
||||
values.SetFloat(InputGain, "voice.inputGain"); values.SetFloat(OutputGain, "voice.outputGain");
|
||||
values.SetInt(AudioBufferMilliseconds, "voice.audioBufferMs");
|
||||
values.SetBool(InputNoiseReduction, "voice.inputNoiseReduction"); values.SetBool(EventSounds, "feedback.sounds");
|
||||
values.SetBool(SpokenEvents, "feedback.speech"); values.SetFloat(EventVolume, "feedback.volume");
|
||||
values.SetBool(SelfTalkSounds, "feedback.selfTalk"); values.SetBool(PushToTalkSound, "feedback.ptt"); values.Synchronize();
|
||||
|
||||
@@ -14,23 +14,23 @@ internal sealed class SettingsController : UITableViewController
|
||||
public override void ViewDidLoad() { base.ViewDidLoad(); TableView.RegisterClassForCellReuse(typeof(UITableViewCell), "setting"); }
|
||||
private void Reload() => TableView.ReloadData();
|
||||
public override nint NumberOfSections(UITableView tableView) => 5;
|
||||
public override nint RowsInSection(UITableView tableView, nint section) => section switch { 0 => 4, 1 => 4, 2 => 5, 3 => model.Client?.Permissions is { } p && (p.IsAdmin || p.CanAdminAccounts) ? 1 : 0, _ => 2 };
|
||||
public override nint RowsInSection(UITableView tableView, nint section) => section switch { 0 => 5, 1 => 4, 2 => 5, 3 => model.Client?.Permissions is { } p && (p.IsAdmin || p.CanAdminAccounts) ? 1 : 0, _ => 2 };
|
||||
public override string? TitleForHeader(UITableView tableView, nint section) => section switch { 0 => "Audio", 1 => "Voice", 2 => "Notifications", 3 => "Administration", _ => "Server" };
|
||||
public override UITableViewCell GetCell(UITableView tableView, NSIndexPath path)
|
||||
{
|
||||
UITableViewCell cell = tableView.DequeueReusableCell("setting", path); cell.AccessoryView = null; cell.Accessory = UITableViewCellAccessory.None;
|
||||
string title = path.Section switch
|
||||
{
|
||||
0 => path.Row switch { 0 => "Audio preset", 1 => "Speaker output", 2 => "Advanced audio", _ => model.ScreenSharing ? "Stop sharing screen audio" : "Share screen audio" },
|
||||
0 => path.Row switch { 0 => "Audio preset", 1 => "Speaker output", 2 => "Advanced audio", 3 => "Audio buffering", _ => model.ScreenSharing ? "Stop sharing screen audio" : "Share screen audio" },
|
||||
1 => path.Row switch { 0 => "Input mode", 1 => $"VAD threshold: {model.Settings.VadThreshold:0.000}", 2 => $"Microphone volume: {model.Settings.InputGain:P0}", _ => "Microphone noise reduction" },
|
||||
2 => path.Row switch { 0 => "Event sounds", 1 => $"Sound volume: {model.Settings.EventVolume:P0}", 2 => "Speak events", 3 => "Own voice activity sounds", _ => "Push-to-talk cue" },
|
||||
3 => "Manage accounts", _ => path.Row == 0 ? "Disconnect" : "VoiceCat 0.0.1"
|
||||
};
|
||||
var content = cell.DefaultContentConfiguration; content.Text = title; content.SecondaryText = path.Section == 0 && path.Row == 0 ? IosAudioRouter.Shared.Preset.ToString() : path.Section == 1 && path.Row == 0 ? model.Settings.InputMode.ToString() : path.Section == 0 && path.Row == 3 && model.ScreenSharing ? "Sharing" : null; cell.ContentConfiguration = content; cell.AccessibilityLabel = title;
|
||||
var content = cell.DefaultContentConfiguration; content.Text = title; content.SecondaryText = path.Section == 0 && path.Row == 0 ? IosAudioRouter.Shared.Preset.ToString() : path.Section == 1 && path.Row == 0 ? model.Settings.InputMode.ToString() : path.Section == 0 && path.Row == 3 ? $"{model.Settings.AudioBufferMilliseconds} ms" : path.Section == 0 && path.Row == 4 && model.ScreenSharing ? "Sharing" : null; cell.ContentConfiguration = content; cell.AccessibilityLabel = title + (content.SecondaryText is null ? "" : ", " + content.SecondaryText);
|
||||
if (path.Section == 0 && path.Row == 1) cell.AccessoryView = Toggle(IosAudioRouter.Shared.ForceSpeaker, "Speaker output", (_, _) => IosAudioRouter.Shared.SetForceSpeaker(((UISwitch)cell.AccessoryView!).On));
|
||||
else if (path.Section == 1 && path.Row == 3) cell.AccessoryView = Toggle(model.Settings.InputNoiseReduction, title, (_, _) => { model.Settings.InputNoiseReduction = ((UISwitch)cell.AccessoryView!).On; model.ApplyVoiceSettings(); });
|
||||
else if (path.Section == 2 && path.Row is 0 or 2 or 3 or 4) { bool value = path.Row switch { 0 => model.Settings.EventSounds, 2 => model.Settings.SpokenEvents, 3 => model.Settings.SelfTalkSounds, _ => model.Settings.PushToTalkSound }; int row = path.Row; cell.AccessoryView = Toggle(value, title, (_, _) => { bool on = ((UISwitch)cell.AccessoryView!).On; if (row == 0) model.Settings.EventSounds = on; else if (row == 2) model.Settings.SpokenEvents = on; else if (row == 3) model.Settings.SelfTalkSounds = on; else model.Settings.PushToTalkSound = on; model.Save(); }); }
|
||||
else if (path.Section == 0 && path.Row == 2 || path.Section == 3) cell.Accessory = UITableViewCellAccessory.DisclosureIndicator;
|
||||
else if (path.Section == 0 && path.Row is 2 or 3 || path.Section == 3) cell.Accessory = UITableViewCellAccessory.DisclosureIndicator;
|
||||
return cell;
|
||||
}
|
||||
public override async void RowSelected(UITableView tableView, NSIndexPath path)
|
||||
@@ -39,7 +39,8 @@ internal sealed class SettingsController : UITableViewController
|
||||
{
|
||||
if (path.Section == 0 && path.Row == 0) Choice("Audio preset", Enum.GetValues<IosAudioPreset>().Select(value => value.ToString()).ToArray(), index => IosAudioRouter.Shared.SelectPreset(Enum.GetValues<IosAudioPreset>()[index]));
|
||||
else if (path.Section == 0 && path.Row == 2) NavigationController?.PushViewController(new AdvancedAudioController(), true);
|
||||
else if (path.Section == 0 && path.Row == 3) ShowScreenSharing();
|
||||
else if (path.Section == 0 && path.Row == 3) Choice("Audio buffering", ["Low latency (20 ms)", "Balanced (40 ms)", "Stable (60 ms)"], index => { model.Settings.AudioBufferMilliseconds = index switch { 0 => 20, 2 => 60, _ => 40 }; model.ApplyVoiceSettings(); });
|
||||
else if (path.Section == 0 && path.Row == 4) ShowScreenSharing();
|
||||
else if (path.Section == 1 && path.Row == 0) Choice("Input mode", ["Voice activation", "Push to talk", "Always on"], index => { model.Settings.InputMode = (AudioInputMode)index; model.ApplyVoiceSettings(); });
|
||||
else if (path.Section == 1 && path.Row == 1) Slider("Voice activation threshold", 0.001f, 0.1f, model.Settings.VadThreshold, value => { model.Settings.VadThreshold = value; model.ApplyVoiceSettings(); });
|
||||
else if (path.Section == 1 && path.Row == 2) Slider("Microphone volume", 0, 4, model.Settings.InputGain, value => { model.Settings.InputGain = value; model.ApplyVoiceSettings(); });
|
||||
|
||||
@@ -34,7 +34,7 @@ public sealed class WasapiAudioBackend : IAudioDeviceBackend
|
||||
private sealed class Playback : IAudioPlayback
|
||||
{
|
||||
private readonly string? deviceId;
|
||||
private readonly PcmRing pcm = new(32768);
|
||||
private readonly AdaptivePcmBuffer pcm = new(2);
|
||||
private readonly ManualResetEventSlim initialized = new(false);
|
||||
private readonly Thread thread;
|
||||
private volatile bool running = true;
|
||||
@@ -47,6 +47,7 @@ public sealed class WasapiAudioBackend : IAudioDeviceBackend
|
||||
thread.Start();
|
||||
if (!initialized.Wait(5000) || !ready) { Dispose(); throw new InvalidOperationException("WASAPI playback could not start."); }
|
||||
}
|
||||
public int BufferMilliseconds { get => pcm.BufferMilliseconds; set => pcm.BufferMilliseconds = value; }
|
||||
public void Write(ReadOnlySpan<short> stereoPcm) => pcm.TryWrite(stereoPcm);
|
||||
private unsafe void Work()
|
||||
{
|
||||
@@ -68,7 +69,6 @@ public sealed class WasapiAudioBackend : IAudioDeviceBackend
|
||||
render = (IAudioRenderClient)output;
|
||||
if (client.Start() < 0) return;
|
||||
ready = true; initialized.Set();
|
||||
Span<short> discard = stackalloc short[1920];
|
||||
while (running)
|
||||
{
|
||||
bufferReady.WaitOne(20); // Scheduling wait is outside the buffer-fill cycle.
|
||||
@@ -77,8 +77,6 @@ public sealed class WasapiAudioBackend : IAudioDeviceBackend
|
||||
if (frames == 0) continue;
|
||||
if (render.GetBuffer(frames, out nint buffer) < 0) break;
|
||||
var destination = new Span<short>((void*)buffer, checked((int)frames * 2));
|
||||
// Keep queued playback bounded to ~120 ms, then fill underflow with silence.
|
||||
while (pcm.Count > 11520) pcm.Read(discard);
|
||||
int count = pcm.Read(destination); destination[count..].Clear();
|
||||
if (render.ReleaseBuffer(frames, 0) < 0) break;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ public sealed class AudioSettingsForm : Form
|
||||
private readonly bool _origAuxEnabled;
|
||||
private readonly string? _origAuxDeviceId;
|
||||
private readonly int _origAuxGain;
|
||||
private readonly int _origAudioBufferMilliseconds;
|
||||
|
||||
private readonly ComboBox _cboDevice;
|
||||
private readonly Button _btnRefresh;
|
||||
@@ -54,6 +55,7 @@ public sealed class AudioSettingsForm : Form
|
||||
private readonly Button _btnAuxRefresh;
|
||||
private readonly Label _lblAuxGain;
|
||||
private readonly TrackBar _trkAuxGain;
|
||||
private readonly ComboBox _cboBuffer;
|
||||
|
||||
private Keys _pttKey;
|
||||
|
||||
@@ -80,6 +82,7 @@ public sealed class AudioSettingsForm : Form
|
||||
_origAuxEnabled = settings.AuxEnabled;
|
||||
_origAuxDeviceId = settings.AuxDeviceId;
|
||||
_origAuxGain = settings.AuxGain;
|
||||
_origAudioBufferMilliseconds = settings.AudioBufferMilliseconds;
|
||||
|
||||
Text = "Audio settings";
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||
@@ -87,7 +90,7 @@ public sealed class AudioSettingsForm : Form
|
||||
MinimizeBox = false;
|
||||
StartPosition = FormStartPosition.CenterParent;
|
||||
AutoScaleMode = AutoScaleMode.Font;
|
||||
ClientSize = new Size(420, 639);
|
||||
ClientSize = new Size(420, 690);
|
||||
|
||||
// ── Device row ────────────────────────────────────────────────────────
|
||||
var lblDevice = new Label
|
||||
@@ -319,19 +322,34 @@ public sealed class AudioSettingsForm : Form
|
||||
"Volume of the aux input stream. 100 is unity gain; range 0–400 percent.";
|
||||
_trkAuxGain.Scroll += TrkAuxGain_Scroll;
|
||||
|
||||
var lblBuffer = new Label { Text = "Audio &buffering:", Location = new Point(12, 590), AutoSize = true };
|
||||
_cboBuffer = new ComboBox
|
||||
{
|
||||
Location = new Point(130, 586), Width = 190, DropDownStyle = ComboBoxStyle.DropDownList,
|
||||
AccessibleName = "Audio buffering",
|
||||
AccessibleDescription = "Local capture and playback buffering. Lower values reduce latency; higher values tolerate more scheduling jitter."
|
||||
};
|
||||
_cboBuffer.Items.AddRange(["Low latency (20 ms)", "Balanced (40 ms)", "Stable (60 ms)"]);
|
||||
_cboBuffer.SelectedIndex = settings.AudioBufferMilliseconds switch { 20 => 0, 60 => 2, _ => 1 };
|
||||
_cboBuffer.SelectedIndexChanged += (_, _) =>
|
||||
{
|
||||
_settings.AudioBufferMilliseconds = _cboBuffer.SelectedIndex switch { 0 => 20, 2 => 60, _ => 40 };
|
||||
_client.SetAudioBufferMilliseconds(_settings.AudioBufferMilliseconds);
|
||||
};
|
||||
|
||||
// ── OK / Cancel ───────────────────────────────────────────────────────
|
||||
var btnOk = new Button
|
||||
{
|
||||
Text = "&OK",
|
||||
DialogResult = DialogResult.OK,
|
||||
Location = new Point(228, 600),
|
||||
Location = new Point(228, 642),
|
||||
Size = new Size(80, 27),
|
||||
};
|
||||
var btnCancel = new Button
|
||||
{
|
||||
Text = "&Cancel",
|
||||
DialogResult = DialogResult.Cancel,
|
||||
Location = new Point(316, 600),
|
||||
Location = new Point(316, 642),
|
||||
Size = new Size(80, 27),
|
||||
};
|
||||
|
||||
@@ -350,6 +368,7 @@ public sealed class AudioSettingsForm : Form
|
||||
_radioPtt, _lblPttKey, _btnChangePtt, _chkSystemWidePtt, _radioAlwaysOn,
|
||||
lblGain, _trkGain, _chkNoiseReduction, _chkStereoMic,
|
||||
_chkAux, _lblAuxDevice, _cboAuxDevice, _btnAuxRefresh, _lblAuxGain, _trkAuxGain,
|
||||
lblBuffer, _cboBuffer,
|
||||
btnOk, btnCancel,
|
||||
]);
|
||||
|
||||
@@ -534,6 +553,8 @@ public sealed class AudioSettingsForm : Form
|
||||
_settings.StereoMic = _origStereoMic;
|
||||
_settings.PttKey = (int)_origPttKey;
|
||||
_settings.SystemWidePtt = _origSystemWidePtt;
|
||||
_settings.AudioBufferMilliseconds = _origAudioBufferMilliseconds;
|
||||
_client.SetAudioBufferMilliseconds(_origAudioBufferMilliseconds);
|
||||
|
||||
if (_micStreamId != 0)
|
||||
{
|
||||
|
||||
@@ -109,8 +109,11 @@ public partial class MainForm : Form
|
||||
BootstrapFromServer();
|
||||
}
|
||||
|
||||
private void ApplyPersistedVoiceSettings() =>
|
||||
private void ApplyPersistedVoiceSettings()
|
||||
{
|
||||
_pttKey = (Keys)_voiceSettings.PttKey;
|
||||
_client.SetAudioBufferMilliseconds(_voiceSettings.AudioBufferMilliseconds);
|
||||
}
|
||||
|
||||
// ── Startup ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -59,6 +59,9 @@ public sealed class VoiceSettings
|
||||
/// to the captured PCM before feeding (the core's input gain is mic-only and global).</summary>
|
||||
public int AuxGain { get; set; } = 100;
|
||||
|
||||
/// <summary>Local capture/playback safety buffer in milliseconds: 20, 40, or 60.</summary>
|
||||
public int AudioBufferMilliseconds { get; set; } = 40;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
||||
|
||||
private static string AppDataDir => Path.Combine(
|
||||
@@ -72,7 +75,9 @@ public sealed class VoiceSettings
|
||||
{
|
||||
if (!File.Exists(FilePath)) return new VoiceSettings();
|
||||
string json = File.ReadAllText(FilePath);
|
||||
return JsonSerializer.Deserialize<VoiceSettings>(json) ?? new VoiceSettings();
|
||||
VoiceSettings result = JsonSerializer.Deserialize<VoiceSettings>(json) ?? new VoiceSettings();
|
||||
if (result.AudioBufferMilliseconds is not (20 or 40 or 60)) result.AudioBufferMilliseconds = 40;
|
||||
return result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
@@ -55,6 +55,16 @@ public sealed partial class VoiceCatClient
|
||||
public VcResult SetOutputVolume(float gain) { if (!float.IsFinite(gain) || gain is < 0 or > 4) return VcResult.InvalidArg; core.Audio.OutputGain = gain; return VcResult.Ok; }
|
||||
public VcResult SetInputGain(float gain) { if (!float.IsFinite(gain) || gain is < 0 or > 4) return VcResult.InvalidArg; core.Audio.InputGain = gain; return VcResult.Ok; }
|
||||
public VcResult SetInputNoiseReduction(bool enabled) { core.Audio.InputNoiseReduction = enabled; return VcResult.Ok; }
|
||||
public VcResult SetAudioBufferMilliseconds(int milliseconds)
|
||||
{
|
||||
try
|
||||
{
|
||||
core.Audio.DeviceBufferMilliseconds = milliseconds;
|
||||
if (Volatile.Read(ref playback) is { } output) output.BufferMilliseconds = milliseconds;
|
||||
return VcResult.Ok;
|
||||
}
|
||||
catch (ArgumentOutOfRangeException) { return VcResult.InvalidArg; }
|
||||
}
|
||||
public VcResult SetRemoteStream(uint userId, uint streamId, float gain, bool muted, bool nr)
|
||||
{ try { core.Audio.SetRemotePlayback(userId, streamId, gain, muted, nr); return VcResult.Ok; } catch { return VcResult.InvalidArg; } }
|
||||
public (VcResult Result, RemoteStreamState? State) GetRemoteStream(uint userId, uint streamId)
|
||||
|
||||
@@ -159,7 +159,11 @@ public sealed partial class VoiceCatClient : IDisposable
|
||||
{
|
||||
try
|
||||
{
|
||||
if (backend is not null && playback is null) playback = backend.OpenPlayback();
|
||||
if (backend is not null && playback is null)
|
||||
{
|
||||
playback = backend.OpenPlayback();
|
||||
playback.BufferMilliseconds = core.Audio.DeviceBufferMilliseconds;
|
||||
}
|
||||
var result = core.SubscribeVoiceAsync().GetAwaiter().GetResult();
|
||||
if (!result.Ok) { Interlocked.Exchange(ref playback, null)?.Dispose(); }
|
||||
return result.Ok ? VcResult.Ok : VcResult.Audio;
|
||||
|
||||
Reference in New Issue
Block a user