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).
This commit is contained in:
2026-06-21 04:03:50 +02:00
parent b07362e525
commit 6b7f06a282
22 changed files with 292 additions and 24 deletions

View File

@@ -10,6 +10,31 @@ up instantly. Newest status at the top.
## ▶ Where we left off / next action ## ▶ Where we left off / next action
- **Done (2026-06-21):** **Expose all channel codec params + guest nickname in every client.**
- **DRED everywhere + ABI fix.** `dred` (Opus 1.6 Deep REDundancy) existed in the C ABI
(`vc_audio_config.dred`) and proto but was absent from *both* client marshaling layers — a
latent ABI mismatch: Swift `AudioConfig` and the C# `VcAudioConfigNative` blittable struct
were each one `int` short of the native struct passed to `vc_create_channel`/`vc_edit_channel`.
Added `dred` through Swift (`Models.swift`, `Marshaling.swift`, `VoiceCatClient.toNative`) and
C# (`Structs.cs`, `Models.cs`, `Marshaling.cs`, `VoiceCatClient.cs`).
- **Windows:** added the one missing DRED checkbox to `ChannelEditDialog` (all other params
were already present).
- **macOS:** `ChannelEditSheet` now exposes the previously-hidden params — application profile,
sample rate, expected packet loss, complexity, and DRED (was only stereo/bitrate/frame/FEC/DTX).
- **iOS:** `ChannelEditView` was name+topic only; rebuilt into a full create **and edit** form
(General: name/topic/parent/password/max-users/sort-order; Audio: stereo/bitrate/sample-rate/
frame/application/packet-loss/complexity/FEC/DTX/DRED). Added `SessionState.editChannel` and an
"Edit" swipe action (admins) in `ChannelTreeView` + `ChannelBrowserView` (iOS previously had no
edit-channel UI at all). Note: the channel list doesn't carry the current audio config, so on
edit the audio fields start from codec defaults — same limitation as macOS/Windows.
- **Guest nickname.** Guests could not set a display name on iOS *or* macOS (the field was
absent/disabled; only Windows had it). Added a dedicated `nickname` to `SavedServer` on both
(backward-compatible Codable), a Nickname field shown in Guest mode (`AddServerView` /
`AddServerSheet`), and wired the guest auth path to use it (`AppState`, `ConnectWindowController`).
- **Verified:** `xcodebuild` Debug — macOS BUILD SUCCEEDED; iOS (sim, `ARCHS=arm64`) BUILD
SUCCEEDED. Core `ctest --preset dev` 22/23 (only `external_pcm` aborts on a pre-existing
shutdown mutex race; no C++ was changed). Windows C# not buildable on macOS — changes reviewed.
- **Done (2026-06-21):** **iOS iPhone-layout UX fixes.** (1) Channels are now a **drill-down** - **Done (2026-06-21):** **iOS iPhone-layout UX fixes.** (1) Channels are now a **drill-down**
on iPhone — new `ChannelBrowserView` (root list of top-level channels) → `ChannelDetailView` on iPhone — new `ChannelBrowserView` (root list of top-level channels) → `ChannelDetailView`
(people in the channel + sub-channels + an explicit "Join Channel" button with password (people in the channel + sub-channels + an explicit "Join Channel" button with password

View File

@@ -94,7 +94,7 @@ internal enum Marshaling {
AudioConfig(codec: c.codec, stereo: c.mode != 0, sampleRate: c.sample_rate, AudioConfig(codec: c.codec, stereo: c.mode != 0, sampleRate: c.sample_rate,
bitrateBps: c.bitrate_bps, frameMs: c.frame_ms, application: c.application, bitrateBps: c.bitrate_bps, frameMs: c.frame_ms, application: c.application,
fec: c.fec != 0, expectedPacketLoss: c.expected_packet_loss, fec: c.fec != 0, expectedPacketLoss: c.expected_packet_loss,
dtx: c.dtx != 0, complexity: c.complexity) dtx: c.dtx != 0, complexity: c.complexity, dred: c.dred != 0)
} }
static func permissions(_ p: vc_permissions) -> Permissions { static func permissions(_ p: vc_permissions) -> Permissions {

View File

@@ -193,15 +193,16 @@ public struct AudioConfig: Sendable, Equatable {
public let expectedPacketLoss: UInt32 // % 0..100 public let expectedPacketLoss: UInt32 // % 0..100
public let dtx: Bool public let dtx: Bool
public let complexity: UInt32 // 0..10 public let complexity: UInt32 // 0..10
public let dred: Bool // Deep REDundancy (Opus 1.6), off by default
public init(codec: UInt32 = 0, stereo: Bool = false, sampleRate: UInt32 = 48000, public init(codec: UInt32 = 0, stereo: Bool = false, sampleRate: UInt32 = 48000,
bitrateBps: UInt32 = 64000, frameMs: UInt32 = 20, application: UInt32 = 0, bitrateBps: UInt32 = 64000, frameMs: UInt32 = 20, application: UInt32 = 0,
fec: Bool = true, expectedPacketLoss: UInt32 = 5, dtx: Bool = false, fec: Bool = true, expectedPacketLoss: UInt32 = 5, dtx: Bool = false,
complexity: UInt32 = 10) { complexity: UInt32 = 10, dred: Bool = false) {
self.codec = codec; self.stereo = stereo; self.sampleRate = sampleRate self.codec = codec; self.stereo = stereo; self.sampleRate = sampleRate
self.bitrateBps = bitrateBps; self.frameMs = frameMs; self.application = application self.bitrateBps = bitrateBps; self.frameMs = frameMs; self.application = application
self.fec = fec; self.expectedPacketLoss = expectedPacketLoss; self.dtx = dtx self.fec = fec; self.expectedPacketLoss = expectedPacketLoss; self.dtx = dtx
self.complexity = complexity self.complexity = complexity; self.dred = dred
} }
} }

View File

@@ -586,6 +586,7 @@ extension AudioConfig {
n.expected_packet_loss = expectedPacketLoss n.expected_packet_loss = expectedPacketLoss
n.dtx = dtx ? 1 : 0 n.dtx = dtx ? 1 : 0
n.complexity = complexity n.complexity = complexity
n.dred = dred ? 1 : 0
return n return n
} }
} }

