feat(apple): VoiceCatCore Swift package + XCFramework build for macOS/iOS clients
Lays the groundwork for the macOS (AppKit) and iOS (SwiftUI) clients with a shared
Swift core wrapping the C ABI, mirroring the proven Windows VoiceCat.Interop layer.
Architecture decision: macOS UI = AppKit (not SwiftUI) for the most mature VoiceOver
accessibility story — same rationale as the Windows client's WinForms-over-WinUI-3
decision. iOS stays SwiftUI. Recorded in docs/roadmap.md §2.
Build infrastructure (Phase 0):
- clients/apple/scripts/build-xcframework.sh: runs cmake --preset apple-dev, merges
libvoicecat.a + 107 vcpkg static deps into a single ~30 MB fat static library
(libvoicecat-fat.a) via libtool -static (SPM binary targets link one .a per slice),
stages voicecat.h + a generated module.modulemap (module VoiceCatC) into the headers,
runs xcodebuild -create-xcframework -> clients/apple/VoiceCatCore.xcframework.
VoiceCatCore Swift Package (Phase 1):
- Package.swift: binary target (VoiceCatCoreXCF) + library (VoiceCatCore) + test target.
- Sources/VoiceCatCore/: 7 files mirroring the C# VoiceCat.Interop patterns adapted to
Swift native C interop — Enums (9 Swift mirrors of C enums, UInt32-backed), Config,
Event (copies ev.text to String inside the callback — the #1 lifetime rule), Models
(10 Swift value types), Marshaling (C arrays -> Swift + immediate vc_free_*),
Callbacks (@convention(c) + Unmanaged.passUnretained, the Swift analog of C#'s
[UnmanagedCallersOnly] + GCHandle), VoiceCatClient (owns vc_client* as OpaquePointer,
all 38 C ABI functions, deinit -> vc_client_destroy then frees config CStrings, event
delivery on main queue via coalesced DispatchQueue.main drain).
Tests — 6/6 green (swift test against a real voicecat-server):
- testConnectTofuAuthListChannelsRoundTrips, testAdminChannelCrudAccountCrudRoundTrips,
testScreenAudioStreamStartsAndStops, testPerStreamRecvControlsRoundTrip, plus two
static smoke tests. Catches Swift-specific interop bugs (@convention(c) callback
lifetime, Unmanaged pointer resolution, CString memory management, enum raw-value
bridging, struct layout) that C++ ctest cannot. C++ suite still 21/21 green.
Docs updated (house rule): tech-stack.md §2, architecture.md §4, roadmap.md M4 + §2,
clients/apple/README.md (full rewrite), PROGRESS.md, .gitignore.
2026-06-18 14:20:38 +02:00
|
|
|
|
// 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
|
|
|
|
|
|
|
feat(macos): UI overhaul -- toolbar, unified log, PM windows, settings window, hotkeys
Mirrors the Windows client's UI overhaul (commit 97fa659 + 540ec13) adapted to
Mac-native conventions. The main window is now just toolbar + channels + users
+ chat; audio device settings moved to a modeless Settings window.
- NSToolbar: Join Voice, Share Screen Audio, Mute, Deafen (SF Symbol toggle
buttons) + Output Volume slider (NSSlider 0-100, default 80). Voice actions,
mute/deafen, and output volume moved out of the bottom panel into the toolbar
- Audio device settings (input mode, VAD sensitivity, PTT key, device picker,
level meter) moved to a new SettingsWindowController -- a modeless window
opened via the app menu's "Settings..." (Cmd+,) item. Source-of-truth for
audio state lives in MainWindowController so voice start applies settings even
before the window has been opened; SettingsWindowController reads from /
writes back to those properties and applies changes live when voice is active.
Level meter forwarded from handleLevel -> updateLevel(rms:)
- Unified log: chat NSTextView + activity NSTableView collapsed into a single
NSTextView -- activity events in secondaryLabelColor (gray), chat in default
- Private messaging: scope dropdown removed; compose always sends to the
current channel. Each PM conversation opens in its own modeless
PrivateMessageWindowController. Incoming .textMessage with .private scope
routed to the right window; outgoing PMs echoed by server arrive through the
same path. "Send Private Message..." added to user context menu.
- Messages menu: "New Private Message..." (Cmd+Shift+N) opens a UserPickerSheet
listing all server users so you can PM anyone on the server
- Channel tree now shows live user counts, e.g. "General (3)"; refreshChannelTree
called on .userJoined/.userLeft (was missing)
- Voice menu: Join Voice (Cmd+Shift+V), Share Screen Audio (Cmd+Shift+S),
Mute (Cmd+Shift+M), Deafen (Cmd+Shift+D) -- NSMenuItem key equivalents with
[.command, .shift] mask, dispatched by the responder chain
- setOutputVolume(_:) wrapper added to VoiceCatClient.swift (was missing -- the
C ABI + C# wrapper shipped in commit 97fa659 but the Swift wrapper was never
added); wired end-to-end: toolbar slider -> client.setOutputVolume(gain)
Part A -- fixed and verified the previously-uncompiled Swift from the external
PCM feed/tap commit (615d2a8):
- Rebuilt the macOS xcframework slice (regenerated the module map from current
voicecat.h, exposing vc_pcm_sink_cb / vc_stream_feed_pcm / vc_set_pcm_sink)
- Fixed feedPcm type bug: size_t imports as Int in Swift not UInt; the original
UInt(samplesPerChannel) was wrong
- Added VoiceCatPcmSinkCallback typealias -- a Swift-idiomatic public alias for
the C vc_pcm_sink_cb so consumers (tests, the macOS app) can declare a sink
callback without directly importing the VoiceCatC C module. Mirrors the C#
VcPcmSinkCallback delegate
- keyCodeName helper deduplicated (was in PttKeyCaptureSheet.swift +
MainWindowController.swift -- now shared)
Platform-specific adaptations (vs. Windows): NSToolbar instead of ToolStrip;
global menu bar + NSMenuItem key equivalents (Cmd not Ctrl, responder-chain
dispatched, no custom key monitor needed); PM windows as modeless NSWindows;
picker as Mac sheet; gray = secondaryLabelColor; SF Symbols for toolbar icons.
swift test 10/10 (4 ExternalPcmTests + 6 VoiceCatClientSmokeTests against a
live server); xcodebuild Debug + Release BUILD SUCCEEDED with 0 Swift warnings.
2026-06-20 23:30:52 +02:00
|
|
|
|
/// 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
|
|
|
|
|
|
|
2026-06-22 02:38:01 +02:00
|
|
|
|
/// 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
|
|
|
|
|
|
|
feat(apple): VoiceCatCore Swift package + XCFramework build for macOS/iOS clients
Lays the groundwork for the macOS (AppKit) and iOS (SwiftUI) clients with a shared
Swift core wrapping the C ABI, mirroring the proven Windows VoiceCat.Interop layer.
Architecture decision: macOS UI = AppKit (not SwiftUI) for the most mature VoiceOver
accessibility story — same rationale as the Windows client's WinForms-over-WinUI-3
decision. iOS stays SwiftUI. Recorded in docs/roadmap.md §2.
Build infrastructure (Phase 0):
- clients/apple/scripts/build-xcframework.sh: runs cmake --preset apple-dev, merges
libvoicecat.a + 107 vcpkg static deps into a single ~30 MB fat static library
(libvoicecat-fat.a) via libtool -static (SPM binary targets link one .a per slice),
stages voicecat.h + a generated module.modulemap (module VoiceCatC) into the headers,
runs xcodebuild -create-xcframework -> clients/apple/VoiceCatCore.xcframework.
VoiceCatCore Swift Package (Phase 1):
- Package.swift: binary target (VoiceCatCoreXCF) + library (VoiceCatCore) + test target.
- Sources/VoiceCatCore/: 7 files mirroring the C# VoiceCat.Interop patterns adapted to
Swift native C interop — Enums (9 Swift mirrors of C enums, UInt32-backed), Config,
Event (copies ev.text to String inside the callback — the #1 lifetime rule), Models
(10 Swift value types), Marshaling (C arrays -> Swift + immediate vc_free_*),
Callbacks (@convention(c) + Unmanaged.passUnretained, the Swift analog of C#'s
[UnmanagedCallersOnly] + GCHandle), VoiceCatClient (owns vc_client* as OpaquePointer,
all 38 C ABI functions, deinit -> vc_client_destroy then frees config CStrings, event
delivery on main queue via coalesced DispatchQueue.main drain).
Tests — 6/6 green (swift test against a real voicecat-server):
- testConnectTofuAuthListChannelsRoundTrips, testAdminChannelCrudAccountCrudRoundTrips,
testScreenAudioStreamStartsAndStops, testPerStreamRecvControlsRoundTrip, plus two
static smoke tests. Catches Swift-specific interop bugs (@convention(c) callback
lifetime, Unmanaged pointer resolution, CString memory management, enum raw-value
bridging, struct layout) that C++ ctest cannot. C++ suite still 21/21 green.
Docs updated (house rule): tech-stack.md §2, architecture.md §4, roadmap.md M4 + §2,
clients/apple/README.md (full rewrite), PROGRESS.md, .gitignore.
2026-06-18 14:20:38 +02:00
|
|
|
|
/// 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))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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)
|
2026-06-22 02:38:01 +02:00
|
|
|
|
desc.external_feed = descriptor.externalFeed ? 1 : 0
|
feat(apple): VoiceCatCore Swift package + XCFramework build for macOS/iOS clients
Lays the groundwork for the macOS (AppKit) and iOS (SwiftUI) clients with a shared
Swift core wrapping the C ABI, mirroring the proven Windows VoiceCat.Interop layer.
Architecture decision: macOS UI = AppKit (not SwiftUI) for the most mature VoiceOver
accessibility story — same rationale as the Windows client's WinForms-over-WinUI-3
decision. iOS stays SwiftUI. Recorded in docs/roadmap.md §2.
Build infrastructure (Phase 0):
- clients/apple/scripts/build-xcframework.sh: runs cmake --preset apple-dev, merges
libvoicecat.a + 107 vcpkg static deps into a single ~30 MB fat static library
(libvoicecat-fat.a) via libtool -static (SPM binary targets link one .a per slice),
stages voicecat.h + a generated module.modulemap (module VoiceCatC) into the headers,
runs xcodebuild -create-xcframework -> clients/apple/VoiceCatCore.xcframework.
VoiceCatCore Swift Package (Phase 1):
- Package.swift: binary target (VoiceCatCoreXCF) + library (VoiceCatCore) + test target.
- Sources/VoiceCatCore/: 7 files mirroring the C# VoiceCat.Interop patterns adapted to
Swift native C interop — Enums (9 Swift mirrors of C enums, UInt32-backed), Config,
Event (copies ev.text to String inside the callback — the #1 lifetime rule), Models
(10 Swift value types), Marshaling (C arrays -> Swift + immediate vc_free_*),
Callbacks (@convention(c) + Unmanaged.passUnretained, the Swift analog of C#'s
[UnmanagedCallersOnly] + GCHandle), VoiceCatClient (owns vc_client* as OpaquePointer,
all 38 C ABI functions, deinit -> vc_client_destroy then frees config CStrings, event
delivery on main queue via coalesced DispatchQueue.main drain).
Tests — 6/6 green (swift test against a real voicecat-server):
- testConnectTofuAuthListChannelsRoundTrips, testAdminChannelCrudAccountCrudRoundTrips,
testScreenAudioStreamStartsAndStops, testPerStreamRecvControlsRoundTrip, plus two
static smoke tests. Catches Swift-specific interop bugs (@convention(c) callback
lifetime, Unmanaged pointer resolution, CString memory management, enum raw-value
bridging, struct layout) that C++ ctest cannot. C++ suite still 21/21 green.
Docs updated (house rule): tech-stack.md §2, architecture.md §4, roadmap.md M4 + §2,
clients/apple/README.md (full rewrite), PROGRESS.md, .gitignore.
2026-06-18 14:20:38 +02:00
|
|
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(ios): audio overhaul, Join/Leave Voice, channel-id sync fix, stereo mic capture
Three iOS client problems fixed plus a new core stereo-mic capture ABI:
1. Channel-id sync bug (mic button permanently dimmed): SessionState never
synced currentChannelId from the self user's channelId on connect, so the
mic button (gated on currentChannelId == 0) stayed dimmed. Added
syncSelfChannel() (mirrors macOS MainWindowController.swift:461,491,522);
called from init/.channelList/.userJoined/.userLeft/.userUpdated/.joinResult.
Added applyServerMuteState() + serverMuted/serverDeafened to VoiceState.
2. Join/Leave Voice button: replaced icon-only mic toggle with explicit
text button (parity with macOS). Mute/deafen disable when not in voice.
3. IOSAudioRouter.swift (new): full AVAudioSession routing layer — input
port selection, built-in mic orientation/polar patterns, Bluetooth
HFP/A2DP/Off modes, Standard/Raw mic processing, stereo capture, AirPlay,
UserDefaults persistence. AudioSessionManager delegates to it.
4. Core stereo-mic capture (append-only ABI): vc_set_capture_channels()
lets the core open the mic device in stereo (2-ch interleaved). LocalStream
gains capture_channels; ensure_audio_running reads it; audio_engine.cpp
capture_accum_ + on_capture updated to channel-aware accumulation. Test
test_stereo_mic_capture (headless, L!=R stereo round-trip). Swift wrapper
VoiceCatClient.setCaptureChannels.
5. Settings UI rework: AVAudioSession-derived input/output tree replaces
miniaudio device picker.
6. iOS deployment target raised to 18.0 (Package.swift + project.pbxproj).
swift-tools-version 6.0 with swiftLanguageModes .v5.
Docs: tech-stack.md, architecture.md, voice.md, roadmap.md, building.md
updated; stale 'vc_audio_suspend/resume deferred' claims corrected.
Verified: ctest --preset dev 21/21 green; swift test 6/6 green;
xcodebuild -target VoiceCatiOS -sdk iphonesimulator BUILD SUCCEEDED.
2026-06-19 13:17:52 +02:00
|
|
|
|
/// 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))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: external PCM feed/tap API (vc_stream_feed_pcm + vc_set_pcm_sink)
Promotes vc_test_inject_capture (mono-only, TEST-ONLY) to a public,
stereo-capable production API and adds a symmetric PCM tap on the
receive side. Enables ReplayKit (iOS), ScreenCaptureKit (macOS), bots,
soundboards, and custom clients — all without a hardware audio device.
Core C++:
- voicecat.h: new vc_stream_feed_pcm, vc_pcm_sink_cb typedef,
vc_set_pcm_sink; vc_test_inject_capture kept as deprecated alias
- audio_engine: stereo-aware inject_capture (channels param + ring
reset on channel-count change); atomic pcm_sink_ fired per decoded
frame in on_playback; RemoteStream carries user_id/stream_id for
RT-safe sink metadata; init_recv_stream takes user_id+stream_id
- client.cpp: stream_feed_pcm / set_pcm_sink implementations;
sync_remote_streams passes user_id/stream_id to init_recv_stream
- voicecat.cpp: trampolines + channels=1/2 validation
Tests: test_external_pcm (headless, 3 sub-tests: mono round-trip,
stereo feed L≠R, sink metadata+disable). ctest 23/23.
Swift: feedPcm / setPcmSink in VoiceCatClient.swift + 4 XCTest
smoke tests (ExternalPcmTests.swift).
C#: StreamFeedPcm / SetPcmSink in VoiceCatClient.cs + NativeMethods.cs
(vc_stream_feed_pcm unsafe P/Invoke, VcPcmSinkCallback delegate,
vc_set_pcm_sink via nint) + 4 xUnit smoke tests (ExternalPcmTests.cs).
Docs: architecture.md §4 new subsection, voice.md §9 updated
(macOS/iOS now reference vc_stream_feed_pcm), protocol.md §8 explicit
no-protocol-change note, roadmap.md M5 entry.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 17:52:09 +02:00
|
|
|
|
// 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,
|
feat(macos): UI overhaul -- toolbar, unified log, PM windows, settings window, hotkeys
Mirrors the Windows client's UI overhaul (commit 97fa659 + 540ec13) adapted to
Mac-native conventions. The main window is now just toolbar + channels + users
+ chat; audio device settings moved to a modeless Settings window.
- NSToolbar: Join Voice, Share Screen Audio, Mute, Deafen (SF Symbol toggle
buttons) + Output Volume slider (NSSlider 0-100, default 80). Voice actions,
mute/deafen, and output volume moved out of the bottom panel into the toolbar
- Audio device settings (input mode, VAD sensitivity, PTT key, device picker,
level meter) moved to a new SettingsWindowController -- a modeless window
opened via the app menu's "Settings..." (Cmd+,) item. Source-of-truth for
audio state lives in MainWindowController so voice start applies settings even
before the window has been opened; SettingsWindowController reads from /
writes back to those properties and applies changes live when voice is active.
Level meter forwarded from handleLevel -> updateLevel(rms:)
- Unified log: chat NSTextView + activity NSTableView collapsed into a single
NSTextView -- activity events in secondaryLabelColor (gray), chat in default
- Private messaging: scope dropdown removed; compose always sends to the
current channel. Each PM conversation opens in its own modeless
PrivateMessageWindowController. Incoming .textMessage with .private scope
routed to the right window; outgoing PMs echoed by server arrive through the
same path. "Send Private Message..." added to user context menu.
- Messages menu: "New Private Message..." (Cmd+Shift+N) opens a UserPickerSheet
listing all server users so you can PM anyone on the server
- Channel tree now shows live user counts, e.g. "General (3)"; refreshChannelTree
called on .userJoined/.userLeft (was missing)
- Voice menu: Join Voice (Cmd+Shift+V), Share Screen Audio (Cmd+Shift+S),
Mute (Cmd+Shift+M), Deafen (Cmd+Shift+D) -- NSMenuItem key equivalents with
[.command, .shift] mask, dispatched by the responder chain
- setOutputVolume(_:) wrapper added to VoiceCatClient.swift (was missing -- the
C ABI + C# wrapper shipped in commit 97fa659 but the Swift wrapper was never
added); wired end-to-end: toolbar slider -> client.setOutputVolume(gain)
Part A -- fixed and verified the previously-uncompiled Swift from the external
PCM feed/tap commit (615d2a8):
- Rebuilt the macOS xcframework slice (regenerated the module map from current
voicecat.h, exposing vc_pcm_sink_cb / vc_stream_feed_pcm / vc_set_pcm_sink)
- Fixed feedPcm type bug: size_t imports as Int in Swift not UInt; the original
UInt(samplesPerChannel) was wrong
- Added VoiceCatPcmSinkCallback typealias -- a Swift-idiomatic public alias for
the C vc_pcm_sink_cb so consumers (tests, the macOS app) can declare a sink
callback without directly importing the VoiceCatC C module. Mirrors the C#
VcPcmSinkCallback delegate
- keyCodeName helper deduplicated (was in PttKeyCaptureSheet.swift +
MainWindowController.swift -- now shared)
Platform-specific adaptations (vs. Windows): NSToolbar instead of ToolStrip;
global menu bar + NSMenuItem key equivalents (Cmd not Ctrl, responder-chain
dispatched, no custom key monitor needed); PM windows as modeless NSWindows;
picker as Mac sheet; gray = secondaryLabelColor; SF Symbols for toolbar icons.
swift test 10/10 (4 ExternalPcmTests + 6 VoiceCatClientSmokeTests against a
live server); xcodebuild Debug + Release BUILD SUCCEEDED with 0 Swift warnings.
2026-06-20 23:30:52 +02:00
|
|
|
|
samplesPerChannel, channels))
|
feat: external PCM feed/tap API (vc_stream_feed_pcm + vc_set_pcm_sink)
Promotes vc_test_inject_capture (mono-only, TEST-ONLY) to a public,
stereo-capable production API and adds a symmetric PCM tap on the
receive side. Enables ReplayKit (iOS), ScreenCaptureKit (macOS), bots,
soundboards, and custom clients — all without a hardware audio device.
Core C++:
- voicecat.h: new vc_stream_feed_pcm, vc_pcm_sink_cb typedef,
vc_set_pcm_sink; vc_test_inject_capture kept as deprecated alias
- audio_engine: stereo-aware inject_capture (channels param + ring
reset on channel-count change); atomic pcm_sink_ fired per decoded
frame in on_playback; RemoteStream carries user_id/stream_id for
RT-safe sink metadata; init_recv_stream takes user_id+stream_id
- client.cpp: stream_feed_pcm / set_pcm_sink implementations;
sync_remote_streams passes user_id/stream_id to init_recv_stream
- voicecat.cpp: trampolines + channels=1/2 validation
Tests: test_external_pcm (headless, 3 sub-tests: mono round-trip,
stereo feed L≠R, sink metadata+disable). ctest 23/23.
Swift: feedPcm / setPcmSink in VoiceCatClient.swift + 4 XCTest
smoke tests (ExternalPcmTests.swift).
C#: StreamFeedPcm / SetPcmSink in VoiceCatClient.cs + NativeMethods.cs
(vc_stream_feed_pcm unsafe P/Invoke, VcPcmSinkCallback delegate,
vc_set_pcm_sink via nint) + 4 xUnit smoke tests (ExternalPcmTests.cs).
Docs: architecture.md §4 new subsection, voice.md §9 updated
(macOS/iOS now reference vc_stream_feed_pcm), protocol.md §8 explicit
no-protocol-change note, roadmap.md M5 entry.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 17:52:09 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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
|
feat(macos): UI overhaul -- toolbar, unified log, PM windows, settings window, hotkeys
Mirrors the Windows client's UI overhaul (commit 97fa659 + 540ec13) adapted to
Mac-native conventions. The main window is now just toolbar + channels + users
+ chat; audio device settings moved to a modeless Settings window.
- NSToolbar: Join Voice, Share Screen Audio, Mute, Deafen (SF Symbol toggle
buttons) + Output Volume slider (NSSlider 0-100, default 80). Voice actions,
mute/deafen, and output volume moved out of the bottom panel into the toolbar
- Audio device settings (input mode, VAD sensitivity, PTT key, device picker,
level meter) moved to a new SettingsWindowController -- a modeless window
opened via the app menu's "Settings..." (Cmd+,) item. Source-of-truth for
audio state lives in MainWindowController so voice start applies settings even
before the window has been opened; SettingsWindowController reads from /
writes back to those properties and applies changes live when voice is active.
Level meter forwarded from handleLevel -> updateLevel(rms:)
- Unified log: chat NSTextView + activity NSTableView collapsed into a single
NSTextView -- activity events in secondaryLabelColor (gray), chat in default
- Private messaging: scope dropdown removed; compose always sends to the
current channel. Each PM conversation opens in its own modeless
PrivateMessageWindowController. Incoming .textMessage with .private scope
routed to the right window; outgoing PMs echoed by server arrive through the
same path. "Send Private Message..." added to user context menu.
- Messages menu: "New Private Message..." (Cmd+Shift+N) opens a UserPickerSheet
listing all server users so you can PM anyone on the server
- Channel tree now shows live user counts, e.g. "General (3)"; refreshChannelTree
called on .userJoined/.userLeft (was missing)
- Voice menu: Join Voice (Cmd+Shift+V), Share Screen Audio (Cmd+Shift+S),
Mute (Cmd+Shift+M), Deafen (Cmd+Shift+D) -- NSMenuItem key equivalents with
[.command, .shift] mask, dispatched by the responder chain
- setOutputVolume(_:) wrapper added to VoiceCatClient.swift (was missing -- the
C ABI + C# wrapper shipped in commit 97fa659 but the Swift wrapper was never
added); wired end-to-end: toolbar slider -> client.setOutputVolume(gain)
Part A -- fixed and verified the previously-uncompiled Swift from the external
PCM feed/tap commit (615d2a8):
- Rebuilt the macOS xcframework slice (regenerated the module map from current
voicecat.h, exposing vc_pcm_sink_cb / vc_stream_feed_pcm / vc_set_pcm_sink)
- Fixed feedPcm type bug: size_t imports as Int in Swift not UInt; the original
UInt(samplesPerChannel) was wrong
- Added VoiceCatPcmSinkCallback typealias -- a Swift-idiomatic public alias for
the C vc_pcm_sink_cb so consumers (tests, the macOS app) can declare a sink
callback without directly importing the VoiceCatC C module. Mirrors the C#
VcPcmSinkCallback delegate
- keyCodeName helper deduplicated (was in PttKeyCaptureSheet.swift +
MainWindowController.swift -- now shared)
Platform-specific adaptations (vs. Windows): NSToolbar instead of ToolStrip;
global menu bar + NSMenuItem key equivalents (Cmd not Ctrl, responder-chain
dispatched, no custom key monitor needed); PM windows as modeless NSWindows;
picker as Mac sheet; gray = secondaryLabelColor; SF Symbols for toolbar icons.
swift test 10/10 (4 ExternalPcmTests + 6 VoiceCatClientSmokeTests against a
live server); xcodebuild Debug + Release BUILD SUCCEEDED with 0 Swift warnings.
2026-06-20 23:30:52 +02:00
|
|
|
|
public func setPcmSink(_ cb: VoiceCatPcmSinkCallback?, user: UnsafeMutableRawPointer?) -> VoiceCatResult {
|
feat: external PCM feed/tap API (vc_stream_feed_pcm + vc_set_pcm_sink)
Promotes vc_test_inject_capture (mono-only, TEST-ONLY) to a public,
stereo-capable production API and adds a symmetric PCM tap on the
receive side. Enables ReplayKit (iOS), ScreenCaptureKit (macOS), bots,
soundboards, and custom clients — all without a hardware audio device.
Core C++:
- voicecat.h: new vc_stream_feed_pcm, vc_pcm_sink_cb typedef,
vc_set_pcm_sink; vc_test_inject_capture kept as deprecated alias
- audio_engine: stereo-aware inject_capture (channels param + ring
reset on channel-count change); atomic pcm_sink_ fired per decoded
frame in on_playback; RemoteStream carries user_id/stream_id for
RT-safe sink metadata; init_recv_stream takes user_id+stream_id
- client.cpp: stream_feed_pcm / set_pcm_sink implementations;
sync_remote_streams passes user_id/stream_id to init_recv_stream
- voicecat.cpp: trampolines + channels=1/2 validation
Tests: test_external_pcm (headless, 3 sub-tests: mono round-trip,
stereo feed L≠R, sink metadata+disable). ctest 23/23.
Swift: feedPcm / setPcmSink in VoiceCatClient.swift + 4 XCTest
smoke tests (ExternalPcmTests.swift).
C#: StreamFeedPcm / SetPcmSink in VoiceCatClient.cs + NativeMethods.cs
(vc_stream_feed_pcm unsafe P/Invoke, VcPcmSinkCallback delegate,
vc_set_pcm_sink via nint) + 4 xUnit smoke tests (ExternalPcmTests.cs).
Docs: architecture.md §4 new subsection, voice.md §9 updated
(macOS/iOS now reference vc_stream_feed_pcm), protocol.md §8 explicit
no-protocol-change note, roadmap.md M5 entry.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 17:52:09 +02:00
|
|
|
|
VoiceCatResult(vc_set_pcm_sink(handle, cb, user))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-22 02:38:01 +02:00
|
|
|
|
/// 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))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(apple): VoiceCatCore Swift package + XCFramework build for macOS/iOS clients
Lays the groundwork for the macOS (AppKit) and iOS (SwiftUI) clients with a shared
Swift core wrapping the C ABI, mirroring the proven Windows VoiceCat.Interop layer.
Architecture decision: macOS UI = AppKit (not SwiftUI) for the most mature VoiceOver
accessibility story — same rationale as the Windows client's WinForms-over-WinUI-3
decision. iOS stays SwiftUI. Recorded in docs/roadmap.md §2.
Build infrastructure (Phase 0):
- clients/apple/scripts/build-xcframework.sh: runs cmake --preset apple-dev, merges
libvoicecat.a + 107 vcpkg static deps into a single ~30 MB fat static library
(libvoicecat-fat.a) via libtool -static (SPM binary targets link one .a per slice),
stages voicecat.h + a generated module.modulemap (module VoiceCatC) into the headers,
runs xcodebuild -create-xcframework -> clients/apple/VoiceCatCore.xcframework.
VoiceCatCore Swift Package (Phase 1):
- Package.swift: binary target (VoiceCatCoreXCF) + library (VoiceCatCore) + test target.
- Sources/VoiceCatCore/: 7 files mirroring the C# VoiceCat.Interop patterns adapted to
Swift native C interop — Enums (9 Swift mirrors of C enums, UInt32-backed), Config,
Event (copies ev.text to String inside the callback — the #1 lifetime rule), Models
(10 Swift value types), Marshaling (C arrays -> Swift + immediate vc_free_*),
Callbacks (@convention(c) + Unmanaged.passUnretained, the Swift analog of C#'s
[UnmanagedCallersOnly] + GCHandle), VoiceCatClient (owns vc_client* as OpaquePointer,
all 38 C ABI functions, deinit -> vc_client_destroy then frees config CStrings, event
delivery on main queue via coalesced DispatchQueue.main drain).
Tests — 6/6 green (swift test against a real voicecat-server):
- testConnectTofuAuthListChannelsRoundTrips, testAdminChannelCrudAccountCrudRoundTrips,
testScreenAudioStreamStartsAndStops, testPerStreamRecvControlsRoundTrip, plus two
static smoke tests. Catches Swift-specific interop bugs (@convention(c) callback
lifetime, Unmanaged pointer resolution, CString memory management, enum raw-value
bridging, struct layout) that C++ ctest cannot. C++ suite still 21/21 green.
Docs updated (house rule): tech-stack.md §2, architecture.md §4, roadmap.md M4 + §2,
clients/apple/README.md (full rewrite), PROGRESS.md, .gitignore.
2026-06-18 14:20:38 +02:00
|
|
|
|
@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))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(macos): UI overhaul -- toolbar, unified log, PM windows, settings window, hotkeys
Mirrors the Windows client's UI overhaul (commit 97fa659 + 540ec13) adapted to
Mac-native conventions. The main window is now just toolbar + channels + users
+ chat; audio device settings moved to a modeless Settings window.
- NSToolbar: Join Voice, Share Screen Audio, Mute, Deafen (SF Symbol toggle
buttons) + Output Volume slider (NSSlider 0-100, default 80). Voice actions,
mute/deafen, and output volume moved out of the bottom panel into the toolbar
- Audio device settings (input mode, VAD sensitivity, PTT key, device picker,
level meter) moved to a new SettingsWindowController -- a modeless window
opened via the app menu's "Settings..." (Cmd+,) item. Source-of-truth for
audio state lives in MainWindowController so voice start applies settings even
before the window has been opened; SettingsWindowController reads from /
writes back to those properties and applies changes live when voice is active.
Level meter forwarded from handleLevel -> updateLevel(rms:)
- Unified log: chat NSTextView + activity NSTableView collapsed into a single
NSTextView -- activity events in secondaryLabelColor (gray), chat in default
- Private messaging: scope dropdown removed; compose always sends to the
current channel. Each PM conversation opens in its own modeless
PrivateMessageWindowController. Incoming .textMessage with .private scope
routed to the right window; outgoing PMs echoed by server arrive through the
same path. "Send Private Message..." added to user context menu.
- Messages menu: "New Private Message..." (Cmd+Shift+N) opens a UserPickerSheet
listing all server users so you can PM anyone on the server
- Channel tree now shows live user counts, e.g. "General (3)"; refreshChannelTree
called on .userJoined/.userLeft (was missing)
- Voice menu: Join Voice (Cmd+Shift+V), Share Screen Audio (Cmd+Shift+S),
Mute (Cmd+Shift+M), Deafen (Cmd+Shift+D) -- NSMenuItem key equivalents with
[.command, .shift] mask, dispatched by the responder chain
- setOutputVolume(_:) wrapper added to VoiceCatClient.swift (was missing -- the
C ABI + C# wrapper shipped in commit 97fa659 but the Swift wrapper was never
added); wired end-to-end: toolbar slider -> client.setOutputVolume(gain)
Part A -- fixed and verified the previously-uncompiled Swift from the external
PCM feed/tap commit (615d2a8):
- Rebuilt the macOS xcframework slice (regenerated the module map from current
voicecat.h, exposing vc_pcm_sink_cb / vc_stream_feed_pcm / vc_set_pcm_sink)
- Fixed feedPcm type bug: size_t imports as Int in Swift not UInt; the original
UInt(samplesPerChannel) was wrong
- Added VoiceCatPcmSinkCallback typealias -- a Swift-idiomatic public alias for
the C vc_pcm_sink_cb so consumers (tests, the macOS app) can declare a sink
callback without directly importing the VoiceCatC C module. Mirrors the C#
VcPcmSinkCallback delegate
- keyCodeName helper deduplicated (was in PttKeyCaptureSheet.swift +
MainWindowController.swift -- now shared)
Platform-specific adaptations (vs. Windows): NSToolbar instead of ToolStrip;
global menu bar + NSMenuItem key equivalents (Cmd not Ctrl, responder-chain
dispatched, no custom key monitor needed); PM windows as modeless NSWindows;
picker as Mac sheet; gray = secondaryLabelColor; SF Symbols for toolbar icons.
swift test 10/10 (4 ExternalPcmTests + 6 VoiceCatClientSmokeTests against a
live server); xcodebuild Debug + Release BUILD SUCCEEDED with 0 Swift warnings.
2026-06-20 23:30:52 +02:00
|
|
|
|
/// 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))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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.
2026-06-23 03:35:26 +02:00
|
|
|
|
/// 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))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(clients): wire RNNoise mic noise reduction into Windows, macOS, and iOS
Expose the existing send-side vc_set_input_noise_reduction C ABI (MIC-only,
mono, LOCAL — denoises captured mic PCM before input gain and VAD/PTT gate)
as a persisted global toggle in each client's audio settings, applied live
and re-applied on Join Voice. Mirrors the existing mic-gain wiring pattern.
- Shared Swift (VoiceCatCore): add setInputNoiseReduction(_:) wrapper
- Windows: P/Invoke + SetInputNoiseReduction wrapper, MicNoiseReduction in
VoiceSettings, new checkbox in AudioSettingsForm (layout shifted +28px),
apply on Join Voice; also fix stale 'planned - currently passthrough'
label on the receive-side per-user NR checkbox (RNNoise now backs it)
- macOS: inputNoiseReduction state + UserDefaults in MainWindowController,
NR checkbox + nrChanged action in SettingsWindowController
- iOS: inputNoiseReduction in VoiceState + setter + restore in SessionState,
NR Toggle in SettingsView Voice section
Aux/screen are out of scope by design (core's NR guards kind == MIC). Apple
builds require a rebuilt VoiceCatCore.xcframework with VOICECAT_HAS_NS.
2026-06-23 14:11:18 +02:00
|
|
|
|
/// 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))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-19 02:10:25 +02:00
|
|
|
|
// 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))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-19 16:58:21 +02:00
|
|
|
|
/// 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
|
2026-06-20 03:03:34 +02:00
|
|
|
|
/// active when they were opened), this fully re-initializes them. Safe to call when the
|
|
|
|
|
|
/// engine is not running (it will just start it).
|
2026-06-19 16:58:21 +02:00
|
|
|
|
@discardableResult
|
|
|
|
|
|
public func audioRestart() -> VoiceCatResult {
|
|
|
|
|
|
VoiceCatResult(vc_audio_restart(handle))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(apple): VoiceCatCore Swift package + XCFramework build for macOS/iOS clients
Lays the groundwork for the macOS (AppKit) and iOS (SwiftUI) clients with a shared
Swift core wrapping the C ABI, mirroring the proven Windows VoiceCat.Interop layer.
Architecture decision: macOS UI = AppKit (not SwiftUI) for the most mature VoiceOver
accessibility story — same rationale as the Windows client's WinForms-over-WinUI-3
decision. iOS stays SwiftUI. Recorded in docs/roadmap.md §2.
Build infrastructure (Phase 0):
- clients/apple/scripts/build-xcframework.sh: runs cmake --preset apple-dev, merges
libvoicecat.a + 107 vcpkg static deps into a single ~30 MB fat static library
(libvoicecat-fat.a) via libtool -static (SPM binary targets link one .a per slice),
stages voicecat.h + a generated module.modulemap (module VoiceCatC) into the headers,
runs xcodebuild -create-xcframework -> clients/apple/VoiceCatCore.xcframework.
VoiceCatCore Swift Package (Phase 1):
- Package.swift: binary target (VoiceCatCoreXCF) + library (VoiceCatCore) + test target.
- Sources/VoiceCatCore/: 7 files mirroring the C# VoiceCat.Interop patterns adapted to
Swift native C interop — Enums (9 Swift mirrors of C enums, UInt32-backed), Config,
Event (copies ev.text to String inside the callback — the #1 lifetime rule), Models
(10 Swift value types), Marshaling (C arrays -> Swift + immediate vc_free_*),
Callbacks (@convention(c) + Unmanaged.passUnretained, the Swift analog of C#'s
[UnmanagedCallersOnly] + GCHandle), VoiceCatClient (owns vc_client* as OpaquePointer,
all 38 C ABI functions, deinit -> vc_client_destroy then frees config CStrings, event
delivery on main queue via coalesced DispatchQueue.main drain).
Tests — 6/6 green (swift test against a real voicecat-server):
- testConnectTofuAuthListChannelsRoundTrips, testAdminChannelCrudAccountCrudRoundTrips,
testScreenAudioStreamStartsAndStops, testPerStreamRecvControlsRoundTrip, plus two
static smoke tests. Catches Swift-specific interop bugs (@convention(c) callback
lifetime, Unmanaged pointer resolution, CString memory management, enum raw-value
bridging, struct layout) that C++ ctest cannot. C++ suite still 21/21 green.
Docs updated (house rule): tech-stack.md §2, architecture.md §4, roadmap.md M4 + §2,
clients/apple/README.md (full rewrite), PROGRESS.md, .gitignore.
2026-06-18 14:20:38 +02:00
|
|
|
|
// 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
|
feat(clients): expose all channel codec params + guest nickname everywhere
Channel create/edit UIs only surfaced a subset of the core's vc_audio_config,
and DRED was exposed nowhere. While adding it, found a latent ABI mismatch:
both Swift AudioConfig and the C# VcAudioConfigNative blittable struct were one
int short of the native vc_audio_config (missing the trailing `dred`), so native
read past the managed struct in vc_create_channel/vc_edit_channel.
- core marshaling: thread `dred` through Swift (Models/Marshaling/toNative) and
C# (Structs/Models/Marshaling/VoiceCatClient) -- fixes the ABI gap + enables it
- windows: add the one missing DRED checkbox to ChannelEditDialog
- macos: ChannelEditSheet now exposes application, sample rate, packet loss,
complexity, and DRED (was stereo/bitrate/frame/FEC/DTX only)
- ios: rebuild ChannelEditView into a full create+edit form (all params); add
SessionState.editChannel + an admin Edit swipe action (iOS had no edit UI)
- guest nickname: add a dedicated `nickname` to SavedServer on macOS+iOS
(backward-compatible Codable), shown in Guest mode, wired into the guest auth
path -- guests could not set a display name on either before (only Windows)
Verified: macOS + iOS (sim, arm64) xcodebuild BUILD SUCCEEDED; core ctest 22/23
(only external_pcm aborts on a pre-existing shutdown mutex race; no C++ changed).
2026-06-21 04:03:50 +02:00
|
|
|
|
n.dred = dred ? 1 : 0
|
feat(apple): VoiceCatCore Swift package + XCFramework build for macOS/iOS clients
Lays the groundwork for the macOS (AppKit) and iOS (SwiftUI) clients with a shared
Swift core wrapping the C ABI, mirroring the proven Windows VoiceCat.Interop layer.
Architecture decision: macOS UI = AppKit (not SwiftUI) for the most mature VoiceOver
accessibility story — same rationale as the Windows client's WinForms-over-WinUI-3
decision. iOS stays SwiftUI. Recorded in docs/roadmap.md §2.
Build infrastructure (Phase 0):
- clients/apple/scripts/build-xcframework.sh: runs cmake --preset apple-dev, merges
libvoicecat.a + 107 vcpkg static deps into a single ~30 MB fat static library
(libvoicecat-fat.a) via libtool -static (SPM binary targets link one .a per slice),
stages voicecat.h + a generated module.modulemap (module VoiceCatC) into the headers,
runs xcodebuild -create-xcframework -> clients/apple/VoiceCatCore.xcframework.
VoiceCatCore Swift Package (Phase 1):
- Package.swift: binary target (VoiceCatCoreXCF) + library (VoiceCatCore) + test target.
- Sources/VoiceCatCore/: 7 files mirroring the C# VoiceCat.Interop patterns adapted to
Swift native C interop — Enums (9 Swift mirrors of C enums, UInt32-backed), Config,
Event (copies ev.text to String inside the callback — the #1 lifetime rule), Models
(10 Swift value types), Marshaling (C arrays -> Swift + immediate vc_free_*),
Callbacks (@convention(c) + Unmanaged.passUnretained, the Swift analog of C#'s
[UnmanagedCallersOnly] + GCHandle), VoiceCatClient (owns vc_client* as OpaquePointer,
all 38 C ABI functions, deinit -> vc_client_destroy then frees config CStrings, event
delivery on main queue via coalesced DispatchQueue.main drain).
Tests — 6/6 green (swift test against a real voicecat-server):
- testConnectTofuAuthListChannelsRoundTrips, testAdminChannelCrudAccountCrudRoundTrips,
testScreenAudioStreamStartsAndStops, testPerStreamRecvControlsRoundTrip, plus two
static smoke tests. Catches Swift-specific interop bugs (@convention(c) callback
lifetime, Unmanaged pointer resolution, CString memory management, enum raw-value
bridging, struct layout) that C++ ctest cannot. C++ suite still 21/21 green.
Docs updated (house rule): tech-stack.md §2, architecture.md §4, roadmap.md M4 + §2,
clients/apple/README.md (full rewrite), PROGRESS.md, .gitignore.
2026-06-18 14:20:38 +02:00
|
|
|
|
return n
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|