Level Networking on watchOS for Duplex audio streaming

I did watch WWDC 2019 Session 716 and understand that an active audio session is key to unlocking low‑level networking on watchOS. I’m configuring my audio session and engine as follows:

	private func configureAudioSession(completion: @escaping (Bool) -> Void) {
	    let audioSession = AVAudioSession.sharedInstance()
	    do {
	        try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: [])
	        try audioSession.setActive(true, options: .notifyOthersOnDeactivation)
	        // Retrieve sample rate and configure the audio format.
	        let sampleRate = audioSession.sampleRate
	        print("Active hardware sample rate: \(sampleRate)")
	        audioFormat = AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 1)
	        // Configure the audio engine.
	        audioInputNode = audioEngine.inputNode
	        audioEngine.attach(audioPlayerNode)
	        audioEngine.connect(audioPlayerNode, to: audioEngine.mainMixerNode, format: audioFormat)
	        
	        try audioEngine.start()
	        completion(true)
	    } catch {
	        print("Error configuring audio session: \(error.localizedDescription)")
	        completion(false)
	    }
	}
	 
	private func setupUDPConnection() {
	    let parameters = NWParameters.udp
	    parameters.includePeerToPeer = true
	    connection = NWConnection(host: "***.***.xxxxx.***", port: 0000, using: parameters)
	    setupNWConnectionHandlers()
	}
	 
	   
	    private func setupTCPConnection() {
	        let parameters = NWParameters.tcp
	        connection = NWConnection(host: "***.***.xxxxx.***", port: 0000, using: parameters)
	        setupNWConnectionHandlers()
	    }
	 
	    
	    private func setupWebSocketConnection() {
	        guard let url = URL(string: "ws://***.***.xxxxx.***:0000") else {
	            print("Invalid WebSocket URL")
	            return
	        }
	 
	        let session = URLSession(configuration: .default)
	        webSocketTask = session.webSocketTask(with: url)
	        webSocketTask?.resume()
	        print("WebSocket connection initiated")
	        sendAudioToServer()
	        receiveDataFromServer()
	        sendWebSocketPing(after: 0.6)
	    }
	 
	    private func setupNWConnectionHandlers() {
	        connection?.stateUpdateHandler = { [weak self] state in
	            DispatchQueue.main.async {
	                switch state {
	                case .ready:
	                    print("Connected (NWConnection)")
	                    self?.isConnected = true
	                    self?.failToConnect = false
	                    self?.receiveDataFromServer()
	                    self?.sendAudioToServer()
	                case .waiting(let error), .failed(let error):
	 
	                    print("Connection error: \(error.localizedDescription)")
	                    DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
	                        self?.setupNetwork()
	                    }
	                case .cancelled:
	                    print("NWConnection cancelled")
	                    self?.isConnected = false
	 
	                default:
	                    break
	                }
	            }
	        }
	        connection?.start(queue: .main)
	    }
	 

I am reaching out to seek further assistance regarding the challenges I've been experiencing with establishing a UDP, TCP & web socket connection on watchOS using NWConnection for duplex audio streaming. Despite implementing the recommendations provided earlier, I am still encountering difficulties. Or duplex audio streaming not possible on apple watch?

Answered by DTS Engineer in 832918022

Lemme start by dropping some links:

I don’t know a lot about audio, but this question crops up a lot in networking circles so I decide to create a small project to try it out. Here’s what I did:

  1. Using Xcode 16.3 on macOS 15.3.2, I created a new project from the watchOS > App template.

  2. I set the deployment target to watchOS 11.0.

  3. I replaced ContentView with the code at the end of this post.

  4. I ran it on a device running watchOS 11.3.1. Note that:

    • This device has Wi-Fi but no WWAN.
    • It’s paired to an iPhone.
    • Which is nearby.
    • And both are in range of a known Wi-Fi network.
  5. I tapped Connect. The status changed to “Waiting…” because there’s no audio session in place.

  6. I tapped Disconnect.

  7. I enabled the Session switch.

  8. This presented the audio route UI. In that, I chose my AirPods.

  9. Back in the app, I saw the status change to “Activated”.

  10. I tapped Connect. After a few seconds the status changed to “Connected”.

Please repeat these steps in your environment and let me know how you get along.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"


IMPORTANT The following code is not meant to be a good example of how to use Swift, SwiftUI, Network framework, or audio sessions. Rather, it’s the smallest example I could come up with to exercise the specific situation discussed in this thread.


import SwiftUI
import AVFAudio
import Network

struct ContentView: View {
    @State var status: String = "Tap someting!"
    @State var sessionIsActive: Bool = false
    @State var connectionQ: NWConnection? = nil
    var body: some View {
        VStack {
            Text(status)
            Toggle("Session", isOn: $sessionIsActive)
            Button(connectionQ == nil ? "Connect" : "Disconnect") {
                if let connection = connectionQ {
                    self.connectionQ = nil
                    connection.stateUpdateHandler = nil
                    connection.cancel()
                    self.status = "Disconnected"
                } else {
                    self.status = "Connecting…"
                    let connection = NWConnection(host: "example.com", port: 80, using: .tcp)
                    self.connectionQ = connection
                    connection.stateUpdateHandler = { newState in
                        switch newState {
                        case .setup: break
                        case .waiting(_): self.status = "Waiting…"
                        case .preparing: break
                        case .ready: self.status = "Connected"
                        case .failed(_): self.status = "Failed"
                        case .cancelled: break
                        @unknown default: break
                        }
                    }
                    connection.start(queue: .main)
                }
            }
        }
        .onChange(of: sessionIsActive) { _, newValue in
            let session = AVAudioSession.sharedInstance()
            if newValue {
                do {
                    self.status = "Activating…"
                    try session.setCategory(
                        .playback,
                        mode: .default,
                        policy: .longFormAudio,
                        options: []
                    )
                    session.activate(options: []) { didActivate, error in
                        DispatchQueue.main.async {
                            if didActivate {
                                self.status = "Activated"
                            } else {
                                self.status = "Activation failed"
                            }
                        }
                    }
                } catch {
                    self.status = "Activation failed"
                }
            } else {
                try? session.setActive(true)
                self.status = "Deactivated"
            }
        }
        .padding()
    }
}