View File

@@ -76,7 +76,7 @@ final class AppState {
// Auth is queued immediately the core serialises it behind TLS + TOFU. // Auth is queued immediately the core serialises it behind TLS + TOFU.
switch server.authMode { switch server.authMode {
case .guest: case .guest:
let nick = server.savedUsername.isEmpty ? "iOS User" : server.savedUsername let nick = (server.nickname?.isEmpty == false) ? server.nickname! : "iOS User"
client.authenticateGuest(nick) client.authenticateGuest(nick)
case .password: case .password:
let savedPw = ServerListStore.shared.loadPassword(tag: server.keychainTag) let savedPw = ServerListStore.shared.loadPassword(tag: server.keychainTag)

View File

@@ -6,17 +6,22 @@ struct SavedServer: Codable, Identifiable, Equatable {
var port: UInt16 var port: UInt16
var authMode: AuthMode var authMode: AuthMode
var savedUsername: String var savedUsername: String
/// Free-form display name used when connecting as a guest. Distinct from the account
/// `savedUsername`. Empty falls back to a default. Optional for backward-compatible decoding.
var nickname: String?
var keychainTag: String var keychainTag: String
enum AuthMode: String, Codable { case guest, password } enum AuthMode: String, Codable { case guest, password }
init(id: UUID = UUID(), host: String, port: UInt16, init(id: UUID = UUID(), host: String, port: UInt16,
authMode: AuthMode = .guest, savedUsername: String = "", keychainTag: String = "") { authMode: AuthMode = .guest, savedUsername: String = "",
nickname: String? = nil, keychainTag: String = "") {
self.id = id self.id = id
self.host = host self.host = host
self.port = port self.port = port
self.authMode = authMode self.authMode = authMode
self.savedUsername = savedUsername self.savedUsername = savedUsername
self.nickname = nickname
self.keychainTag = keychainTag.isEmpty ? id.uuidString : keychainTag self.keychainTag = keychainTag.isEmpty ? id.uuidString : keychainTag
} }

View File

@@ -342,13 +342,14 @@ final class SessionState {
client.setServerMute(userId, muted: muted, deafened: deafened) client.setServerMute(userId, muted: muted, deafened: deafened)
} }
func createChannel(name: String, topic: String) { func createChannel(_ info: ChannelEdit) {
let info = ChannelEdit(id: 0, parentId: 0, name: name, topic: topic,
passwordProtected: false, password: nil,
maxUsers: 0, sortOrder: 0, audio: AudioConfig())
client.createChannel(info) client.createChannel(info)
} }
func editChannel(_ info: ChannelEdit) {
client.editChannel(info)
}
func deleteChannel(_ channelId: UInt32) { func deleteChannel(_ channelId: UInt32) {
client.deleteChannel(channelId) client.deleteChannel(channelId)
} }

View File

@@ -9,6 +9,7 @@ struct AddServerView: View {
@State private var host = "" @State private var host = ""
@State private var port = "7878" @State private var port = "7878"
@State private var authMode = SavedServer.AuthMode.guest @State private var authMode = SavedServer.AuthMode.guest
@State private var nickname = ""
@State private var username = "" @State private var username = ""
@State private var password = "" @State private var password = ""
@State private var savePassword = false @State private var savePassword = false
@@ -35,6 +36,13 @@ struct AddServerView: View {
.pickerStyle(.segmented) .pickerStyle(.segmented)
.accessibilityLabel("Authentication mode") .accessibilityLabel("Authentication mode")
if authMode == .guest {
TextField("Nickname (optional)", text: $nickname)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.accessibilityLabel("Guest nickname, optional display name")
}
if authMode == .password { if authMode == .password {
TextField("Username", text: $username) TextField("Username", text: $username)
.textContentType(.username) .textContentType(.username)
@@ -67,6 +75,7 @@ struct AddServerView: View {
port = "\(s.port)" port = "\(s.port)"
authMode = s.authMode authMode = s.authMode
username = s.savedUsername username = s.savedUsername
nickname = s.nickname ?? ""
} }
} }
} }
@@ -76,17 +85,21 @@ struct AddServerView: View {
guard !trimmedHost.isEmpty, let portNum = UInt16(port) else { return } guard !trimmedHost.isEmpty, let portNum = UInt16(port) else { return }
let pw = (savePassword && authMode == .password && !password.isEmpty) ? password : nil let pw = (savePassword && authMode == .password && !password.isEmpty) ? password : nil
let trimmedNick = nickname.trimmingCharacters(in: .whitespaces)
let nick: String? = (authMode == .guest && !trimmedNick.isEmpty) ? trimmedNick : nil
if var s = editing { if var s = editing {
s.host = trimmedHost s.host = trimmedHost
s.port = portNum s.port = portNum
s.authMode = authMode s.authMode = authMode
s.savedUsername = authMode == .password ? username : "" s.savedUsername = authMode == .password ? username : ""
s.nickname = nick
appState.updateServer(s, password: pw) appState.updateServer(s, password: pw)
} else { } else {
let s = SavedServer(host: trimmedHost, port: portNum, let s = SavedServer(host: trimmedHost, port: portNum,
authMode: authMode, authMode: authMode,
savedUsername: authMode == .password ? username : "") savedUsername: authMode == .password ? username : "",
nickname: nick)
appState.addServer(s, password: pw) appState.addServer(s, password: pw)
} }
dismiss() dismiss()

View File

@@ -7,6 +7,7 @@ import VoiceCatCore
struct ChannelBrowserView: View { struct ChannelBrowserView: View {
@Bindable var session: SessionState @Bindable var session: SessionState
@State private var showCreateChannel = false @State private var showCreateChannel = false
@State private var editChannel: Channel?
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@@ -26,6 +27,16 @@ struct ChannelBrowserView: View {
} }
} }
} }
.swipeActions(edge: .leading) {
if session.permissions.isAdmin {
Button {
editChannel = ch
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.blue)
}
}
} }
} }
.navigationTitle("Channels") .navigationTitle("Channels")
@@ -44,6 +55,9 @@ struct ChannelBrowserView: View {
.sheet(isPresented: $showCreateChannel) { .sheet(isPresented: $showCreateChannel) {
ChannelEditView(channelId: nil, session: session) ChannelEditView(channelId: nil, session: session)
} }
.sheet(item: $editChannel) { ch in
ChannelEditView(channelId: ch.id, session: session)
}
} }
} }

