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

@@ -24,6 +24,7 @@
#include <algorithm>
#include <chrono>
#include <cmath>
#include <csignal>
#include <cstring>
#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) ||
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
// 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.
@@ -1452,6 +1468,11 @@ vc_result vc_client::set_output_volume(float gain) {
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,
bool muted, bool noise_reduction) {
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_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_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_self_mute(bool, bool) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::set_remote_stream(uint32_t, uint32_t, float, bool, bool) {

View File

@@ -55,6 +55,7 @@ struct vc_client {
vc_result set_push_to_talk(bool active);
vc_result set_self_mute(bool mic_muted, bool deafened);
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,
bool noise_reduction);
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<bool> ptt_active_{false};
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
// AudioEngine to skip its hardware playback device and drive the mixer on a timer instead,

View File

@@ -122,6 +122,11 @@ vc_result vc_set_output_volume(vc_client* c, float 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,
int muted, int noise_reduction) {
if (c == nullptr) return VC_ERR_INVALID_ARG;