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:
30
PROGRESS.md
30
PROGRESS.md
@@ -10,6 +10,36 @@ up instantly. Newest status at the top.
|
|||||||
|
|
||||||
## ▶ Where we left off / next action
|
## ▶ Where we left off / next action
|
||||||
|
|
||||||
|
- **Done (2026-06-23):** **Input-settings persistence, mic input gain, + two iOS bugs (all 3
|
||||||
|
clients).** Four fixes:
|
||||||
|
1. **Input settings now persist.** Transmission 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 via `UserDefaults`
|
||||||
|
(`SessionState.loadAndApplyVoiceSettings` + setter writes, keys `voice.*`); macOS via
|
||||||
|
`UserDefaults` (`MainWindowController` `didSet` + `loadPersistedAudioSettings`, also restores
|
||||||
|
the VAD slider from the stored threshold); Windows via new
|
||||||
|
`VoiceCat.App/Models/VoiceSettings.cs` (JSON at `%AppData%\VoiceCat\voice.json`, mirrors
|
||||||
|
`FeedbackSettings`) loaded/applied in `MainForm`.
|
||||||
|
2. **Microphone input gain.** New global send-side API `vc_set_input_gain` (voicecat.h →
|
||||||
|
`client.cpp::on_capture_frame`, applied to MIC PCM before the VAD gate, clamped to int16) plus
|
||||||
|
Swift (`setInputGain`) and C# (`SetInputGain`) bindings. Mic-volume slider (0–300 %, default
|
||||||
|
100 %) added to all three clients' input settings, persisted with the rest.
|
||||||
|
3. **iOS chat send fixed.** `ChatView` called `sendText(scope:.channel)` with no `targetId` (→ 0),
|
||||||
|
so channel messages went nowhere; now passes `session.currentChannelId`.
|
||||||
|
4. **iOS per-user tuning reachable via VoiceOver.** The tuning sheet was long-press
|
||||||
|
`.contextMenu` only (invisible to VoiceOver); `UserRow` now also exposes the same buttons as
|
||||||
|
`.accessibilityActions` (no visual change), so the actions rotor reaches tuning + admin actions.
|
||||||
|
- **Verified:** core `cmake --build --preset dev` clean; `ctest --preset dev` = 24/27 (the 3
|
||||||
|
failures — `external_pcm`, `frame_ms_reframe`, `channel_samplerate` — are a pre-existing
|
||||||
|
teardown crash on this machine, reproduced identically with the changes stashed). xcframework
|
||||||
|
rebuilt (`--all`); **VoiceCatMac** and **VoiceCatiOS** (arm64 sim) → BUILD SUCCEEDED;
|
||||||
|
`VoiceCat.Interop` (`dotnet build`) succeeded. **Windows App not built** (WinForms
|
||||||
|
net10.0-windows can't build on macOS) — changes follow existing patterns; needs a Windows
|
||||||
|
build + manual check.
|
||||||
|
- **Next (manual):** on each client, set PTT + non-default VAD/mic-gain, relaunch → settings
|
||||||
|
restored; boost a quiet mic and confirm others hear it louder; iOS send a channel message;
|
||||||
|
iOS VoiceOver → focus a user → actions rotor opens tuning.
|
||||||
|
|
||||||
- **Done (2026-06-22):** **Fixed growing voice latency (jitter-buffer depth ratchet).** Symptom:
|
- **Done (2026-06-22):** **Fixed growing voice latency (jitter-buffer depth ratchet).** Symptom:
|
||||||
end-to-end latency grew to multiple seconds and "drifted backward," reset only by leaving/
|
end-to-end latency grew to multiple seconds and "drifted backward," reset only by leaving/
|
||||||
rejoining voice (DTX/FEC/DRED on, 10% loss). Root cause was **not** the codec settings (10% loss
|
rejoining voice (DTX/FEC/DRED on, 10% loss). Root cause was **not** the codec settings (10% loss
|
||||||
|
|||||||
@@ -409,6 +409,14 @@ public final class VoiceCatClient {
|
|||||||
VoiceCatResult(vc_set_output_volume(handle, gain < 0 ? 0 : gain))
|
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)
|
// MARK: - AVAudioSession interruption hooks (iOS)
|
||||||
|
|
||||||
/// Pause miniaudio device I/O. Call when AVAudioSession interruption begins.
|
/// Pause miniaudio device I/O. Call when AVAudioSession interruption begins.
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ struct VoiceState {
|
|||||||
var serverDeafened = false
|
var serverDeafened = false
|
||||||
var inputMode: VoiceCatInputMode = .voiceActivation
|
var inputMode: VoiceCatInputMode = .voiceActivation
|
||||||
var vadThreshold: Float = 0.025
|
var vadThreshold: Float = 0.025
|
||||||
|
var inputGain: Float = 1.0
|
||||||
var level: Float = 0.0
|
var level: Float = 0.0
|
||||||
var currentDeviceId: String?
|
var currentDeviceId: String?
|
||||||
var localStreamId: UInt32 = 0
|
var localStreamId: UInt32 = 0
|
||||||
@@ -59,6 +60,7 @@ final class SessionState {
|
|||||||
self.client = client
|
self.client = client
|
||||||
self.selfUserId = selfUserId
|
self.selfUserId = selfUserId
|
||||||
self.permissions = permissions
|
self.permissions = permissions
|
||||||
|
loadAndApplyVoiceSettings()
|
||||||
refreshChannels()
|
refreshChannels()
|
||||||
refreshUsers()
|
refreshUsers()
|
||||||
syncSelfChannel()
|
syncSelfChannel()
|
||||||
@@ -336,11 +338,46 @@ final class SessionState {
|
|||||||
func setInputMode(_ mode: VoiceCatInputMode) {
|
func setInputMode(_ mode: VoiceCatInputMode) {
|
||||||
client.setInputMode(mode)
|
client.setInputMode(mode)
|
||||||
voiceState.inputMode = mode
|
voiceState.inputMode = mode
|
||||||
|
UserDefaults.standard.set(Int(mode.rawValue), forKey: DefaultsKey.inputMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setVadThreshold(_ threshold: Float) {
|
func setVadThreshold(_ threshold: Float) {
|
||||||
client.setVadThreshold(threshold)
|
client.setVadThreshold(threshold)
|
||||||
voiceState.vadThreshold = 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
|
private var pttEngaged = false
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ struct ChatView: View {
|
|||||||
private func sendMessage() {
|
private func sendMessage() {
|
||||||
let text = composeText.trimmingCharacters(in: .whitespacesAndNewlines)
|
let text = composeText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !text.isEmpty else { return }
|
guard !text.isEmpty else { return }
|
||||||
session.sendText(text, scope: .channel)
|
session.sendText(text, scope: .channel, targetId: session.currentChannelId)
|
||||||
composeText = ""
|
composeText = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,6 +237,20 @@ struct SettingsView: View {
|
|||||||
.accessibilityLabel("Voice activation threshold")
|
.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
|
// MARK: - Notifications
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ struct UserRow: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
UserRowView(user: user, isSelf: user.id == session.selfUserId)
|
UserRowView(user: user, isSelf: user.id == session.selfUserId)
|
||||||
.contextMenu { contextMenu }
|
.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
|
.sheet(item: $activeSheet) { sheet in
|
||||||
switch sheet {
|
switch sheet {
|
||||||
case .tuning: PerUserTuningView(user: user, session: session)
|
case .tuning: PerUserTuningView(user: user, session: session)
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
private var screenCapture: ScreenAudioCapture?
|
private var screenCapture: ScreenAudioCapture?
|
||||||
// Last app/exclusion choice from the share picker; reused as the default next time.
|
// Last app/exclusion choice from the share picker; reused as the default next time.
|
||||||
private var screenAudioSelection: ScreenAudioSelection = .default
|
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 pttMonitor: Any?
|
||||||
private var pttEngaged = false // guards the PTT cue against key-repeat
|
private var pttEngaged = false // guards the PTT cue against key-repeat
|
||||||
private var serverMuted = false
|
private var serverMuted = false
|
||||||
@@ -49,9 +51,26 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
private var settingsWindowController: SettingsWindowController?
|
private var settingsWindowController: SettingsWindowController?
|
||||||
|
|
||||||
// MARK: - Audio settings state (source of truth — read/written by 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
|
enum AudioDefaults {
|
||||||
internal var vadThresholdValue: Float = 0.05
|
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?
|
internal var selectedInputDeviceId: String?
|
||||||
|
|
||||||
// MARK: - UI components
|
// MARK: - UI components
|
||||||
@@ -97,12 +116,32 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
super.init(window: window)
|
super.init(window: window)
|
||||||
window.delegate = self
|
window.delegate = self
|
||||||
|
|
||||||
|
loadPersistedAudioSettings()
|
||||||
buildUI()
|
buildUI()
|
||||||
buildToolbar()
|
buildToolbar()
|
||||||
wireEvents()
|
wireEvents()
|
||||||
bootstrap()
|
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() }
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
@@ -712,6 +751,7 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
if selectedInputMode == .voiceActivation {
|
if selectedInputMode == .voiceActivation {
|
||||||
client.setVadThreshold(vadThresholdValue)
|
client.setVadThreshold(vadThresholdValue)
|
||||||
}
|
}
|
||||||
|
client.setInputGain(inputGain)
|
||||||
setVoiceJoinedState(true)
|
setVoiceJoinedState(true)
|
||||||
addActivity("Joined voice — microphone active")
|
addActivity("Joined voice — microphone active")
|
||||||
EventFeedback.shared.play(.voiceOn)
|
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.
|
// Cached VAD slider position so we can restore it when the window reopens.
|
||||||
private var vadSliderValue: Double = 50
|
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
|
// Notification feedback controls. Read/write UserDefaults with the same keys VoiceCatCore's
|
||||||
// FeedbackSettings reads, so EventFeedback honours these immediately.
|
// FeedbackSettings reads, so EventFeedback honours these immediately.
|
||||||
private let soundsCheckbox = NSButton(checkboxWithTitle: "Event sounds", target: nil, action: nil)
|
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.setAccessibilityLabel("Microphone input level")
|
||||||
levelMeter.setAccessibilityHelp("Shows current microphone volume 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])
|
let inputModeRow = NSStackView(views: [inputModeLabel, inputModeControl])
|
||||||
inputModeRow.orientation = .horizontal
|
inputModeRow.orientation = .horizontal
|
||||||
inputModeRow.spacing = 8
|
inputModeRow.spacing = 8
|
||||||
@@ -155,6 +171,10 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
levelRow.orientation = .horizontal
|
levelRow.orientation = .horizontal
|
||||||
levelRow.spacing = 8
|
levelRow.spacing = 8
|
||||||
|
|
||||||
|
let inputGainRow = NSStackView(views: [inputGainLabel, inputGainSlider, inputGainValueLabel])
|
||||||
|
inputGainRow.orientation = .horizontal
|
||||||
|
inputGainRow.spacing = 8
|
||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
let notificationsHeader = NSTextField(labelWithString: "Notifications")
|
let notificationsHeader = NSTextField(labelWithString: "Notifications")
|
||||||
notificationsHeader.font = .boldSystemFont(ofSize: NSFont.systemFontSize)
|
notificationsHeader.font = .boldSystemFont(ofSize: NSFont.systemFontSize)
|
||||||
@@ -177,8 +197,8 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
volumeRow.orientation = .horizontal
|
volumeRow.orientation = .horizontal
|
||||||
volumeRow.spacing = 8
|
volumeRow.spacing = 8
|
||||||
|
|
||||||
let stack = NSStackView(views: [inputModeRow, vadRow, pttRow, deviceRow, levelRow,
|
let stack = NSStackView(views: [inputModeRow, vadRow, inputGainRow, pttRow, deviceRow,
|
||||||
notificationsHeader, soundsCheckbox, volumeRow,
|
levelRow, notificationsHeader, soundsCheckbox, volumeRow,
|
||||||
speechCheckbox, selfTalkCheckbox, pttSoundCheckbox])
|
speechCheckbox, selfTalkCheckbox, pttSoundCheckbox])
|
||||||
stack.orientation = .vertical
|
stack.orientation = .vertical
|
||||||
stack.spacing = 12
|
stack.spacing = 12
|
||||||
@@ -194,6 +214,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
||||||
|
|
||||||
vadSlider.widthAnchor.constraint(greaterThanOrEqualToConstant: 200),
|
vadSlider.widthAnchor.constraint(greaterThanOrEqualToConstant: 200),
|
||||||
|
inputGainSlider.widthAnchor.constraint(greaterThanOrEqualToConstant: 180),
|
||||||
levelMeter.widthAnchor.constraint(equalToConstant: 200),
|
levelMeter.widthAnchor.constraint(equalToConstant: 200),
|
||||||
devicePicker.widthAnchor.constraint(greaterThanOrEqualToConstant: 180),
|
devicePicker.widthAnchor.constraint(greaterThanOrEqualToConstant: 180),
|
||||||
soundsVolumeSlider.widthAnchor.constraint(greaterThanOrEqualToConstant: 200),
|
soundsVolumeSlider.widthAnchor.constraint(greaterThanOrEqualToConstant: 200),
|
||||||
@@ -235,9 +256,15 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
case .alwaysOn: inputModeControl.selectedSegment = 2
|
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
|
vadSlider.doubleValue = vadSliderValue
|
||||||
pttKeyLabel.stringValue = "(\(keyCodeName(mc.pttKeyCode)))"
|
pttKeyLabel.stringValue = "(\(keyCodeName(mc.pttKeyCode)))"
|
||||||
|
|
||||||
|
inputGainSlider.doubleValue = Double(mc.inputGain * 100)
|
||||||
|
updateInputGainLabel()
|
||||||
|
|
||||||
updateConditionalControls()
|
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() {
|
@objc private func changePttClicked() {
|
||||||
guard let mc = mainController else { return }
|
guard let mc = mainController else { return }
|
||||||
let sheet = PttKeyCaptureSheet(currentKeyCode: mc.pttKeyCode)
|
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)
|
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) {
|
private func presentSheet(_ vc: NSViewController) {
|
||||||
if let cvc = window?.contentViewController {
|
if let cvc = window?.contentViewController {
|
||||||
cvc.presentAsSheet(vc)
|
cvc.presentAsSheet(vc)
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ partial class MainForm
|
|||||||
private ProgressBar pbLevel = null!;
|
private ProgressBar pbLevel = null!;
|
||||||
private Label lblVadThreshold = null!;
|
private Label lblVadThreshold = null!;
|
||||||
private TrackBar trkVadThreshold = null!;
|
private TrackBar trkVadThreshold = null!;
|
||||||
|
private Label lblMicGain = null!;
|
||||||
|
private TrackBar trkMicGain = null!;
|
||||||
|
|
||||||
protected override void Dispose(bool disposing)
|
protected override void Dispose(bool disposing)
|
||||||
{
|
{
|
||||||
@@ -94,6 +96,8 @@ partial class MainForm
|
|||||||
pbLevel = new ProgressBar();
|
pbLevel = new ProgressBar();
|
||||||
lblVadThreshold = new Label();
|
lblVadThreshold = new Label();
|
||||||
trkVadThreshold = new TrackBar();
|
trkVadThreshold = new TrackBar();
|
||||||
|
lblMicGain = new Label();
|
||||||
|
trkMicGain = new TrackBar();
|
||||||
menuStrip = new MenuStrip();
|
menuStrip = new MenuStrip();
|
||||||
toolStrip = new ToolStrip();
|
toolStrip = new ToolStrip();
|
||||||
tsbJoinVoice = new ToolStripButton();
|
tsbJoinVoice = new ToolStripButton();
|
||||||
@@ -336,6 +340,23 @@ partial class MainForm
|
|||||||
trkVadThreshold.TabIndex = 9;
|
trkVadThreshold.TabIndex = 9;
|
||||||
trkVadThreshold.Visible = true;
|
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.Dock = DockStyle.Fill;
|
||||||
flpVoiceBottom.Padding = new Padding(4, 0, 4, 2);
|
flpVoiceBottom.Padding = new Padding(4, 0, 4, 2);
|
||||||
flpVoiceBottom.Controls.Add(lblInputDevice);
|
flpVoiceBottom.Controls.Add(lblInputDevice);
|
||||||
@@ -345,6 +366,8 @@ partial class MainForm
|
|||||||
flpVoiceBottom.Controls.Add(pbLevel);
|
flpVoiceBottom.Controls.Add(pbLevel);
|
||||||
flpVoiceBottom.Controls.Add(lblVadThreshold);
|
flpVoiceBottom.Controls.Add(lblVadThreshold);
|
||||||
flpVoiceBottom.Controls.Add(trkVadThreshold);
|
flpVoiceBottom.Controls.Add(trkVadThreshold);
|
||||||
|
flpVoiceBottom.Controls.Add(lblMicGain);
|
||||||
|
flpVoiceBottom.Controls.Add(trkMicGain);
|
||||||
|
|
||||||
pnlVoice.Dock = DockStyle.Bottom;
|
pnlVoice.Dock = DockStyle.Bottom;
|
||||||
pnlVoice.Height = 68;
|
pnlVoice.Height = 68;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using VoiceCat.App.Audio;
|
using VoiceCat.App.Audio;
|
||||||
|
using VoiceCat.App.Models;
|
||||||
using VoiceCat.App.Notifications;
|
using VoiceCat.App.Notifications;
|
||||||
using VoiceCat.Interop;
|
using VoiceCat.Interop;
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ public partial class MainForm : Form
|
|||||||
private readonly string _nickname;
|
private readonly string _nickname;
|
||||||
private readonly System.Windows.Forms.Timer _pumpTimer = new() { Interval = 30 };
|
private readonly System.Windows.Forms.Timer _pumpTimer = new() { Interval = 30 };
|
||||||
private readonly EventFeedback _feedback = new(FeedbackSettings.Load());
|
private readonly EventFeedback _feedback = new(FeedbackSettings.Load());
|
||||||
|
private readonly VoiceSettings _voiceSettings = VoiceSettings.Load();
|
||||||
|
|
||||||
// Channel / user state
|
// Channel / user state
|
||||||
private uint _currentChannelId;
|
private uint _currentChannelId;
|
||||||
@@ -85,6 +87,7 @@ public partial class MainForm : Form
|
|||||||
radioPtt.CheckedChanged += RadioPtt_CheckedChanged;
|
radioPtt.CheckedChanged += RadioPtt_CheckedChanged;
|
||||||
radioAlwaysOn.CheckedChanged += RadioAlwaysOn_CheckedChanged;
|
radioAlwaysOn.CheckedChanged += RadioAlwaysOn_CheckedChanged;
|
||||||
trkVadThreshold.Scroll += TrkVadThreshold_Scroll;
|
trkVadThreshold.Scroll += TrkVadThreshold_Scroll;
|
||||||
|
trkMicGain.Scroll += TrkMicGain_Scroll;
|
||||||
btnChangePtt.Click += BtnChangePtt_Click;
|
btnChangePtt.Click += BtnChangePtt_Click;
|
||||||
btnRefreshDevices.Click += (_, _) => LoadInputDevices();
|
btnRefreshDevices.Click += (_, _) => LoadInputDevices();
|
||||||
cboInputDevice.SelectedIndexChanged += CboInputDevice_SelectedIndexChanged;
|
cboInputDevice.SelectedIndexChanged += CboInputDevice_SelectedIndexChanged;
|
||||||
@@ -98,9 +101,33 @@ public partial class MainForm : Form
|
|||||||
if (_micStreamId != 0) _client.SetPushToTalk(false);
|
if (_micStreamId != 0) _client.SetPushToTalk(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
ApplyPersistedVoiceSettings();
|
||||||
BootstrapFromServer();
|
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 ──────────────────────────────────────────────────────────────
|
// ── Startup ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private void BootstrapFromServer()
|
private void BootstrapFromServer()
|
||||||
@@ -661,6 +688,7 @@ public partial class MainForm : Form
|
|||||||
_client.SetInputDevice(streamId, dev.Id);
|
_client.SetInputDevice(streamId, dev.Id);
|
||||||
_client.SetInputMode(CurrentInputMode());
|
_client.SetInputMode(CurrentInputMode());
|
||||||
if (radioVad.Checked) _client.SetVadThreshold(VadThresholdFromSlider());
|
if (radioVad.Checked) _client.SetVadThreshold(VadThresholdFromSlider());
|
||||||
|
_client.SetInputGain(trkMicGain.Value / 100f);
|
||||||
SetVoiceJoinedState(true);
|
SetVoiceJoinedState(true);
|
||||||
AddActivity("Joined voice — microphone active");
|
AddActivity("Joined voice — microphone active");
|
||||||
_feedback.PlaySound(SoundEvent.VoiceOn);
|
_feedback.PlaySound(SoundEvent.VoiceOn);
|
||||||
@@ -779,6 +807,7 @@ public partial class MainForm : Form
|
|||||||
btnChangePtt.Visible = false;
|
btnChangePtt.Visible = false;
|
||||||
lblVadThreshold.Visible = true;
|
lblVadThreshold.Visible = true;
|
||||||
trkVadThreshold.Visible = true;
|
trkVadThreshold.Visible = true;
|
||||||
|
SaveInputMode(VcInputMode.VoiceActivation);
|
||||||
if (_micStreamId != 0)
|
if (_micStreamId != 0)
|
||||||
{
|
{
|
||||||
_client.SetInputMode(VcInputMode.VoiceActivation);
|
_client.SetInputMode(VcInputMode.VoiceActivation);
|
||||||
@@ -794,6 +823,7 @@ public partial class MainForm : Form
|
|||||||
btnChangePtt.Visible = true;
|
btnChangePtt.Visible = true;
|
||||||
lblVadThreshold.Visible = false;
|
lblVadThreshold.Visible = false;
|
||||||
trkVadThreshold.Visible = false;
|
trkVadThreshold.Visible = false;
|
||||||
|
SaveInputMode(VcInputMode.PushToTalk);
|
||||||
if (_micStreamId != 0)
|
if (_micStreamId != 0)
|
||||||
{
|
{
|
||||||
_client.SetInputMode(VcInputMode.PushToTalk);
|
_client.SetInputMode(VcInputMode.PushToTalk);
|
||||||
@@ -808,15 +838,31 @@ public partial class MainForm : Form
|
|||||||
btnChangePtt.Visible = false;
|
btnChangePtt.Visible = false;
|
||||||
lblVadThreshold.Visible = false;
|
lblVadThreshold.Visible = false;
|
||||||
trkVadThreshold.Visible = false;
|
trkVadThreshold.Visible = false;
|
||||||
|
SaveInputMode(VcInputMode.AlwaysOn);
|
||||||
if (_micStreamId != 0) _client.SetInputMode(VcInputMode.AlwaysOn);
|
if (_micStreamId != 0) _client.SetInputMode(VcInputMode.AlwaysOn);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void TrkVadThreshold_Scroll(object? sender, EventArgs e)
|
private void TrkVadThreshold_Scroll(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
|
_voiceSettings.VadThresholdSlider = trkVadThreshold.Value;
|
||||||
|
_voiceSettings.Save();
|
||||||
if (_micStreamId != 0 && radioVad.Checked)
|
if (_micStreamId != 0 && radioVad.Checked)
|
||||||
_client.SetVadThreshold(VadThresholdFromSlider());
|
_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() =>
|
private float VadThresholdFromSlider() =>
|
||||||
0.1f * (1f - (trkVadThreshold.Value - 1f) / 99f);
|
0.1f * (1f - (trkVadThreshold.Value - 1f) / 99f);
|
||||||
|
|
||||||
@@ -832,6 +878,8 @@ public partial class MainForm : Form
|
|||||||
{
|
{
|
||||||
_pttKey = dlg.CapturedKey;
|
_pttKey = dlg.CapturedKey;
|
||||||
lblPttKey.Text = $"({_pttKey})";
|
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)]
|
[LibraryImport(LibName)]
|
||||||
internal static partial VcResult vc_set_output_volume(nint c, float gain);
|
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)]
|
[LibraryImport(LibName)]
|
||||||
internal static partial VcResult vc_set_remote_stream(nint c, uint userId, uint streamId,
|
internal static partial VcResult vc_set_remote_stream(nint c, uint userId, uint streamId,
|
||||||
float gain, int muted, int noiseReduction);
|
float gain, int muted, int noiseReduction);
|
||||||
|
|||||||
@@ -227,6 +227,11 @@ public sealed class VoiceCatClient : IDisposable
|
|||||||
public VcResult SetOutputVolume(float gain) =>
|
public VcResult SetOutputVolume(float gain) =>
|
||||||
NativeMethods.vc_set_output_volume(_handle.DangerousGetHandle(), gain < 0f ? 0f : 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) =>
|
public VcResult SetRemoteStream(uint userId, uint streamId, float gain, bool muted, bool noiseReduction) =>
|
||||||
NativeMethods.vc_set_remote_stream(_handle.DangerousGetHandle(), userId, streamId, gain,
|
NativeMethods.vc_set_remote_stream(_handle.DangerousGetHandle(), userId, streamId, gain,
|
||||||
muted ? 1 : 0, noiseReduction ? 1 : 0);
|
muted ? 1 : 0, noiseReduction ? 1 : 0);
|
||||||
|
|||||||
@@ -386,6 +386,12 @@ VC_API vc_result vc_set_self_mute(vc_client* c, int mic_muted, int deafened);
|
|||||||
* 1.0 = unity (default), >1.0 amplifies. Always LOCAL — no protocol traffic. */
|
* 1.0 = unity (default), >1.0 amplifies. Always LOCAL — no protocol traffic. */
|
||||||
VC_API vc_result vc_set_output_volume(vc_client* c, float gain);
|
VC_API vc_result vc_set_output_volume(vc_client* c, float 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; the boosted signal is clamped to int16. MIC stream only;
|
||||||
|
* always LOCAL — no protocol traffic. */
|
||||||
|
VC_API vc_result vc_set_input_gain(vc_client* c, float gain);
|
||||||
|
|
||||||
/* Receive-side, per remote stream, all LOCAL (no protocol traffic) — docs/voice.md §10:
|
/* Receive-side, per remote stream, all LOCAL (no protocol traffic) — docs/voice.md §10:
|
||||||
* gain (0..) , mute, and listener-chosen noise reduction on a specific user's stream. */
|
* gain (0..) , mute, and listener-chosen noise reduction on a specific user's stream. */
|
||||||
VC_API vc_result vc_set_remote_stream(vc_client* c, uint32_t user_id, uint32_t stream_id,
|
VC_API vc_result vc_set_remote_stream(vc_client* c, uint32_t user_id, uint32_t stream_id,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
|
#include <cmath>
|
||||||
#include <csignal>
|
#include <csignal>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
@@ -1008,6 +1009,21 @@ void vc_client::on_capture_frame(int kind, const int16_t* pcm, int samples, int
|
|||||||
(self_mic_muted_.load(std::memory_order_acquire) ||
|
(self_mic_muted_.load(std::memory_order_acquire) ||
|
||||||
server_muted_.load(std::memory_order_acquire))) return;
|
server_muted_.load(std::memory_order_acquire))) return;
|
||||||
|
|
||||||
|
// Send-side mic input gain (vc_set_input_gain) — MIC only. Applied in place before the gate
|
||||||
|
// so a boosted quiet mic also helps cross the VAD threshold. EnergyVadProcessor never writes
|
||||||
|
// through its pointer, so the const_cast (same as the VAD path below) is safe; no allocation.
|
||||||
|
if (kind == static_cast<int>(VC_STREAM_MIC)) {
|
||||||
|
const float gain = input_gain_.load(std::memory_order_relaxed);
|
||||||
|
if (gain != 1.0f) {
|
||||||
|
int16_t* w = const_cast<int16_t*>(pcm);
|
||||||
|
const int n = samples * std::max(1, channels);
|
||||||
|
for (int i = 0; i < n; ++i) {
|
||||||
|
int32_t v = static_cast<int32_t>(std::lround(w[i] * gain));
|
||||||
|
w[i] = static_cast<int16_t>(std::clamp(v, -32768, 32767));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Send-side input gate (docs/voice.md §11) — MIC only. SCREEN_AUDIO/AUX_DEVICE always
|
// Send-side input gate (docs/voice.md §11) — MIC only. SCREEN_AUDIO/AUX_DEVICE always
|
||||||
// bypass this: gating a screen-share on the user's own voice activity would silently drop
|
// bypass this: gating a screen-share on the user's own voice activity would silently drop
|
||||||
// shared music/video audio whenever the user isn't talking, which defeats the feature.
|
// shared music/video audio whenever the user isn't talking, which defeats the feature.
|
||||||
@@ -1452,6 +1468,11 @@ vc_result vc_client::set_output_volume(float gain) {
|
|||||||
return VC_OK;
|
return VC_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
vc_result vc_client::set_input_gain(float gain) {
|
||||||
|
input_gain_.store(gain < 0.0f ? 0.0f : gain, std::memory_order_relaxed);
|
||||||
|
return VC_OK;
|
||||||
|
}
|
||||||
|
|
||||||
vc_result vc_client::set_remote_stream(uint32_t user_id, uint32_t stream_id, float gain,
|
vc_result vc_client::set_remote_stream(uint32_t user_id, uint32_t stream_id, float gain,
|
||||||
bool muted, bool noise_reduction) {
|
bool muted, bool noise_reduction) {
|
||||||
if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
|
if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
|
||||||
@@ -1968,6 +1989,7 @@ vc_result vc_client::set_input_device(uint32_t, const char*) { return VC_ERR_NOT
|
|||||||
vc_result vc_client::set_capture_channels(uint32_t, uint32_t) { return VC_ERR_NOT_IMPLEMENTED; }
|
vc_result vc_client::set_capture_channels(uint32_t, uint32_t) { return VC_ERR_NOT_IMPLEMENTED; }
|
||||||
vc_result vc_client::set_input_mode(vc_input_mode) { return VC_ERR_NOT_IMPLEMENTED; }
|
vc_result vc_client::set_input_mode(vc_input_mode) { return VC_ERR_NOT_IMPLEMENTED; }
|
||||||
vc_result vc_client::set_vad_threshold(float) { return VC_ERR_NOT_IMPLEMENTED; }
|
vc_result vc_client::set_vad_threshold(float) { return VC_ERR_NOT_IMPLEMENTED; }
|
||||||
|
vc_result vc_client::set_input_gain(float) { return VC_ERR_NOT_IMPLEMENTED; }
|
||||||
vc_result vc_client::set_push_to_talk(bool) { return VC_ERR_NOT_IMPLEMENTED; }
|
vc_result vc_client::set_push_to_talk(bool) { return VC_ERR_NOT_IMPLEMENTED; }
|
||||||
vc_result vc_client::set_self_mute(bool, bool) { return VC_ERR_NOT_IMPLEMENTED; }
|
vc_result vc_client::set_self_mute(bool, bool) { return VC_ERR_NOT_IMPLEMENTED; }
|
||||||
vc_result vc_client::set_remote_stream(uint32_t, uint32_t, float, bool, bool) {
|
vc_result vc_client::set_remote_stream(uint32_t, uint32_t, float, bool, bool) {
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ struct vc_client {
|
|||||||
vc_result set_push_to_talk(bool active);
|
vc_result set_push_to_talk(bool active);
|
||||||
vc_result set_self_mute(bool mic_muted, bool deafened);
|
vc_result set_self_mute(bool mic_muted, bool deafened);
|
||||||
vc_result set_output_volume(float gain);
|
vc_result set_output_volume(float gain);
|
||||||
|
vc_result set_input_gain(float gain);
|
||||||
vc_result set_remote_stream(uint32_t user_id, uint32_t stream_id, float gain, bool muted,
|
vc_result set_remote_stream(uint32_t user_id, uint32_t stream_id, float gain, bool muted,
|
||||||
bool noise_reduction);
|
bool noise_reduction);
|
||||||
vc_result get_remote_stream(uint32_t user_id, uint32_t stream_id,
|
vc_result get_remote_stream(uint32_t user_id, uint32_t stream_id,
|
||||||
@@ -311,6 +312,7 @@ struct vc_client {
|
|||||||
std::atomic<vc_input_mode> current_input_mode_{VC_INPUT_VOICE_ACTIVATION};
|
std::atomic<vc_input_mode> current_input_mode_{VC_INPUT_VOICE_ACTIVATION};
|
||||||
std::atomic<bool> ptt_active_{false};
|
std::atomic<bool> ptt_active_{false};
|
||||||
std::atomic<float> vad_threshold_{0.025f}; // remembered across mode switches
|
std::atomic<float> vad_threshold_{0.025f}; // remembered across mode switches
|
||||||
|
std::atomic<float> input_gain_{1.0f}; // send-side MIC gain (vc_set_input_gain)
|
||||||
|
|
||||||
// External-playback mode (iOS VPIO): when true, ensure_audio_running() configures the
|
// External-playback mode (iOS VPIO): when true, ensure_audio_running() configures the
|
||||||
// AudioEngine to skip its hardware playback device and drive the mixer on a timer instead,
|
// AudioEngine to skip its hardware playback device and drive the mixer on a timer instead,
|
||||||
|
|||||||
@@ -122,6 +122,11 @@ vc_result vc_set_output_volume(vc_client* c, float gain) {
|
|||||||
return c->set_output_volume(gain);
|
return c->set_output_volume(gain);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
vc_result vc_set_input_gain(vc_client* c, float gain) {
|
||||||
|
if (c == nullptr) return VC_ERR_INVALID_ARG;
|
||||||
|
return c->set_input_gain(gain);
|
||||||
|
}
|
||||||
|
|
||||||
vc_result vc_set_remote_stream(vc_client* c, uint32_t user_id, uint32_t stream_id, float gain,
|
vc_result vc_set_remote_stream(vc_client* c, uint32_t user_id, uint32_t stream_id, float gain,
|
||||||
int muted, int noise_reduction) {
|
int muted, int noise_reduction) {
|
||||||
if (c == nullptr) return VC_ERR_INVALID_ARG;
|
if (c == nullptr) return VC_ERR_INVALID_ARG;
|
||||||
|
|||||||
Reference in New Issue
Block a user