feat(clients): persist input settings, add mic input gain, fix iOS chat + VoiceOver
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:
@@ -409,6 +409,14 @@ public final class VoiceCatClient {
|
||||
VoiceCatResult(vc_set_output_volume(handle, gain < 0 ? 0 : gain))
|
||||
}
|
||||
|
||||
/// Send-side microphone input gain. Applied to captured MIC PCM before the VAD/PTT gate and
|
||||
/// Opus encode (so boosting a quiet mic also helps it cross the VAD threshold). gain 0.0 =
|
||||
/// silent, 1.0 = unity (default), >1.0 amplifies (clamped to int16). Always LOCAL.
|
||||
@discardableResult
|
||||
public func setInputGain(_ gain: Float) -> VoiceCatResult {
|
||||
VoiceCatResult(vc_set_input_gain(handle, gain < 0 ? 0 : gain))
|
||||
}
|
||||
|
||||
// MARK: - AVAudioSession interruption hooks (iOS)
|
||||
|
||||
/// Pause miniaudio device I/O. Call when AVAudioSession interruption begins.
|
||||
|
||||
@@ -26,6 +26,7 @@ struct VoiceState {
|
||||
var serverDeafened = false
|
||||
var inputMode: VoiceCatInputMode = .voiceActivation
|
||||
var vadThreshold: Float = 0.025
|
||||
var inputGain: Float = 1.0
|
||||
var level: Float = 0.0
|
||||
var currentDeviceId: String?
|
||||
var localStreamId: UInt32 = 0
|
||||
@@ -59,6 +60,7 @@ final class SessionState {
|
||||
self.client = client
|
||||
self.selfUserId = selfUserId
|
||||
self.permissions = permissions
|
||||
loadAndApplyVoiceSettings()
|
||||
refreshChannels()
|
||||
refreshUsers()
|
||||
syncSelfChannel()
|
||||
@@ -336,11 +338,46 @@ final class SessionState {
|
||||
func setInputMode(_ mode: VoiceCatInputMode) {
|
||||
client.setInputMode(mode)
|
||||
voiceState.inputMode = mode
|
||||
UserDefaults.standard.set(Int(mode.rawValue), forKey: DefaultsKey.inputMode)
|
||||
}
|
||||
|
||||
func setVadThreshold(_ threshold: Float) {
|
||||
client.setVadThreshold(threshold)
|
||||
voiceState.vadThreshold = threshold
|
||||
UserDefaults.standard.set(threshold, forKey: DefaultsKey.vadThreshold)
|
||||
}
|
||||
|
||||
func setInputGain(_ gain: Float) {
|
||||
client.setInputGain(gain)
|
||||
voiceState.inputGain = gain
|
||||
UserDefaults.standard.set(gain, forKey: DefaultsKey.inputGain)
|
||||
}
|
||||
|
||||
// MARK: - Persisted input settings
|
||||
|
||||
private enum DefaultsKey {
|
||||
static let inputMode = "voice.inputMode"
|
||||
static let vadThreshold = "voice.vadThreshold"
|
||||
static let inputGain = "voice.inputGain"
|
||||
}
|
||||
|
||||
/// Restore the saved input mode / VAD threshold / mic gain and push them into the core so a
|
||||
/// relaunch keeps the user's transmission settings instead of resetting to VAD defaults.
|
||||
private func loadAndApplyVoiceSettings() {
|
||||
let d = UserDefaults.standard
|
||||
if d.object(forKey: DefaultsKey.inputMode) != nil {
|
||||
let raw = UInt32(d.integer(forKey: DefaultsKey.inputMode))
|
||||
voiceState.inputMode = VoiceCatInputMode(rawValue: raw) ?? .voiceActivation
|
||||
}
|
||||
if d.object(forKey: DefaultsKey.vadThreshold) != nil {
|
||||
voiceState.vadThreshold = d.float(forKey: DefaultsKey.vadThreshold)
|
||||
}
|
||||
if d.object(forKey: DefaultsKey.inputGain) != nil {
|
||||
voiceState.inputGain = d.float(forKey: DefaultsKey.inputGain)
|
||||
}
|
||||
client.setInputMode(voiceState.inputMode)
|
||||
client.setVadThreshold(voiceState.vadThreshold)
|
||||
client.setInputGain(voiceState.inputGain)
|
||||
}
|
||||
|
||||
private var pttEngaged = false
|
||||
|
||||
@@ -91,7 +91,7 @@ struct ChatView: View {
|
||||
private func sendMessage() {
|
||||
let text = composeText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return }
|
||||
session.sendText(text, scope: .channel)
|
||||
session.sendText(text, scope: .channel, targetId: session.currentChannelId)
|
||||
composeText = ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,6 +237,20 @@ struct SettingsView: View {
|
||||
.accessibilityLabel("Voice activation threshold")
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Mic Volume: \(Int((session.voiceState.inputGain * 100).rounded()))%")
|
||||
.font(.caption)
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { Double(session.voiceState.inputGain) },
|
||||
set: { session.setInputGain(Float($0)) }
|
||||
),
|
||||
in: 0...3, step: 0.05
|
||||
)
|
||||
.accessibilityLabel("Microphone volume")
|
||||
.accessibilityValue("\(Int((session.voiceState.inputGain * 100).rounded())) percent")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notifications
|
||||
|
||||
@@ -18,6 +18,10 @@ struct UserRow: View {
|
||||
var body: some View {
|
||||
UserRowView(user: user, isSelf: user.id == session.selfUserId)
|
||||
.contextMenu { contextMenu }
|
||||
// The context menu is long-press only, which VoiceOver doesn't surface — mirror the
|
||||
// same buttons as accessibility actions so VoiceOver users can reach per-user tuning
|
||||
// (and the admin actions) via the actions rotor on the focused row.
|
||||
.accessibilityActions { contextMenu }
|
||||
.sheet(item: $activeSheet) { sheet in
|
||||
switch sheet {
|
||||
case .tuning: PerUserTuningView(user: user, session: session)
|
||||
|
||||
@@ -33,7 +33,9 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
||||
private var screenCapture: ScreenAudioCapture?
|
||||
// Last app/exclusion choice from the share picker; reused as the default next time.
|
||||
private var screenAudioSelection: ScreenAudioSelection = .default
|
||||
internal var pttKeyCode: UInt16 = 0x60 // F8
|
||||
internal var pttKeyCode: UInt16 = 0x60 { // F8
|
||||
didSet { UserDefaults.standard.set(Int(pttKeyCode), forKey: AudioDefaults.pttKeyCode) }
|
||||
}
|
||||
private var pttMonitor: Any?
|
||||
private var pttEngaged = false // guards the PTT cue against key-repeat
|
||||
private var serverMuted = false
|
||||
@@ -49,9 +51,26 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
||||
private var settingsWindowController: SettingsWindowController?
|
||||
|
||||
// MARK: - Audio settings state (source of truth — read/written by SettingsWindowController)
|
||||
// The input mode / VAD threshold / mic gain / PTT key persist via UserDefaults (didSet below)
|
||||
// so they survive relaunch; loadPersistedAudioSettings() restores them at startup and they are
|
||||
// pushed into the core when the mic stream starts (micToggleClicked).
|
||||
|
||||
internal var selectedInputMode: VoiceCatInputMode = .voiceActivation
|
||||
internal var vadThresholdValue: Float = 0.05
|
||||
enum AudioDefaults {
|
||||
static let inputMode = "voice.inputMode"
|
||||
static let vadThreshold = "voice.vadThreshold"
|
||||
static let inputGain = "voice.inputGain"
|
||||
static let pttKeyCode = "voice.pttKeyCode"
|
||||
}
|
||||
|
||||
internal var selectedInputMode: VoiceCatInputMode = .voiceActivation {
|
||||
didSet { UserDefaults.standard.set(Int(selectedInputMode.rawValue), forKey: AudioDefaults.inputMode) }
|
||||
}
|
||||
internal var vadThresholdValue: Float = 0.05 {
|
||||
didSet { UserDefaults.standard.set(vadThresholdValue, forKey: AudioDefaults.vadThreshold) }
|
||||
}
|
||||
internal var inputGain: Float = 1.0 {
|
||||
didSet { UserDefaults.standard.set(inputGain, forKey: AudioDefaults.inputGain) }
|
||||
}
|
||||
internal var selectedInputDeviceId: String?
|
||||
|
||||
// MARK: - UI components
|
||||
@@ -97,12 +116,32 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
||||
super.init(window: window)
|
||||
window.delegate = self
|
||||
|
||||
loadPersistedAudioSettings()
|
||||
buildUI()
|
||||
buildToolbar()
|
||||
wireEvents()
|
||||
bootstrap()
|
||||
}
|
||||
|
||||
/// Restore the saved input mode / VAD threshold / mic gain / PTT key from UserDefaults so a
|
||||
/// relaunch keeps the user's transmission settings instead of resetting to VAD defaults.
|
||||
private func loadPersistedAudioSettings() {
|
||||
let d = UserDefaults.standard
|
||||
if d.object(forKey: AudioDefaults.inputMode) != nil {
|
||||
let raw = UInt32(d.integer(forKey: AudioDefaults.inputMode))
|
||||
selectedInputMode = VoiceCatInputMode(rawValue: raw) ?? .voiceActivation
|
||||
}
|
||||
if d.object(forKey: AudioDefaults.vadThreshold) != nil {
|
||||
vadThresholdValue = d.float(forKey: AudioDefaults.vadThreshold)
|
||||
}
|
||||
if d.object(forKey: AudioDefaults.inputGain) != nil {
|
||||
inputGain = d.float(forKey: AudioDefaults.inputGain)
|
||||
}
|
||||
if d.object(forKey: AudioDefaults.pttKeyCode) != nil {
|
||||
pttKeyCode = UInt16(d.integer(forKey: AudioDefaults.pttKeyCode))
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
deinit {
|
||||
@@ -712,6 +751,7 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
||||
if selectedInputMode == .voiceActivation {
|
||||
client.setVadThreshold(vadThresholdValue)
|
||||
}
|
||||
client.setInputGain(inputGain)
|
||||
setVoiceJoinedState(true)
|
||||
addActivity("Joined voice — microphone active")
|
||||
EventFeedback.shared.play(.voiceOn)
|
||||
|
||||
@@ -50,6 +50,14 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
// Cached VAD slider position so we can restore it when the window reopens.
|
||||
private var vadSliderValue: Double = 50
|
||||
|
||||
// Mic input gain: 0–300 % (100 = unity). Persisted via MainWindowController.inputGain.
|
||||
private let inputGainSlider: NSSlider = {
|
||||
let s = NSSlider(value: 100, minValue: 0, maxValue: 300, target: nil, action: nil)
|
||||
s.numberOfTickMarks = 0
|
||||
return s
|
||||
}()
|
||||
private let inputGainValueLabel = NSTextField(labelWithString: "100%")
|
||||
|
||||
// Notification feedback controls. Read/write UserDefaults with the same keys VoiceCatCore's
|
||||
// FeedbackSettings reads, so EventFeedback honours these immediately.
|
||||
private let soundsCheckbox = NSButton(checkboxWithTitle: "Event sounds", target: nil, action: nil)
|
||||
@@ -135,6 +143,14 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
levelMeter.setAccessibilityLabel("Microphone input level")
|
||||
levelMeter.setAccessibilityHelp("Shows current microphone volume level")
|
||||
|
||||
let inputGainLabel = NSTextField(labelWithString: "Mic volume:")
|
||||
inputGainLabel.setAccessibilityLabel("Microphone volume")
|
||||
inputGainSlider.target = self
|
||||
inputGainSlider.action = #selector(inputGainChanged)
|
||||
inputGainSlider.setAccessibilityLabel("Microphone volume")
|
||||
inputGainSlider.setAccessibilityHelp("Boost a quiet microphone. 100% is unity.")
|
||||
inputGainValueLabel.setAccessibilityLabel("Microphone volume value")
|
||||
|
||||
let inputModeRow = NSStackView(views: [inputModeLabel, inputModeControl])
|
||||
inputModeRow.orientation = .horizontal
|
||||
inputModeRow.spacing = 8
|
||||
@@ -155,6 +171,10 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
levelRow.orientation = .horizontal
|
||||
levelRow.spacing = 8
|
||||
|
||||
let inputGainRow = NSStackView(views: [inputGainLabel, inputGainSlider, inputGainValueLabel])
|
||||
inputGainRow.orientation = .horizontal
|
||||
inputGainRow.spacing = 8
|
||||
|
||||
// Notifications
|
||||
let notificationsHeader = NSTextField(labelWithString: "Notifications")
|
||||
notificationsHeader.font = .boldSystemFont(ofSize: NSFont.systemFontSize)
|
||||
@@ -177,8 +197,8 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
volumeRow.orientation = .horizontal
|
||||
volumeRow.spacing = 8
|
||||
|
||||
let stack = NSStackView(views: [inputModeRow, vadRow, pttRow, deviceRow, levelRow,
|
||||
notificationsHeader, soundsCheckbox, volumeRow,
|
||||
let stack = NSStackView(views: [inputModeRow, vadRow, inputGainRow, pttRow, deviceRow,
|
||||
levelRow, notificationsHeader, soundsCheckbox, volumeRow,
|
||||
speechCheckbox, selfTalkCheckbox, pttSoundCheckbox])
|
||||
stack.orientation = .vertical
|
||||
stack.spacing = 12
|
||||
@@ -194,6 +214,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
||||
|
||||
vadSlider.widthAnchor.constraint(greaterThanOrEqualToConstant: 200),
|
||||
inputGainSlider.widthAnchor.constraint(greaterThanOrEqualToConstant: 180),
|
||||
levelMeter.widthAnchor.constraint(equalToConstant: 200),
|
||||
devicePicker.widthAnchor.constraint(greaterThanOrEqualToConstant: 180),
|
||||
soundsVolumeSlider.widthAnchor.constraint(greaterThanOrEqualToConstant: 200),
|
||||
@@ -235,9 +256,15 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
case .alwaysOn: inputModeControl.selectedSegment = 2
|
||||
}
|
||||
|
||||
// Restore the slider from the persisted threshold (invert vadThresholdFromSlider) so a
|
||||
// relaunch shows the saved sensitivity, not the default mid-point.
|
||||
vadSliderValue = vadSliderFromThreshold(mc.vadThresholdValue)
|
||||
vadSlider.doubleValue = vadSliderValue
|
||||
pttKeyLabel.stringValue = "(\(keyCodeName(mc.pttKeyCode)))"
|
||||
|
||||
inputGainSlider.doubleValue = Double(mc.inputGain * 100)
|
||||
updateInputGainLabel()
|
||||
|
||||
updateConditionalControls()
|
||||
}
|
||||
|
||||
@@ -275,6 +302,21 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func inputGainChanged() {
|
||||
let gain = Float(inputGainSlider.doubleValue) / 100.0
|
||||
mainController?.inputGain = gain
|
||||
updateInputGainLabel()
|
||||
if let mc = mainController, mc.micStreamId != 0 {
|
||||
client.setInputGain(gain)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateInputGainLabel() {
|
||||
let pct = Int(inputGainSlider.doubleValue.rounded())
|
||||
inputGainValueLabel.stringValue = "\(pct)%"
|
||||
inputGainSlider.setAccessibilityValue("\(pct) percent")
|
||||
}
|
||||
|
||||
@objc private func changePttClicked() {
|
||||
guard let mc = mainController else { return }
|
||||
let sheet = PttKeyCaptureSheet(currentKeyCode: mc.pttKeyCode)
|
||||
@@ -346,6 +388,12 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
0.1 * (1.0 - Float(vadSlider.doubleValue - 1.0) / 99.0)
|
||||
}
|
||||
|
||||
/// Inverse of vadThresholdFromSlider: map a stored threshold back to a 1…100 slider position.
|
||||
private func vadSliderFromThreshold(_ threshold: Float) -> Double {
|
||||
let clamped = min(max(threshold, 0.0), 0.1)
|
||||
return Double(1.0 + (1.0 - clamped / 0.1) * 99.0)
|
||||
}
|
||||
|
||||
private func presentSheet(_ vc: NSViewController) {
|
||||
if let cvc = window?.contentViewController {
|
||||
cvc.presentAsSheet(vc)
|
||||
|
||||
@@ -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 0–300 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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
53
clients/windows/VoiceCat.App/Models/VoiceSettings.cs
Normal file
53
clients/windows/VoiceCat.App/Models/VoiceSettings.cs
Normal 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, 1–100 (default mirrors the designer's 76).</summary>
|
||||
public int VadThresholdSlider { get; set; } = 76;
|
||||
|
||||
/// <summary>Microphone input gain slider position, 0–300 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));
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,9 @@ internal static partial class NativeMethods
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_set_output_volume(nint c, float gain);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_set_input_gain(nint c, float gain);
|
||||
|
||||
[LibraryImport(LibName)]
|
||||
internal static partial VcResult vc_set_remote_stream(nint c, uint userId, uint streamId,
|
||||
float gain, int muted, int noiseReduction);
|
||||
|
||||
@@ -227,6 +227,11 @@ public sealed class VoiceCatClient : IDisposable
|
||||
public VcResult SetOutputVolume(float gain) =>
|
||||
NativeMethods.vc_set_output_volume(_handle.DangerousGetHandle(), gain < 0f ? 0f : gain);
|
||||
|
||||
/// <summary>Send-side microphone input gain, applied to captured MIC PCM before VAD/encode.
|
||||
/// 0.0 = silent, 1.0 = unity (default), >1.0 amplifies (clamped to int16). LOCAL only.</summary>
|
||||
public VcResult SetInputGain(float gain) =>
|
||||
NativeMethods.vc_set_input_gain(_handle.DangerousGetHandle(), gain < 0f ? 0f : gain);
|
||||
|
||||
public VcResult SetRemoteStream(uint userId, uint streamId, float gain, bool muted, bool noiseReduction) =>
|
||||
NativeMethods.vc_set_remote_stream(_handle.DangerousGetHandle(), userId, streamId, gain,
|
||||
muted ? 1 : 0, noiseReduction ? 1 : 0);
|
||||
|
||||
Reference in New Issue
Block a user