2
\$\begingroup\$

Due to some bug somewhere, my speaker balance kept moving off-center and it was getting annoying. So I cobbled together the code below (based on this question) which—to my surprise—does compile and run on macOS 11.1 / Swift 5.3.2.

I am a beginner and am trying to learn, trying to avoid errors and adhere to the DRY principle. I would also like to know if there are any glaring mistakes.

For example, I believe there is a way to make a single function that can return the default Audio Device as well as fetch properties from it, since there are a lot of similarities between getDefaultOutputDevice() and aPropGet()

I also am not at all sure about the use of the struct {...} to declare "global" variables.

Can anyone please pass along any pointers?

To compile save the code below as bal-reset.swift and build with swiftc -O bal_reset.swift

import AudioToolbox
import CoreAudio
import Foundation
// global variables
struct g {
 static var deviceId: AudioDeviceID = 0
 static var deviceName = "" as CFString
 static var eq_balance: Float32 = 0.50 // 0.0 (left) through 1.0 (right)
}
func getDefaultOutputDevice() {
 var aSize = UInt32(MemoryLayout.size(ofValue: g.deviceId))
 var address = AudioObjectPropertyAddress(
 mSelector: kAudioHardwarePropertyDefaultOutputDevice,
 mScope: kAudioObjectPropertyScopeGlobal,
 mElement: kAudioObjectPropertyElementMaster)
 var err = AudioObjectGetPropertyData(
 AudioObjectID(kAudioObjectSystemObject),
 &address,
 0,
 nil,
 &aSize,
 &g.deviceId)
 if (err == 0) {
 address.mSelector = kAudioDevicePropertyDeviceNameCFString
 aSize = UInt32(MemoryLayout.size(ofValue: g.deviceName))
 err = AudioObjectGetPropertyData(
 g.deviceId,
 &address,
 0,
 nil,
 &aSize,
 &g.deviceName)
 if (err == 0) {
 print("dev:", String(g.deviceName) + " [" + String(g.deviceId) + "]")
 } else {
 print("dev:", g.deviceId)
 }
 } else {
 print("error [" + String(err) + "] could not determine output device") 
 exit(1)
 }
}
getDefaultOutputDevice()
func aPropGet(selector: UInt32) -> (err: OSStatus, val: Float32) {
 var val: Float32 = 0.0
 var aSize = UInt32(MemoryLayout.size(ofValue: val))
 var address = AudioObjectPropertyAddress(
 mSelector: selector,
 mScope: kAudioObjectPropertyScopeOutput,
 mElement: kAudioObjectPropertyElementMaster
 )
 let err = AudioObjectGetPropertyData(
 g.deviceId,
 &address,
 0,
 nil,
 &aSize,
 &val)
 return (err, val)
}
func setBalance(selector: UInt32) -> OSStatus {
 let aSize = UInt32(MemoryLayout.size(ofValue: g.eq_balance))
 var address = AudioObjectPropertyAddress(
 mSelector: selector,
 mScope: kAudioObjectPropertyScopeOutput,
 mElement: kAudioObjectPropertyElementMaster
 )
 let err = AudioObjectSetPropertyData(
 g.deviceId,
 &address,
 0,
 nil,
 aSize,
 &g.eq_balance)
 return err
}
func getBalance() -> (err: OSStatus, val: Float32) {
 var res = aPropGet(selector: kAudioDevicePropertyStereoPan)
 if (res.err != 0) {
 res = aPropGet(selector: kAudioHardwareServiceDeviceProperty_VirtualMasterBalance)
 }
 return (res.err, res.val)
}
var res = aPropGet(selector: kAudioHardwareServiceDeviceProperty_VirtualMasterVolume)
if (res.err == 0) {
 print("vol:", String(format: "%.0f", res.val*100))
}
res = getBalance()
switch res.err {
 case 0:
 print("old:", String(format: "%.2f", res.val))
 case kAudioCodecUnknownPropertyError:
 print("device does not support balance adjustment")
 exit(0)
 default:
 print("unknown error [" + String(res.err) + "] while trying to query balance")
 exit(1)
}
switch res.val {
 case 0.5:
 exit(0)
 default:
 var bal = setBalance(selector: kAudioDevicePropertyStereoPan)
 if (bal != 0) {
 bal = setBalance(selector: kAudioHardwareServiceDeviceProperty_VirtualMasterBalance)
 }
 switch bal {
 case 0:
 print("new:", String(format: "%.2f", g.eq_balance))
 exit(0)
 default:
 print("error [" + String(bal) + "] while trying to set balance")
 exit(1)
 }
}
asked Jan 19, 2021 at 14:36
\$\endgroup\$
1
  • \$\begingroup\$ Just want to note that there's another utility (undoubtedly better-written) that does this as well: see Wevah/sndctl \$\endgroup\$ Commented Jun 16, 2023 at 21:39

1 Answer 1

1
\$\begingroup\$

Use string interpolation:

"unknown error [" + String(res.err) + "] while trying to query balance"

is normally written like this:

"unknown error [\(String(res.err))] while trying to query balance"

Split out functions into pieces dealing with different issues

