chore: archive mac mini automation baseline
This commit is contained in:
+202
@@ -0,0 +1,202 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user