#Preview {
    ContentView()
}

I’m not sure what you mean by “duplex” in this context. Please clarify.

My recommendation in cases like this is that you open a vanilla TCP connection to a known test server. Test that code in your iOS app first, just to confirm that the code works in general. Then move that code over to the watch. If it works there, you know that the audio session is set up correctly, and thus you have a networking issue. OTOH, if that fails, it confirms that you have an audio session issue.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Duplex in this context refers to two-way audio transmission simultaneously recording and sending audio while also receiving and playing back incoming audio, similar to a VoIP/SIP call.

The setup works fine on the simulator, which suggests that the core logic is correct. However, since the simulator doesn’t fully replicate WatchOS hardware behavior especially for audio sessions and networking issues might arise when running on a real device.

Given your suggestion, I’ve already confirmed that the TCP connection works on iOS. Since the same logic works in the WatchOS simulator, it further indicates that the networking and audio logic should be functional. The problem likely lies in either the Watch’s actual hardware limitations, permission constraints, or specific audio session configurations.

Lemme start by dropping some links:

I don’t know a lot about audio, but this question crops up a lot in networking circles so I decide to create a small project to try it out. Here’s what I did:

  1. Using Xcode 16.3 on macOS 15.3.2, I created a new project from the watchOS > App template.

  2. I set the deployment target to watchOS 11.0.

  3. I replaced ContentView with the code at the end of this post.

  4. I ran it on a device running watchOS 11.3.1. Note that:

    • This device has Wi-Fi but no WWAN.
    • It’s paired to an iPhone.
    • Which is nearby.
    • And both are in range of a known Wi-Fi network.
  5. I tapped Connect. The status changed to “Waiting…” because there’s no audio session in place.

  6. I tapped Disconnect.

  7. I enabled the Session switch.

  8. This presented the audio route UI. In that, I chose my AirPods.

  9. Back in the app, I saw the status change to “Activated”.

  10. I tapped Connect. After a few seconds the status changed to “Connected”.

Please repeat these steps in your environment and let me know how you get along.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"


IMPORTANT The following code is not meant to be a good example of how to use Swift, SwiftUI, Network framework, or audio sessions. Rather, it’s the smallest example I could come up with to exercise the specific situation discussed in this thread.


import SwiftUI
import AVFAudio
import Network

struct ContentView: View {
    @State var status: String = "Tap someting!"
    @State var sessionIsActive: Bool = false
    @State var connectionQ: NWConnection? = nil
    var body: some View {
        VStack {
            Text(status)
            Toggle("Session", isOn: $sessionIsActive)
            Button(connectionQ == nil ? "Connect" : "Disconnect") {
                if let connection = connectionQ {
                    self.connectionQ = nil
                    connection.stateUpdateHandler = nil
                    connection.cancel()
                    self.status = "Disconnected"
                } else {
                    self.status = "Connecting…"
                    let connection = NWConnection(host: "example.com", port: 80, using: .tcp)
                    self.connectionQ = connection
                    connection.stateUpdateHandler = { newState in
                        switch newState {
                        case .setup: break
                        case .waiting(_): self.status = "Waiting…"
                        case .preparing: break
                        case .ready: self.status = "Connected"
                        case .failed(_): self.status = "Failed"
                        case .cancelled: break
                        @unknown default: break
                        }
                    }
                    connection.start(queue: .main)
                }
            }
        }
        .onChange(of: sessionIsActive) { _, newValue in
            let session = AVAudioSession.sharedInstance()
            if newValue {
                do {
                    self.status = "Activating…"
                    try session.setCategory(
                        .playback,
                        mode: .default,
                        policy: .longFormAudio,
                        options: []
                    )
                    session.activate(options: []) { didActivate, error in
                        DispatchQueue.main.async {
                            if didActivate {
                                self.status = "Activated"
                            } else {
                                self.status = "Activation failed"
                            }
                        }
                    }
                } catch {
                    self.status = "Activation failed"
                }
            } else {
                try? session.setActive(true)
                self.status = "Deactivated"
            }
        }
        .padding()
    }
}

#Preview {
    ContentView()
}

Thanks a lot for sharing your detailed steps and the useful links! I really appreciate the effort you put into setting this up. From what I can see, your implementation is focused on streaming audio playback with the server. In my case, I'm looking for a slightly different approach: I want to capture audio and send buffers of a specific size to the server while playing audio simultaneously, essentially achieving full duplex streaming similar to a VOIP call. Additionally, I’d like to ensure that if no external audio route is connected, the Apple Watch speaker is used by default. Any thoughts or insights on adapting this setup for those requirements would be very welcome. Thanks again!

Accepted Answer
I'm looking for a slightly different approach

Ah, OK. I can’t really help you with the audio stuff; my focus is more on the networking side of things.

If you want to explore these audio issues, I recommend that you start a new thread in the Media Technologies > Audio topic area.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Thanks a lot for sharing your detailed answers. Just added new Thread

Level Networking on watchOS for Duplex audio streaming
 
 
Q