For example, your getDefaultOutputDevice function has lines to do with the mechanics of calling the API (MemoryLayout.size(ofValue... etc), and lines to do with dealing with errors (print error... etc)

Splitting those things up into separate pieces unlocks the parts

Use a suitable existing type or make new ones, to avoid global scope. Your struct "g" can be avoided completely by sticking to functions that return values. I would be wary about storing any of the values you read back from the system, like the name or the value of the balance etc because they are volatile and can change without you changing them, so for example, if name is always read afresh each time you need it then it's never stale data.

Here is how you might use generics to wrap the get and set calls:

import AudioToolbox
import CoreAudio
import Foundation
// The API uses AudioObjectPropertyAddress structs
// Let's make some helpers to make working with these struts easier.
extension AudioObjectPropertyAddress {
 /// We have to use backticks here because we want to use a swift keyword
 /// for our variable name, or we could call it something else like defaultOutput
 static var `default`: Self {
 .init(
 mSelector: kAudioHardwarePropertyDefaultOutputDevice,
 mScope: kAudioObjectPropertyScopeGlobal,
 mElement: kAudioObjectPropertyElementMaster)
 }
 
 static var masterOutput: Self {
 .init(
 mSelector: kAudioHardwarePropertyDefaultOutputDevice,
 mScope: kAudioObjectPropertyScopeOutput,
 mElement: kAudioObjectPropertyElementMaster)
 }
 
 /// Take our existing self, and return a new struct with just the scope
 /// different
 func scoped(_ s: AudioObjectPropertyScope) -> Self {
 .init(mSelector: mSelector, mScope: s, mElement: mElement)
 }
 
 /// same for element etc
 func elemented(_ e: AudioObjectPropertyScope) -> Self {
 .init(mSelector: mSelector, mScope: mScope, mElement: e)
 }
 
 func selectored(_ s: AudioObjectPropertySelector) -> Self {
 .init(mSelector: s, mScope: mScope, mElement: mElement)
 }
}
/// With these helpers in place we can be more consise at the call site,
/// transforming these values to say something like default master output, but selecting
/// the balance property
//let example = AudioObjectPropertyAddress
// .default
// .selectored(kAudioHardwareServiceDeviceProperty_VirtualMasterBalance)
/// The API emits errors, so wrapping it to make it more Swifty, I wrap those OSStatus errors
/// in swift's Error
struct OSStatusError: Swift.Error {
 var value: Int32
}
/// The audio API likes to use inout arguments. These functions are of a type
/// f(address, inout Something)
/// these can always be converted into equivalent functions but of type f(address, something) -> something
/// and that can make them easier to work with
/// the pattern to use it is:
/// make a new Float or something, pass that into GetPropertyData, check for errors
/// let's wrap the noise involved in making one of these calls:
extension AudioObjectID {
 /// We have to use a generic here because the function can deal with any type of
 /// data from AudioObject
 func get<T>(_ address: AudioObjectPropertyAddress,
 v: inout T) throws {
 var addr = address
 var size = UInt32(MemoryLayout.size(ofValue: v))
 let err = AudioObjectGetPropertyData(self, &addr, 0, nil, &size, &v)
 
 if err != 0 {
 throw OSStatusError(value: err)
 }
 }
 
 /// And we prefer using the f(A, V) -> V style rather than the f(A, inout V) style
 /// so let's make that
 func get<T>(_ address: AudioObjectPropertyAddress,
 v: T) throws -> T {
 var intoV = v
 
 try get(address, v: &intoV)
 
 return intoV
 }
 /// Set is similar, you can make this be like f(A, X) -> X if you want
 /// typically a setter would return void but there is the chance that the value is
 /// set to something other than the value you specified
 func set<T>(_ address: AudioObjectPropertyAddress,
 v: inout T) throws {
 var address = address
 let size = UInt32(MemoryLayout.size(ofValue: v))
 
 let err = AudioObjectSetPropertyData(self, &address, 0, nil, size, &v)
 
 if err != 0 {
 throw OSStatusError(value: err)
 }
 }
}
/// With that in place we can extend an existing type, to make it more ergonomic to use
extension AudioDeviceID {
 static func defaultOutput() throws -> Self {
 try AudioObjectID(kAudioObjectSystemObject)
 .get(.default,
 v: AudioObjectID()) // Here we have to instantiate an instance of the type
 // that's then used in the inout getting dance
 }
 func name() throws -> String {
 try get(AudioObjectPropertyAddress
 .default
 .selectored(kAudioDevicePropertyDeviceNameCFString),
 v: "" as CFString) as String
 }
 
 func balance() throws -> Float {
 try get(AudioObjectPropertyAddress
 .masterOutput
 .selectored(kAudioHardwareServiceDeviceProperty_VirtualMasterBalance),
 v: Float(0))
 }
 
 func setBalance(_ target: Float) throws -> Float {
 let existing = try balance()
 guard existing != target else {
 return existing
 }
 
 var result = target
 try set(AudioObjectPropertyAddress
 .masterOutput
 .selectored(kAudioHardwareServiceDeviceProperty_VirtualMasterBalance),
 v: &result)
 return result
 }
}
/// With all these helpers in place, and with the throwing and catching,
/// our program now reads like what it does
do {
 try AudioDeviceID.defaultOutput().setBalance(0.5)
}
catch {
 print(error)
 exit(1)
}

Using throwing functions cleans up the code, there is one place where we deal with any errors.

Overall, we split a function like getDefaultOutputDevice(andPrintErrors...) into pieces that deal with getting stuff from the audio api, the default device, and handling errors, and that breaking stuff up is normally a good idea. Avoid side effects in functions too, like your print(error)/exit in getDefaultOutputDevice, these side effects make the functions harder to test and harder to combine together.

answered Jan 25, 2021 at 12:52
\$\endgroup\$

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.