feat(macos): add macOS menu-bar app and harden cross-device iCloud sync
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.
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user