203 lines
6.0 KiB
Swift
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
|
|
}
|
|
}
|