melonamin/swiftkef
A Swift library for controlling KEF wireless speakers (LSX II, LS50 Wireless II, LS60) over the network with real-time event monitoring.
Features
- π Volume Control: Set volume, mute/unmute
- π΅ Playback Control: Play/pause, next/previous track
- π» Source Selection: Switch between inputs (WiFi, Bluetooth, Optic, etc.)
- π Power Management: Turn speakers on/off
- βΉοΈ Speaker Information: Get name, MAC address, firmware details
- πΌ Track Information: Get current playing track metadata
- ποΈ Music Quality: Get active stream codec/bitrate/sample rate (when available)
- π Real-time Event Monitoring: Live updates for volume, playback, and track changes
- β±οΈ Song Position Tracking: Monitor playback progress in real-time
- π Auto-Discovery: Find KEF speakers on your network using mDNS/Bonjour (Apple platforms)
- β‘ Async/Await: Modern Swift concurrency support
- π‘οΈ Type Safety: Strongly typed enums for sources and status
Installation
Swift Package Manager
Add SwiftKEF to your Package.swift file:
dependencies: [
.package(url: "https://github.com/melonamin/SwiftKEF.git", from: "1.1.0")
]Or add it through Xcode:
- File β Add Package Dependencies
- Enter the repository URL
- Select the version you want to use
Requirements
- Swift 6.1+
- macOS 10.15+ / iOS 13+ / tvOS 13+ / watchOS 6+ / Linux
- KEF wireless speaker on the same network
Usage
Basic Setup
import SwiftKEF
import AsyncHTTPClient
// Create HTTP client
let httpClient = HTTPClient(eventLoopGroupProvider: .singleton)
defer {
try await httpClient.shutdown()
}
// Initialize speaker with known IP
let speaker = KEFSpeaker(host: "192.168.1.100", httpClient: httpClient)Speaker Discovery (Apple platforms only)
// Discover speakers on the network
let speakers = try await KEFSpeaker.discover(httpClient: httpClient, timeout: 5.0)
for discovered in speakers {
print("Found: \(discovered.name) at \(discovered.host)")
// Create speaker instance from discovery
let speaker = KEFSpeaker.from(discovered: discovered, httpClient: httpClient)
}
// Or use real-time discovery stream
for await discovered in KEFSpeaker.discoverStream(httpClient: httpClient) {
print("Discovered: \(discovered.name) at \(discovered.host)")
// Optional: Check model and MAC address
if let model = discovered.model {
print("Model: \(model)")
}
}Volume Control
// Set volume (0-100)
try await speaker.setVolume(50)
// Get current volume
let volume = try await speaker.getVolume()
print("Current volume: \(volume)")
// Mute
try await speaker.mute()
// Unmute
try await speaker.unmute()Power Control
// Turn on
try await speaker.powerOn()
// Turn off (standby)
try await speaker.shutdown()
// Check power status
let status = try await speaker.getStatus()
if status == .powerOn {
print("Speaker is on")
}Source Selection
// Set input source
try await speaker.setSource(.bluetooth)
try await speaker.setSource(.optic)
// Get current source
let source = try await speaker.getSource()
print("Current source: \(source.rawValue)")
// Available sources
for source in KEFSource.allCases {
print(source.rawValue)
}Playback Control
// Toggle play/pause
try await speaker.togglePlayPause()
// Next track
try await speaker.nextTrack()
// Previous track
try await speaker.previousTrack()
// Check if playing
let isPlaying = try await speaker.isPlaying()
// Get track information
if isPlaying {
let songInfo = try await speaker.getSongInformation()
print("Now playing: \(songInfo.title ?? "Unknown")")
print("Artist: \(songInfo.artist ?? "Unknown")")
print("Album: \(songInfo.album ?? "Unknown")")
// Get active music quality information (when available)
// This is parsed from `trackRoles.mediaData.activeResource`
let quality = try await speaker.getSongQuality()
if quality.codec != nil || quality.bitRate != nil {
print("Codec: \(quality.codec ?? "Unknown")")
if let bitRate = quality.bitRate {
print("Bitrate: \(Int(Double(bitRate) / 1000)) kbps")
}
if let sampleFrequency = quality.sampleFrequency, let bitsPerSample = quality.bitsPerSample {
print("Format: \(bitsPerSample)-bit / \(sampleFrequency) Hz")
}
if let channels = quality.nrAudioChannels {
print("Channels: \(channels)")
}
}
// Get playback position
if let position = try await speaker.getSongPosition(),
let duration = try await speaker.getSongDuration() {
let progress = Double(position) / Double(duration)
print("Progress: \(Int(progress * 100))%")
}
}Music Quality (Active Resource)
getSongQuality() returns a SongQuality struct parsed from player:player/data β trackRoles.mediaData.activeResource. All fields are optional and may be nil depending on the source/service and what the speaker reports.
let quality = try await speaker.getSongQuality()
print("Codec: \(quality.codec ?? "Unknown")")
print("Bitrate: \(quality.bitRate.map { "\($0) bps" } ?? "Unknown")")
print("Sample frequency: \(quality.sampleFrequency.map(String.init) ?? "Unknown")")
print("Bits per sample: \(quality.bitsPerSample.map(String.init) ?? "Unknown")")
print("Channels: \(quality.nrAudioChannels.map(String.init) ?? "Unknown")")Speaker Information
// Get speaker name
let name = try await speaker.getSpeakerName()
// Get MAC address
let mac = try await speaker.getMacAddress()
// Get firmware info
let firmware = try await speaker.getFirmwareVersion()
print("Model: \(firmware.model)")
print("Version: \(firmware.version)")Error Handling
do {
try await speaker.setVolume(75)
} catch KEFError.networkError(let message) {
print("Network error: \(message)")
} catch KEFError.speakerNotResponding {
print("Speaker is not responding")
} catch {
print("Error: \(error)")
}Real-time Event Monitoring
Monitor speaker status changes in real-time using the polling API. The speaker sends immediate updates when any monitored parameter changes:
// Single poll for current events
let event = try await speaker.pollSpeaker(timeout: 10)
if let volume = event.volume {
print("Volume changed to: \(volume)")
}
// Continuous polling stream
let eventStream = await speaker.startPolling(
pollInterval: 10, // Check for events every 10 seconds
pollSongStatus: true // Include real-time song position updates
)
for try await event in eventStream {
// Handle volume changes
if let volume = event.volume {
print("Volume: \(volume)")
}
// Handle source changes
if let source = event.source {
print("Source: \(source.rawValue)")
}
// Handle playback updates
if let state = event.playbackState {
print("Playback: \(state.rawValue)")
}
// Track position updates (when pollSongStatus is true)
if let position = event.songPosition,
let duration = event.songDuration {
let progress = Double(position) / Double(duration)
print("Progress: \(Int(progress * 100))%")
}
}Example Applications
Command Line Tools
- KefirCLI - Feature-rich CLI with interactive TUI mode and real-time updates
- KEFControl - Simple command-line interface
SwiftUI Example
import SwiftUI
import SwiftKEF
import AsyncHTTPClient
struct ContentView: View {
@State private var volume: Int = 0
@State private var isPlaying = false
let speaker: KEFSpeaker
var body: some View {
VStack {
Text("Volume: \(volume)")
Slider(value: Binding(
get: { Double(volume) },
set: { newValue in
Task {
try await speaker.setVolume(Int(newValue))
}
}
), in: 0...100)
Button(isPlaying ? "Pause" : "Play") {
Task {
try await speaker.togglePlayPause()
isPlaying.toggle()
}
}
}
.task {
volume = try await speaker.getVolume()
isPlaying = try await speaker.isPlaying()
}
}
}Supported Speakers
- KEF LSX II
- KEF LS50 Wireless II
- KEF LS60
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
License
This project is licensed under the MIT License - see the LICENSE file for details.
Acknowledgments
This Swift implementation is inspired by the pykefcontrol Python library.
Links
Package Metadata
Repository: melonamin/swiftkef
Default branch: main
README: README.md