fix(apple-client/audio): capture the right channel of a multi-channel mic + diagnostics
apple / swift (push) Successful in 1m5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
release / apple (push) Has been cancelled
apple / swift (push) Successful in 1m5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
release / apple (push) Has been cancelled
The mic uplink handed the host pure digital silence on a multi-channel interface: AVAudioConverter's N→stereo downmix takes channels 0/1, but a pro interface puts the mic on ONE higher discrete channel. Fold the input to a mono bus ourselves instead — pick the mic's channel (or sum all) and resample that to the encoder's 48 kHz stereo, so the silent 0/1 downmix never happens. - New "Microphone channel" setting (macOS): Auto (sum every channel — a lone hot mic passes at full level) or pin 1-based channel N. Picker appears only for multi-channel devices, driven by the device's input channel count. - Diagnostics that make this class of failure self-naming next session: log the actual live capture device + format + fold mode, warn on a silent UID fallback, and a one-shot silence tripwire on the EXTRACTED signal (WARN on 10 s of zeros, else peak dBFS). - foldToMono extracted as a pure, unit-tested helper (pin / sum-clamp x interleaved / deinterleaved / mono / out-of-range). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -338,6 +338,7 @@ final class SessionModel: ObservableObject {
|
|||||||
audio.start(
|
audio.start(
|
||||||
speakerUID: defaults.string(forKey: DefaultsKey.speakerUID) ?? "",
|
speakerUID: defaults.string(forKey: DefaultsKey.speakerUID) ?? "",
|
||||||
micUID: defaults.string(forKey: DefaultsKey.micUID) ?? "",
|
micUID: defaults.string(forKey: DefaultsKey.micUID) ?? "",
|
||||||
|
micChannel: defaults.integer(forKey: DefaultsKey.micChannel),
|
||||||
micEnabled: defaults.object(forKey: DefaultsKey.micEnabled) as? Bool ?? true)
|
micEnabled: defaults.object(forKey: DefaultsKey.micEnabled) as? Bool ?? true)
|
||||||
self.audio = audio
|
self.audio = audio
|
||||||
// Gamepads: forward GamepadManager's active controller as pad 0 and render the
|
// Gamepads: forward GamepadManager's active controller as pad 0 and render the
|
||||||
|
|||||||
@@ -208,6 +208,17 @@ extension SettingsView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.disabled(!micEnabled)
|
.disabled(!micEnabled)
|
||||||
|
// Multi-channel interfaces only: the mic sits on ONE discrete input, so let the user
|
||||||
|
// pick it. Auto sums every channel (a lone hot mic still passes at full level).
|
||||||
|
if micChannelCount > 1 {
|
||||||
|
Picker("Microphone channel", selection: $micChannel) {
|
||||||
|
Text("Auto (all channels)").tag(0)
|
||||||
|
ForEach(1...micChannelCount, id: \.self) { ch in
|
||||||
|
Text("Channel \(ch)").tag(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(!micEnabled)
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
} header: {
|
} header: {
|
||||||
Text("Audio")
|
Text("Audio")
|
||||||
|
|||||||
@@ -61,8 +61,12 @@ struct SettingsView: View {
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@AppStorage(DefaultsKey.speakerUID) var speakerUID = ""
|
@AppStorage(DefaultsKey.speakerUID) var speakerUID = ""
|
||||||
@AppStorage(DefaultsKey.micUID) var micUID = ""
|
@AppStorage(DefaultsKey.micUID) var micUID = ""
|
||||||
|
@AppStorage(DefaultsKey.micChannel) var micChannel = 0
|
||||||
@State var outputDevices: [AudioDevice] = []
|
@State var outputDevices: [AudioDevice] = []
|
||||||
@State var inputDevices: [AudioDevice] = []
|
@State var inputDevices: [AudioDevice] = []
|
||||||
|
// Input channels of the selected mic — drives the "Microphone channel" picker, which only
|
||||||
|
// appears for a multi-channel interface (>1). 0 until the Audio tab loads it.
|
||||||
|
@State var micChannelCount = 0
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -115,6 +119,12 @@ struct SettingsView: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
outputDevices = AudioDevices.outputs()
|
outputDevices = AudioDevices.outputs()
|
||||||
inputDevices = AudioDevices.inputs()
|
inputDevices = AudioDevices.inputs()
|
||||||
|
micChannelCount = AudioDevices.inputChannelCount(forUID: micUID)
|
||||||
|
}
|
||||||
|
.onChange(of: micUID) { _, newUID in
|
||||||
|
// A different mic → different channel count; drop a now-out-of-range pin to Auto.
|
||||||
|
micChannelCount = AudioDevices.inputChannelCount(forUID: newUID)
|
||||||
|
if micChannel > micChannelCount { micChannel = 0 }
|
||||||
}
|
}
|
||||||
.tabItem { Label("Audio", systemImage: "speaker.wave.2") }
|
.tabItem { Label("Audio", systemImage: "speaker.wave.2") }
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,49 @@ public enum AudioDevices {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Input channel count of the mic the picker would use — the device with this UID, or the
|
||||||
|
/// system default input when `uid` is empty. 0 when it can't be resolved. Drives the
|
||||||
|
/// "Microphone channel" picker (only shown for multi-channel interfaces).
|
||||||
|
public static func inputChannelCount(forUID uid: String) -> Int {
|
||||||
|
let id = uid.isEmpty ? defaultInputDevice() : deviceID(forUID: uid)
|
||||||
|
guard let id else { return 0 }
|
||||||
|
return channelCount(id, scope: kAudioObjectPropertyScopeInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func defaultInputDevice() -> AudioDeviceID? {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDefaultInputDevice,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var dev = AudioDeviceID(0)
|
||||||
|
var size = UInt32(MemoryLayout<AudioDeviceID>.size)
|
||||||
|
guard AudioObjectGetPropertyData(
|
||||||
|
AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &dev) == noErr,
|
||||||
|
dev != 0
|
||||||
|
else { return nil }
|
||||||
|
return dev
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sum of channels across the device's streams in `scope` (its total input/output channels).
|
||||||
|
private static func channelCount(
|
||||||
|
_ id: AudioDeviceID, scope: AudioObjectPropertyScope
|
||||||
|
) -> Int {
|
||||||
|
var address = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioDevicePropertyStreamConfiguration,
|
||||||
|
mScope: scope,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var size: UInt32 = 0
|
||||||
|
guard AudioObjectGetPropertyDataSize(id, &address, 0, nil, &size) == noErr, size > 0
|
||||||
|
else { return 0 }
|
||||||
|
let raw = UnsafeMutableRawPointer.allocate(
|
||||||
|
byteCount: Int(size), alignment: MemoryLayout<AudioBufferList>.alignment)
|
||||||
|
defer { raw.deallocate() }
|
||||||
|
guard AudioObjectGetPropertyData(id, &address, 0, nil, &size, raw) == noErr else { return 0 }
|
||||||
|
let abl = UnsafeMutableAudioBufferListPointer(
|
||||||
|
raw.assumingMemoryBound(to: AudioBufferList.self))
|
||||||
|
return abl.reduce(0) { $0 + Int($1.mNumberChannels) }
|
||||||
|
}
|
||||||
|
|
||||||
private static func all() -> [AudioDeviceID] {
|
private static func all() -> [AudioDeviceID] {
|
||||||
var address = AudioObjectPropertyAddress(
|
var address = AudioObjectPropertyAddress(
|
||||||
mSelector: kAudioHardwarePropertyDevices,
|
mSelector: kAudioHardwarePropertyDevices,
|
||||||
@@ -62,7 +105,8 @@ public enum AudioDevices {
|
|||||||
return AudioObjectGetPropertyDataSize(id, &address, 0, nil, &size) == noErr && size > 0
|
return AudioObjectGetPropertyDataSize(id, &address, 0, nil, &size) == noErr && size > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func describe(_ id: AudioDeviceID) -> AudioDevice? {
|
/// UID + human name for a live AudioDeviceID (nil if either property is unreadable).
|
||||||
|
static func describe(_ id: AudioDeviceID) -> AudioDevice? {
|
||||||
guard let uid = stringProperty(id, kAudioDevicePropertyDeviceUID),
|
guard let uid = stringProperty(id, kAudioDevicePropertyDeviceUID),
|
||||||
let name = stringProperty(id, kAudioObjectPropertyName)
|
let name = stringProperty(id, kAudioObjectPropertyName)
|
||||||
else { return nil }
|
else { return nil }
|
||||||
|
|||||||
@@ -5,9 +5,10 @@
|
|||||||
// AVAudioSourceNode pulls from the ring (silence on underrun with re-priming, so a
|
// AVAudioSourceNode pulls from the ring (silence on underrun with re-priming, so a
|
||||||
// network gap costs one dip, not permanent crackle).
|
// network gap costs one dip, not permanent crackle).
|
||||||
//
|
//
|
||||||
// mic → host: a second AVAudioEngine taps the input device, resamples to 48 kHz
|
// mic → host: a second AVAudioEngine taps the input device, folds it to one mono bus (the
|
||||||
// stereo, slices 20 ms chunks, Opus-encodes, and sendMic()s each packet — the host
|
// chosen channel of a multi-channel interface, or a sum of all channels), resamples to 48 kHz
|
||||||
// feeds them into a virtual PipeWire source.
|
// stereo, slices 20 ms chunks, Opus-encodes, and sendMic()s each packet — the host feeds them
|
||||||
|
// into a virtual PipeWire source.
|
||||||
//
|
//
|
||||||
// Devices are chosen by UID ("" = system default: the engine is then never pinned to a
|
// Devices are chosen by UID ("" = system default: the engine is then never pinned to a
|
||||||
// concrete device and follows default-device changes). Two engines, not one — a single
|
// concrete device and follows default-device changes). Two engines, not one — a single
|
||||||
@@ -68,10 +69,11 @@ public final class SessionAudio {
|
|||||||
/// ASYNCHRONOUS: it activates the AVAudioSession off the main thread, then starts the engines on
|
/// ASYNCHRONOUS: it activates the AVAudioSession off the main thread, then starts the engines on
|
||||||
/// a later main-queue hop (gated by `!flag.isStopped`) — so playback is live shortly after, not
|
/// a later main-queue hop (gated by `!flag.isStopped`) — so playback is live shortly after, not
|
||||||
/// on return. The mic may start later still if the permission prompt is pending.
|
/// on return. The mic may start later still if the permission prompt is pending.
|
||||||
public func start(speakerUID: String, micUID: String, micEnabled: Bool) {
|
public func start(speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool) {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
// No AVAudioSession on macOS — start the engines directly (caller's thread, as before).
|
// No AVAudioSession on macOS — start the engines directly (caller's thread, as before).
|
||||||
startEngines(speakerUID: speakerUID, micUID: micUID, micEnabled: micEnabled)
|
startEngines(
|
||||||
|
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel, micEnabled: micEnabled)
|
||||||
#else
|
#else
|
||||||
// Configure + activate the session OFF the main thread (it blocks on the audio server),
|
// Configure + activate the session OFF the main thread (it blocks on the audio server),
|
||||||
// then start the engines back on the main thread once it's active — engine routing/format
|
// then start the engines back on the main thread once it's active — engine routing/format
|
||||||
@@ -81,7 +83,9 @@ public final class SessionAudio {
|
|||||||
self.activateAudioSession(micEnabled: micEnabled)
|
self.activateAudioSession(micEnabled: micEnabled)
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let self, !self.flag.isStopped else { return }
|
guard let self, !self.flag.isStopped else { return }
|
||||||
self.startEngines(speakerUID: speakerUID, micUID: micUID, micEnabled: micEnabled)
|
self.startEngines(
|
||||||
|
speakerUID: speakerUID, micUID: micUID, micChannel: micChannel,
|
||||||
|
micEnabled: micEnabled)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -115,7 +119,9 @@ public final class SessionAudio {
|
|||||||
|
|
||||||
/// Build + start the playback engine (and the mic uplink when enabled + authorized). Main
|
/// Build + start the playback engine (and the mic uplink when enabled + authorized). Main
|
||||||
/// thread (engine setup); on iOS/tvOS the session is already active by the time this runs.
|
/// thread (engine setup); on iOS/tvOS the session is already active by the time this runs.
|
||||||
private func startEngines(speakerUID: String, micUID: String, micEnabled: Bool) {
|
private func startEngines(
|
||||||
|
speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool
|
||||||
|
) {
|
||||||
startPlayback(speakerUID: speakerUID)
|
startPlayback(speakerUID: speakerUID)
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
// No app-accessible microphone input on tvOS — playback only.
|
// No app-accessible microphone input on tvOS — playback only.
|
||||||
@@ -123,12 +129,12 @@ public final class SessionAudio {
|
|||||||
guard micEnabled else { return }
|
guard micEnabled else { return }
|
||||||
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
||||||
case .authorized:
|
case .authorized:
|
||||||
startCapture(micUID: micUID)
|
startCapture(micUID: micUID, micChannel: micChannel)
|
||||||
case .notDetermined:
|
case .notDetermined:
|
||||||
AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in
|
AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
guard let self, granted, !self.flag.isStopped else { return }
|
guard let self, granted, !self.flag.isStopped else { return }
|
||||||
self.startCapture(micUID: micUID)
|
self.startCapture(micUID: micUID, micChannel: micChannel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
@@ -280,7 +286,7 @@ public final class SessionAudio {
|
|||||||
// MARK: - Mic (mic → host)
|
// MARK: - Mic (mic → host)
|
||||||
|
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
private func startCapture(micUID: String) {
|
private func startCapture(micUID: String, micChannel: Int) {
|
||||||
let engine = AVAudioEngine()
|
let engine = AVAudioEngine()
|
||||||
let input = engine.inputNode
|
let input = engine.inputNode
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@@ -300,8 +306,63 @@ public final class SessionAudio {
|
|||||||
log.error("no usable input device — mic uplink disabled")
|
log.error("no usable input device — mic uplink disabled")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let encoder = try? OpusEncoder(),
|
|
||||||
let resampler = AVAudioConverter(from: inFormat, to: encoder.pcmFormat),
|
// Multi-channel-interface handling. A pro interface exposes N discrete inputs with the mic
|
||||||
|
// on ONE of them, but AVAudioConverter's N→stereo downmix takes channels 0/1 — dead
|
||||||
|
// silence when the mic sits higher up (the classic "host receives zeros"). So we fold the
|
||||||
|
// input to a single mono bus OURSELVES and resample that. micChannel: 0 = Auto (sum every
|
||||||
|
// channel — a lone hot mic passes at full level), n≥1 pins 1-based input channel n.
|
||||||
|
let inChannels = Int(inFormat.channelCount)
|
||||||
|
let pinnedChannel: Int? = {
|
||||||
|
guard micChannel >= 1 else { return nil }
|
||||||
|
let idx = micChannel - 1
|
||||||
|
guard idx < inChannels else {
|
||||||
|
log.warning(
|
||||||
|
"mic channel \(micChannel) out of range (device has \(inChannels)) — mixing all")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return idx
|
||||||
|
}()
|
||||||
|
let channelPlan = pinnedChannel.map { "channel \($0 + 1)/\(inChannels)" }
|
||||||
|
?? (inChannels > 1 ? "mix \(inChannels)ch→mono" : "mono")
|
||||||
|
|
||||||
|
// Name the device we're ACTUALLY recording from + its format + how we fold it, once per
|
||||||
|
// session. This single line localizes the whole class of "host receives silence" failures
|
||||||
|
// that otherwise need a host-side tone injection to pin down: a UID that silently fell back
|
||||||
|
// to the default, the wrong device being live, or the wrong channel picked.
|
||||||
|
#if os(macOS)
|
||||||
|
if let unit = input.audioUnit, let live = Self.currentDevice(of: unit),
|
||||||
|
let dev = AudioDevices.describe(live) {
|
||||||
|
if !micUID.isEmpty, dev.uid != micUID {
|
||||||
|
log.warning("""
|
||||||
|
mic selection not honored — requested \(micUID) but capturing from \
|
||||||
|
\(dev.name) [\(dev.uid)]; the device's UID likely changed (replug) — \
|
||||||
|
reselect it in Settings
|
||||||
|
""")
|
||||||
|
}
|
||||||
|
log.info("""
|
||||||
|
mic capture: \(dev.name) [\(dev.uid)] — \(Int(inFormat.sampleRate)) Hz, \
|
||||||
|
\(inChannels) ch, \(channelPlan)
|
||||||
|
""")
|
||||||
|
} else {
|
||||||
|
log.info("""
|
||||||
|
mic capture: <device unavailable> — \(Int(inFormat.sampleRate)) Hz, \
|
||||||
|
\(inChannels) ch, \(channelPlan)
|
||||||
|
""")
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
log.info(
|
||||||
|
"mic capture: \(Int(inFormat.sampleRate)) Hz, \(inChannels) ch, \(channelPlan)")
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Encode a single mono bus (folded from `inFormat` in the tap): the resampler goes
|
||||||
|
// mono@inputSR → the encoder's 48 kHz stereo, so it handles both the rate change and the
|
||||||
|
// mono→stereo duplication, and the wrong-channel downmix never happens.
|
||||||
|
guard let monoFormat = AVAudioFormat(
|
||||||
|
commonFormat: .pcmFormatFloat32, sampleRate: inFormat.sampleRate,
|
||||||
|
channels: 1, interleaved: false),
|
||||||
|
let encoder = try? OpusEncoder(),
|
||||||
|
let resampler = AVAudioConverter(from: monoFormat, to: encoder.pcmFormat),
|
||||||
let chunk = AVAudioPCMBuffer(
|
let chunk = AVAudioPCMBuffer(
|
||||||
pcmFormat: encoder.pcmFormat, frameCapacity: OpusEncoder.framesPerPacket)
|
pcmFormat: encoder.pcmFormat, frameCapacity: OpusEncoder.framesPerPacket)
|
||||||
else {
|
else {
|
||||||
@@ -317,11 +378,59 @@ public final class SessionAudio {
|
|||||||
let connection = connection
|
let connection = connection
|
||||||
let flag = flag
|
let flag = flag
|
||||||
|
|
||||||
|
// Silence tripwire (tap-confined): a "recording" app can be handed pure digital zeros —
|
||||||
|
// a zeroed input-volume slider, a stale TCC grant, a muted device, OR the wrong channel
|
||||||
|
// picked — and everything downstream looks alive while the host gets silence. Track the
|
||||||
|
// peak of the EXTRACTED mono bus over the first ~10 s (not the raw device — a mic present
|
||||||
|
// on a channel we didn't grab must still read as silence) and emit exactly ONE verdict.
|
||||||
|
// This is the log line whose absence made the last occurrence take a host-side tone.
|
||||||
|
let silenceWindow = Int(inFormat.sampleRate * 10)
|
||||||
|
let deviceLabel = micUID.isEmpty ? "default input" : micUID
|
||||||
|
var framesInspected = 0
|
||||||
|
var inputPeak: Float = 0
|
||||||
|
var levelReported = false
|
||||||
|
|
||||||
input.installTap(onBus: 0, bufferSize: 2048, format: inFormat) { buffer, _ in
|
input.installTap(onBus: 0, bufferSize: 2048, format: inFormat) { buffer, _ in
|
||||||
if flag.isStopped { return }
|
if flag.isStopped { return }
|
||||||
|
let frames = Int(buffer.frameLength)
|
||||||
|
guard frames > 0, let src = buffer.floatChannelData,
|
||||||
|
let mono = AVAudioPCMBuffer(
|
||||||
|
pcmFormat: monoFormat, frameCapacity: buffer.frameLength),
|
||||||
|
let dst = mono.floatChannelData?[0]
|
||||||
|
else { return }
|
||||||
|
mono.frameLength = buffer.frameLength
|
||||||
|
|
||||||
|
// Fold the multi-channel input down to the one mono bus we encode.
|
||||||
|
Self.foldToMono(
|
||||||
|
input: src, frames: frames, channels: Int(buffer.format.channelCount),
|
||||||
|
interleaved: buffer.format.isInterleaved, pinned: pinnedChannel, out: dst)
|
||||||
|
|
||||||
|
if !levelReported {
|
||||||
|
var localPeak: Float = 0
|
||||||
|
for i in 0..<frames where abs(dst[i]) > localPeak { localPeak = abs(dst[i]) }
|
||||||
|
if localPeak > inputPeak { inputPeak = localPeak }
|
||||||
|
framesInspected += frames
|
||||||
|
if framesInspected >= silenceWindow {
|
||||||
|
levelReported = true
|
||||||
|
if inputPeak == 0 {
|
||||||
|
log.warning("""
|
||||||
|
mic uplink has been pure digital SILENCE for 10 s (\(deviceLabel), \
|
||||||
|
\(channelPlan)) — check the input level (System Settings → Sound → \
|
||||||
|
Input), Privacy & Security → Microphone, and the Microphone channel in \
|
||||||
|
Settings; the host is receiving zeros
|
||||||
|
""")
|
||||||
|
} else {
|
||||||
|
let dbfs = 20 * log10(inputPeak)
|
||||||
|
log.info("""
|
||||||
|
mic uplink OK — peak \(String(format: "%.1f", dbfs)) dBFS over first \
|
||||||
|
10 s (\(deviceLabel), \(channelPlan))
|
||||||
|
""")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let ratio = 48_000 / inFormat.sampleRate
|
let ratio = 48_000 / inFormat.sampleRate
|
||||||
let outCapacity = AVAudioFrameCount(
|
let outCapacity = AVAudioFrameCount((Double(frames) * ratio).rounded(.up) + 64)
|
||||||
(Double(buffer.frameLength) * ratio).rounded(.up) + 64)
|
|
||||||
guard let staging = AVAudioPCMBuffer(
|
guard let staging = AVAudioPCMBuffer(
|
||||||
pcmFormat: encoder.pcmFormat, frameCapacity: outCapacity)
|
pcmFormat: encoder.pcmFormat, frameCapacity: outCapacity)
|
||||||
else { return }
|
else { return }
|
||||||
@@ -334,7 +443,7 @@ public final class SessionAudio {
|
|||||||
}
|
}
|
||||||
fed = true
|
fed = true
|
||||||
outStatus.pointee = .haveData
|
outStatus.pointee = .haveData
|
||||||
return buffer
|
return mono
|
||||||
}
|
}
|
||||||
guard status != .error, let p = staging.floatChannelData?[0] else { return }
|
guard status != .error, let p = staging.floatChannelData?[0] else { return }
|
||||||
fifo.append(contentsOf: UnsafeBufferPointer(
|
fifo.append(contentsOf: UnsafeBufferPointer(
|
||||||
@@ -378,6 +487,42 @@ public final class SessionAudio {
|
|||||||
stateLock.unlock()
|
stateLock.unlock()
|
||||||
log.info("mic uplink started (\(micUID.isEmpty ? "default input" : micUID))")
|
log.info("mic uplink started (\(micUID.isEmpty ? "default input" : micUID))")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fold `channels` of input (`floatChannelData` layout: `interleaved` → one buffer strided by
|
||||||
|
/// channel count; else one buffer per channel) down to a single mono bus in `out` (`frames`
|
||||||
|
/// long). `pinned` (0-based, must be `< channels`) copies exactly that channel — the fix for a
|
||||||
|
/// mic on one input of a multi-channel interface; `nil` sums every channel, clamped to
|
||||||
|
/// [-1, 1], so a lone hot channel still passes at full level instead of the silent 0/1 the
|
||||||
|
/// default N→stereo downmix would grab. Pure + `internal` for unit testing the index math.
|
||||||
|
static func foldToMono(
|
||||||
|
input: UnsafePointer<UnsafeMutablePointer<Float>>, frames: Int, channels: Int,
|
||||||
|
interleaved: Bool, pinned: Int?, out: UnsafeMutablePointer<Float>
|
||||||
|
) {
|
||||||
|
if let ch = pinned, ch < channels {
|
||||||
|
if interleaved {
|
||||||
|
let d = input[0]
|
||||||
|
for i in 0..<frames { out[i] = d[i * channels + ch] }
|
||||||
|
} else {
|
||||||
|
let d = input[ch]
|
||||||
|
for i in 0..<frames { out[i] = d[i] }
|
||||||
|
}
|
||||||
|
} else if interleaved {
|
||||||
|
let d = input[0]
|
||||||
|
for i in 0..<frames {
|
||||||
|
var s: Float = 0
|
||||||
|
for c in 0..<channels { s += d[i * channels + c] }
|
||||||
|
out[i] = max(-1, min(1, s))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let d0 = input[0]
|
||||||
|
for i in 0..<frames { out[i] = d0[i] }
|
||||||
|
for c in 1..<channels {
|
||||||
|
let d = input[c]
|
||||||
|
for i in 0..<frames { out[i] += d[i] }
|
||||||
|
}
|
||||||
|
if channels > 1 { for i in 0..<frames { out[i] = max(-1, min(1, out[i])) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@@ -387,5 +532,18 @@ public final class SessionAudio {
|
|||||||
unit, kAudioOutputUnitProperty_CurrentDevice, kAudioUnitScope_Global, 0,
|
unit, kAudioOutputUnitProperty_CurrentDevice, kAudioUnitScope_Global, 0,
|
||||||
&dev, UInt32(MemoryLayout<AudioDeviceID>.size)) == noErr
|
&dev, UInt32(MemoryLayout<AudioDeviceID>.size)) == noErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read back the AUHAL's live device — the definitive "what are we actually capturing
|
||||||
|
/// from", which catches a selection that succeeded on paper but silently fell back to
|
||||||
|
/// the system default (a stale/changed UID, a device that vanished between resolve and
|
||||||
|
/// start). 0 / an error means we couldn't tell.
|
||||||
|
private static func currentDevice(of unit: AudioUnit) -> AudioDeviceID? {
|
||||||
|
var dev = AudioDeviceID(0)
|
||||||
|
var size = UInt32(MemoryLayout<AudioDeviceID>.size)
|
||||||
|
let status = AudioUnitGetProperty(
|
||||||
|
unit, kAudioOutputUnitProperty_CurrentDevice, kAudioUnitScope_Global, 0, &dev, &size)
|
||||||
|
guard status == noErr, dev != 0 else { return nil }
|
||||||
|
return dev
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ public enum DefaultsKey {
|
|||||||
public static let micEnabled = "punktfunk.micEnabled"
|
public static let micEnabled = "punktfunk.micEnabled"
|
||||||
public static let speakerUID = "punktfunk.speakerUID"
|
public static let speakerUID = "punktfunk.speakerUID"
|
||||||
public static let micUID = "punktfunk.micUID"
|
public static let micUID = "punktfunk.micUID"
|
||||||
|
/// macOS: which input channel of the chosen mic device feeds the host. 0 = "Auto" (sum every
|
||||||
|
/// channel to mono — a mic on a single input of a multi-channel interface passes at full
|
||||||
|
/// level); n≥1 pins 1-based input channel n. Multi-channel interfaces expose the mic on ONE
|
||||||
|
/// discrete channel, and the default N→stereo downmix grabs channels 0/1 (silence when the mic
|
||||||
|
/// is higher up), so we fold to mono ourselves. Only meaningful for multi-channel devices.
|
||||||
|
public static let micChannel = "punktfunk.micChannel"
|
||||||
public static let presenter = "punktfunk.presenter"
|
public static let presenter = "punktfunk.presenter"
|
||||||
/// Request a 10-bit BT.2020 PQ (HDR10) stream. On by default; only takes effect when the host
|
/// Request a 10-bit BT.2020 PQ (HDR10) stream. On by default; only takes effect when the host
|
||||||
/// has HDR content AND this display supports HDR — otherwise the stream stays 8-bit SDR.
|
/// has HDR content AND this display supports HDR — otherwise the stream stays 8-bit SDR.
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// Multi-channel input → mono fold (SessionAudio.foldToMono): the fix for a mic on one channel of
|
||||||
|
// a multi-channel interface. AVAudioConverter's default N→stereo downmix grabs channels 0/1 — dead
|
||||||
|
// silence when the mic sits higher up — so we fold ourselves. This pins the fiddly bits (the
|
||||||
|
// interleaved stride, channel pinning, the sum-clamp) against regressions without needing hardware.
|
||||||
|
|
||||||
|
#if !os(tvOS)
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
@testable import PunktfunkKit
|
||||||
|
|
||||||
|
final class AudioChannelFoldTests: XCTestCase {
|
||||||
|
/// Drive `foldToMono` over channel data expressed as `[[Float]]`, mirroring the two
|
||||||
|
/// `floatChannelData` layouts:
|
||||||
|
/// - deinterleaved: each inner array is one channel (all `frames` long).
|
||||||
|
/// - interleaved: a single inner array already interleaved (c0f0, c1f0, …), with the real
|
||||||
|
/// channel count passed separately.
|
||||||
|
private func fold(
|
||||||
|
_ planes: [[Float]], frames: Int, channels: Int, interleaved: Bool, pinned: Int?
|
||||||
|
) -> [Float] {
|
||||||
|
// One C buffer per plane + a table of pointers to them — the shape of floatChannelData.
|
||||||
|
let buffers: [UnsafeMutablePointer<Float>] = planes.map { plane in
|
||||||
|
let p = UnsafeMutablePointer<Float>.allocate(capacity: plane.count)
|
||||||
|
for i in 0..<plane.count { p[i] = plane[i] }
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
let table = UnsafeMutablePointer<UnsafeMutablePointer<Float>>.allocate(
|
||||||
|
capacity: buffers.count)
|
||||||
|
for (i, b) in buffers.enumerated() { table[i] = b }
|
||||||
|
let out = UnsafeMutablePointer<Float>.allocate(capacity: frames)
|
||||||
|
defer {
|
||||||
|
buffers.forEach { $0.deallocate() }
|
||||||
|
table.deallocate()
|
||||||
|
out.deallocate()
|
||||||
|
}
|
||||||
|
SessionAudio.foldToMono(
|
||||||
|
input: table, frames: frames, channels: channels,
|
||||||
|
interleaved: interleaved, pinned: pinned, out: out)
|
||||||
|
return (0..<frames).map { out[$0] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pinned channel is copied verbatim — the exact fix: mic on a HIGH channel, not 0/1.
|
||||||
|
func testPinsHigherChannelDeinterleaved() {
|
||||||
|
let result = fold(
|
||||||
|
[[0, 0, 0], [0, 0, 0], [0.1, 0.2, 0.3], [0, 0, 0]],
|
||||||
|
frames: 3, channels: 4, interleaved: false, pinned: 2)
|
||||||
|
XCTAssertEqual(result, [0.1, 0.2, 0.3])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same signal, interleaved layout: [c0f0,c1f0,c2f0,c3f0, c0f1,…]. Guards the `i*ch + c` stride.
|
||||||
|
func testPinsHigherChannelInterleaved() {
|
||||||
|
let interleaved: [Float] = [
|
||||||
|
0, 0, 0.1, 0,
|
||||||
|
0, 0, 0.2, 0,
|
||||||
|
0, 0, 0.3, 0,
|
||||||
|
]
|
||||||
|
let result = fold([interleaved], frames: 3, channels: 4, interleaved: true, pinned: 2)
|
||||||
|
XCTAssertEqual(result, [0.1, 0.2, 0.3])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto (pinned: nil): a lone hot channel amid silence passes at FULL level, never attenuated.
|
||||||
|
func testAutoSumsAllChannelsSoALoneMicSurvives() {
|
||||||
|
let result = fold(
|
||||||
|
[[0, 0], [0.4, -0.4], [0, 0]],
|
||||||
|
frames: 2, channels: 3, interleaved: false, pinned: nil)
|
||||||
|
XCTAssertEqual(result, [0.4, -0.4])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two simultaneously-hot channels sum past the unit range → clamped, never wraps/overflows.
|
||||||
|
func testAutoSumClampsToUnitRange() {
|
||||||
|
let result = fold(
|
||||||
|
[[0.8, -0.8], [0.9, -0.9]],
|
||||||
|
frames: 2, channels: 2, interleaved: false, pinned: nil)
|
||||||
|
XCTAssertEqual(result, [1.0, -1.0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// A plain mono device is passed through untouched (no clamp, no attenuation).
|
||||||
|
func testMonoIsIdentity() {
|
||||||
|
let result = fold(
|
||||||
|
[[0.25, -0.5, 0.75]], frames: 3, channels: 1, interleaved: false, pinned: nil)
|
||||||
|
XCTAssertEqual(result, [0.25, -0.5, 0.75])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Belt-and-suspenders: an out-of-range pin (the tap already guards, but the setting is
|
||||||
|
// persisted) is ignored by foldToMono's own `ch < channels` guard, which sums instead of
|
||||||
|
// reading past the buffer.
|
||||||
|
func testOutOfRangePinFallsBackToSum() {
|
||||||
|
let result = fold(
|
||||||
|
[[0, 0], [0.3, 0.3]],
|
||||||
|
frames: 2, channels: 2, interleaved: false, pinned: 2)
|
||||||
|
XCTAssertEqual(result, [0.3, 0.3])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
Reference in New Issue
Block a user