mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 20:45:19 +00:00
Centralize PTT and voice audio-session ownership, harden courier/bridge/outbox delivery and recovery, correct location and delivery-state races, add privacy/release metadata, and ship reproducible universal Arti slices with Release CI coverage. Validated by the full iOS suite, repeated audio/fragment/performance regressions, BitFoundation tests, strict lint and dead-code analysis, universal iOS Release builds, and iOS/macOS archives.
327 lines
12 KiB
Swift
327 lines
12 KiB
Swift
//
|
|
// PTTCaptureEngine.swift
|
|
// bitchat
|
|
//
|
|
// This is free and unencumbered software released into the public domain.
|
|
// For more information, see <https://unlicense.org>
|
|
//
|
|
|
|
import AVFoundation
|
|
import BitLogger
|
|
import Foundation
|
|
|
|
/// Owns one capture token and returns it even when the capture engine's owner
|
|
/// disappears without reaching its normal stop/cancel path. The coordinator
|
|
/// retains registered tokens strongly, so relying on `Token.deinit` cannot
|
|
/// reclaim an abandoned hold.
|
|
final class PTTCaptureSessionLease: @unchecked Sendable {
|
|
private let coordinator: AudioSessionCoordinator
|
|
private let lock = NSLock()
|
|
private var token: AudioSessionCoordinator.Token?
|
|
|
|
init(coordinator: AudioSessionCoordinator) {
|
|
self.coordinator = coordinator
|
|
}
|
|
|
|
func install(_ token: AudioSessionCoordinator.Token) {
|
|
let previous = lock.withLock {
|
|
let previous = self.token
|
|
self.token = token
|
|
return previous
|
|
}
|
|
previous.map(coordinator.release)
|
|
}
|
|
|
|
func release() {
|
|
let token = lock.withLock {
|
|
let token = self.token
|
|
self.token = nil
|
|
return token
|
|
}
|
|
token.map(coordinator.release)
|
|
}
|
|
|
|
deinit {
|
|
release()
|
|
}
|
|
}
|
|
|
|
/// Monotonic capture identity shared by main-actor lifecycle code and queued
|
|
/// engine callbacks. Removing a notification observer does not cancel a block
|
|
/// already enqueued on the main queue, so every callback must also prove it
|
|
/// still belongs to the current hold before mutating capture state.
|
|
final class PTTCaptureGeneration: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var value: UInt = 0
|
|
|
|
func begin() -> UInt {
|
|
lock.withLock {
|
|
value &+= 1
|
|
return value
|
|
}
|
|
}
|
|
|
|
func invalidate() {
|
|
lock.withLock { value &+= 1 }
|
|
}
|
|
|
|
func invalidate(ifCurrent generation: UInt) -> Bool {
|
|
lock.withLock {
|
|
guard value == generation else { return false }
|
|
value &+= 1
|
|
return true
|
|
}
|
|
}
|
|
|
|
func isCurrent(_ generation: UInt) -> Bool {
|
|
lock.withLock { value == generation }
|
|
}
|
|
}
|
|
|
|
/// Captures microphone audio for a live push-to-talk burst, producing both:
|
|
/// - live AAC frames via `onFrames` (called on the capture queue), and
|
|
/// - a finalized `.m4a` voice note on `stop()` — the same artifact
|
|
/// `VoiceRecorder` produces, so the existing voice-note send pipeline
|
|
/// handles delivery to receivers that missed the live stream.
|
|
/// `@unchecked Sendable`: every mutable property is confined to one executor —
|
|
/// the capture `queue` (resampler/encoder/file/counters) or the main actor
|
|
/// (`engine`, `engineStarted`, `sessionLease`, `configChangeObserver`) — so
|
|
/// weak references may cross the `@Sendable` tap/notification closures, which
|
|
/// immediately hop back to the owning executor.
|
|
final class PTTCaptureEngine: @unchecked Sendable {
|
|
/// Hard cap matching `VoiceRecorder.maxRecordingDuration`: past it the
|
|
/// engine keeps running (the UI owns the gesture) but stops encoding.
|
|
private static let maxCaptureDuration: TimeInterval = 120
|
|
|
|
/// Recreated on every `start()`: an engine whose input unit was
|
|
/// instantiated against an earlier (playback-only or inactive) audio
|
|
/// session keeps reporting a dead 0 Hz / 2 ch input format and fails to
|
|
/// enable the mic (AURemoteIO -10851, observed on iPhone field tests).
|
|
private var engine = AVAudioEngine()
|
|
private let queue = DispatchQueue(label: "chat.bitchat.ptt.capture", qos: .userInitiated)
|
|
private let coordinator: AudioSessionCoordinator
|
|
private let sessionLease: PTTCaptureSessionLease
|
|
private let captureGeneration = PTTCaptureGeneration()
|
|
|
|
// Capture-queue-confined state.
|
|
private var resampler: PTTInputResampler?
|
|
private var encoder: PTTFrameEncoder?
|
|
private var file: AVAudioFile?
|
|
private var fileURL: URL?
|
|
private var encodedFrameCount = 0
|
|
private var running = false
|
|
private var captureStart = Date()
|
|
/// Whether `engine.start()` succeeded for the current capture
|
|
/// (see `stopEngineIfStarted`).
|
|
@MainActor private var engineStarted = false
|
|
@MainActor private var configChangeObserver: NSObjectProtocol?
|
|
/// Called on the capture queue with each batch of encoded AAC frames.
|
|
var onFrames: (([Data]) -> Void)?
|
|
|
|
enum CaptureError: Error {
|
|
case inputUnavailable
|
|
case audioSetupFailed
|
|
}
|
|
|
|
init(coordinator: AudioSessionCoordinator = .shared) {
|
|
self.coordinator = coordinator
|
|
self.sessionLease = PTTCaptureSessionLease(coordinator: coordinator)
|
|
}
|
|
|
|
deinit {
|
|
sessionLease.release()
|
|
}
|
|
|
|
/// Async because acquiring the session hops its blocking IPC off the main
|
|
/// actor (a PTT press used to stall main >1 s in `setActive`); the engine
|
|
/// itself still starts back on main once the session is configured.
|
|
@MainActor
|
|
func start(outputURL: URL) async throws {
|
|
let generation = captureGeneration.begin()
|
|
let token = try await coordinator.acquire(.capture) { [weak self] in
|
|
self?.handleInterruption(for: generation)
|
|
}
|
|
// The hold ended (stop/cancel) while the session was activating:
|
|
// starting the engine now would leave a hot mic after release.
|
|
guard captureGeneration.isCurrent(generation) else {
|
|
coordinator.release(token)
|
|
throw CancellationError()
|
|
}
|
|
sessionLease.install(token)
|
|
do {
|
|
try beginCapture(outputURL: outputURL, generation: generation)
|
|
} catch {
|
|
releaseSessionToken()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func beginCapture(outputURL: URL, generation: UInt) throws {
|
|
// Fresh engine per capture so its input unit binds to the session
|
|
// that is active *now* (see `engine` doc comment).
|
|
engine = AVAudioEngine()
|
|
let inputFormat = engine.inputNode.outputFormat(forBus: 0)
|
|
guard inputFormat.sampleRate > 0, inputFormat.channelCount > 0 else {
|
|
SecureLogger.error("PTT: capture input unavailable (input reports \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch)", category: .session)
|
|
throw CaptureError.inputUnavailable
|
|
}
|
|
guard let resampler = PTTInputResampler(inputFormat: inputFormat),
|
|
let encoder = PTTFrameEncoder(),
|
|
let pcmFormat = PTTAudioFormat.pcmFormat
|
|
else { throw CaptureError.audioSetupFailed }
|
|
|
|
let file = try AVAudioFile(
|
|
forWriting: outputURL,
|
|
settings: PTTAudioFormat.voiceNoteFileSettings,
|
|
commonFormat: pcmFormat.commonFormat,
|
|
interleaved: pcmFormat.isInterleaved
|
|
)
|
|
|
|
queue.sync {
|
|
self.resampler = resampler
|
|
self.encoder = encoder
|
|
self.file = file
|
|
self.fileURL = outputURL
|
|
self.encodedFrameCount = 0
|
|
self.captureStart = Date()
|
|
self.running = true
|
|
}
|
|
|
|
engine.inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] buffer, _ in
|
|
self?.queue.async { self?.process(buffer, generation: generation) }
|
|
}
|
|
// Route/category changes reconfigure the engine underneath the tap;
|
|
// stop and finalize cleanly — the .m4a captured so far still sends.
|
|
// Registered before start() so no reconfigure lands unobserved
|
|
// (handleInterruption also validates this capture generation).
|
|
configChangeObserver = NotificationCenter.default.addObserver(
|
|
forName: .AVAudioEngineConfigurationChange,
|
|
object: engine,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
Task { @MainActor [weak self] in
|
|
self?.handleInterruption(for: generation)
|
|
}
|
|
}
|
|
engine.prepare()
|
|
do {
|
|
try engine.start()
|
|
} catch {
|
|
SecureLogger.error("PTT: capture engine failed to start (input: \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch): \(error)", category: .session)
|
|
if let observer = configChangeObserver {
|
|
NotificationCenter.default.removeObserver(observer)
|
|
configChangeObserver = nil
|
|
}
|
|
engine.inputNode.removeTap(onBus: 0)
|
|
queue.sync { self.teardown(deleteFile: true) }
|
|
throw error
|
|
}
|
|
engineStarted = true
|
|
SecureLogger.info("PTT: capture engine running (input: \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch)", category: .session)
|
|
}
|
|
|
|
/// Stops capture and finalizes the `.m4a`. Returns the file URL and the
|
|
/// number of encoded AAC frames (each `PTTAudioFormat.frameDuration` long).
|
|
@MainActor
|
|
func stop() -> (url: URL?, encodedFrames: Int) {
|
|
captureGeneration.invalidate()
|
|
stopEngineIfStarted()
|
|
let result: (URL?, Int) = queue.sync {
|
|
let url = fileURL
|
|
let frames = encodedFrameCount
|
|
teardown(deleteFile: false)
|
|
return (url, frames)
|
|
}
|
|
releaseSessionToken()
|
|
return result
|
|
}
|
|
|
|
@MainActor
|
|
func cancel() {
|
|
captureGeneration.invalidate()
|
|
stopEngineIfStarted()
|
|
queue.sync { teardown(deleteFile: true) }
|
|
releaseSessionToken()
|
|
}
|
|
|
|
/// Audio session interrupted (call, Siri) or the engine was reconfigured
|
|
/// mid-capture: behave like `stop()` — finalize the `.m4a` container but
|
|
/// keep `fileURL`/`encodedFrameCount` so the caller's pending `stop()`
|
|
/// still returns the note for delivery.
|
|
@MainActor
|
|
private func handleInterruption(for generation: UInt) {
|
|
// Also invalidate a start whose acquire has registered its token but
|
|
// has not returned to this actor yet. Without this bump the callback
|
|
// is lost while `engineStarted == false`, and the resumed start can
|
|
// open the mic after the stop signal.
|
|
guard captureGeneration.invalidate(ifCurrent: generation) else { return }
|
|
guard engineStarted else {
|
|
releaseSessionToken()
|
|
return
|
|
}
|
|
stopEngineIfStarted()
|
|
queue.sync {
|
|
running = false
|
|
// Releasing the AVAudioFile finalizes the .m4a container.
|
|
file = nil
|
|
encoder = nil
|
|
resampler = nil
|
|
}
|
|
releaseSessionToken()
|
|
SecureLogger.info("PTT: capture interrupted — burst finalized early", category: .session)
|
|
}
|
|
|
|
/// Touching `inputNode` on an engine that never started instantiates its
|
|
/// input unit against whatever session is active and spams AURemoteIO
|
|
/// errors — a canceled-before-start hold must not touch the engine.
|
|
@MainActor
|
|
private func stopEngineIfStarted() {
|
|
if let observer = configChangeObserver {
|
|
NotificationCenter.default.removeObserver(observer)
|
|
configChangeObserver = nil
|
|
}
|
|
guard engineStarted else { return }
|
|
engineStarted = false
|
|
engine.inputNode.removeTap(onBus: 0)
|
|
engine.stop()
|
|
}
|
|
|
|
@MainActor
|
|
private func releaseSessionToken() {
|
|
sessionLease.release()
|
|
}
|
|
|
|
// MARK: - Capture queue
|
|
|
|
private func process(_ buffer: AVAudioPCMBuffer, generation: UInt) {
|
|
guard captureGeneration.isCurrent(generation),
|
|
running,
|
|
Date().timeIntervalSince(captureStart) < Self.maxCaptureDuration,
|
|
let resampled = resampler?.resample(buffer)
|
|
else { return }
|
|
|
|
do {
|
|
try file?.write(from: resampled)
|
|
} catch {
|
|
SecureLogger.error("PTT capture file write failed: \(error)", category: .session)
|
|
}
|
|
|
|
guard let frames = encoder?.encode(resampled), !frames.isEmpty else { return }
|
|
encodedFrameCount += frames.count
|
|
onFrames?(frames)
|
|
}
|
|
|
|
private func teardown(deleteFile: Bool) {
|
|
running = false
|
|
// Releasing the AVAudioFile finalizes the .m4a container.
|
|
file = nil
|
|
encoder = nil
|
|
resampler = nil
|
|
if deleteFile, let url = fileURL {
|
|
try? FileManager.default.removeItem(at: url)
|
|
}
|
|
fileURL = nil
|
|
}
|
|
}
|