Files
OSGKeyboard/OSGKeyboardMac/MacAudioRecorder.swift
T
Rocky c2f07bd8d2 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.
2026-07-08 18:13:56 +08:00

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
}
}
}