Files
reyna-cli/backup/macmini-automation-baseline-2026-08-03/macmini-mcp/WatchHermes/AudioStreamManager.swift
T
2026-08-03 11:47:09 -04:00

203 lines
6.0 KiB
Swift

import AVFoundation
import Foundation
enum HermesConnectionState: Equatable {
case disconnected
case connecting
case connected
case failed(String)
}
enum HermesPlaybackState: Equatable {
case idle
case playing
case failed(String)
}
@MainActor
final class AudioStreamManager: NSObject, ObservableObject {
@Published private(set) var connectionState: HermesConnectionState = .disconnected
@Published private(set) var playbackState: HermesPlaybackState = .idle
private let endpoint: URL
private let session: URLSession
private var task: URLSessionWebSocketTask?
private var avPlayer: AVAudioPlayer?
private let rawPlaybackEngine = AVAudioEngine()
private let rawPlaybackNode = AVAudioPlayerNode()
private let rawPlaybackFormat = AVAudioFormat(
commonFormat: .pcmFormatInt16,
sampleRate: 16_000,
channels: 1,
interleaved: true
)!
init(endpoint: URL) {
self.endpoint = endpoint
self.session = URLSession(configuration: .default)
super.init()
configureRawPlaybackEngine()
}
func connect() {
guard task == nil else { return }
connectionState = .connecting
let task = session.webSocketTask(with: endpoint)
self.task = task
task.resume()
connectionState = .connected
receiveLoop()
}
func disconnect() {
task?.cancel(with: .goingAway, reason: nil)
task = nil
connectionState = .disconnected
}
func sendPCMChunk(_ data: Data) {
guard let task else { return }
task.send(.data(data)) { [weak self] error in
guard let error else { return }
Task { @MainActor in
self?.connectionState = .failed(error.localizedDescription)
}
}
}
func sendText(_ text: String) {
guard let task else { return }
task.send(.string(text)) { [weak self] error in
guard let error else { return }
Task { @MainActor in
self?.connectionState = .failed(error.localizedDescription)
}
}
}
/// Optional protocol hint for servers that distinguish press/release.
func sendControlEvent(_ name: String) {
sendText(#"{"type":"\#(name)"}"#)
}
private func receiveLoop() {
task?.receive { [weak self] result in
Task { @MainActor in
guard let self else { return }
switch result {
case .success(let message):
self.handle(message)
self.receiveLoop()
case .failure(let error):
self.task = nil
self.connectionState = .failed(error.localizedDescription)
}
}
}
}
private func handle(_ message: URLSessionWebSocketTask.Message) {
switch message {
case .data(let data):
if data.isWAV {
playWAV(data)
} else {
playRawPCMChunk(data)
}
case .string(let text):
handleControlMessage(text)
@unknown default:
break
}
}
private func handleControlMessage(_ text: String) {
if text == "done" || text.contains(#""type":"done""#) {
playbackState = .idle
}
}
private func playWAV(_ data: Data) {
do {
try configurePlaybackSession()
avPlayer = try AVAudioPlayer(data: data)
avPlayer?.delegate = self
avPlayer?.prepareToPlay()
avPlayer?.play()
playbackState = .playing
} catch {
playbackState = .failed(error.localizedDescription)
}
}
private func configureRawPlaybackEngine() {
rawPlaybackEngine.attach(rawPlaybackNode)
rawPlaybackEngine.connect(rawPlaybackNode, to: rawPlaybackEngine.mainMixerNode, format: rawPlaybackFormat)
}
private func playRawPCMChunk(_ data: Data) {
do {
try configurePlaybackSession()
if !rawPlaybackEngine.isRunning {
try rawPlaybackEngine.start()
}
if !rawPlaybackNode.isPlaying {
rawPlaybackNode.play()
}
guard let buffer = data.makePCMBuffer(format: rawPlaybackFormat) else { return }
rawPlaybackNode.scheduleBuffer(buffer, completionHandler: nil)
playbackState = .playing
} catch {
playbackState = .failed(error.localizedDescription)
}
}
private func configurePlaybackSession() throws {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker])
try session.setActive(true)
}
}
extension AudioStreamManager: AVAudioPlayerDelegate {
nonisolated func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
Task { @MainActor in
self.playbackState = .idle
}
}
}
private extension Data {
var isWAV: Bool {
count >= 12 &&
self[0] == 0x52 && self[1] == 0x49 && self[2] == 0x46 && self[3] == 0x46 &&
self[8] == 0x57 && self[9] == 0x41 && self[10] == 0x56 && self[11] == 0x45
}
func makePCMBuffer(format: AVAudioFormat) -> AVAudioPCMBuffer? {
let bytesPerFrame = Int(format.streamDescription.pointee.mBytesPerFrame)
guard bytesPerFrame > 0 else { return nil }
let frameCount = AVAudioFrameCount(count / bytesPerFrame)
guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else { return nil }
buffer.frameLength = frameCount
let audioBuffer = buffer.audioBufferList.pointee.mBuffers
guard let destination = audioBuffer.mData else { return nil }
withUnsafeBytes { rawBuffer in
guard let source = rawBuffer.baseAddress else { return }
destination.copyMemory(from: source, byteCount: Int(audioBuffer.mDataByteSize))
}
return buffer
}
}