View File

@@ -1,12 +1,34 @@
import SwiftUI import SwiftUI
import VoiceCatCore
struct ChannelEditView: View { struct ChannelEditView: View {
let channelId: UInt32? let channelId: UInt32?
@Bindable var session: SessionState @Bindable var session: SessionState
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
// General
@State private var name = "" @State private var name = ""
@State private var topic = "" @State private var topic = ""
@State private var parentId: UInt32 = 0
@State private var passwordProtected = false
@State private var password = ""
@State private var maxUsers = "0"
@State private var sortOrder = "0"
// Audio (Opus). Note: the channel list does not carry the current audio config, so when
// editing an existing channel these start from the codec defaults (same as macOS/Windows).
@State private var stereo = false
@State private var bitrate = "64000"
@State private var sampleRate = "48000"
@State private var frameMs: UInt32 = 20
@State private var application: UInt32 = 0
@State private var packetLoss = "5"
@State private var complexity = 10
@State private var fec = true
@State private var dtx = false
@State private var dred = false
private var isEditing: Bool { channelId != nil }
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@@ -17,9 +39,57 @@ struct ChannelEditView: View {
.accessibilityLabel("Channel name") .accessibilityLabel("Channel name")
TextField("Topic (optional)", text: $topic) TextField("Topic (optional)", text: $topic)
.accessibilityLabel("Channel topic, optional") .accessibilityLabel("Channel topic, optional")
Picker("Parent", selection: $parentId) {
Text("(root)").tag(UInt32(0))
ForEach(parentOptions) { ch in
Text(ch.name).tag(ch.id)
} }
} }
.navigationTitle(channelId == nil ? "New Channel" : "Edit Channel") .accessibilityLabel("Parent channel")
Toggle("Password protected", isOn: $passwordProtected)
if passwordProtected {
SecureField("Password (blank keeps existing)", text: $password)
.accessibilityLabel("Channel password")
}
TextField("Max users (0 = unlimited)", text: $maxUsers)
.keyboardType(.numberPad)
.accessibilityLabel("Maximum users, zero means unlimited")
TextField("Sort order", text: $sortOrder)
.keyboardType(.numberPad)
.accessibilityLabel("Sort order")
}
Section("Audio (Opus)") {
Toggle("Stereo", isOn: $stereo)
TextField("Bitrate (bps)", text: $bitrate)
.keyboardType(.numberPad)
.accessibilityLabel("Bitrate in bits per second")
TextField("Sample rate (Hz)", text: $sampleRate)
.keyboardType(.numberPad)
.accessibilityLabel("Sample rate in Hz")
Picker("Frame", selection: $frameMs) {
ForEach([UInt32(10), 20, 40, 60], id: \.self) { ms in
Text("\(ms) ms").tag(ms)
}
}
.accessibilityLabel("Opus frame duration")
Picker("Application", selection: $application) {
Text("VoIP").tag(UInt32(0))
Text("Audio").tag(UInt32(1))
Text("Low delay").tag(UInt32(2))
}
.accessibilityLabel("Opus application profile")
TextField("Expected packet loss %", text: $packetLoss)
.keyboardType(.numberPad)
.accessibilityLabel("Expected packet loss percent, 0 to 100")
Stepper("Complexity: \(complexity)", value: $complexity, in: 0...10)
.accessibilityLabel("Opus complexity, 0 to 10")
Toggle("FEC (forward error correction)", isOn: $fec)
Toggle("DTX (discontinuous transmission)", isOn: $dtx)
Toggle("DRED (deep redundancy)", isOn: $dred)
}
}
.navigationTitle(isEditing ? "Edit Channel" : "New Channel")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
@@ -27,12 +97,67 @@ struct ChannelEditView: View {
} }
ToolbarItem(placement: .confirmationAction) { ToolbarItem(placement: .confirmationAction) {
Button("Save") { Button("Save") {
session.createChannel(name: name, topic: topic) save()
dismiss() dismiss()
} }
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
} }
} }
.onAppear(perform: loadIfEditing)
}
}
/// Channels offered as a parent. Excludes the channel being edited so it can't parent itself.
private var parentOptions: [Channel] {
session.channels
.filter { $0.id != channelId }
.sorted { $0.name < $1.name }
}
private func loadIfEditing() {
guard let id = channelId,
let ch = session.channels.first(where: { $0.id == id }) else { return }
name = ch.name
topic = ch.topic
parentId = ch.parentId
passwordProtected = ch.passwordProtected
maxUsers = "\(ch.maxUsers)"
}
private func save() {
let trimmedName = name.trimmingCharacters(in: .whitespaces)
guard !trimmedName.isEmpty else { return }
let audio = AudioConfig(
stereo: stereo,
sampleRate: UInt32(sampleRate) ?? 48000,
bitrateBps: UInt32(bitrate) ?? 64000,
frameMs: frameMs,
application: application,
fec: fec,
expectedPacketLoss: min(UInt32(packetLoss) ?? 5, 100),
dtx: dtx,
complexity: UInt32(complexity),
dred: dred
)
let pw: String? = passwordProtected ? (password.isEmpty ? nil : password) : nil
let info = ChannelEdit(
id: channelId ?? 0,
parentId: parentId,
name: trimmedName,
topic: topic,
passwordProtected: passwordProtected,
password: pw,
maxUsers: UInt32(maxUsers) ?? 0,
sortOrder: UInt32(sortOrder) ?? 0,
audio: audio
)
if isEditing {
session.editChannel(info)
} else {
session.createChannel(info)
} }
} }
} }

