feat(clients): persist input settings, add mic input gain, fix iOS chat + VoiceOver
Some checks failed
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled

Input mode (VAD/PTT/Always-On), VAD threshold, and the new mic gain were
applied to the core + UI but never saved, so every relaunch reset to VAD
defaults. Each client now persists them and re-applies on connect:
  - iOS: UserDefaults (SessionState.loadAndApplyVoiceSettings + setter writes)
  - macOS: UserDefaults via MainWindowController didSet + loadPersistedAudioSettings
    (settings window also restores the VAD slider from the stored threshold)
  - Windows: new Models/VoiceSettings.cs (JSON at %AppData%\VoiceCat\voice.json,
    mirrors FeedbackSettings) loaded/applied in MainForm

Add global send-side mic gain API vc_set_input_gain (applied to MIC PCM in
on_capture_frame before the VAD gate, clamped to int16) + Swift/C# bindings,
and a 0-300% (default 100%) mic-volume slider on all three clients.

Fix iOS chat: ChatView called sendText(scope:.channel) with no targetId (0),
so channel messages went nowhere; now passes session.currentChannelId.

Fix iOS per-user tuning for VoiceOver: the tuning sheet was long-press
.contextMenu only (invisible to VoiceOver); UserRow now also exposes the same
buttons via .accessibilityActions (no visual change).

Verified: core builds clean; ctest 24/27 (3 pre-existing teardown crashes,
reproduced with changes stashed); VoiceCatMac + VoiceCatiOS (arm64 sim) build
SUCCEEDED; VoiceCat.Interop dotnet build succeeded. Windows App not built
(WinForms can't build on macOS) — follows existing patterns.
This commit is contained in:
2026-06-23 03:35:26 +02:00
parent d30c4ee2f5
commit 95f1fb70b0
17 changed files with 354 additions and 6 deletions

View File

@@ -51,6 +51,8 @@ partial class MainForm
private ProgressBar pbLevel = null!;
private Label lblVadThreshold = null!;
private TrackBar trkVadThreshold = null!;
private Label lblMicGain = null!;
private TrackBar trkMicGain = null!;
protected override void Dispose(bool disposing)
{
@@ -94,6 +96,8 @@ partial class MainForm
pbLevel = new ProgressBar();
lblVadThreshold = new Label();
trkVadThreshold = new TrackBar();
lblMicGain = new Label();
trkMicGain = new TrackBar();
menuStrip = new MenuStrip();
toolStrip = new ToolStrip();
tsbJoinVoice = new ToolStripButton();
@@ -336,6 +340,23 @@ partial class MainForm
trkVadThreshold.TabIndex = 9;
trkVadThreshold.Visible = true;
lblMicGain.Text = "Mic volume:";
lblMicGain.AutoSize = true;
lblMicGain.Margin = new Padding(12, 5, 4, 0);
trkMicGain.AccessibleName = "Microphone volume";
trkMicGain.AccessibleDescription =
"Boost a quiet microphone. 100 is unity; range 0300 percent.";
trkMicGain.Minimum = 0;
trkMicGain.Maximum = 300;
trkMicGain.Value = 100;
trkMicGain.TickFrequency = 25;
trkMicGain.SmallChange = 5;
trkMicGain.LargeChange = 25;
trkMicGain.Width = 120;
trkMicGain.Margin = new Padding(0, 2, 0, 0);
trkMicGain.TabIndex = 10;
flpVoiceBottom.Dock = DockStyle.Fill;
flpVoiceBottom.Padding = new Padding(4, 0, 4, 2);
flpVoiceBottom.Controls.Add(lblInputDevice);
@@ -345,6 +366,8 @@ partial class MainForm
flpVoiceBottom.Controls.Add(pbLevel);
flpVoiceBottom.Controls.Add(lblVadThreshold);
flpVoiceBottom.Controls.Add(trkVadThreshold);
flpVoiceBottom.Controls.Add(lblMicGain);
flpVoiceBottom.Controls.Add(trkMicGain);
pnlVoice.Dock = DockStyle.Bottom;
pnlVoice.Height = 68;

View File

@@ -1,4 +1,5 @@
using VoiceCat.App.Audio;
using VoiceCat.App.Models;
using VoiceCat.App.Notifications;
using VoiceCat.Interop;
@@ -14,6 +15,7 @@ public partial class MainForm : Form
private readonly string _nickname;
private readonly System.Windows.Forms.Timer _pumpTimer = new() { Interval = 30 };
private readonly EventFeedback _feedback = new(FeedbackSettings.Load());
private readonly VoiceSettings _voiceSettings = VoiceSettings.Load();
// Channel / user state
private uint _currentChannelId;
@@ -85,6 +87,7 @@ public partial class MainForm : Form
radioPtt.CheckedChanged += RadioPtt_CheckedChanged;
radioAlwaysOn.CheckedChanged += RadioAlwaysOn_CheckedChanged;
trkVadThreshold.Scroll += TrkVadThreshold_Scroll;
trkMicGain.Scroll += TrkMicGain_Scroll;
btnChangePtt.Click += BtnChangePtt_Click;
btnRefreshDevices.Click += (_, _) => LoadInputDevices();
cboInputDevice.SelectedIndexChanged += CboInputDevice_SelectedIndexChanged;
@@ -98,9 +101,33 @@ public partial class MainForm : Form
if (_micStreamId != 0) _client.SetPushToTalk(false);
};
ApplyPersistedVoiceSettings();
BootstrapFromServer();
}
/// <summary>
/// Restore the saved transmission mode / VAD sensitivity / mic gain / PTT key into the UI so a
/// relaunch keeps the user's input settings instead of resetting to the designer defaults. The
/// values are pushed into the core when the mic stream starts (<see cref="BtnMicToggle_Click"/>).
/// Setting the radio fires CheckedChanged, which only adjusts visibility while the mic is idle.
/// </summary>
private void ApplyPersistedVoiceSettings()
{
_pttKey = (Keys)_voiceSettings.PttKey;
trkVadThreshold.Value = Math.Clamp(_voiceSettings.VadThresholdSlider,
trkVadThreshold.Minimum, trkVadThreshold.Maximum);
trkMicGain.Value = Math.Clamp(_voiceSettings.MicGain,
trkMicGain.Minimum, trkMicGain.Maximum);
switch ((VcInputMode)_voiceSettings.InputMode)
{
case VcInputMode.PushToTalk: radioPtt.Checked = true; break;
case VcInputMode.AlwaysOn: radioAlwaysOn.Checked = true; break;
default: radioVad.Checked = true; break;
}
}
// ── Startup ──────────────────────────────────────────────────────────────
private void BootstrapFromServer()
@@ -661,6 +688,7 @@ public partial class MainForm : Form
_client.SetInputDevice(streamId, dev.Id);
_client.SetInputMode(CurrentInputMode());
if (radioVad.Checked) _client.SetVadThreshold(VadThresholdFromSlider());
_client.SetInputGain(trkMicGain.Value / 100f);
SetVoiceJoinedState(true);
AddActivity("Joined voice — microphone active");
_feedback.PlaySound(SoundEvent.VoiceOn);
@@ -779,6 +807,7 @@ public partial class MainForm : Form
btnChangePtt.Visible = false;
lblVadThreshold.Visible = true;
trkVadThreshold.Visible = true;
SaveInputMode(VcInputMode.VoiceActivation);
if (_micStreamId != 0)
{
_client.SetInputMode(VcInputMode.VoiceActivation);
@@ -794,6 +823,7 @@ public partial class MainForm : Form
btnChangePtt.Visible = true;
lblVadThreshold.Visible = false;
trkVadThreshold.Visible = false;
SaveInputMode(VcInputMode.PushToTalk);
if (_micStreamId != 0)
{
_client.SetInputMode(VcInputMode.PushToTalk);
@@ -808,15 +838,31 @@ public partial class MainForm : Form
btnChangePtt.Visible = false;
lblVadThreshold.Visible = false;
trkVadThreshold.Visible = false;
SaveInputMode(VcInputMode.AlwaysOn);
if (_micStreamId != 0) _client.SetInputMode(VcInputMode.AlwaysOn);
}
private void TrkVadThreshold_Scroll(object? sender, EventArgs e)
{
_voiceSettings.VadThresholdSlider = trkVadThreshold.Value;
_voiceSettings.Save();
if (_micStreamId != 0 && radioVad.Checked)
_client.SetVadThreshold(VadThresholdFromSlider());
}
private void TrkMicGain_Scroll(object? sender, EventArgs e)
{
_voiceSettings.MicGain = trkMicGain.Value;
_voiceSettings.Save();
if (_micStreamId != 0) _client.SetInputGain(trkMicGain.Value / 100f);
}
private void SaveInputMode(VcInputMode mode)
{
_voiceSettings.InputMode = (int)mode;
_voiceSettings.Save();
}
private float VadThresholdFromSlider() =>
0.1f * (1f - (trkVadThreshold.Value - 1f) / 99f);
@@ -832,6 +878,8 @@ public partial class MainForm : Form
{
_pttKey = dlg.CapturedKey;
lblPttKey.Text = $"({_pttKey})";
_voiceSettings.PttKey = (int)_pttKey;
_voiceSettings.Save();
}
}

