c2f07bd8d2
Introduce a standalone macOS menu-bar app (OSGKeyboardMac) that reuses the platform-agnostic OSGKeyboardShared core: record -> cloud/local ASR -> polish -> insert. Local mode uses Qwen3-ASR via mlx-swift-asr (macOS 15+, Apple Silicon); iOS targets stay zero-SPM. Harden iCloud sync for multi-device correctness: - Per-field settings merge (appSettings.v2) so concurrent edits no longer clobber each other's unrelated fields. - Per-device usage statistics (G-Counter) that sum instead of max(). - Tombstoned dictionary/history merge so deletes propagate and entries can't resurrect. - API keys replicate via iCloud Keychain, never iCloud KVS JSON; pulling a legacy blob without key fields no longer wipes local Keychain entries. - Add a low-risk "Sync Now" action in Settings. Fix Flow keyboard mic state: stay orange until the host publishes a real ready contract, share a single MicVoiceAvailability gate, and self-heal stale cross-process heartbeat jitter instead of getting stuck. Extract shared storage (SpeechHistoryStore/UsageStatisticsStore, ConfigurationStore) into OSGKeyboardShared and add tests for the new sync/merge logic.
113 lines
4.1 KiB
Swift
113 lines
4.1 KiB
Swift
// MacAudioRecorder.swift
|
|
// OSGKeyboard · Mac
|
|
//
|
|
// Captures microphone audio via AVAudioEngine and resamples it to the
|
|
// 16 kHz mono Float32 buffer the cloud ASR clients expect. The tap
|
|
// callback runs on the audio render thread, so sample accumulation is
|
|
// guarded by a lock and the type is `@unchecked Sendable`.
|
|
|
|
@preconcurrency import AVFoundation
|
|
|
|
final class MacAudioRecorder: @unchecked Sendable {
|
|
enum RecorderError: Error, LocalizedError {
|
|
case converterUnavailable
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .converterUnavailable:
|
|
return "无法初始化音频转换器 / Failed to initialize audio converter"
|
|
}
|
|
}
|
|
}
|
|
|
|
private let engine = AVAudioEngine()
|
|
private let targetFormat = AVAudioFormat(
|
|
commonFormat: .pcmFormatFloat32,
|
|
sampleRate: 16_000,
|
|
channels: 1,
|
|
interleaved: false
|
|
)!
|
|
private var converter: AVAudioConverter?
|
|
private let lock = NSLock()
|
|
private var samples: [Float] = []
|
|
private var isRunning = false
|
|
private var smoothedLevel: Float = 0
|
|
/// One-shot flag for the converter pull block. Taps are serialized per
|
|
/// bus, so a plain instance property (not a captured local) is safe here.
|
|
private var didProvideInput = false
|
|
|
|
/// Normalised input level (0…1), smoothed for a calm waveform.
|
|
/// Read from the main thread by a polling timer while recording.
|
|
func level() -> Float {
|
|
lock.withLock { smoothedLevel }
|
|
}
|
|
|
|
func start() throws {
|
|
lock.withLock { samples.removeAll(keepingCapacity: true) }
|
|
|
|
let input = engine.inputNode
|
|
let inputFormat = input.outputFormat(forBus: 0)
|
|
guard let converter = AVAudioConverter(from: inputFormat, to: targetFormat) else {
|
|
throw RecorderError.converterUnavailable
|
|
}
|
|
self.converter = converter
|
|
|
|
input.installTap(onBus: 0, bufferSize: 4_096, format: inputFormat) { [weak self] buffer, _ in
|
|
self?.appendResampled(buffer)
|
|
}
|
|
engine.prepare()
|
|
try engine.start()
|
|
isRunning = true
|
|
}
|
|
|
|
/// Stops capture and returns the accumulated 16 kHz mono samples.
|
|
func stop() -> [Float] {
|
|
guard isRunning else { return [] }
|
|
engine.inputNode.removeTap(onBus: 0)
|
|
engine.stop()
|
|
isRunning = false
|
|
return lock.withLock {
|
|
let out = samples
|
|
samples.removeAll(keepingCapacity: false)
|
|
return out
|
|
}
|
|
}
|
|
|
|
private func appendResampled(_ buffer: AVAudioPCMBuffer) {
|
|
guard let converter else { return }
|
|
let ratio = targetFormat.sampleRate / buffer.format.sampleRate
|
|
let capacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio) + 1_024
|
|
guard let output = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: capacity) else { return }
|
|
|
|
didProvideInput = false
|
|
var conversionError: NSError?
|
|
converter.convert(to: output, error: &conversionError) { [self] _, statusPointer in
|
|
if didProvideInput {
|
|
statusPointer.pointee = .noDataNow
|
|
return nil
|
|
}
|
|
didProvideInput = true
|
|
statusPointer.pointee = .haveData
|
|
return buffer
|
|
}
|
|
guard conversionError == nil, let channel = output.floatChannelData else { return }
|
|
|
|
let frameCount = Int(output.frameLength)
|
|
guard frameCount > 0 else { return }
|
|
let chunk = Array(UnsafeBufferPointer(start: channel[0], count: frameCount))
|
|
|
|
// RMS → rough 0…1 level with an attack/decay smoothing so the UI
|
|
// waveform breathes rather than jitters.
|
|
var sumSquares: Float = 0
|
|
for sample in chunk { sumSquares += sample * sample }
|
|
let rms = (sumSquares / Float(frameCount)).squareRoot()
|
|
let normalized = min(1, max(0, rms * 12))
|
|
|
|
lock.withLock {
|
|
samples.append(contentsOf: chunk)
|
|
let factor: Float = normalized > smoothedLevel ? 0.5 : 0.15
|
|
smoothedLevel += (normalized - smoothedLevel) * factor
|
|
}
|
|
}
|
|
}
|