Contents

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:

  1. File β†’ Add Package Dependencies
  2. Enter the repository URL
  3. 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.

Author

@melonamin

Package Metadata

Repository: melonamin/swiftkef

Default branch: main

README: README.md