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))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
chore: comment cleanup pass ahead of open-sourcing
Removes leftover debug scaffolding (stray Console.WriteLine/NSLog traces,
dead nick_buf_ptr, a no-op --print-config flag now implemented for real),
fixes stale/misleading comments (channel passwords are no longer a "future
M5+" feature, a wrong cross-reference, a stale TlsContext::close() mention,
an incomplete BanRecord::subject_type doc, and a smoke test pointing at a
build/m1-dev preset that no longer exists), strips internal M1-M5 milestone
jargon from comments now that the roadmap is done, trims comments that just
restated the following line, and consolidates a few "why" explanations that
were duplicated 2-3 times in the same file.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 10:20:18 +01:00
|
|
|
|
// MARK: - TOFU server-identity gate
|
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
|
|
|
|
|
|
|
|
|
|
/// 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))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: fix voice join/leave, channel edit defaults, channel-update stream restart
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).
2026-06-24 14:29:39 +02:00
|
|
|
|
@discardableResult
|
|
|
|
|
|
public func joinVoice() -> VoiceCatResult {
|
|
|
|
|
|
VoiceCatResult(vc_join_voice(handle))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@discardableResult
|
|
|
|
|
|
public func leaveVoice() -> VoiceCatResult {
|
|
|
|
|
|
VoiceCatResult(vc_leave_voice(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
|
|
|
|
/// 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
|
chore: comment cleanup pass ahead of open-sourcing
Removes leftover debug scaffolding (stray Console.WriteLine/NSLog traces,
dead nick_buf_ptr, a no-op --print-config flag now implemented for real),
fixes stale/misleading comments (channel passwords are no longer a "future
M5+" feature, a wrong cross-reference, a stale TlsContext::close() mention,
an incomplete BanRecord::subject_type doc, and a smoke test pointing at a
build/m1-dev preset that no longer exists), strips internal M1-M5 milestone
jargon from comments now that the roadmap is done, trims comments that just
restated the following line, and consolidates a few "why" explanations that
were duplicated 2-3 times in the same file.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 10:20:18 +01:00
|
|
|
|
/// Windows client's `SetOutputVolume` and the C ABI `vc_set_output_volume`.
|
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
|
|
|
|
@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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
chore: comment cleanup pass ahead of open-sourcing
Removes leftover debug scaffolding (stray Console.WriteLine/NSLog traces,
dead nick_buf_ptr, a no-op --print-config flag now implemented for real),
fixes stale/misleading comments (channel passwords are no longer a "future
M5+" feature, a wrong cross-reference, a stale TlsContext::close() mention,
an incomplete BanRecord::subject_type doc, and a smoke test pointing at a
build/m1-dev preset that no longer exists), strips internal M1-M5 milestone
jargon from comments now that the roadmap is done, trims comments that just
restated the following line, and consolidates a few "why" explanations that
were duplicated 2-3 times in the same file.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 10:20:18 +01:00
|
|
|
|
// MARK: - Moderation
|
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 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))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
chore: comment cleanup pass ahead of open-sourcing
Removes leftover debug scaffolding (stray Console.WriteLine/NSLog traces,
dead nick_buf_ptr, a no-op --print-config flag now implemented for real),
fixes stale/misleading comments (channel passwords are no longer a "future
M5+" feature, a wrong cross-reference, a stale TlsContext::close() mention,
an incomplete BanRecord::subject_type doc, and a smoke test pointing at a
build/m1-dev preset that no longer exists), strips internal M1-M5 milestone
jargon from comments now that the roadmap is done, trims comments that just
restated the following line, and consolidates a few "why" explanations that
were duplicated 2-3 times in the same file.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 10:20:18 +01:00
|
|
|
|
// MARK: - Channel admin
|
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 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))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
chore: comment cleanup pass ahead of open-sourcing
Removes leftover debug scaffolding (stray Console.WriteLine/NSLog traces,
dead nick_buf_ptr, a no-op --print-config flag now implemented for real),
fixes stale/misleading comments (channel passwords are no longer a "future
M5+" feature, a wrong cross-reference, a stale TlsContext::close() mention,
an incomplete BanRecord::subject_type doc, and a smoke test pointing at a
build/m1-dev preset that no longer exists), strips internal M1-M5 milestone
jargon from comments now that the roadmap is done, trims comments that just
restated the following line, and consolidates a few "why" explanations that
were duplicated 2-3 times in the same file.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 10:20:18 +01:00
|
|
|
|
// MARK: - Account admin
|
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 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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|