chore: remove dead AudioCaptureService and clarify legacy class scope

OSGKeyboardExt/Services/AudioCaptureService.swift was a true
duplicate of OSGKeyboardShared/Services/LiveDictationController
that was replaced by FlowContinuousCapture in v0.1.1. It is
referenced from no Swift code (only from comments), so removing
it has zero runtime impact.

LiveDictationController and DictationBridge are kept (still used
by PreviewASRController, DictationCaptureView, KeyboardPreviewSheet
and the host-app one-shot dictation handoff path), but their file
headers now state the v0.1.2 status explicitly so future readers
do not try to "modernize" them away.

Verified by:
  grep -rn "AudioCaptureService" --include="*.swift" .  -> no hits
  xcodebuild build  -> BUILD SUCCEEDED

Refs: AUDIT_APPSTORE.md P0-3
This commit is contained in:
Zhongshu Audit
2026-06-20 21:33:33 +08:00
parent 842777ea4a
commit fbc3bef13d
@@ -1,315 +0,0 @@
// AudioCaptureService.swift
// OSGKeyboard · Keyboard Extension
//
// Captures microphone audio at 16 kHz mono Float32 using AVAudioEngine.
// Designed for use inside an iOS Custom Keyboard Extension:
// Uses `.record` (not `.playAndRecord`) keyboards cannot play.
// Exposes a Sendable `Session` with two streams:
// - `audio`: 16 kHz mono Float32 frames for ASR.
// - `levels`: RMS + peak dBFS for the animated waveform.
// All mutable state is guarded by a lock; class is `@unchecked Sendable`
// for use with Swift 6 strict concurrency.
import Foundation
import AVFoundation
import os.lock
import OSGKeyboardShared
// MARK: - Sendable conformance
//
// `AVAudioEngine` is not Sendable, but the iOS audio APIs hand us
// closures that need to capture it. We never mutate the engine
// concurrently capture / conversion are serialised on the actor, and
// the tap closure only reads pointers into it. So an unchecked
// retroactive Sendable conformance is sound here.
// `AVAudioConverter` and `AVAudioFormat` are already Sendable in newer
// SDKs; we don't need to redeclare.
extension AVAudioEngine: @unchecked @retroactive Sendable {}
public final class AudioCaptureService: @unchecked Sendable {
// MARK: - Errors
public enum CaptureError: LocalizedError, Sendable {
case sessionConfigFailed(String)
case engineStartFailed(String)
case noInputNode
case alreadyRunning
public var errorDescription: String? {
switch self {
case .sessionConfigFailed(let s): return "Audio session config failed: \(s)"
case .engineStartFailed(let s): return "Audio engine failed to start: \(s)"
case .noInputNode: return "No microphone input available."
case .alreadyRunning: return "Audio capture is already running."
}
}
}
// MARK: - Level payload (Sendable)
public struct Level: Sendable, Equatable {
/// Root-mean-square, 0...1 (linear).
public let rms: Float
/// Peak amplitude, 0...1 (linear).
public let peak: Float
public let timestamp: TimeInterval
/// Convenience: -20 dBFS 0 dBFS mapped to 01 for UI meters.
public var meter: Float {
// Clamp floor at -50 dB so silence still shows a tiny bar.
let db = 20 * log10(max(rms, 1e-7))
let clamped = max(-50, min(0, db))
return Float((clamped + 50) / 50)
}
}
// MARK: - Session (one capture run)
/// A single capture run. Two streams + a stop handle.
public final class Session: @unchecked Sendable {
public let audio: AsyncStream<AudioBufferSnapshot>
public let levels: AsyncStream<Level>
private let onStop: @Sendable () -> Void
fileprivate init(
audio: AsyncStream<AudioBufferSnapshot>,
levels: AsyncStream<Level>,
onStop: @escaping @Sendable () -> Void
) {
self.audio = audio
self.levels = levels
self.onStop = onStop
}
public func stop() { onStop() }
}
// MARK: - State
private let lock = OSAllocatedUnfairLock()
private var engine: AVAudioEngine?
private var converter: AVAudioConverter?
private var audioContinuation: AsyncStream<AudioBufferSnapshot>.Continuation?
private var levelContinuation: AsyncStream<Level>.Continuation?
private var isRunning: Bool = false
public init() {}
deinit { stopInternal() }
// MARK: - Public API
/// Start capture. Returns a `Session` whose streams yield audio + level data.
/// The session ends when `Session.stop()` is called or the extension is torn down.
@discardableResult
public func start() -> Session {
let (audioStream, audioCont) = AsyncStream<AudioBufferSnapshot>.makeStream()
let (levelStream, levelCont) = AsyncStream<Level>.makeStream()
// Fail fast if already running.
let alreadyRunning: Bool = lock.withLock { isRunning }
if alreadyRunning {
audioCont.finish()
levelCont.finish()
return Session(audio: audioStream, levels: levelStream, onStop: {})
}
do {
try configureSession()
try bootstrap(audioCont: audioCont, levelCont: levelCont)
lock.withLock {
isRunning = true
audioContinuation = audioCont
levelContinuation = levelCont
}
} catch {
// Tear down whatever we partially created.
audioCont.finish()
levelCont.finish()
teardownEngine()
return Session(audio: audioStream, levels: levelStream, onStop: {})
}
return Session(
audio: audioStream,
levels: levelStream,
onStop: { [weak self] in self?.stop() }
)
}
public func stop() {
stopInternal()
}
// MARK: - Setup
private func configureSession() throws {
#if canImport(UIKit)
let session = AVAudioSession.sharedInstance()
do {
// `.record` keyboards cannot play audio, so .playAndRecord is wrong.
// `.measurement` mode disables system AGC/echo cancellation for cleaner ASR input.
// `.duckOthers` is harmless in record-only.
try session.setCategory(.record, mode: .measurement, options: [.duckOthers])
try session.setActive(true, options: .notifyOthersOnDeactivation)
} catch {
throw CaptureError.sessionConfigFailed(error.localizedDescription)
}
#endif
}
private func bootstrap(
audioCont: AsyncStream<AudioBufferSnapshot>.Continuation,
levelCont: AsyncStream<Level>.Continuation
) throws {
let engine = AVAudioEngine()
let input = engine.inputNode
let hardwareFormat = input.outputFormat(forBus: 0)
guard hardwareFormat.sampleRate > 0, hardwareFormat.channelCount > 0 else {
throw CaptureError.noInputNode
}
let target = AVAudioFormat(
commonFormat: .pcmFormatFloat32,
sampleRate: 16_000,
channels: 1,
interleaved: false
)!
guard let converter = AVAudioConverter(from: hardwareFormat, to: target) else {
throw CaptureError.engineStartFailed("converter init failed")
}
// Persistent buffer for level computation: we re-use Float arrays to
// avoid per-tap allocations.
let levelScratch = LevelScratch()
input.installTap(onBus: 0, bufferSize: 1024, format: hardwareFormat) { [weak self] buffer, when in
// We capture self weakly only to keep the AudioCaptureService
// alive while the tap is installed; the tap itself only
// touches the local `audioCont` / `levelCont` continuations.
guard self != nil else { return }
// 1) Compute level from raw hardware buffer (preserves true amplitude).
let (rms, peak) = levelScratch.measure(buffer: buffer)
// `AVAudioTime` carries both `sampleTime` (frames on the device
// clock) and `hostTime` (mach absolute time). We only need a
// monotonically increasing source for the timestamp; sample
// time / sample rate is good enough and is independent of the
// host clock.
let ts: Double
if when.sampleTime > 0, hardwareFormat.sampleRate > 0 {
ts = Double(when.sampleTime) / hardwareFormat.sampleRate
} else {
ts = Date().timeIntervalSinceReferenceDate
}
levelCont.yield(Level(rms: rms, peak: peak, timestamp: ts))
// 2) Convert to 16 kHz mono Float32 for ASR.
let outFrames = AVAudioFrameCount(
Double(buffer.frameLength) * 16_000.0 / hardwareFormat.sampleRate
)
guard outFrames > 0,
let outBuffer = AVAudioPCMBuffer(pcmFormat: target, frameCapacity: outFrames)
else { return }
var error: NSError?
let status = converter.convert(to: outBuffer, error: &error) { _, outStatus in
outStatus.pointee = .haveData
return buffer
}
if status == .haveData, error == nil {
let snap = AudioBufferSnapshot(buffer: outBuffer)
audioCont.yield(snap)
}
}
do {
try engine.start()
} catch {
input.removeTap(onBus: 0)
throw CaptureError.engineStartFailed(error.localizedDescription)
}
lock.withLock {
self.engine = engine
self.converter = converter
}
}
// MARK: - Teardown
private func stopInternal() {
let (wasRunning, engine, audioCont, levelCont) = lock.withLock { () -> (Bool, AVAudioEngine?, AsyncStream<AudioBufferSnapshot>.Continuation?, AsyncStream<Level>.Continuation?) in
let was = isRunning
isRunning = false
let eng = self.engine
let ac = audioContinuation
let lc = levelContinuation
self.engine = nil
self.converter = nil
self.audioContinuation = nil
self.levelContinuation = nil
return (was, eng, ac, lc)
}
guard wasRunning else { return }
engine?.inputNode.removeTap(onBus: 0)
engine?.stop()
#if canImport(UIKit)
// Deactivate the session so other apps' audio routing is restored.
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
#endif
audioCont?.finish()
levelCont?.finish()
}
private func teardownEngine() {
let (engine, audioCont, levelCont) = lock.withLock { () -> (AVAudioEngine?, AsyncStream<AudioBufferSnapshot>.Continuation?, AsyncStream<Level>.Continuation?) in
let e = self.engine
let a = audioContinuation
let l = levelContinuation
self.engine = nil
self.converter = nil
self.audioContinuation = nil
self.levelContinuation = nil
self.isRunning = false
return (e, a, l)
}
engine?.inputNode.removeTap(onBus: 0)
engine?.stop()
audioCont?.finish()
levelCont?.finish()
}
}
// MARK: - Level scratch (lock-free, single-writer / single-reader per tap)
/// Lock-free per-tap scratch for RMS + peak measurement. The AVAudio tap is
/// always invoked serially per input node, so we don't need a lock here.
private final class LevelScratch: @unchecked Sendable {
private var last: (rms: Float, peak: Float) = (0, 0)
func measure(buffer: AVAudioPCMBuffer) -> (rms: Float, peak: Float) {
guard let ch = buffer.floatChannelData?[0] else { return last }
let n = Int(buffer.frameLength)
guard n > 0 else { return last }
// Decay smoothing keeps the meter lively but not jittery.
var sumSq: Float = 0
var peak: Float = 0
for i in 0..<n {
let s = ch[i]
sumSq += s * s
let a = abs(s)
if a > peak { peak = a }
}
let rms = sqrtf(sumSq / Float(n))
// Exponential moving average for visual smoothness.
let alpha: Float = 0.35
let smoothedRms = alpha * rms + (1 - alpha) * last.rms
let smoothedPeak = max(alpha * peak, (1 - alpha) * last.peak)
last = (smoothedRms, smoothedPeak)
return (smoothedRms, smoothedPeak)
}
}