chore: archive mac mini automation baseline
This commit is contained in:
+127
@@ -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))
|
||||
}
|
||||
}
|
||||
+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
|
||||
}
|
||||
}
|
||||
+135
@@ -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()
|
||||
}
|
||||
+9
@@ -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()`.
|
||||
Reference in New Issue
Block a user