View File

@@ -0,0 +1,53 @@
using System.Text.Json;
namespace VoiceCat.App.Models;
/// <summary>
/// User's send-side voice input preferences — transmission mode, VAD sensitivity, mic input
/// gain, and the push-to-talk key. Persisted to %AppData%\VoiceCat\voice.json, same pattern as
/// <see cref="ServerListStore"/> and FeedbackSettings: a missing or corrupt file yields defaults
/// rather than throwing. Slider-position values are stored as-is so MainForm can restore the
/// TrackBars directly.
/// </summary>
public sealed class VoiceSettings
{
/// <summary>Transmission mode: 0 = voice activation, 1 = push-to-talk, 2 = always on
/// (matches Interop's VcInputMode).</summary>
public int InputMode { get; set; } = 0;
/// <summary>VAD sensitivity slider position, 1100 (default mirrors the designer's 76).</summary>
public int VadThresholdSlider { get; set; } = 76;
/// <summary>Microphone input gain slider position, 0300 percent (100 = unity).</summary>
public int MicGain { get; set; } = 100;
/// <summary>Push-to-talk key, stored as the integer value of System.Windows.Forms.Keys.</summary>
public int PttKey { get; set; } = (int)Keys.F8;
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
private static string AppDataDir => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "VoiceCat");
private static string FilePath => Path.Combine(AppDataDir, "voice.json");
public static VoiceSettings Load()
{
try
{
if (!File.Exists(FilePath)) return new VoiceSettings();
string json = File.ReadAllText(FilePath);
return JsonSerializer.Deserialize<VoiceSettings>(json) ?? new VoiceSettings();
}
catch
{
return new VoiceSettings();
}
}
public void Save()
{
Directory.CreateDirectory(AppDataDir);
File.WriteAllText(FilePath, JsonSerializer.Serialize(this, JsonOptions));
}
}