View File

@@ -12,6 +12,7 @@ struct ChannelNode: Identifiable {
struct ChannelTreeView: View { struct ChannelTreeView: View {
@Bindable var session: SessionState @Bindable var session: SessionState
@State private var showCreateChannel = false @State private var showCreateChannel = false
@State private var editChannel: Channel?
@State private var channelPassword = "" @State private var channelPassword = ""
@State private var passwordChannelId: UInt32? @State private var passwordChannelId: UInt32?
@@ -34,6 +35,16 @@ struct ChannelTreeView: View {
} }
} }
} }
.swipeActions(edge: .leading) {
if session.permissions.isAdmin {
Button {
editChannel = node.channel
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.blue)
}
}
} }
.listStyle(.sidebar) .listStyle(.sidebar)
.toolbar { .toolbar {
@@ -59,6 +70,9 @@ struct ChannelTreeView: View {
.sheet(isPresented: $showCreateChannel) { .sheet(isPresented: $showCreateChannel) {
ChannelEditView(channelId: nil, session: session) ChannelEditView(channelId: nil, session: session)
} }
.sheet(item: $editChannel) { ch in
ChannelEditView(channelId: ch.id, session: session)
}
.alert("Channel Password", isPresented: Binding( .alert("Channel Password", isPresented: Binding(
get: { passwordChannelId != nil }, get: { passwordChannelId != nil },
set: { if !$0 { passwordChannelId = nil; channelPassword = "" } } set: { if !$0 { passwordChannelId = nil; channelPassword = "" } }

View File

@@ -11,6 +11,9 @@ struct SavedServer: Codable, Identifiable {
var port: UInt16 var port: UInt16
var authMode: AuthMode var authMode: AuthMode
var savedUsername: String? var savedUsername: String?
/// Free-form display name used when connecting as a guest. Distinct from the account
/// `savedUsername`. Empty/nil falls back to the system full name.
var nickname: String?
var keychainTag: String? var keychainTag: String?
var displayString: String { var displayString: String {

View File

@@ -13,6 +13,7 @@ final class AddServerSheet: NSViewController {
return f return f
}() }()
private let authPicker = NSPopUpButton() private let authPicker = NSPopUpButton()
private let nicknameField = NSTextField()
private let usernameField = NSTextField() private let usernameField = NSTextField()
private let passwordField = NSSecureTextField() private let passwordField = NSSecureTextField()
private let savePwCheckbox = NSButton(checkboxWithTitle: "Save password in Keychain", target: nil, action: nil) private let savePwCheckbox = NSButton(checkboxWithTitle: "Save password in Keychain", target: nil, action: nil)
@@ -35,6 +36,7 @@ final class AddServerSheet: NSViewController {
hostField.stringValue = s.host hostField.stringValue = s.host
portField.stringValue = "\(s.port)" portField.stringValue = "\(s.port)"
authPicker.selectItem(withTitle: s.authMode == .guest ? "Guest" : "Account") authPicker.selectItem(withTitle: s.authMode == .guest ? "Guest" : "Account")
nicknameField.stringValue = s.nickname ?? ""
usernameField.stringValue = s.savedUsername ?? "" usernameField.stringValue = s.savedUsername ?? ""
updateAuthVisibility() updateAuthVisibility()
} }
@@ -47,6 +49,7 @@ final class AddServerSheet: NSViewController {
let hostLabel = NSTextField(labelWithString: "Host:") let hostLabel = NSTextField(labelWithString: "Host:")
let portLabel = NSTextField(labelWithString: "Port:") let portLabel = NSTextField(labelWithString: "Port:")
let authLabel = NSTextField(labelWithString: "Auth:") let authLabel = NSTextField(labelWithString: "Auth:")
let nickLabel = NSTextField(labelWithString: "Nickname:")
let userLabel = NSTextField(labelWithString: "Username:") let userLabel = NSTextField(labelWithString: "Username:")
let pwLabel = NSTextField(labelWithString: "Password:") let pwLabel = NSTextField(labelWithString: "Password:")
@@ -60,6 +63,9 @@ final class AddServerSheet: NSViewController {
authPicker.target = self; authPicker.action = #selector(authChanged) authPicker.target = self; authPicker.action = #selector(authChanged)
authPicker.setAccessibilityLabel("Authentication mode") authPicker.setAccessibilityLabel("Authentication mode")
nicknameField.placeholderString = "leave blank to use your system name"
nicknameField.setAccessibilityLabel("Guest nickname (display name)")
usernameField.placeholderString = "username" usernameField.placeholderString = "username"
usernameField.setAccessibilityLabel("Username") usernameField.setAccessibilityLabel("Username")
@@ -79,6 +85,7 @@ final class AddServerSheet: NSViewController {
[hostLabel, hostField], [hostLabel, hostField],
[portLabel, portField], [portLabel, portField],
[authLabel, authPicker], [authLabel, authPicker],
[nickLabel, nicknameField],
[userLabel, usernameField], [userLabel, usernameField],
[pwLabel, passwordField], [pwLabel, passwordField],
[NSView(), savePwCheckbox], [NSView(), savePwCheckbox],
@@ -110,6 +117,7 @@ final class AddServerSheet: NSViewController {
private func updateAuthVisibility() { private func updateAuthVisibility() {
let isAccount = authPicker.titleOfSelectedItem == "Account" let isAccount = authPicker.titleOfSelectedItem == "Account"
nicknameField.isEnabled = !isAccount
usernameField.isEnabled = isAccount usernameField.isEnabled = isAccount
passwordField.isEnabled = isAccount passwordField.isEnabled = isAccount
savePwCheckbox.isEnabled = isAccount savePwCheckbox.isEnabled = isAccount
@@ -130,6 +138,8 @@ final class AddServerSheet: NSViewController {
server.host = host; server.port = port server.host = host; server.port = port
server.authMode = isGuest ? .guest : .password server.authMode = isGuest ? .guest : .password
server.savedUsername = isGuest ? nil : usernameField.stringValue.trimmingCharacters(in: .whitespaces) server.savedUsername = isGuest ? nil : usernameField.stringValue.trimmingCharacters(in: .whitespaces)
let nick = nicknameField.stringValue.trimmingCharacters(in: .whitespaces)
server.nickname = nick.isEmpty ? nil : nick
if !isGuest && savePwCheckbox.state == .on { if !isGuest && savePwCheckbox.state == .on {
let pw = passwordField.stringValue let pw = passwordField.stringValue

View File

@@ -25,7 +25,18 @@ final class ChannelEditSheet: NSViewController {
}() }()
private let fecCheckbox = NSButton(checkboxWithTitle: "FEC", target: nil, action: nil) private let fecCheckbox = NSButton(checkboxWithTitle: "FEC", target: nil, action: nil)
private let dtxCheckbox = NSButton(checkboxWithTitle: "DTX", target: nil, action: nil) private let dtxCheckbox = NSButton(checkboxWithTitle: "DTX", target: nil, action: nil)
private let dredCheckbox = NSButton(checkboxWithTitle: "DRED", target: nil, action: nil)
private let frameMsPicker = NSPopUpButton() private let frameMsPicker = NSPopUpButton()
private let applicationPicker = NSPopUpButton()
private let sampleRateField: NSTextField = {
let f = NSTextField(); f.stringValue = "48000"; return f
}()
private let packetLossField: NSTextField = {
let f = NSTextField(); f.stringValue = "5"; return f
}()
private let complexityField: NSTextField = {
let f = NSTextField(); f.stringValue = "10"; return f
}()
init(channels: [Channel], editing: ChannelEdit?) { init(channels: [Channel], editing: ChannelEdit?) {
self.channels = channels self.channels = channels
@@ -72,14 +83,24 @@ final class ChannelEditSheet: NSViewController {
maxUsersField.setAccessibilityLabel("Max users (0 = unlimited)") maxUsersField.setAccessibilityLabel("Max users (0 = unlimited)")
sortOrderField.setAccessibilityLabel("Sort order") sortOrderField.setAccessibilityLabel("Sort order")
for ms in ["20", "40", "60"] { frameMsPicker.addItem(withTitle: "\(ms) ms") } for ms in ["10", "20", "40", "60"] { frameMsPicker.addItem(withTitle: "\(ms) ms") }
frameMsPicker.selectItem(withTitle: "20 ms")
frameMsPicker.setAccessibilityLabel("Opus frame duration") frameMsPicker.setAccessibilityLabel("Opus frame duration")
applicationPicker.addItem(withTitle: "VoIP")
applicationPicker.addItem(withTitle: "Audio")
applicationPicker.addItem(withTitle: "Low delay")
applicationPicker.setAccessibilityLabel("Opus application profile")
fecCheckbox.state = .on fecCheckbox.state = .on
stereoCheckbox.setAccessibilityLabel("Stereo audio") stereoCheckbox.setAccessibilityLabel("Stereo audio")
bitrateField.setAccessibilityLabel("Bitrate in bits per second") bitrateField.setAccessibilityLabel("Bitrate in bits per second")
sampleRateField.setAccessibilityLabel("Sample rate in Hz")
packetLossField.setAccessibilityLabel("Expected packet loss percent (0 to 100)")
complexityField.setAccessibilityLabel("Opus complexity (0 to 10)")
fecCheckbox.setAccessibilityLabel("Forward error correction") fecCheckbox.setAccessibilityLabel("Forward error correction")
dtxCheckbox.setAccessibilityLabel("Discontinuous transmission") dtxCheckbox.setAccessibilityLabel("Discontinuous transmission")
dredCheckbox.setAccessibilityLabel("Deep redundancy (DRED)")
let generalGrid = NSGridView(views: [ let generalGrid = NSGridView(views: [
[NSTextField(labelWithString: "Name:"), nameField], [NSTextField(labelWithString: "Name:"), nameField],
@@ -94,9 +115,13 @@ final class ChannelEditSheet: NSViewController {
let audioGrid = NSGridView(views: [ let audioGrid = NSGridView(views: [
[NSTextField(labelWithString: "Bitrate:"), bitrateField], [NSTextField(labelWithString: "Bitrate:"), bitrateField],
[NSTextField(labelWithString: "Sample rate:"), sampleRateField],
[NSTextField(labelWithString: "Frame:"), frameMsPicker], [NSTextField(labelWithString: "Frame:"), frameMsPicker],
[NSTextField(labelWithString: "Application:"), applicationPicker],
[NSTextField(labelWithString: "Packet loss %:"), packetLossField],
[NSTextField(labelWithString: "Complexity:"), complexityField],
[stereoCheckbox, fecCheckbox], [stereoCheckbox, fecCheckbox],
[dtxCheckbox, NSView()], [dtxCheckbox, dredCheckbox],
]) ])
audioGrid.rowSpacing = 8; audioGrid.columnSpacing = 8 audioGrid.rowSpacing = 8; audioGrid.columnSpacing = 8
audioGrid.column(at: 0).xPlacement = .trailing audioGrid.column(at: 0).xPlacement = .trailing
@@ -149,8 +174,13 @@ final class ChannelEditSheet: NSViewController {
sortOrderField.stringValue = "\(e.sortOrder)" sortOrderField.stringValue = "\(e.sortOrder)"
stereoCheckbox.state = e.audio.stereo ? .on : .off stereoCheckbox.state = e.audio.stereo ? .on : .off
bitrateField.stringValue = "\(e.audio.bitrateBps)" bitrateField.stringValue = "\(e.audio.bitrateBps)"
sampleRateField.stringValue = "\(e.audio.sampleRate)"
packetLossField.stringValue = "\(e.audio.expectedPacketLoss)"
complexityField.stringValue = "\(e.audio.complexity)"
fecCheckbox.state = e.audio.fec ? .on : .off fecCheckbox.state = e.audio.fec ? .on : .off
dtxCheckbox.state = e.audio.dtx ? .on : .off dtxCheckbox.state = e.audio.dtx ? .on : .off
dredCheckbox.state = e.audio.dred ? .on : .off
applicationPicker.selectItem(at: Int(min(e.audio.application, 2)))
let frameStr = "\(e.audio.frameMs) ms" let frameStr = "\(e.audio.frameMs) ms"
if let item = frameMsPicker.item(withTitle: frameStr) { frameMsPicker.select(item) } if let item = frameMsPicker.item(withTitle: frameStr) { frameMsPicker.select(item) }
} }
@@ -167,15 +197,24 @@ final class ChannelEditSheet: NSViewController {
let maxUsers = UInt32(maxUsersField.stringValue) ?? 0 let maxUsers = UInt32(maxUsersField.stringValue) ?? 0
let sortOrder = UInt32(sortOrderField.stringValue) ?? 0 let sortOrder = UInt32(sortOrderField.stringValue) ?? 0
let bitrate = UInt32(bitrateField.stringValue) ?? 64000 let bitrate = UInt32(bitrateField.stringValue) ?? 64000
let sampleRate = UInt32(sampleRateField.stringValue) ?? 48000
let packetLoss = min(UInt32(packetLossField.stringValue) ?? 5, 100)
let complexity = min(UInt32(complexityField.stringValue) ?? 10, 10)
let frameMsStr = frameMsPicker.titleOfSelectedItem?.replacingOccurrences(of: " ms", with: "") ?? "20" let frameMsStr = frameMsPicker.titleOfSelectedItem?.replacingOccurrences(of: " ms", with: "") ?? "20"
let frameMs = UInt32(frameMsStr) ?? 20 let frameMs = UInt32(frameMsStr) ?? 20
let application = UInt32(max(applicationPicker.indexOfSelectedItem, 0))
let audio = AudioConfig( let audio = AudioConfig(
stereo: stereoCheckbox.state == .on, stereo: stereoCheckbox.state == .on,
sampleRate: sampleRate,
bitrateBps: bitrate, bitrateBps: bitrate,
frameMs: frameMs, frameMs: frameMs,
application: application,
fec: fecCheckbox.state == .on, fec: fecCheckbox.state == .on,
dtx: dtxCheckbox.state == .on expectedPacketLoss: packetLoss,
dtx: dtxCheckbox.state == .on,
complexity: complexity,
dred: dredCheckbox.state == .on
) )
let pwProtected = pwCheckbox.state == .on let pwProtected = pwCheckbox.state == .on
let pw: String? = pwProtected ? (pwField.stringValue.isEmpty ? nil : pwField.stringValue) : nil let pw: String? = pwProtected ? (pwField.stringValue.isEmpty ? nil : pwField.stringValue) : nil

View File

@@ -216,7 +216,7 @@ final class ConnectWindowController: NSWindowController, NSWindowDelegate {
switch server.authMode { switch server.authMode {
case .guest: case .guest:
let nick = server.savedUsername?.isEmpty == false ? server.savedUsername! : NSFullUserName() let nick = server.nickname?.isEmpty == false ? server.nickname! : NSFullUserName()
newClient.authenticateGuest(nick) newClient.authenticateGuest(nick)
case .password: case .password:
let username = server.savedUsername ?? "" let username = server.savedUsername ?? ""
@@ -259,7 +259,7 @@ final class ConnectWindowController: NSWindowController, NSWindowDelegate {
case .authResult: case .authResult:
if event.result == .ok { if event.result == .ok {
let nickname = server.authMode == .guest let nickname = server.authMode == .guest
? (server.savedUsername?.isEmpty == false ? server.savedUsername! : NSFullUserName()) ? (server.nickname?.isEmpty == false ? server.nickname! : NSFullUserName())
: (server.savedUsername ?? "") : (server.savedUsername ?? "")
authSucceeded(client: client!, selfUserId: event.userId, nickname: nickname) authSucceeded(client: client!, selfUserId: event.userId, nickname: nickname)
} else { } else {

View File

@@ -28,6 +28,7 @@ public sealed class ChannelEditDialog : Form
private CheckBox _chkFec = null!; private CheckBox _chkFec = null!;
private NumericUpDown _numExpectedLoss = null!; private NumericUpDown _numExpectedLoss = null!;
private CheckBox _chkDtx = null!; private CheckBox _chkDtx = null!;
private CheckBox _chkDred = null!;
private NumericUpDown _numComplexity = null!; private NumericUpDown _numComplexity = null!;
public ChannelEditInfo? Result { get; private set; } public ChannelEditInfo? Result { get; private set; }
@@ -183,7 +184,7 @@ public sealed class ChannelEditDialog : Form
private void BuildAudioPage(TabPage page, AudioConfigInfo? audio) private void BuildAudioPage(TabPage page, AudioConfigInfo? audio)
{ {
audio ??= new AudioConfigInfo(0, false, 48000, 0, 20, 0, true, 0, false, 10); audio ??= new AudioConfigInfo(0, false, 48000, 0, 20, 0, true, 0, false, 10, false);
int y = 16; int y = 16;
int labelWidth = 150; int labelWidth = 150;
@@ -314,6 +315,17 @@ public sealed class ChannelEditDialog : Form
TabIndex = 18, TabIndex = 18,
}; };
page.Controls.Add(_chkDtx); page.Controls.Add(_chkDtx);
y += 28;
_chkDred = new CheckBox
{
Text = "D&RED (deep redundancy)",
Location = new Point(inputX, y),
AutoSize = true,
Checked = audio.Dred,
TabIndex = 19,
};
page.Controls.Add(_chkDred);
} }
private static void AddLabel(Control parent, string text, int x, int y, int width) private static void AddLabel(Control parent, string text, int x, int y, int width)
@@ -362,7 +374,8 @@ public sealed class ChannelEditDialog : Form
Fec: _chkFec.Checked, Fec: _chkFec.Checked,
ExpectedPacketLoss: (uint)_numExpectedLoss.Value, ExpectedPacketLoss: (uint)_numExpectedLoss.Value,
Dtx: _chkDtx.Checked, Dtx: _chkDtx.Checked,
Complexity: (uint)_numComplexity.Value); Complexity: (uint)_numComplexity.Value,
Dred: _chkDred.Checked);
Result = new ChannelEditInfo( Result = new ChannelEditInfo(
Id: _editingId, Id: _editingId,

View File

@@ -876,7 +876,7 @@ public partial class MainForm : Form
var editInfo = new ChannelEditInfo( var editInfo = new ChannelEditInfo(
channel.Id, channel.ParentId, channel.Name, channel.Topic, channel.Id, channel.ParentId, channel.Name, channel.Topic,
channel.PasswordProtected, null, channel.MaxUsers, 0, channel.PasswordProtected, null, channel.MaxUsers, 0,
new AudioConfigInfo(0, false, 48000, 0, 20, 0, true, 0, false, 10)); new AudioConfigInfo(0, false, 48000, 0, 20, 0, true, 0, false, 10, false));
using var dlg = new ChannelEditDialog(_channels, editInfo); using var dlg = new ChannelEditDialog(_channels, editInfo);
if (dlg.ShowDialog(this) != DialogResult.OK || dlg.Result is null) return; if (dlg.ShowDialog(this) != DialogResult.OK || dlg.Result is null) return;

View File

@@ -188,7 +188,7 @@ public sealed class VoiceCatClientSmokeTests : IDisposable
Password: null, Password: null,
MaxUsers: 42, MaxUsers: 42,
SortOrder: 0, SortOrder: 0,
Audio: new AudioConfigInfo(0, true, 48000, 64000, 20, 1, true, 5, false, 10)))); Audio: new AudioConfigInfo(0, true, 48000, 64000, 20, 1, true, 5, false, 10, false))));
Assert.True(PumpUntil(client, Assert.True(PumpUntil(client,
() => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000), () => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000),
"CreateChannel did not succeed"); "CreateChannel did not succeed");
@@ -208,7 +208,7 @@ public sealed class VoiceCatClientSmokeTests : IDisposable
null, null,
100, 100,
0, 0,
new AudioConfigInfo(0, true, 48000, 64000, 20, 1, true, 5, false, 10)))); new AudioConfigInfo(0, true, 48000, 64000, 20, 1, true, 5, false, 10, false))));
events.Clear(); events.Clear();
Assert.True(PumpUntil(client, Assert.True(PumpUntil(client,
() => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000), () => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000),

View File

@@ -96,7 +96,8 @@ internal static class Marshaling
native.Fec != 0, native.Fec != 0,
native.ExpectedPacketLoss, native.ExpectedPacketLoss,
native.Dtx != 0, native.Dtx != 0,
native.Complexity); native.Complexity,
native.Dred != 0);
public static PermissionsInfo ToManaged(in VcPermissionsNative native) => new( public static PermissionsInfo ToManaged(in VcPermissionsNative native) => new(
native.CanCreateTempChannel != 0, native.CanCreateTempChannel != 0,

View File

@@ -71,4 +71,5 @@ public sealed record AudioConfigInfo(
bool Fec, bool Fec,
uint ExpectedPacketLoss, uint ExpectedPacketLoss,
bool Dtx, bool Dtx,
uint Complexity); uint Complexity,
bool Dred);

View File

@@ -61,6 +61,7 @@ internal struct VcAudioConfigNative
public uint ExpectedPacketLoss; public uint ExpectedPacketLoss;
public int Dtx; public int Dtx;
public uint Complexity; public uint Complexity;
public int Dred;
} }
[StructLayout(LayoutKind.Sequential)] [StructLayout(LayoutKind.Sequential)]

View File

@@ -346,6 +346,7 @@ public sealed class VoiceCatClient : IDisposable
ExpectedPacketLoss = info.Audio.ExpectedPacketLoss, ExpectedPacketLoss = info.Audio.ExpectedPacketLoss,
Dtx = info.Audio.Dtx ? 1 : 0, Dtx = info.Audio.Dtx ? 1 : 0,
Complexity = info.Audio.Complexity, Complexity = info.Audio.Complexity,
Dred = info.Audio.Dred ? 1 : 0,
} }
}; };
} }