chore: archive mac mini automation baseline

This commit is contained in:
Adolfo Reyna
2026-08-03 11:47:09 -04:00
parent a0a26565f0
commit 6e2117188e
93 changed files with 10005 additions and 5 deletions
@@ -0,0 +1,127 @@
import AVFoundation
import Foundation
enum AudioCaptureState: Equatable {
case idle
case starting
case recording
case failed(String)
}
@MainActor
final class AudioCaptureManager: ObservableObject {
@Published private(set) var state: AudioCaptureState = .idle
var onPCMChunk: ((Data) -> Void)?
private let engine = AVAudioEngine()
private var converter: AVAudioConverter?
private var inputFormat: AVAudioFormat?
private let targetFormat = AVAudioFormat(
commonFormat: .pcmFormatInt16,
sampleRate: 16_000,
channels: 1,
interleaved: true
)!
func requestPermission() async -> Bool {
await withCheckedContinuation { continuation in
AVAudioSession.sharedInstance().requestRecordPermission { granted in
continuation.resume(returning: granted)
}
}
}
func startRecording() async {
guard state != .recording && state != .starting else { return }
state = .starting
guard await requestPermission() else {
state = .failed("Microphone permission was denied.")
return
}
do {
try configureSession()
try configureEngine()
try engine.start()
state = .recording
} catch {
stopRecording()
state = .failed(error.localizedDescription)
}
}
func stopRecording() {
guard state == .recording || state == .starting else { return }
engine.inputNode.removeTap(onBus: 0)
engine.stop()
converter = nil
inputFormat = nil
state = .idle
}
private func configureSession() throws {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker])
try session.setPreferredSampleRate(16_000)
try session.setPreferredIOBufferDuration(0.02)
try session.setActive(true)
}
private func configureEngine() throws {
let inputNode = engine.inputNode
let hardwareFormat = inputNode.outputFormat(forBus: 0)
inputFormat = hardwareFormat
converter = AVAudioConverter(from: hardwareFormat, to: targetFormat)
inputNode.removeTap(onBus: 0)
inputNode.installTap(onBus: 0, bufferSize: 1024, format: hardwareFormat) { [weak self] buffer, _ in
Task { @MainActor in
guard let self else { return }
guard let data = self.convertToTargetPCM(buffer) else { return }
self.onPCMChunk?(data)
}
}
engine.prepare()
}
private func convertToTargetPCM(_ inputBuffer: AVAudioPCMBuffer) -> Data? {
guard let converter else { return nil }
let ratio = targetFormat.sampleRate / inputBuffer.format.sampleRate
let targetFrameCapacity = AVAudioFrameCount(Double(inputBuffer.frameLength) * ratio) + 1
guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: targetFrameCapacity) else {
return nil
}
var didProvideInput = false
var conversionError: NSError?
converter.convert(to: outputBuffer, error: &conversionError) { _, status in
if didProvideInput {
status.pointee = .noDataNow
return nil
}
didProvideInput = true
status.pointee = .haveData
return inputBuffer
}
guard conversionError == nil else { return nil }
return outputBuffer.interleavedPCMData()
}
}
private extension AVAudioPCMBuffer {
func interleavedPCMData() -> Data? {
let audioBuffer = audioBufferList.pointee.mBuffers
guard let source = audioBuffer.mData else { return nil }
return Data(bytes: source, count: Int(audioBuffer.mDataByteSize))
}
}
@@ -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
}
}
@@ -0,0 +1,135 @@
import SwiftUI
struct ContentView: View {
@StateObject private var streamManager: AudioStreamManager
@StateObject private var captureManager = AudioCaptureManager()
@State private var isPressing = false
init() {
let manager = AudioStreamManager(endpoint: URL(string: "ws://192.168.68.126:8642/stream")!)
_streamManager = StateObject(wrappedValue: manager)
}
var body: some View {
VStack(spacing: 14) {
Text("Hermes")
.font(.headline)
Text(statusText)
.font(.caption2)
.foregroundStyle(statusColor)
.lineLimit(2)
.multilineTextAlignment(.center)
.frame(minHeight: 28)
Circle()
.fill(buttonFill)
.overlay {
Image(systemName: isPressing ? "waveform" : "mic.fill")
.font(.system(size: 36, weight: .semibold))
.foregroundStyle(.white)
}
.overlay {
Circle()
.stroke(.white.opacity(isPressing ? 0.9 : 0.25), lineWidth: 3)
}
.frame(width: 112, height: 112)
.scaleEffect(isPressing ? 0.94 : 1.0)
.animation(.spring(response: 0.2, dampingFraction: 0.75), value: isPressing)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in
guard !isPressing else { return }
isPressing = true
startPTT()
}
.onEnded { _ in
isPressing = false
stopPTT()
}
)
Text(isPressing ? "Release to send" : "Hold to talk")
.font(.caption2)
.foregroundStyle(.secondary)
}
.padding()
.onAppear {
streamManager.connect()
captureManager.onPCMChunk = { [streamManager] chunk in
streamManager.sendPCMChunk(chunk)
}
}
.onDisappear {
captureManager.stopRecording()
streamManager.disconnect()
}
}
private var statusText: String {
switch captureManager.state {
case .recording:
return "Listening"
case .starting:
return "Starting mic"
case .failed(let message):
return message
case .idle:
break
}
switch streamManager.playbackState {
case .playing:
return "Playing response"
case .failed(let message):
return message
case .idle:
break
}
switch streamManager.connectionState {
case .connected:
return "Ready"
case .connecting:
return "Connecting"
case .disconnected:
return "Disconnected"
case .failed(let message):
return message
}
}
private var statusColor: Color {
if case .failed = captureManager.state { return .red }
if case .failed = streamManager.connectionState { return .red }
if case .failed = streamManager.playbackState { return .red }
if captureManager.state == .recording { return .green }
if streamManager.playbackState == .playing { return .blue }
return .secondary
}
private var buttonFill: Color {
if captureManager.state == .recording { return .red }
if streamManager.playbackState == .playing { return .blue }
return .accentColor
}
private func startPTT() {
streamManager.connect()
streamManager.sendControlEvent("start")
Task {
await captureManager.startRecording()
}
}
private func stopPTT() {
captureManager.stopRecording()
streamManager.sendControlEvent("stop")
}
}
#Preview {
ContentView()
}
@@ -0,0 +1,9 @@
<!-- Add these entries inside the top-level <dict> of the watchOS target Info.plist. -->
<key>NSMicrophoneUsageDescription</key>
<string>Hermes needs microphone access so you can hold the talk button and speak to your assistant.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Hermes connects to your local assistant server over your Wi-Fi network.</string>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
@@ -0,0 +1,18 @@
# Hermes Watch PTT Foundation
These files are intended to be added to a standalone watchOS SwiftUI app target:
- `AudioCaptureManager.swift`: captures microphone audio with `AVAudioEngine`, converts it to 16 kHz, 16-bit, mono PCM, and emits raw `Data` chunks.
- `AudioStreamManager.swift`: opens a `URLSessionWebSocketTask`, sends binary PCM chunks, receives binary response audio, and plays either complete WAV blobs or raw 16 kHz PCM chunks.
- `ContentView.swift`: minimal push-to-talk UI using `DragGesture(minimumDistance: 0)` for press/release.
- `InfoPlistAdditions.xml`: exact plist entries for microphone, LAN access, and background audio.
The referenced ESP32 demo posts a complete WAV file to `http://192.168.68.126:8642/api/esp32/voice` with 16 kHz, 16-bit, mono PCM and receives WAV audio back. The watch implementation here follows your requested WebSocket model: it streams raw PCM chunks while the button is held, sends simple `{"type":"start"}` and `{"type":"stop"}` text control messages, and accepts either WAV or raw PCM binary responses.
Update the endpoint in `ContentView.init()`:
```swift
AudioStreamManager(endpoint: URL(string: "ws://YOUR_LOCAL_IP:PORT/stream")!)
```
If your Hermes WebSocket endpoint expects authentication headers, create a `URLRequest`, set the headers, and pass that request into `session.webSocketTask(with:)` in `AudioStreamManager.connect()`.