Three bugs fixed across the full stack (proto/server/core/ABI/Win/macOS/iOS): 1. Join/Leave Voice now truly subscribes/unsubscribes from the voice plane. Previously the button only toggled the local mic — receiving was always on (gated by channel membership alone). Added a protocol-level voice subscription concept: new SubscribeVoiceRequest/UnsubscribeVoiceRequest/VoiceSubscriptionResult proto messages, User.voice_subscribed field, vc_join_voice/vc_leave_voice C ABI functions, VC_EVENT_VOICE_STATE event, server-side voice_subscribed flag checked by the SFU relay recipient filter, and core-client gating of remote-stream decoder setup. All three clients rewired to subscribe+mic on Join / unsubscribe on Leave. Text chat works regardless of voice subscription. 2. Channel edit dialog now shows the channel's actual current settings. The read struct vc_channel was missing sort_order and audio fields — only the write struct vc_channel_info had them. Extended vc_channel with both (additive, no ABI break), updated the session model and list_channels marshaling to populate them, and updated all three clients' edit callers to use actual channel info instead of hardcoded defaults. 3. Channel parameter updates now automatically restart everyone's streams. Previously editing a channel's audio config persisted and broadcast a ChannelEvent::UPDATED, but no layer restarted streams — encoders/decoders are frozen at announce time. handle_channel_event now detects audio-config changes on the user's current channel and stop->starts each active local stream. The server reads the updated config on re-announce; peers wire up fresh decoders at the new ssrc. All 29 CTest tests pass; Windows DLL + C# client build clean. Apple clients not yet compile-verified (Windows environment).
645 lines
29 KiB
Swift
645 lines
29 KiB
Swift
// VoiceCatClient — the public, Swift-idiomatic surface over libvoicecat. This is the Swift
|
||
// analog of the C# client's `VoiceCatClient.cs` (clients/windows/VoiceCat.Interop).
|
||
//
|
||
// Key patterns carried over from the proven C# implementation (see docs/architecture.md §4
|
||
// per-platform binding notes):
|
||
//
|
||
// 1. HANDLE OWNERSHIP: the class owns `vc_client*`; `deinit` calls `vc_client_destroy`
|
||
// (which synchronously joins every internal thread, so nothing can still be reading the
|
||
// config-string pointers or firing callbacks by the time it returns).
|
||
//
|
||
// 2. CONFIG STRING LIFETIMES: the core stores raw pointers from `vc_config` by value — it
|
||
// does NOT copy the string data. `client_name`/`client_version`/`tofu_store_path` are
|
||
// read later, whenever `connect()` actually runs on the io_thread_. So the native CString
|
||
// storage (`_clientNamePtr` etc.) must outlive the WHOLE client, not just `init`. It's
|
||
// freed in `deinit`, AFTER `vc_client_destroy` has returned. (C#: Marshal.StringToCoTask
|
||
// MemUTF8 in ctor, FreeCoTaskMem in Dispose after destroy.)
|
||
//
|
||
// 3. EVENT DELIVERY THREAD HANDOFF: `on_event` fires on the core's event thread. Events are
|
||
// buffered in a lock-protected array and drained on `DispatchQueue.main` — this is the
|
||
// boundary where the core's thread hands off to the UI thread. The C# analog is
|
||
// `Channel<VoiceCatEvent>` drained by a 30ms WinForms Timer; the Swift analog is a
|
||
// coalesced main-queue drain (only one async block scheduled at a time). `on_event`'s
|
||
// `text` is copied to a Swift `String` inside the callback (Callbacks.swift) before
|
||
// enqueueing — the raw pointer is dangling by the time the main thread drains.
|
||
//
|
||
// 4. LEVEL METER COALESCING: `on_level` fires far more often than `on_event` and
|
||
// intermediate values are visually irrelevant — coalesced to "latest sample per
|
||
// stream_id" in a lock-protected dictionary, drained on main alongside events.
|
||
// (C#: ConcurrentDictionary<uint,float> cleared in PumpEvents.)
|
||
//
|
||
// 5. IMMEDIATE vc_free_* ON LIST READS: `listChannels()`/`listUsers()`/etc. walk the native
|
||
// array, convert to Swift value types, and call `vc_free_*_list` INSIDE the function —
|
||
// callers never manage native list lifetime. (C#: Marshaling.ToManaged does the same.)
|
||
|
||
import VoiceCatC
|
||
import Foundation
|
||
|
||
/// Swift-idiomatic alias for the C `vc_pcm_sink_cb` function-pointer type from
|
||
/// `voicecat.h`. Exposed publicly so consumers (`VoiceCatMac`, tests) can declare a sink
|
||
/// callback without directly importing the `VoiceCatC` C module. Mirrors the C# wrapper's
|
||
/// `VcPcmSinkCallback` delegate.
|
||
public typealias VoiceCatPcmSinkCallback = vc_pcm_sink_cb
|
||
|
||
/// Swift-idiomatic alias for the C `vc_mixed_output_cb` function-pointer type from `voicecat.h`
|
||
/// — the external mixed-output sink used by the iOS VPIO path (see `setMixedOutputSink`).
|
||
public typealias VoiceCatMixedOutputCallback = vc_mixed_output_cb
|
||
|
||
/// The Swift wrapper around `vc_client*`. Owns the native handle for its entire lifetime;
|
||
/// `deinit` destroys it. Events and level meters are delivered on the main queue via the
|
||
/// `onEvent` / `onLevel` closures.
|
||
///
|
||
/// Thread-safety: the public methods are not thread-safe — call them from the main thread
|
||
/// (the standard AppKit/SwiftUI pattern). The internal event/level buffers are thread-safe
|
||
/// (lock-protected) because they're written from the core's event thread.
|
||
public final class VoiceCatClient {
|
||
|
||
// MARK: - Stored properties
|
||
|
||
/// The opaque C handle (`vc_client*` — Swift imports the incomplete C struct as
|
||
/// `OpaquePointer`). Set in `init`, passed to every C function, destroyed in `deinit`.
|
||
private var handle: OpaquePointer?
|
||
|
||
/// Unmanaged pointer to `self` — passed as `vc_callbacks.user` so the C function-pointer
|
||
/// callbacks can resolve back to this instance. `passUnretained` (not `passRetained`)
|
||
/// because we want normal ARC to control the object's lifetime — `deinit` calls
|
||
/// `vc_client_destroy` (joins all threads) before the object's memory is freed, so no
|
||
/// callback can fire with a dangling `user` pointer. See Callbacks.swift.
|
||
///
|
||
/// Computed (not stored) to break a circular init dependency: it needs `self`, but
|
||
/// stored properties must be initialized before `self` is available. `Unmanaged.passUn
|
||
/// retained(self).toOpaque()` always returns the same address for a given instance, so
|
||
/// computing it on demand is safe and consistent.
|
||
private var selfPointer: UnsafeMutableRawPointer {
|
||
Unmanaged.passUnretained(self).toOpaque()
|
||
}
|
||
|
||
/// Native CString storage backing `vc_config` — must outlive the whole client (the core
|
||
/// stores raw pointers, doesn't copy). Freed in `deinit` after `vc_client_destroy`.
|
||
private var clientNamePtr: UnsafeMutablePointer<CChar>?
|
||
private var clientVersionPtr: UnsafeMutablePointer<CChar>?
|
||
private var tofuStorePathPtr: UnsafeMutablePointer<CChar>?
|
||
|
||
// MARK: - Event / level delivery (main-queue)
|
||
|
||
/// Called on the main queue for every event, in order, never coalesced. Set this from
|
||
/// the main thread (AppKit/SwiftUI) to drive your UI.
|
||
public var onEvent: ((VoiceCatEvent) -> Void)?
|
||
|
||
/// Called on the main queue with the latest RMS level per stream_id since the last drain.
|
||
/// Intermediate values are coalesced (only the latest per stream_id is delivered).
|
||
public var onLevel: ((UInt32, Float) -> Void)?
|
||
|
||
/// Lock-protected buffers, written from the core's event thread, drained on main.
|
||
private let bufferLock = NSLock()
|
||
private var eventBuffer: [VoiceCatEvent] = []
|
||
private var levelSamples: [UInt32: Float] = [:]
|
||
private var drainScheduled = false
|
||
|
||
// MARK: - Init / deinit
|
||
|
||
/// Create a client. `config.clientName`/`clientVersion`/`tofuStorePath` are copied to
|
||
/// native CString storage held for the client's entire lifetime (the core reads them
|
||
/// later, e.g. when `connect()` runs on the io thread).
|
||
public init(config: VoiceCatConfig) {
|
||
// Allocate native C strings — must persist until after vc_client_destroy in deinit.
|
||
// These don't need `self`, so they're safe to set first.
|
||
self.clientNamePtr = strdup(config.clientName)
|
||
self.clientVersionPtr = strdup(config.clientVersion)
|
||
self.tofuStorePathPtr = config.tofuStorePath.flatMap { strdup($0) }
|
||
self.handle = nil // placeholder — set below after callbacks are wired
|
||
|
||
// All stored properties are now initialized → `self` is fully available, so we can
|
||
// call `selfPointer` (the computed property) to build the callbacks struct.
|
||
var nativeConfig = vc_config()
|
||
nativeConfig.client_name = UnsafePointer(clientNamePtr)
|
||
nativeConfig.client_version = UnsafePointer(clientVersionPtr)
|
||
nativeConfig.log_level = config.logLevel.cValue
|
||
nativeConfig.tofu_store_path = UnsafePointer(tofuStorePathPtr)
|
||
|
||
let callbacks = Callbacks.make(user: selfPointer)
|
||
self.handle = vc_client_create(&nativeConfig, callbacks)
|
||
|
||
if handle == nil {
|
||
free(clientNamePtr); clientNamePtr = nil
|
||
free(clientVersionPtr); clientVersionPtr = nil
|
||
if let tofuStorePathPtr { free(tofuStorePathPtr); self.tofuStorePathPtr = nil }
|
||
fatalError("vc_client_create returned nil")
|
||
}
|
||
}
|
||
|
||
deinit {
|
||
if let handle {
|
||
// Joins every internal thread synchronously — no callbacks can fire after this
|
||
// returns, so the selfPointer and config-string pointers are safe to free.
|
||
vc_client_destroy(handle)
|
||
self.handle = nil
|
||
}
|
||
// Free config strings AFTER destroy (the core may have been reading them up until
|
||
// destroy joined the io thread).
|
||
free(clientNamePtr); clientNamePtr = nil
|
||
free(clientVersionPtr); clientVersionPtr = nil
|
||
if let tofuStorePathPtr { free(tofuStorePathPtr); self.tofuStorePathPtr = nil }
|
||
}
|
||
|
||
// MARK: - Internal: event/level enqueue (called from the core's event thread)
|
||
|
||
/// Called by Callbacks.onEvent on the core's event thread. Buffers the event and
|
||
/// schedules a coalesced main-queue drain.
|
||
internal func enqueueEvent(_ event: VoiceCatEvent) {
|
||
bufferLock.lock()
|
||
eventBuffer.append(event)
|
||
let shouldSchedule = !drainScheduled
|
||
drainScheduled = true
|
||
bufferLock.unlock()
|
||
if shouldSchedule {
|
||
DispatchQueue.main.async { [weak self] in self?.drain() }
|
||
}
|
||
}
|
||
|
||
/// Called by Callbacks.onLevel on the core's event thread. Coalesces to latest-per-stream
|
||
/// and schedules a coalesced main-queue drain.
|
||
internal func enqueueLevel(_ streamId: UInt32, _ rms: Float) {
|
||
bufferLock.lock()
|
||
levelSamples[streamId] = rms
|
||
let shouldSchedule = !drainScheduled
|
||
drainScheduled = true
|
||
bufferLock.unlock()
|
||
if shouldSchedule {
|
||
DispatchQueue.main.async { [weak self] in self?.drain() }
|
||
}
|
||
}
|
||
|
||
/// Drains buffered events + coalesced levels on the main queue. Only one drain is
|
||
/// scheduled at a time (debounced via `drainScheduled`).
|
||
private func drain() {
|
||
bufferLock.lock()
|
||
let events = eventBuffer
|
||
eventBuffer.removeAll()
|
||
let levels = levelSamples
|
||
levelSamples.removeAll()
|
||
drainScheduled = false
|
||
bufferLock.unlock()
|
||
|
||
for event in events { onEvent?(event) }
|
||
for (streamId, rms) in levels { onLevel?(streamId, rms) }
|
||
}
|
||
|
||
// MARK: - Lifecycle (statics)
|
||
|
||
/// The core's version string (e.g. "VoiceCat 0.0.1 (protocol v1)"). Static literal — never freed.
|
||
public static var versionString: String {
|
||
String(cString: vc_version_string())
|
||
}
|
||
|
||
/// Human-readable description of a result code. Static literal — never freed.
|
||
public static func resultString(_ code: VoiceCatResult) -> String {
|
||
String(cString: vc_result_string(code.cValue))
|
||
}
|
||
|
||
// MARK: - Connection & auth (async; results via onEvent)
|
||
|
||
@discardableResult
|
||
public func connect(host: String, port: UInt16) -> VoiceCatResult {
|
||
VoiceCatResult(vc_connect(handle, host, port))
|
||
}
|
||
|
||
@discardableResult
|
||
public func disconnect() -> VoiceCatResult {
|
||
VoiceCatResult(vc_disconnect(handle))
|
||
}
|
||
|
||
@discardableResult
|
||
public func authenticateGuest(_ nickname: String) -> VoiceCatResult {
|
||
VoiceCatResult(vc_authenticate_guest(handle, nickname))
|
||
}
|
||
|
||
@discardableResult
|
||
public func authenticateUser(_ username: String, password: String) -> VoiceCatResult {
|
||
VoiceCatResult(vc_authenticate_user(handle, username, password))
|
||
}
|
||
|
||
// MARK: - TOFU server-identity gate (M4)
|
||
|
||
/// Accept or reject the pending server-identity check. Call after a `.serverIdentity`
|
||
/// event. `accept=true` on firstConnect/mismatch updates the pin file and proceeds;
|
||
/// `accept=false` aborts (emits `.disconnected` with `.crypto`). See docs/security.md §1.1.
|
||
@discardableResult
|
||
public func confirmServerIdentity(accept: Bool) -> VoiceCatResult {
|
||
VoiceCatResult(vc_confirm_server_identity(handle, accept ? 1 : 0))
|
||
}
|
||
|
||
/// The Ed25519 identity fingerprint from ServerHello, hex-formatted — DISPLAY ONLY, not
|
||
/// the value the TOFU gate pins on (see docs/security.md §1.1). Empty if not yet available.
|
||
/// Uses the two-call idiom: query size with nil buffer, then allocate + fetch.
|
||
public func getServerIdentityDisplay() -> String {
|
||
var len: Int = 0
|
||
_ = vc_get_server_identity_display(handle, nil, 0, &len)
|
||
if len == 0 { return "" }
|
||
let buf = UnsafeMutablePointer<CChar>.allocate(capacity: len + 1)
|
||
defer { buf.deallocate() }
|
||
_ = vc_get_server_identity_display(handle, buf, len + 1, &len)
|
||
return String(cString: buf)
|
||
}
|
||
|
||
// MARK: - Channels
|
||
|
||
/// Join a channel. Result arrives as a `.joinResult` event (not via the return value,
|
||
/// which only reflects "request queued"). `password` is for password-protected channels.
|
||
@discardableResult
|
||
public func joinChannel(_ channelId: UInt32, password: String? = nil) -> VoiceCatResult {
|
||
VoiceCatResult(vc_join_channel(handle, channelId, password))
|
||
}
|
||
|
||
@discardableResult
|
||
public func leaveChannel() -> VoiceCatResult {
|
||
VoiceCatResult(vc_leave_channel(handle))
|
||
}
|
||
|
||
@discardableResult
|
||
public func joinVoice() -> VoiceCatResult {
|
||
VoiceCatResult(vc_join_voice(handle))
|
||
}
|
||
|
||
@discardableResult
|
||
public func leaveVoice() -> VoiceCatResult {
|
||
VoiceCatResult(vc_leave_voice(handle))
|
||
}
|
||
|
||
/// Pull the current channel tree. Re-call after `.channelList`/`.userJoined`/`.userLeft`/
|
||
/// `.userUpdated` events. The native list is freed inside this call — callers never
|
||
/// manage native lifetime.
|
||
public func listChannels() -> [Channel] {
|
||
var native = vc_channel_list()
|
||
_ = vc_list_channels(handle, &native)
|
||
return Marshaling.channels(&native)
|
||
}
|
||
|
||
public func listUsers() -> [User] {
|
||
var native = vc_user_list()
|
||
_ = vc_list_users(handle, &native)
|
||
return Marshaling.users(&native)
|
||
}
|
||
|
||
public func listUserStreams(_ userId: UInt32) -> [StreamSummary] {
|
||
var native = vc_stream_summary_list()
|
||
let r = vc_list_user_streams(handle, userId, &native)
|
||
guard r == VC_OK else { return [] }
|
||
return Marshaling.streamSummaries(&native)
|
||
}
|
||
|
||
// MARK: - Local media streams
|
||
|
||
/// Start a mic / screen-audio / aux stream. Returns `(result, streamId)` — `streamId`
|
||
/// is non-zero on success. The `label` and `deviceId` C strings are only needed for the
|
||
/// duration of the call (the core copies what it needs), so we use temporary strdup'd
|
||
/// buffers freed via `defer`.
|
||
@discardableResult
|
||
public func startStream(_ descriptor: StreamDescriptor) -> (VoiceCatResult, UInt32) {
|
||
var streamId: UInt32 = 0
|
||
let labelPtr = strdup(descriptor.label)
|
||
defer { free(labelPtr) }
|
||
let deviceIdPtr = descriptor.deviceId.flatMap { strdup($0) }
|
||
defer { if let deviceIdPtr { free(deviceIdPtr) } }
|
||
|
||
var desc = vc_stream_desc()
|
||
desc.kind = descriptor.kind.cValue
|
||
desc.device_id = deviceIdPtr.map { UnsafePointer($0) }
|
||
desc.label = UnsafePointer(labelPtr)
|
||
desc.external_feed = descriptor.externalFeed ? 1 : 0
|
||
|
||
let r = vc_stream_start(handle, &desc, &streamId)
|
||
return (VoiceCatResult(r), streamId)
|
||
}
|
||
|
||
@discardableResult
|
||
public func stopStream(_ streamId: UInt32) -> VoiceCatResult {
|
||
VoiceCatResult(vc_stream_stop(handle, streamId))
|
||
}
|
||
|
||
@discardableResult
|
||
public func setInputDevice(streamId: UInt32, deviceId: String?) -> VoiceCatResult {
|
||
VoiceCatResult(vc_set_input_device(handle, streamId, deviceId))
|
||
}
|
||
|
||
/// Set the capture channel count for a local MIC stream (1 = mono, 2 = stereo interleaved).
|
||
/// Takes effect on the next AudioEngine restart (immediately if already running). Used by
|
||
/// the iOS `IOSAudioRouter` when the user picks stereo built-in mic capture.
|
||
@discardableResult
|
||
public func setCaptureChannels(streamId: UInt32, channels: UInt32) -> VoiceCatResult {
|
||
VoiceCatResult(vc_set_capture_channels(handle, streamId, channels))
|
||
}
|
||
|
||
// MARK: - External PCM feed / tap
|
||
|
||
/// External PCM feed — drives a local stream's encode pipeline with caller-supplied PCM
|
||
/// instead of (or in addition to) a hardware capture device. Intended for ReplayKit
|
||
/// Broadcast Extension (iOS), ScreenCaptureKit (macOS), bots, and soundboard use cases.
|
||
///
|
||
/// - Parameters:
|
||
/// - streamId: The stream returned by `startStream`. Must be active.
|
||
/// - pcm: Raw int16 PCM pointer. Caller must keep the buffer alive for the duration of the call.
|
||
/// - samplesPerChannel: Samples per channel (e.g. 960 for 20 ms @ 48 kHz).
|
||
/// - channels: 1 (mono) or 2 (stereo interleaved L/R).
|
||
@discardableResult
|
||
public func feedPcm(streamId: UInt32, pcm: UnsafePointer<Int16>,
|
||
samplesPerChannel: Int, channels: UInt32) -> VoiceCatResult {
|
||
VoiceCatResult(vc_stream_feed_pcm(handle, streamId, pcm,
|
||
samplesPerChannel, channels))
|
||
}
|
||
|
||
/// Convenience overload for feeding from a Swift `[Int16]` array.
|
||
@discardableResult
|
||
public func feedPcm(streamId: UInt32, pcm: [Int16],
|
||
samplesPerChannel: Int, channels: UInt32) -> VoiceCatResult {
|
||
pcm.withUnsafeBufferPointer {
|
||
feedPcm(streamId: streamId, pcm: $0.baseAddress!,
|
||
samplesPerChannel: samplesPerChannel, channels: channels)
|
||
}
|
||
}
|
||
|
||
/// External PCM tap — receive decoded per-stream audio as raw int16 PCM before it
|
||
/// reaches the hardware mix. Fires once per decoded Opus frame per remote stream.
|
||
///
|
||
/// The callback is a C function pointer (`@convention(c)`) receiving:
|
||
/// `(user, userId, streamId, pcm, samplesPerChannel, channels, sampleRate)`
|
||
///
|
||
/// Pass `nil` to disable (default). The callback MUST NOT block or allocate.
|
||
@discardableResult
|
||
public func setPcmSink(_ cb: VoiceCatPcmSinkCallback?, user: UnsafeMutableRawPointer?) -> VoiceCatResult {
|
||
VoiceCatResult(vc_set_pcm_sink(handle, cb, user))
|
||
}
|
||
|
||
/// External mixed-output sink (iOS VPIO) — receives the FINAL mixed remote audio as int16
|
||
/// PCM on the core's mixer-timer thread when external playback is enabled. The Swift VPIO
|
||
/// renderer copies this into its ring and plays it through the voice-processing output so
|
||
/// echo cancellation has its reference signal. Pass `nil` to disable. Mirrors
|
||
/// `vc_set_mixed_output_sink`. The callback MUST NOT block or allocate.
|
||
@discardableResult
|
||
public func setMixedOutputSink(_ cb: VoiceCatMixedOutputCallback?,
|
||
user: UnsafeMutableRawPointer?) -> VoiceCatResult {
|
||
VoiceCatResult(vc_set_mixed_output_sink(handle, cb, user))
|
||
}
|
||
|
||
/// Enable/disable external-playback mode (iOS VPIO). When enabled, the core opens NO hardware
|
||
/// playback device; it drives decode+mix on a timer and delivers the final mix via
|
||
/// `setMixedOutputSink`. Apply before the engine starts, or follow with `audioRestart()` to
|
||
/// apply to a running engine. Mirrors `vc_set_external_playback`.
|
||
@discardableResult
|
||
public func setExternalPlayback(_ enabled: Bool) -> VoiceCatResult {
|
||
VoiceCatResult(vc_set_external_playback(handle, enabled ? 1 : 0))
|
||
}
|
||
|
||
@discardableResult
|
||
public func setInputMode(_ mode: VoiceCatInputMode) -> VoiceCatResult {
|
||
VoiceCatResult(vc_set_input_mode(handle, mode.cValue))
|
||
}
|
||
|
||
/// VAD threshold: normalized RMS 0.0–1.0 (default ~0.025). Takes effect immediately.
|
||
@discardableResult
|
||
public func setVadThreshold(_ threshold: Float) -> VoiceCatResult {
|
||
VoiceCatResult(vc_set_vad_threshold(handle, threshold))
|
||
}
|
||
|
||
@discardableResult
|
||
public func setPushToTalk(_ active: Bool) -> VoiceCatResult {
|
||
VoiceCatResult(vc_set_push_to_talk(handle, active ? 1 : 0))
|
||
}
|
||
|
||
@discardableResult
|
||
public func setSelfMute(micMuted: Bool, deafened: Bool) -> VoiceCatResult {
|
||
VoiceCatResult(vc_set_self_mute(handle, micMuted ? 1 : 0, deafened ? 1 : 0))
|
||
}
|
||
|
||
/// Global playback volume applied after mixing all remote streams. gain 0.0 = silent,
|
||
/// 1.0 = unity (default), >1.0 amplifies. Always LOCAL — no protocol traffic. Mirrors the
|
||
/// Windows client's `SetOutputVolume` and the C ABI `vc_set_output_volume` added in M5.
|
||
@discardableResult
|
||
public func setOutputVolume(_ gain: Float) -> VoiceCatResult {
|
||
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))
|
||
}
|
||
|
||
/// Send-side microphone noise suppression (RNNoise). Denoises captured MIC PCM before the
|
||
/// input gain and VAD/PTT gate, so everyone hears the cleaned signal (one pass for all
|
||
/// listeners). MIC stream only, mono only; always LOCAL — no protocol traffic. Independent
|
||
/// of the per-listener receive-side NR in `setRemoteStream` (docs/voice.md §10).
|
||
@discardableResult
|
||
public func setInputNoiseReduction(_ enable: Bool) -> VoiceCatResult {
|
||
VoiceCatResult(vc_set_input_noise_reduction(handle, enable ? 1 : 0))
|
||
}
|
||
|
||
// MARK: - AVAudioSession interruption hooks (iOS)
|
||
|
||
/// Pause miniaudio device I/O. Call when AVAudioSession interruption begins.
|
||
@discardableResult
|
||
public func audioSuspend() -> VoiceCatResult {
|
||
VoiceCatResult(vc_audio_suspend(handle))
|
||
}
|
||
|
||
/// Resume miniaudio device I/O. Call after re-activating AVAudioSession.
|
||
@discardableResult
|
||
public func audioResume() -> VoiceCatResult {
|
||
VoiceCatResult(vc_audio_resume(handle))
|
||
}
|
||
|
||
/// Full audio engine restart — uninitialize and re-initialize the capture and playback
|
||
/// devices so they pick up a new AVAudioSession route. Call this AFTER reconfiguring
|
||
/// AVAudioSession (setCategory, setPreferredInput, setPreferredPolarPattern, etc.) so the
|
||
/// core's devices reopen against the new route. Unlike `audioSuspend()`/`audioResume()`
|
||
/// (which only stop/start the existing devices, leaving them bound to the route that was
|
||
/// active when they were opened), this fully re-initializes them. Safe to call when the
|
||
/// engine is not running (it will just start it).
|
||
@discardableResult
|
||
public func audioRestart() -> VoiceCatResult {
|
||
VoiceCatResult(vc_audio_restart(handle))
|
||
}
|
||
|
||
// MARK: - Receive-side, per remote stream (LOCAL — no protocol traffic; docs/voice.md §10)
|
||
|
||
@discardableResult
|
||
public func setRemoteStream(userId: UInt32, streamId: UInt32, gain: Float,
|
||
muted: Bool, noiseReduction: Bool) -> VoiceCatResult {
|
||
VoiceCatResult(vc_set_remote_stream(handle, userId, streamId, gain,
|
||
muted ? 1 : 0, noiseReduction ? 1 : 0))
|
||
}
|
||
|
||
public func getRemoteStream(userId: UInt32, streamId: UInt32) -> (VoiceCatResult, RemoteStreamState?) {
|
||
var state = vc_remote_stream_state()
|
||
let r = vc_get_remote_stream(handle, userId, streamId, &state)
|
||
guard r == VC_OK else { return (VoiceCatResult(r), nil) }
|
||
return (VoiceCatResult(r), Marshaling.remoteStreamState(state))
|
||
}
|
||
|
||
public func getStreamAudioConfig(userId: UInt32, streamId: UInt32) -> (VoiceCatResult, AudioConfig?) {
|
||
var cfg = vc_audio_config()
|
||
let r = vc_get_stream_audio_config(handle, userId, streamId, &cfg)
|
||
guard r == VC_OK else { return (VoiceCatResult(r), nil) }
|
||
return (VoiceCatResult(r), Marshaling.audioConfig(cfg))
|
||
}
|
||
|
||
// MARK: - Text
|
||
|
||
@discardableResult
|
||
public func sendText(scope: VoiceCatTextScope, targetId: UInt32, text: String) -> VoiceCatResult {
|
||
VoiceCatResult(vc_send_text(handle, scope.cValue, targetId, text))
|
||
}
|
||
|
||
// MARK: - Device enumeration (works pre-connect)
|
||
|
||
public func listDevices(_ kind: VoiceCatDeviceKind) -> [Device] {
|
||
var native = vc_device_list()
|
||
_ = vc_list_devices(handle, kind.cValue, &native)
|
||
return Marshaling.devices(&native)
|
||
}
|
||
|
||
// MARK: - M5: Moderation
|
||
|
||
@discardableResult
|
||
public func kickUser(_ userId: UInt32, reason: String? = nil) -> VoiceCatResult {
|
||
VoiceCatResult(vc_kick_user(handle, userId, reason))
|
||
}
|
||
|
||
@discardableResult
|
||
public func banUser(_ userId: UInt32, reason: String? = nil,
|
||
expiresUnixMs: UInt64 = 0) -> VoiceCatResult {
|
||
VoiceCatResult(vc_ban_user(handle, userId, reason, expiresUnixMs))
|
||
}
|
||
|
||
@discardableResult
|
||
public func setPermission(_ userId: UInt32, perms: Permissions) -> VoiceCatResult {
|
||
var native = vc_permissions()
|
||
native.can_create_temp_channel = perms.canCreateTempChannel ? 1 : 0
|
||
native.can_kick = perms.canKick ? 1 : 0
|
||
native.can_ban = perms.canBan ? 1 : 0
|
||
native.can_move_users = perms.canMoveUsers ? 1 : 0
|
||
native.can_admin_accounts = perms.canAdminAccounts ? 1 : 0
|
||
native.is_admin = perms.isAdmin ? 1 : 0
|
||
return VoiceCatResult(vc_set_permission(handle, userId, &native))
|
||
}
|
||
|
||
@discardableResult
|
||
public func setServerMute(_ userId: UInt32, muted: Bool, deafened: Bool) -> VoiceCatResult {
|
||
VoiceCatResult(vc_set_server_mute(handle, userId, muted ? 1 : 0, deafened ? 1 : 0))
|
||
}
|
||
|
||
@discardableResult
|
||
public func moveUser(_ userId: UInt32, toChannel channelId: UInt32) -> VoiceCatResult {
|
||
VoiceCatResult(vc_move_user(handle, userId, channelId))
|
||
}
|
||
|
||
// MARK: - M5: Channel admin
|
||
|
||
@discardableResult
|
||
public func createChannel(_ info: ChannelEdit) -> VoiceCatResult {
|
||
var native = vc_channel_info()
|
||
Self.populateChannelInfo(&native, from: info)
|
||
defer { Self.freeChannelInfoStrings(&native) }
|
||
return VoiceCatResult(vc_create_channel(handle, &native))
|
||
}
|
||
|
||
@discardableResult
|
||
public func editChannel(_ info: ChannelEdit) -> VoiceCatResult {
|
||
var native = vc_channel_info()
|
||
Self.populateChannelInfo(&native, from: info)
|
||
defer { Self.freeChannelInfoStrings(&native) }
|
||
return VoiceCatResult(vc_edit_channel(handle, &native))
|
||
}
|
||
|
||
@discardableResult
|
||
public func deleteChannel(_ channelId: UInt32) -> VoiceCatResult {
|
||
VoiceCatResult(vc_delete_channel(handle, channelId))
|
||
}
|
||
|
||
// MARK: - M5: Account admin
|
||
|
||
@discardableResult
|
||
public func createAccount(_ username: String, password: String) -> VoiceCatResult {
|
||
VoiceCatResult(vc_create_account(handle, username, password))
|
||
}
|
||
|
||
@discardableResult
|
||
public func resetPassword(_ username: String, newPassword: String) -> VoiceCatResult {
|
||
VoiceCatResult(vc_reset_password(handle, username, newPassword))
|
||
}
|
||
|
||
@discardableResult
|
||
public func deleteAccount(_ username: String) -> VoiceCatResult {
|
||
VoiceCatResult(vc_delete_account(handle, username))
|
||
}
|
||
|
||
/// Request the account list — result arrives as a `.accountList` event, then call
|
||
/// `listAccounts()` to pull the cached list.
|
||
@discardableResult
|
||
public func requestAccountList() -> VoiceCatResult {
|
||
VoiceCatResult(vc_list_accounts(handle))
|
||
}
|
||
|
||
public func listAccounts() -> [Account] {
|
||
var native = vc_account_list()
|
||
_ = vc_get_account_list(handle, &native)
|
||
return Marshaling.accounts(&native)
|
||
}
|
||
|
||
public func getPermissions() -> Permissions {
|
||
var native = vc_permissions()
|
||
_ = vc_get_permissions(handle, &native)
|
||
return Marshaling.permissions(native)
|
||
}
|
||
}
|
||
|
||
// MARK: - Helpers for vc_channel_info / vc_audio_config construction
|
||
|
||
extension VoiceCatClient {
|
||
/// Populate a `vc_channel_info` from a Swift `ChannelEdit`. The string fields
|
||
/// (`name`/`topic`/`password`) are strdup'd — the caller MUST call
|
||
/// `freeChannelInfoStrings(_:)` after the C call returns (the core copies what it needs
|
||
/// during the call, so the temporary buffers can be freed via `defer`).
|
||
internal static func populateChannelInfo(_ native: inout vc_channel_info, from info: ChannelEdit) {
|
||
native.id = info.id
|
||
native.parent_id = info.parentId
|
||
native.name = UnsafePointer(strdup(info.name))
|
||
native.topic = UnsafePointer(strdup(info.topic))
|
||
native.password_protected = info.passwordProtected ? 1 : 0
|
||
native.password = (info.passwordProtected && !(info.password?.isEmpty ?? true))
|
||
? UnsafePointer(strdup(info.password!)) : nil
|
||
native.max_users = info.maxUsers
|
||
native.sort_order = info.sortOrder
|
||
native.audio = info.audio.toNative()
|
||
}
|
||
|
||
/// Free the strdup'd string fields of a `vc_channel_info` populated by
|
||
/// `populateChannelInfo`. Call this in a `defer` after the C call.
|
||
internal static func freeChannelInfoStrings(_ native: inout vc_channel_info) {
|
||
if let p = native.name { free(UnsafeMutablePointer(mutating: p)); native.name = nil }
|
||
if let p = native.topic { free(UnsafeMutablePointer(mutating: p)); native.topic = nil }
|
||
if let p = native.password { free(UnsafeMutablePointer(mutating: p)); native.password = nil }
|
||
}
|
||
}
|
||
|
||
extension AudioConfig {
|
||
/// Convert to a native `vc_audio_config`.
|
||
internal func toNative() -> vc_audio_config {
|
||
var n = vc_audio_config()
|
||
n.codec = codec
|
||
n.mode = stereo ? 1 : 0
|
||
n.sample_rate = sampleRate
|
||
n.bitrate_bps = bitrateBps
|
||
n.frame_ms = frameMs
|
||
n.application = application
|
||
n.fec = fec ? 1 : 0
|
||
n.expected_packet_loss = expectedPacketLoss
|
||
n.dtx = dtx ? 1 : 0
|
||
n.complexity = complexity
|
||
n.dred = dred ? 1 : 0
|
||
return n
|
||
}
|
||
}
|