mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 23:45:19 +00:00
Harden PTT audio and the 1.7.1 release (#1423)
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.
This commit is contained in:
@@ -0,0 +1,472 @@
|
||||
//
|
||||
// AudioSessionCoordinator.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
|
||||
|
||||
/// The raw audio-session calls the coordinator makes, abstracted so the
|
||||
/// state machine is unit-testable with a mock (and compiles on the macOS
|
||||
/// test host, where `AVAudioSession` doesn't exist).
|
||||
///
|
||||
/// Calls arrive on the coordinator's private serial queue — never the main
|
||||
/// thread. `setCategory`/`setActive` block on IPC to the audio server
|
||||
/// (observed >1 s under contention on device, tripping the system gesture
|
||||
/// gate), and Apple explicitly recommends activating the session off the
|
||||
/// main thread.
|
||||
protocol SessionApplying: Sendable {
|
||||
func setCategory(_ category: AudioSessionCoordinator.Category) throws
|
||||
func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws
|
||||
}
|
||||
|
||||
/// Sole owner of `AVAudioSession` category/activation for voice features.
|
||||
///
|
||||
/// Talk-over means capture (push-to-talk) and playback (inbound bursts,
|
||||
/// voice notes) can be live simultaneously; letting each engine configure
|
||||
/// the shared session directly made them stomp each other's category and
|
||||
/// route mid-flight (the AURemoteIO -10851 dead-input class). Instead every
|
||||
/// client acquires a `Token` and the coordinator:
|
||||
///
|
||||
/// - reference-counts activation: `setActive(true)` only on the first
|
||||
/// holder, `setActive(false, notifyOthersOnDeactivation:)` only when the
|
||||
/// last one releases — no client can deactivate another's session;
|
||||
/// - keeps one escalating category: playback-only holders get `.playback`,
|
||||
/// any capture holder escalates to `.playAndRecord`, and the category is
|
||||
/// never downgraded while anyone still holds a token (capture ending must
|
||||
/// not yank the route out from under live playback);
|
||||
/// - fans out `onInterrupted` on system interruptions and when the active
|
||||
/// route's device disappears (no auto-resume: bursts are transient, the
|
||||
/// next press or burst simply re-acquires). The escalating category change
|
||||
/// fans out separately as `onCategoryEscalated` — the session stays live,
|
||||
/// so holders that can rebuild their engine against the new configuration
|
||||
/// keep playing (talk-over is bidirectional); holders that don't provide
|
||||
/// it fall back to `onInterrupted`.
|
||||
///
|
||||
/// Threading: all state lives on a private serial queue, which both
|
||||
/// serializes rapid acquire/release pairs and keeps the blocking session IPC
|
||||
/// off the main thread (`acquire` is `async` for exactly that hop; `release`
|
||||
/// is fire-and-forget onto the queue). Holder callbacks always run on the
|
||||
/// main actor.
|
||||
///
|
||||
/// Microphone *permission* queries stay with their callers; this type owns
|
||||
/// only category and activation.
|
||||
///
|
||||
/// `@unchecked Sendable`: every mutable property is confined to `queue`.
|
||||
final class AudioSessionCoordinator: @unchecked Sendable {
|
||||
enum Use {
|
||||
case playback
|
||||
case capture
|
||||
}
|
||||
|
||||
/// The session category the coordinator has applied (the `SessionApplying`
|
||||
/// adapter maps these to concrete `AVAudioSession` category/mode/options).
|
||||
enum Category {
|
||||
case playback
|
||||
case playAndRecord
|
||||
}
|
||||
|
||||
/// Opaque handle for one client's hold on the session. Release exactly
|
||||
/// once when done (extra releases are ignored).
|
||||
///
|
||||
/// `@unchecked` because the stored callbacks are `@MainActor`-isolated
|
||||
/// closures (non-Sendable as stored types). Lifecycle state is protected
|
||||
/// by `stateLock`, and callbacks are only ever invoked on the main actor.
|
||||
final class Token: @unchecked Sendable {
|
||||
fileprivate enum CallbackKind: Sendable {
|
||||
case interrupted
|
||||
case categoryEscalated
|
||||
}
|
||||
|
||||
/// A callback snapshot is only valid for the lifecycle epoch in which
|
||||
/// it was captured. `release` advances the epoch synchronously before
|
||||
/// its queue work, so a callback already headed to the main actor can't
|
||||
/// reach a client that has since released this token and reacquired a
|
||||
/// different one.
|
||||
fileprivate struct CallbackTicket: Sendable {
|
||||
let token: Token
|
||||
let kind: CallbackKind
|
||||
let lifecycleEpoch: UInt64
|
||||
}
|
||||
|
||||
private enum Lifecycle {
|
||||
/// Registered on the session queue, but `acquire` has not yet
|
||||
/// returned into the client's main-actor call frame.
|
||||
case acquiring
|
||||
case ready
|
||||
case released
|
||||
}
|
||||
|
||||
fileprivate let onInterrupted: @MainActor () -> Void
|
||||
fileprivate let onCategoryEscalated: (@MainActor () -> Void)?
|
||||
private let stateLock = NSLock()
|
||||
private var lifecycle = Lifecycle.acquiring
|
||||
private var lifecycleEpoch: UInt64 = 0
|
||||
/// A terminal event that lands while the token is registered but not
|
||||
/// yet handed off invalidates the acquire before its caller can start.
|
||||
private var terminalEventPendingHandoff = false
|
||||
|
||||
fileprivate init(
|
||||
onInterrupted: @escaping @MainActor () -> Void,
|
||||
onCategoryEscalated: (@MainActor () -> Void)?
|
||||
) {
|
||||
self.onInterrupted = onInterrupted
|
||||
self.onCategoryEscalated = onCategoryEscalated
|
||||
}
|
||||
|
||||
/// Records an event at the same linearization point at which the
|
||||
/// coordinator snapshots its holders. An acquiring token cannot safely
|
||||
/// receive a callback yet: terminal events invalidate the acquire,
|
||||
/// while category escalation needs no callback because its engine will
|
||||
/// start against the already-escalated configuration.
|
||||
fileprivate func record(_ kind: CallbackKind) -> CallbackTicket? {
|
||||
stateLock.withLock {
|
||||
switch lifecycle {
|
||||
case .acquiring:
|
||||
switch kind {
|
||||
case .interrupted:
|
||||
terminalEventPendingHandoff = true
|
||||
case .categoryEscalated:
|
||||
break
|
||||
}
|
||||
return nil
|
||||
case .ready:
|
||||
return CallbackTicket(token: self, kind: kind, lifecycleEpoch: lifecycleEpoch)
|
||||
case .released:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Completes the main-actor ownership handoff if no terminal event
|
||||
/// invalidated it. Because `acquire` itself is main-actor isolated, a
|
||||
/// successful handoff returns directly into the caller without another
|
||||
/// actor hop; no callback can interleave before the caller stores the
|
||||
/// returned token.
|
||||
fileprivate func completeHandoff() -> Bool {
|
||||
stateLock.withLock {
|
||||
guard lifecycle == .acquiring,
|
||||
!terminalEventPendingHandoff
|
||||
else { return false }
|
||||
lifecycle = .ready
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks the token dead synchronously, before the asynchronous holder
|
||||
/// removal. Returns false for an already-released token.
|
||||
fileprivate func markReleased() -> Bool {
|
||||
stateLock.withLock {
|
||||
guard lifecycle != .released else { return false }
|
||||
lifecycle = .released
|
||||
lifecycleEpoch &+= 1
|
||||
terminalEventPendingHandoff = false
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Revalidates a queue snapshot at the main-actor delivery boundary.
|
||||
/// The lock is deliberately released before invoking client code: real
|
||||
/// callbacks commonly call `release` on this same token.
|
||||
@MainActor
|
||||
fileprivate func deliver(_ ticket: CallbackTicket) {
|
||||
let isLive = stateLock.withLock {
|
||||
lifecycle == .ready && lifecycleEpoch == ticket.lifecycleEpoch
|
||||
}
|
||||
guard isLive else { return }
|
||||
switch ticket.kind {
|
||||
case .interrupted:
|
||||
onInterrupted()
|
||||
case .categoryEscalated:
|
||||
(onCategoryEscalated ?? onInterrupted)()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic suspension points for lifecycle race tests. Production
|
||||
/// instances use the nil defaults; the hooks never move session calls off
|
||||
/// the coordinator queue or callback execution off the main actor.
|
||||
struct TestingHooks: Sendable {
|
||||
let beforeAcquireHandoff: (@Sendable () async -> Void)?
|
||||
let beforeCallbackDelivery: (@Sendable () async -> Void)?
|
||||
|
||||
init(
|
||||
beforeAcquireHandoff: (@Sendable () async -> Void)? = nil,
|
||||
beforeCallbackDelivery: (@Sendable () async -> Void)? = nil
|
||||
) {
|
||||
self.beforeAcquireHandoff = beforeAcquireHandoff
|
||||
self.beforeCallbackDelivery = beforeCallbackDelivery
|
||||
}
|
||||
}
|
||||
|
||||
static let shared = AudioSessionCoordinator(session: SystemAudioSession())
|
||||
|
||||
private let session: SessionApplying
|
||||
private let testingHooks: TestingHooks
|
||||
/// Confines all mutable state, serializes whole acquire/release
|
||||
/// operations (two rapid presses can't interleave their category and
|
||||
/// activation calls), and hosts the blocking session IPC off main.
|
||||
private let queue = DispatchQueue(label: "chat.bitchat.audio-session", qos: .userInitiated)
|
||||
|
||||
// Queue-confined state.
|
||||
private var holders: [ObjectIdentifier: Token] = [:]
|
||||
private var currentCategory: Category?
|
||||
private var sessionActive = false
|
||||
/// Written once in init, read in deinit — never touched concurrently.
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
|
||||
init(session: SessionApplying, testingHooks: TestingHooks = TestingHooks()) {
|
||||
self.session = session
|
||||
self.testingHooks = testingHooks
|
||||
observeSystemNotifications()
|
||||
}
|
||||
|
||||
deinit {
|
||||
for observer in observers {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
/// Configures + activates the session for `use` and registers the caller
|
||||
/// as a holder. The blocking `AVAudioSession` calls run on the session
|
||||
/// queue — the caller suspends instead of stalling its thread (a PTT
|
||||
/// press used to block main >1 s in `setActive`, tripping the system
|
||||
/// gesture gate). `onInterrupted` fires (on the main actor) when the
|
||||
/// client must stop using the session: a system interruption began or
|
||||
/// its route's device went away. The client should stop its engine,
|
||||
/// finalize any artifacts, and release — resuming means acquiring again.
|
||||
///
|
||||
/// `onCategoryEscalated` fires instead when the session category
|
||||
/// escalated underneath the holder (a capture client joined): the session
|
||||
/// stays active, so a holder that can rebuild its engine against the new
|
||||
/// configuration should restart and keep going. Holders that pass `nil`
|
||||
/// get `onInterrupted` for escalation too. Escalation is delivered before
|
||||
/// `acquire` returns, so the new holder starts its engine strictly after
|
||||
/// existing ones were told to rebuild. Main-actor isolation is also the
|
||||
/// ownership handoff boundary: if interruption or route loss lands after
|
||||
/// queue registration but before that boundary, the provisional holder is
|
||||
/// removed and `acquire` throws `CancellationError` instead of returning a
|
||||
/// token whose callback already fired.
|
||||
@MainActor
|
||||
func acquire(
|
||||
_ use: Use,
|
||||
onInterrupted: @escaping @MainActor () -> Void,
|
||||
onCategoryEscalated: (@MainActor () -> Void)? = nil
|
||||
) async throws -> Token {
|
||||
let token = Token(onInterrupted: onInterrupted, onCategoryEscalated: onCategoryEscalated)
|
||||
let reconfigured: [Token.CallbackTicket] = try await withCheckedThrowingContinuation { continuation in
|
||||
queue.async {
|
||||
do {
|
||||
continuation.resume(returning: try self.activateOnQueue(use, registering: token))
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Escalating playback -> playAndRecord reconfigures the hardware
|
||||
// route; engines started against the old configuration must restart.
|
||||
if !reconfigured.isEmpty {
|
||||
SecureLogger.info("AudioSession: category escalated to playAndRecord with \(reconfigured.count) live holder(s)", category: .session)
|
||||
await deliver(reconfigured)
|
||||
}
|
||||
if let beforeAcquireHandoff = testingHooks.beforeAcquireHandoff {
|
||||
await beforeAcquireHandoff()
|
||||
}
|
||||
guard token.completeHandoff() else {
|
||||
// A call/Siri interruption or route loss landed after registration
|
||||
// but before ownership handoff. Remove the provisional holder and
|
||||
// fail instead of starting a client engine after the stop event.
|
||||
release(token)
|
||||
throw CancellationError()
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
/// Drops one holder. Deactivates the session (notifying other apps) only
|
||||
/// when the last holder releases. Safe to call more than once, from any
|
||||
/// thread (including `deinit` paths): the work is fire-and-forget onto
|
||||
/// the session queue, so the blocking deactivation IPC never runs on the
|
||||
/// caller.
|
||||
func release(_ token: Token) {
|
||||
guard token.markReleased() else { return }
|
||||
queue.async {
|
||||
self.releaseOnQueue(token)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Queue-confined core
|
||||
|
||||
/// Returns callback tickets for pre-existing live holders whose engines
|
||||
/// must restart because this acquire escalated the category.
|
||||
private func activateOnQueue(_ use: Use, registering token: Token) throws -> [Token.CallbackTicket] {
|
||||
let target: Category = (use == .capture || currentCategory == .playAndRecord) ? .playAndRecord : .playback
|
||||
let categoryChanged = target != currentCategory
|
||||
let previousCategory = currentCategory
|
||||
if categoryChanged {
|
||||
try session.setCategory(target)
|
||||
currentCategory = target
|
||||
}
|
||||
if !sessionActive {
|
||||
do {
|
||||
try session.setActive(true, notifyOthersOnDeactivation: false)
|
||||
} catch {
|
||||
// Activation failed (e.g. a phone call owns the hardware):
|
||||
// with no holder registered, an escalated category recorded
|
||||
// here would stick and pin later playback-only acquires to
|
||||
// .playAndRecord. Existing holders keep the category the
|
||||
// hardware really has.
|
||||
if categoryChanged, holders.isEmpty {
|
||||
currentCategory = previousCategory
|
||||
}
|
||||
throw error
|
||||
}
|
||||
sessionActive = true
|
||||
}
|
||||
|
||||
let reconfigured = categoryChanged
|
||||
? holders.values.compactMap { $0.record(.categoryEscalated) }
|
||||
: []
|
||||
holders[ObjectIdentifier(token)] = token
|
||||
return reconfigured
|
||||
}
|
||||
|
||||
private func releaseOnQueue(_ token: Token) {
|
||||
guard holders.removeValue(forKey: ObjectIdentifier(token)) != nil else { return }
|
||||
guard holders.isEmpty else { return }
|
||||
currentCategory = nil
|
||||
guard sessionActive else { return }
|
||||
sessionActive = false
|
||||
do {
|
||||
try session.setActive(false, notifyOthersOnDeactivation: true)
|
||||
} catch {
|
||||
SecureLogger.error("AudioSession: deactivation failed: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
private func onQueue<T: Sendable>(_ body: @escaping @Sendable () -> T) async -> T {
|
||||
await withCheckedContinuation { continuation in
|
||||
queue.async {
|
||||
continuation.resume(returning: body())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func deliver(_ tickets: [Token.CallbackTicket]) async {
|
||||
guard !tickets.isEmpty else { return }
|
||||
if let beforeCallbackDelivery = testingHooks.beforeCallbackDelivery {
|
||||
await beforeCallbackDelivery()
|
||||
}
|
||||
for ticket in tickets {
|
||||
ticket.token.deliver(ticket)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - System events (internal so tests can drive them directly)
|
||||
|
||||
/// A system interruption began: the session is already deactivated by the
|
||||
/// OS, so just mark it inactive and tell every ready holder (on the main
|
||||
/// actor) to stop. A provisional acquiring holder is invalidated instead.
|
||||
/// No auto-resume — the next acquire re-activates.
|
||||
func handleInterruptionBegan() async {
|
||||
let tickets = await onQueue { () -> [Token.CallbackTicket] in
|
||||
self.sessionActive = false
|
||||
return self.holders.values.compactMap { $0.record(.interrupted) }
|
||||
}
|
||||
await deliver(tickets)
|
||||
}
|
||||
|
||||
/// The active route's input/output device disappeared (e.g. BT headset
|
||||
/// off): ready holders' engines are wedged against a dead route — stop
|
||||
/// them; invalidate a holder whose acquire has not returned yet.
|
||||
func handleRouteDeviceUnavailable() async {
|
||||
let tickets = await onQueue {
|
||||
self.holders.values.compactMap { $0.record(.interrupted) }
|
||||
}
|
||||
await deliver(tickets)
|
||||
}
|
||||
|
||||
/// Test hook: suspends until every session operation enqueued before this
|
||||
/// call — including fire-and-forget `release`s — has completed.
|
||||
func drain() async {
|
||||
await onQueue {}
|
||||
}
|
||||
|
||||
private func observeSystemNotifications() {
|
||||
#if os(iOS)
|
||||
let center = NotificationCenter.default
|
||||
observers.append(center.addObserver(
|
||||
forName: AVAudioSession.interruptionNotification,
|
||||
object: AVAudioSession.sharedInstance(),
|
||||
queue: .main
|
||||
) { [weak self] note in
|
||||
guard let raw = note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||
AVAudioSession.InterruptionType(rawValue: raw) == .began,
|
||||
let self
|
||||
else { return }
|
||||
SecureLogger.info("AudioSession: interruption began", category: .session)
|
||||
Task { await self.handleInterruptionBegan() }
|
||||
})
|
||||
observers.append(center.addObserver(
|
||||
forName: AVAudioSession.routeChangeNotification,
|
||||
object: AVAudioSession.sharedInstance(),
|
||||
queue: .main
|
||||
) { [weak self] note in
|
||||
guard let raw = note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
||||
AVAudioSession.RouteChangeReason(rawValue: raw) == .oldDeviceUnavailable,
|
||||
let self
|
||||
else { return }
|
||||
SecureLogger.info("AudioSession: route device became unavailable", category: .session)
|
||||
Task { await self.handleRouteDeviceUnavailable() }
|
||||
})
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Production adapter
|
||||
|
||||
#if os(iOS)
|
||||
private struct SystemAudioSession: SessionApplying {
|
||||
func setCategory(_ category: AudioSessionCoordinator.Category) throws {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
switch category {
|
||||
case .playback:
|
||||
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
|
||||
case .playAndRecord:
|
||||
// allowBluetoothHFP is not available on iOS Simulator
|
||||
#if targetEnvironment(simulator)
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .default,
|
||||
options: [.defaultToSpeaker, .allowBluetoothA2DP, .mixWithOthers]
|
||||
)
|
||||
#else
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .default,
|
||||
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP, .mixWithOthers]
|
||||
)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws {
|
||||
try AVAudioSession.sharedInstance().setActive(
|
||||
active,
|
||||
options: notifyOthersOnDeactivation ? [.notifyOthersOnDeactivation] : []
|
||||
)
|
||||
}
|
||||
}
|
||||
#else
|
||||
/// macOS has no app-level audio session; the coordinator still runs its
|
||||
/// bookkeeping so client code is identical across platforms.
|
||||
private struct SystemAudioSession: SessionApplying {
|
||||
func setCategory(_ category: AudioSessionCoordinator.Category) throws {}
|
||||
func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws {}
|
||||
}
|
||||
#endif
|
||||
@@ -6,10 +6,142 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
@preconcurrency import AVFoundation
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
/// The engine operations behind live-burst playback, abstracted so the
|
||||
/// player's lifecycle (jitter start, category-escalation restart, stop) is
|
||||
/// unit-testable without real audio hardware.
|
||||
@MainActor
|
||||
protocol PTTPlaybackEngine: AnyObject {
|
||||
/// The object `AVAudioEngineConfigurationChange` notifications are posted
|
||||
/// for (nil for mocks — no observer is registered).
|
||||
var configChangeObject: AnyObject? { get }
|
||||
func start() throws
|
||||
func play()
|
||||
func stop()
|
||||
func schedule(
|
||||
_ buffer: AVAudioPCMBuffer,
|
||||
completionType: PTTPlaybackCompletionType,
|
||||
completionHandler: @escaping @Sendable (PTTPlaybackCompletionEvent) -> Void
|
||||
)
|
||||
}
|
||||
|
||||
/// The lifecycle point requested from `AVAudioPlayerNode` for a scheduled
|
||||
/// buffer. `dataConsumed` only means the node no longer needs the bytes; it
|
||||
/// may arrive before the render pipeline has made the audio audible.
|
||||
enum PTTPlaybackCompletionType: Equatable, Sendable {
|
||||
case dataConsumed
|
||||
case dataPlayedBack
|
||||
}
|
||||
|
||||
enum PTTPlaybackCompletionEvent: Equatable, Sendable {
|
||||
case dataConsumed
|
||||
case dataPlayedBack
|
||||
/// AVAudioPlayerNode invokes the requested callback when the node is
|
||||
/// stopped too. That is not audible completion and must remain replayable.
|
||||
case playbackStopped
|
||||
}
|
||||
|
||||
/// One `AVAudioEngine` + `AVAudioPlayerNode` pair. Created fresh per (re)start:
|
||||
/// an engine instantiated against an earlier audio-session configuration keeps
|
||||
/// rendering to the stale route (same class of failure as the capture side's
|
||||
/// fresh-engine-per-press rule).
|
||||
@MainActor
|
||||
private final class SystemPTTPlaybackEngine: PTTPlaybackEngine {
|
||||
private let engine = AVAudioEngine()
|
||||
private let node = AVAudioPlayerNode()
|
||||
|
||||
init(format: AVAudioFormat) {
|
||||
engine.attach(node)
|
||||
engine.connect(node, to: engine.mainMixerNode, format: format)
|
||||
}
|
||||
|
||||
var configChangeObject: AnyObject? { engine }
|
||||
|
||||
func start() throws {
|
||||
engine.prepare()
|
||||
try engine.start()
|
||||
}
|
||||
|
||||
func play() {
|
||||
node.play()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
node.stop()
|
||||
engine.stop()
|
||||
}
|
||||
|
||||
func schedule(
|
||||
_ buffer: AVAudioPCMBuffer,
|
||||
completionType: PTTPlaybackCompletionType,
|
||||
completionHandler: @escaping @Sendable (PTTPlaybackCompletionEvent) -> Void
|
||||
) {
|
||||
let callbackType: AVAudioPlayerNodeCompletionCallbackType = switch completionType {
|
||||
case .dataConsumed: .dataConsumed
|
||||
case .dataPlayedBack: .dataPlayedBack
|
||||
}
|
||||
let scheduledEngine = engine
|
||||
node.scheduleBuffer(buffer, completionCallbackType: callbackType) { [weak scheduledEngine] callbackType in
|
||||
// The API invokes this callback when the player is stopped as
|
||||
// well. A configuration change can stop the engine before its
|
||||
// notification reaches MainActor, so do not misclassify that
|
||||
// flushed tail as audible playback.
|
||||
guard scheduledEngine?.isRunning == true else {
|
||||
completionHandler(.playbackStopped)
|
||||
return
|
||||
}
|
||||
switch callbackType {
|
||||
case .dataConsumed:
|
||||
completionHandler(.dataConsumed)
|
||||
case .dataRendered:
|
||||
completionHandler(.dataConsumed)
|
||||
case .dataPlayedBack:
|
||||
completionHandler(.dataPlayedBack)
|
||||
@unknown default:
|
||||
completionHandler(.playbackStopped)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Completion callbacks arrive off the main actor, while engine rebuilds are
|
||||
/// serialized on it. This small lock-backed latch lets a rebuild atomically
|
||||
/// claim only buffers whose completion has not already fired — even when the
|
||||
/// callback's hop back to the main actor is still queued.
|
||||
private final class PTTPlaybackCompletionState: @unchecked Sendable {
|
||||
private enum State {
|
||||
case scheduled
|
||||
case completed
|
||||
case retired
|
||||
}
|
||||
|
||||
private let lock = NSLock()
|
||||
private var state: State = .scheduled
|
||||
|
||||
/// Returns true exactly once when playback completion wins the race with
|
||||
/// an engine rebuild or stop.
|
||||
func complete() -> Bool {
|
||||
lock.withLock {
|
||||
guard case .scheduled = state else { return false }
|
||||
state = .completed
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true exactly once when a rebuild or stop claims this
|
||||
/// still-pending schedule. Later callbacks from that engine are stale.
|
||||
func retireIfPending() -> Bool {
|
||||
lock.withLock {
|
||||
guard case .scheduled = state else { return false }
|
||||
state = .retired
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Plays one inbound live voice burst with a small jitter buffer.
|
||||
///
|
||||
/// Frames are decoded and scheduled back-to-back on an `AVAudioPlayerNode`;
|
||||
@@ -17,27 +149,77 @@ import Foundation
|
||||
/// buffer arrives, which self-heals timing without explicit silence
|
||||
/// insertion. Playback starts once `TransportConfig.pttJitterBufferSeconds`
|
||||
/// of audio is queued or `pttJitterDeadlineSeconds` has elapsed.
|
||||
///
|
||||
/// Talk-over is bidirectional: when push-to-talk capture starts while this
|
||||
/// burst plays, the session category escalates underneath the engine — the
|
||||
/// player rebuilds a fresh engine against the new configuration and keeps
|
||||
/// streaming instead of dying. Real interruptions (phone call, route device
|
||||
/// gone) still stop it; the burst keeps assembling to file either way.
|
||||
@MainActor
|
||||
final class PTTBurstPlayer {
|
||||
private let engine = AVAudioEngine()
|
||||
private let node = AVAudioPlayerNode()
|
||||
/// Restart-on-reconfigure ceiling: a burst is at most ~2 minutes, so a
|
||||
/// handful of category/route changes is plenty — beyond it something is
|
||||
/// thrashing and stopping cleanly beats an engine-rebuild loop.
|
||||
private static let maxEngineRestarts = 8
|
||||
|
||||
private let makeEngine: @MainActor () -> PTTPlaybackEngine
|
||||
private var engine: PTTPlaybackEngine
|
||||
private let decoder: PTTFrameDecoder
|
||||
private let coordinator: AudioSessionCoordinator
|
||||
/// Injectable so tests don't fight over the app-wide exclusive-playback
|
||||
/// slot (a parallel test's `play()` would stop this player mid-test).
|
||||
private let exclusivity: VoiceNotePlaybackCoordinator
|
||||
|
||||
private var queuedBuffers: [AVAudioPCMBuffer] = []
|
||||
private var queuedDuration: TimeInterval = 0
|
||||
private var scheduledCount = 0
|
||||
private struct ScheduledBuffer {
|
||||
let id: UInt64
|
||||
let buffer: AVAudioPCMBuffer
|
||||
let completionState: PTTPlaybackCompletionState
|
||||
}
|
||||
/// Buffers handed to the current engine whose completion has not yet
|
||||
/// been processed on the main actor. Keeping the buffers themselves lets
|
||||
/// a category-escalation rebuild replay the unfinished tail in order.
|
||||
private var scheduledBuffers: [ScheduledBuffer] = []
|
||||
private var nextScheduledBufferID: UInt64 = 0
|
||||
/// Bumped on every engine rebuild or stop so completion tasks from a
|
||||
/// torn-down engine cannot mutate the current generation's pending list.
|
||||
private var engineGeneration = 0
|
||||
private var engineRestarts = 0
|
||||
private var engineStarted = false
|
||||
private var finished = false
|
||||
private var stopped = false
|
||||
/// Latched off (internal read so tests can await the async failure path).
|
||||
private(set) var stopped = false
|
||||
/// A session acquire is in flight (it suspends off-main for the blocking
|
||||
/// session IPC); gates `startIfReady` against double acquisition.
|
||||
private var acquiringSession = false
|
||||
private var deadlineTask: Task<Void, Never>?
|
||||
private var sessionToken: AudioSessionCoordinator.Token?
|
||||
/// Reserved before the session acquire suspends. Activation succeeds only
|
||||
/// if no newer playback request claimed the floor in the meantime.
|
||||
private var playbackReservation: VoiceNotePlaybackCoordinator.Reservation?
|
||||
private var configChangeObserver: NSObjectProtocol?
|
||||
|
||||
private(set) var isPlaying = false
|
||||
|
||||
init?() {
|
||||
/// Fires exactly once when the player stops for good (drain-out, cancel,
|
||||
/// interruption, failure). `ChatLiveVoiceCoordinator` uses it to unpark
|
||||
/// the draining player it keeps alive after the assembly — the player's
|
||||
/// only long-lived owner — is discarded on burst END.
|
||||
var onStopped: (() -> Void)?
|
||||
|
||||
init?(
|
||||
coordinator: AudioSessionCoordinator? = nil,
|
||||
exclusivity: VoiceNotePlaybackCoordinator? = nil,
|
||||
makeEngine: (@MainActor () -> PTTPlaybackEngine)? = nil
|
||||
) {
|
||||
guard let format = PTTAudioFormat.pcmFormat, let decoder = PTTFrameDecoder() else { return nil }
|
||||
self.decoder = decoder
|
||||
engine.attach(node)
|
||||
engine.connect(node, to: engine.mainMixerNode, format: format)
|
||||
self.coordinator = coordinator ?? .shared
|
||||
self.exclusivity = exclusivity ?? .shared
|
||||
let factory = makeEngine ?? { SystemPTTPlaybackEngine(format: format) }
|
||||
self.makeEngine = factory
|
||||
self.engine = factory()
|
||||
|
||||
deadlineTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: UInt64(TransportConfig.pttJitterDeadlineSeconds * 1_000_000_000))
|
||||
@@ -45,6 +227,21 @@ final class PTTBurstPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Backstop for an owner dropping the player before it stopped: the
|
||||
// session coordinator retains registered tokens strongly, so a token
|
||||
// leaked here would keep the session active (and pin any escalated
|
||||
// category) for the app's lifetime. `release` is fire-and-forget
|
||||
// onto the coordinator's queue, so it is deinit-safe.
|
||||
if let token = sessionToken {
|
||||
coordinator.release(token)
|
||||
}
|
||||
if let observer = configChangeObserver {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
deadlineTask?.cancel()
|
||||
}
|
||||
|
||||
/// Decodes and queues frames (in burst order). Starts playback when the
|
||||
/// jitter buffer fills.
|
||||
func enqueue(_ frames: [Data]) {
|
||||
@@ -64,49 +261,119 @@ final class PTTBurstPlayer {
|
||||
/// The burst ended: stop once everything scheduled has played out.
|
||||
func finishAfterDrain() {
|
||||
finished = true
|
||||
// The complete burst is queued — no jitter left to wait for. This
|
||||
// also matters when END lands while the async session acquire is
|
||||
// still in flight: the queued audio must play out, not be treated
|
||||
// as already drained.
|
||||
startIfReady(force: true)
|
||||
stopIfDrained()
|
||||
}
|
||||
|
||||
/// Immediate stop (cancel, another playback taking over, teardown).
|
||||
/// Immediate stop (cancel, another playback taking over, interruption,
|
||||
/// teardown).
|
||||
func stop() {
|
||||
guard !stopped else { return }
|
||||
stopped = true
|
||||
deadlineTask?.cancel()
|
||||
removeConfigObserver()
|
||||
queuedBuffers = []
|
||||
retireScheduledBuffers()
|
||||
if engineStarted {
|
||||
node.stop()
|
||||
engine.stop()
|
||||
}
|
||||
isPlaying = false
|
||||
VoiceNotePlaybackCoordinator.shared.deactivate(self)
|
||||
releaseSessionToken()
|
||||
exclusivity.deactivate(self)
|
||||
onStopped?()
|
||||
}
|
||||
|
||||
private func startIfReady(force: Bool) {
|
||||
guard !engineStarted, !stopped, !queuedBuffers.isEmpty else { return }
|
||||
guard !engineStarted, !acquiringSession, !stopped, !queuedBuffers.isEmpty else { return }
|
||||
guard force || queuedDuration >= TransportConfig.pttJitterBufferSeconds else { return }
|
||||
|
||||
#if os(iOS)
|
||||
do {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
|
||||
try session.setActive(true, options: [])
|
||||
} catch {
|
||||
SecureLogger.error("PTT playback session activation failed: \(error)", category: .session)
|
||||
// Acquiring the session suspends for its blocking IPC (off the main
|
||||
// actor); frames arriving meanwhile keep queueing and are flushed
|
||||
// onto the engine once it starts.
|
||||
acquiringSession = true
|
||||
playbackReservation = exclusivity.reserve(self)
|
||||
Task { [weak self] in
|
||||
await self?.acquireSessionAndStart()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
engine.prepare()
|
||||
private func acquireSessionAndStart() async {
|
||||
let token: AudioSessionCoordinator.Token
|
||||
do {
|
||||
token = try await coordinator.acquire(
|
||||
.playback,
|
||||
onInterrupted: { [weak self] in self?.stop() },
|
||||
onCategoryEscalated: { [weak self] in self?.restartEngine() }
|
||||
)
|
||||
} catch {
|
||||
acquiringSession = false
|
||||
SecureLogger.error("PTT playback session activation failed: \(error)", category: .session)
|
||||
// Playing unregistered would leave the engine exposed: another
|
||||
// holder's last release deactivates the session mid-play, and no
|
||||
// interruption/escalation fan-out ever reaches us. Bail like the
|
||||
// engine-start failure below; the burst still assembles to file.
|
||||
// (stop() also fires onStopped so a parked draining player is
|
||||
// unparked instead of leaking.)
|
||||
stop()
|
||||
return
|
||||
}
|
||||
acquiringSession = false
|
||||
// stop() (cancel, exclusivity, teardown) may have landed while the
|
||||
// session was activating: hand the token straight back.
|
||||
guard !stopped else {
|
||||
coordinator.release(token)
|
||||
return
|
||||
}
|
||||
sessionToken = token
|
||||
guard let playbackReservation,
|
||||
exclusivity.isCurrent(playbackReservation, for: self)
|
||||
else {
|
||||
// The request was superseded while audio-session activation was
|
||||
// suspended. Do not even start the retired engine.
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
// Observe reconfiguration before starting so nothing lands between.
|
||||
registerConfigObserver()
|
||||
do {
|
||||
try engine.start()
|
||||
} catch {
|
||||
SecureLogger.error("PTT playback engine failed to start: \(error)", category: .session)
|
||||
stopped = true
|
||||
return
|
||||
// A capture racing this start can reconfigure the session while
|
||||
// the engine spins up (its escalation fan-out no-ops on a player
|
||||
// that never started): rebuild once against the settled
|
||||
// configuration — counted against the restart cap — before
|
||||
// giving up.
|
||||
SecureLogger.warning("PTT playback engine failed to start (\(error)) — rebuilding once", category: .session)
|
||||
removeConfigObserver()
|
||||
engineRestarts += 1
|
||||
engine = makeEngine()
|
||||
registerConfigObserver()
|
||||
do {
|
||||
try engine.start()
|
||||
} catch {
|
||||
SecureLogger.error("PTT playback engine failed to start: \(error)", category: .session)
|
||||
// stop() removes the observer, hands the token back, and
|
||||
// fires onStopped for any parked draining owner.
|
||||
stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
engineStarted = true
|
||||
guard exclusivity.activate(self, reservation: playbackReservation)
|
||||
else {
|
||||
// A newer user-initiated playback reserved the floor while this
|
||||
// older PTT request was suspended in audio-session activation.
|
||||
// Never let the late completion steal playback back.
|
||||
stop()
|
||||
return
|
||||
}
|
||||
isPlaying = true
|
||||
VoiceNotePlaybackCoordinator.shared.activate(self)
|
||||
node.play()
|
||||
engine.play()
|
||||
|
||||
let buffered = queuedBuffers
|
||||
queuedBuffers = []
|
||||
@@ -116,21 +383,119 @@ final class PTTBurstPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
private func schedule(_ buffer: AVAudioPCMBuffer) {
|
||||
scheduledCount += 1
|
||||
node.scheduleBuffer(buffer) { [weak self] in
|
||||
/// The audio session was reconfigured underneath the running engine
|
||||
/// (category escalation for talk-over, or an engine configuration
|
||||
/// change): rebuild a fresh engine against the new configuration and
|
||||
/// keep streaming. Buffers already completed stay completed; the
|
||||
/// unfinished scheduled tail is replayed in order on the fresh engine,
|
||||
/// and frames still arriving continue scheduling after it.
|
||||
private func restartEngine() {
|
||||
guard engineStarted, !stopped else { return }
|
||||
engineRestarts += 1
|
||||
guard engineRestarts <= Self.maxEngineRestarts else {
|
||||
SecureLogger.warning("PTT playback: engine reconfigured \(engineRestarts) times in one burst — stopping", category: .session)
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
removeConfigObserver()
|
||||
// Claim the unfinished tail before stopping the old engine. Stopping
|
||||
// a player node may itself invoke its completion handlers; retiring
|
||||
// the claimed entries first makes those callbacks unambiguously stale.
|
||||
// A completion that fired just before this rebuild wins the latch and
|
||||
// is excluded even if its MainActor task has not run yet.
|
||||
let buffersToReplay = scheduledBuffers.compactMap { scheduled in
|
||||
scheduled.completionState.retireIfPending() ? scheduled.buffer : nil
|
||||
}
|
||||
scheduledBuffers = []
|
||||
engineGeneration += 1
|
||||
engine.stop()
|
||||
engine = makeEngine()
|
||||
registerConfigObserver()
|
||||
do {
|
||||
try engine.start()
|
||||
} catch {
|
||||
SecureLogger.error("PTT playback engine failed to restart after session reconfigure: \(error)", category: .session)
|
||||
stop()
|
||||
return
|
||||
}
|
||||
engine.play()
|
||||
for buffer in buffersToReplay {
|
||||
schedule(buffer)
|
||||
}
|
||||
SecureLogger.info("PTT playback: engine restarted after session reconfigure", category: .session)
|
||||
// If every old buffer completed before the rebuild, a finished burst
|
||||
// can stop now. Otherwise the replayed tail keeps it alive until its
|
||||
// new-generation completions arrive.
|
||||
stopIfDrained()
|
||||
}
|
||||
|
||||
private func registerConfigObserver() {
|
||||
guard let object = engine.configChangeObject else { return }
|
||||
configChangeObserver = NotificationCenter.default.addObserver(
|
||||
forName: .AVAudioEngineConfigurationChange,
|
||||
object: object,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
self.scheduledCount -= 1
|
||||
self?.restartEngine()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func removeConfigObserver() {
|
||||
if let observer = configChangeObserver {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
configChangeObserver = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func schedule(_ buffer: AVAudioPCMBuffer) {
|
||||
let id = nextScheduledBufferID
|
||||
nextScheduledBufferID &+= 1
|
||||
let completionState = PTTPlaybackCompletionState()
|
||||
scheduledBuffers.append(ScheduledBuffer(
|
||||
id: id,
|
||||
buffer: buffer,
|
||||
completionState: completionState
|
||||
))
|
||||
let generation = engineGeneration
|
||||
engine.schedule(buffer, completionType: .dataPlayedBack) { [weak self, completionState] event in
|
||||
guard event == .dataPlayedBack else { return }
|
||||
// Mark completion before hopping to MainActor. A rebuild can then
|
||||
// distinguish already-completed audio from an unfinished tail
|
||||
// even when this task has not run yet.
|
||||
guard completionState.complete() else { return }
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self, self.engineGeneration == generation else { return }
|
||||
self.scheduledBuffers.removeAll { $0.id == id }
|
||||
self.stopIfDrained()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func retireScheduledBuffers() {
|
||||
engineGeneration += 1
|
||||
for scheduled in scheduledBuffers {
|
||||
_ = scheduled.completionState.retireIfPending()
|
||||
}
|
||||
scheduledBuffers = []
|
||||
}
|
||||
|
||||
private func stopIfDrained() {
|
||||
guard finished, scheduledCount <= 0 else { return }
|
||||
guard finished, scheduledBuffers.isEmpty else { return }
|
||||
// Started: everything scheduled has played out. Never started with
|
||||
// nothing queued or in flight (e.g. no decodable frames): nothing
|
||||
// will ever play. Otherwise the engine start is still pending (the
|
||||
// async session acquire) and the queued audio must play out first.
|
||||
guard engineStarted || (!acquiringSession && queuedBuffers.isEmpty) else { return }
|
||||
stop()
|
||||
}
|
||||
|
||||
private func releaseSessionToken() {
|
||||
sessionToken.map(coordinator.release)
|
||||
sessionToken = nil
|
||||
}
|
||||
}
|
||||
|
||||
extension PTTBurstPlayer: ExclusivePlayback {
|
||||
|
||||
@@ -10,12 +10,85 @@ 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.
|
||||
final class PTTCaptureEngine {
|
||||
/// `@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
|
||||
@@ -26,6 +99,9 @@ final class PTTCaptureEngine {
|
||||
/// 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?
|
||||
@@ -35,10 +111,10 @@ final class PTTCaptureEngine {
|
||||
private var encodedFrameCount = 0
|
||||
private var running = false
|
||||
private var captureStart = Date()
|
||||
/// Whether `engine.start()` succeeded for the current capture (main-actor
|
||||
/// callers only; see `stopEngineIfStarted`).
|
||||
private var engineStarted = false
|
||||
|
||||
/// 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)?
|
||||
|
||||
@@ -47,11 +123,41 @@ final class PTTCaptureEngine {
|
||||
case audioSetupFailed
|
||||
}
|
||||
|
||||
func start(outputURL: URL) throws {
|
||||
#if os(iOS)
|
||||
try Self.configureAudioSession()
|
||||
#endif
|
||||
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()
|
||||
@@ -83,13 +189,30 @@ final class PTTCaptureEngine {
|
||||
}
|
||||
|
||||
engine.inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] buffer, _ in
|
||||
self?.queue.async { self?.process(buffer) }
|
||||
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
|
||||
@@ -100,7 +223,9 @@ final class PTTCaptureEngine {
|
||||
|
||||
/// 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
|
||||
@@ -108,34 +233,70 @@ final class PTTCaptureEngine {
|
||||
teardown(deleteFile: false)
|
||||
return (url, frames)
|
||||
}
|
||||
#if os(iOS)
|
||||
Self.deactivateAudioSession()
|
||||
#endif
|
||||
releaseSessionToken()
|
||||
return result
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func cancel() {
|
||||
captureGeneration.invalidate()
|
||||
stopEngineIfStarted()
|
||||
queue.sync { teardown(deleteFile: true) }
|
||||
#if os(iOS)
|
||||
Self.deactivateAudioSession()
|
||||
#endif
|
||||
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) {
|
||||
guard running,
|
||||
private func process(_ buffer: AVAudioPCMBuffer, generation: UInt) {
|
||||
guard captureGeneration.isCurrent(generation),
|
||||
running,
|
||||
Date().timeIntervalSince(captureStart) < Self.maxCaptureDuration,
|
||||
let resampled = resampler?.resample(buffer)
|
||||
else { return }
|
||||
@@ -162,22 +323,4 @@ final class PTTCaptureEngine {
|
||||
}
|
||||
fileURL = nil
|
||||
}
|
||||
|
||||
// MARK: - Audio session (iOS)
|
||||
|
||||
#if os(iOS)
|
||||
private static func configureAudioSession() throws {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
#if targetEnvironment(simulator)
|
||||
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetoothA2DP])
|
||||
#else
|
||||
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP])
|
||||
#endif
|
||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
}
|
||||
|
||||
private static func deactivateAudioSession() {
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -31,25 +31,45 @@ protocol VoiceCaptureSession: AnyObject {
|
||||
/// The classic record-then-send backend, wrapping the shared `VoiceRecorder`.
|
||||
@MainActor
|
||||
final class VoiceNoteCaptureSession: VoiceCaptureSession {
|
||||
private let recorder: VoiceRecorder
|
||||
private let owner = VoiceRecorder.RecordingOwner()
|
||||
|
||||
var isLive: Bool { false }
|
||||
|
||||
init(recorder: VoiceRecorder = .shared) {
|
||||
self.recorder = recorder
|
||||
}
|
||||
|
||||
func requestPermission() async -> Bool {
|
||||
await VoiceRecorder.shared.requestPermission()
|
||||
await recorder.requestPermission()
|
||||
}
|
||||
|
||||
func start() async throws {
|
||||
try await VoiceRecorder.shared.startRecording()
|
||||
try await recorder.startRecording(owner: owner)
|
||||
}
|
||||
|
||||
func finish() async -> URL? {
|
||||
await VoiceRecorder.shared.stopRecording()
|
||||
await recorder.stopRecording(owner: owner)
|
||||
}
|
||||
|
||||
func cancel() async {
|
||||
await VoiceRecorder.shared.cancelRecording()
|
||||
await recorder.cancelRecording(owner: owner)
|
||||
}
|
||||
}
|
||||
|
||||
/// Testable surface of the live capture engine. Production uses
|
||||
/// `PTTCaptureEngine`; tests can supply captured-frame counts without opening
|
||||
/// real audio hardware.
|
||||
@MainActor
|
||||
protocol PTTCapturing: AnyObject {
|
||||
var onFrames: (([Data]) -> Void)? { get set }
|
||||
func start(outputURL: URL) async throws
|
||||
func stop() -> (url: URL?, encodedFrames: Int)
|
||||
func cancel()
|
||||
}
|
||||
|
||||
extension PTTCaptureEngine: PTTCapturing {}
|
||||
|
||||
/// Live push-to-talk backend: streams `VoiceBurstPacket`s to one peer while
|
||||
/// recording, then finalizes the same audio as a standard voice note whose
|
||||
/// file name carries the burst ID (`voice_<burstID>.m4a`) so receivers that
|
||||
@@ -60,7 +80,8 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
|
||||
let burstID: Data
|
||||
|
||||
private let sendPacket: (Data) -> Void
|
||||
private let capture = PTTCaptureEngine()
|
||||
private let capture: any PTTCapturing
|
||||
private let now: () -> Date
|
||||
/// Capture-queue-confined stream state: packetizes frames and lazily
|
||||
/// emits START so packet order is guaranteed by queue serialization.
|
||||
private final class StreamState {
|
||||
@@ -79,10 +100,17 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
|
||||
/// - Parameter sendPacket: delivers one encoded `VoiceBurstPacket` to the
|
||||
/// target peer; must be safe to call from any queue (BLEService hops to
|
||||
/// its own message queue internally).
|
||||
init(sendPacket: @escaping (Data) -> Void) {
|
||||
self.burstID = VoiceBurstPacket.makeBurstID()
|
||||
init(
|
||||
sendPacket: @escaping (Data) -> Void,
|
||||
capture: (any PTTCapturing)? = nil,
|
||||
now: @escaping () -> Date = Date.init,
|
||||
burstID: Data? = nil
|
||||
) {
|
||||
self.burstID = burstID ?? VoiceBurstPacket.makeBurstID()
|
||||
self.sendPacket = sendPacket
|
||||
self.stream = StreamState(burstID: burstID)
|
||||
self.capture = capture ?? PTTCaptureEngine()
|
||||
self.now = now
|
||||
self.stream = StreamState(burstID: self.burstID)
|
||||
}
|
||||
|
||||
func requestPermission() async -> Bool {
|
||||
@@ -117,7 +145,15 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
|
||||
}
|
||||
}
|
||||
do {
|
||||
try capture.start(outputURL: outputURL)
|
||||
try await capture.start(outputURL: outputURL)
|
||||
} catch is CancellationError {
|
||||
// The hold was released/canceled while the session acquire was
|
||||
// in flight: the engine never started and the capture already
|
||||
// handed its token back — nothing to retry. A coordinator-side
|
||||
// interruption during handoff also cancels acquire, but that is
|
||||
// not a successful start and must propagate to the view model.
|
||||
guard completed else { throw CancellationError() }
|
||||
return
|
||||
} catch {
|
||||
// The HAL can briefly report a dead input right after the audio
|
||||
// session (re)activates while the route settles; one retry after
|
||||
@@ -131,9 +167,9 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
|
||||
capture.cancel()
|
||||
return
|
||||
}
|
||||
try capture.start(outputURL: outputURL)
|
||||
try await capture.start(outputURL: outputURL)
|
||||
}
|
||||
startDate = Date()
|
||||
startDate = now()
|
||||
SecureLogger.info("PTT: live burst \(burstID.hexEncodedString()) capture started", category: .session)
|
||||
}
|
||||
|
||||
@@ -141,11 +177,16 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
|
||||
guard !completed else { return nil }
|
||||
completed = true
|
||||
|
||||
let elapsed = startDate.map { Date().timeIntervalSince($0) } ?? 0
|
||||
let elapsed = startDate.map { now().timeIntervalSince($0) } ?? 0
|
||||
let (url, encodedFrames) = capture.stop()
|
||||
// stop() drained the capture queue, so touching `stream` is safe now.
|
||||
|
||||
guard elapsed >= VoiceRecorder.minRecordingDuration, let url else {
|
||||
let capturedDuration = Double(encodedFrames) * PTTAudioFormat.frameDuration
|
||||
|
||||
guard elapsed >= VoiceRecorder.minRecordingDuration,
|
||||
capturedDuration >= VoiceRecorder.minRecordingDuration,
|
||||
let url
|
||||
else {
|
||||
sendControlPacket(.canceled)
|
||||
if let url {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
@@ -156,7 +197,7 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
|
||||
for packet in stream.packetizer.flush() {
|
||||
sendPacket(packet)
|
||||
}
|
||||
let durationMs = UInt32((Double(encodedFrames) * PTTAudioFormat.frameDuration * 1000).rounded())
|
||||
let durationMs = UInt32((capturedDuration * 1000).rounded())
|
||||
sendControlPacket(.end(totalDataPackets: stream.packetizer.dataPacketCount, durationMs: durationMs))
|
||||
SecureLogger.info("PTT: live burst \(burstID.hexEncodedString()) finished — \(stream.packetizer.dataPacketCount) data packets, \(encodedFrames) frames, \(durationMs) ms", category: .session)
|
||||
return url
|
||||
|
||||
@@ -9,6 +9,9 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
||||
@Published private(set) var duration: TimeInterval = 0
|
||||
@Published private(set) var progress: Double = 0
|
||||
|
||||
/// Internal lifecycle visibility for deterministic acquisition tests.
|
||||
var isPlaybackStartPending: Bool { sessionAcquireInFlight }
|
||||
|
||||
/// rounded so 4.9s shows "00:05"
|
||||
var roundedDuration: Int {
|
||||
guard duration.isFinite else { return 0 }
|
||||
@@ -24,9 +27,24 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
||||
private var player: AVAudioPlayer?
|
||||
private var timer: Timer?
|
||||
private var url: URL
|
||||
/// Test seam; `AudioSessionCoordinator.shared` when nil.
|
||||
private let sessionCoordinatorOverride: AudioSessionCoordinator?
|
||||
/// Injectable so tests don't fight over the app-wide exclusive-playback
|
||||
/// slot (a parallel test's `play()` would pause this controller mid-test).
|
||||
private let exclusivity: VoiceNotePlaybackCoordinator
|
||||
private var sessionToken: AudioSessionCoordinator.Token?
|
||||
/// A session acquire is in flight (it suspends off-main for the blocking
|
||||
/// session IPC); gates against double acquisition on rapid play taps.
|
||||
private var sessionAcquireInFlight = false
|
||||
|
||||
init(url: URL) {
|
||||
init(
|
||||
url: URL,
|
||||
sessionCoordinator: AudioSessionCoordinator? = nil,
|
||||
exclusivity: VoiceNotePlaybackCoordinator? = nil
|
||||
) {
|
||||
self.url = url
|
||||
self.sessionCoordinatorOverride = sessionCoordinator
|
||||
self.exclusivity = exclusivity ?? .shared
|
||||
super.init()
|
||||
// Don't load anything eagerly - wait until user interaction or view is fully displayed
|
||||
}
|
||||
@@ -51,6 +69,16 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
||||
|
||||
deinit {
|
||||
timer?.invalidate()
|
||||
player?.stop()
|
||||
// A per-row @StateObject can be discarded mid-playback (navigating
|
||||
// away). Leaking the token here would hold the session forever —
|
||||
// never deactivating it, and pinning any escalated category for the
|
||||
// app's lifetime. `release` is fire-and-forget onto the coordinator's
|
||||
// queue, so it is deinit-safe: only the Sendable token crosses.
|
||||
if let token = sessionToken {
|
||||
sessionToken = nil
|
||||
(sessionCoordinatorOverride ?? .shared).release(token)
|
||||
}
|
||||
}
|
||||
|
||||
func replaceURL(_ url: URL) {
|
||||
@@ -68,11 +96,15 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
||||
|
||||
func play() {
|
||||
guard ensurePlayerReady() else { return }
|
||||
VoiceNotePlaybackCoordinator.shared.activate(self)
|
||||
player?.play()
|
||||
exclusivity.activate(self)
|
||||
isPlaying = true
|
||||
startTimer()
|
||||
updateProgress()
|
||||
isPlaying = true
|
||||
// Acquired here (not in ensurePlayerReady): scrubbing a paused note
|
||||
// must not hold the session while nothing is audible. The session
|
||||
// calls block on audio-server IPC, so they run off the main thread;
|
||||
// the player starts once the session is configured.
|
||||
startPlayerAfterAcquiringSession()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
@@ -80,6 +112,7 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
||||
stopTimer()
|
||||
updateProgress()
|
||||
isPlaying = false
|
||||
releaseSession()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
@@ -88,7 +121,8 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
||||
stopTimer()
|
||||
updateProgress()
|
||||
isPlaying = false
|
||||
VoiceNotePlaybackCoordinator.shared.deactivate(self)
|
||||
releaseSession()
|
||||
exclusivity.deactivate(self)
|
||||
}
|
||||
|
||||
func seek(to fraction: Double) {
|
||||
@@ -96,8 +130,11 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
||||
let clamped = max(0, min(1, fraction))
|
||||
if let player = player {
|
||||
player.currentTime = clamped * player.duration
|
||||
if isPlaying {
|
||||
player.play()
|
||||
// While the session acquire is still in flight, don't start
|
||||
// audio pre-activation — the pending acquire's completion starts
|
||||
// playback (from the new position) once the session resolves.
|
||||
if isPlaying, !sessionAcquireInFlight {
|
||||
startPreparedPlayer()
|
||||
}
|
||||
updateProgress()
|
||||
}
|
||||
@@ -112,18 +149,20 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
||||
self.stopTimer()
|
||||
self.updateProgress()
|
||||
self.isPlaying = false
|
||||
VoiceNotePlaybackCoordinator.shared.deactivate(self)
|
||||
self.releaseSession()
|
||||
self.exclusivity.deactivate(self)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func preparePlayer(for url: URL) {
|
||||
// Prepare player synchronously (only called when playback is requested)
|
||||
// Load metadata synchronously, but do not call prepareToPlay here:
|
||||
// paused scrubbing reaches this path and must not acquire playback
|
||||
// hardware outside the AudioSessionCoordinator token lifetime.
|
||||
do {
|
||||
let player = try AVAudioPlayer(contentsOf: url)
|
||||
player.delegate = self
|
||||
player.prepareToPlay()
|
||||
self.player = player
|
||||
duration = player.duration
|
||||
currentTime = player.currentTime
|
||||
@@ -141,18 +180,81 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
||||
if player == nil {
|
||||
preparePlayer(for: url)
|
||||
}
|
||||
#if os(iOS)
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
|
||||
try session.setActive(true, options: [])
|
||||
} catch {
|
||||
SecureLogger.error("Failed to activate audio session: \(error)", category: .session)
|
||||
}
|
||||
#endif
|
||||
return player != nil
|
||||
}
|
||||
|
||||
/// All entry points (SwiftUI actions, `pauseForExclusivity`, the
|
||||
/// delegate's main-queue hop) run on the main thread; the acquire itself
|
||||
/// suspends while the blocking session IPC runs on the coordinator's
|
||||
/// queue, and the player starts when it resolves. An acquire failure
|
||||
/// leaves playback stopped: starting without a registered token would
|
||||
/// bypass interruption fan-out and the coordinator's refcount. A
|
||||
/// pause/stop landing mid-acquire hands the token straight back.
|
||||
private func startPlayerAfterAcquiringSession() {
|
||||
if sessionToken != nil {
|
||||
startPreparedPlayer()
|
||||
return
|
||||
}
|
||||
guard !sessionAcquireInFlight else { return }
|
||||
sessionAcquireInFlight = true
|
||||
let coordinator = sessionCoordinatorOverride ?? AudioSessionCoordinator.shared
|
||||
Task { @MainActor [weak self] in
|
||||
var token: AudioSessionCoordinator.Token?
|
||||
do {
|
||||
token = try await coordinator.acquire(.playback) { [weak self] in
|
||||
self?.pause()
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Failed to activate audio session: \(error)", category: .session)
|
||||
}
|
||||
guard let self else {
|
||||
// The row was discarded while acquiring; deinit had no token
|
||||
// to release yet.
|
||||
token.map(coordinator.release)
|
||||
return
|
||||
}
|
||||
self.sessionAcquireInFlight = false
|
||||
guard self.isPlaying else {
|
||||
// Paused/stopped while the session was activating.
|
||||
token.map(coordinator.release)
|
||||
return
|
||||
}
|
||||
guard let token else {
|
||||
self.failPlaybackStart()
|
||||
return
|
||||
}
|
||||
self.sessionToken = token
|
||||
self.startPreparedPlayer()
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func startPreparedPlayer() -> Bool {
|
||||
guard let player,
|
||||
player.prepareToPlay(),
|
||||
player.play()
|
||||
else {
|
||||
SecureLogger.error("Voice note player refused to start " + url.lastPathComponent, category: .session)
|
||||
failPlaybackStart()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func failPlaybackStart() {
|
||||
player?.pause()
|
||||
stopTimer()
|
||||
updateProgress()
|
||||
isPlaying = false
|
||||
releaseSession()
|
||||
exclusivity.deactivate(self)
|
||||
}
|
||||
|
||||
private func releaseSession() {
|
||||
sessionToken.map((sessionCoordinatorOverride ?? .shared).release)
|
||||
sessionToken = nil
|
||||
}
|
||||
|
||||
private func startTimer() {
|
||||
if timer != nil { return }
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
|
||||
@@ -197,21 +299,59 @@ extension VoiceNotePlaybackController: ExclusivePlayback {
|
||||
final class VoiceNotePlaybackCoordinator {
|
||||
static let shared = VoiceNotePlaybackCoordinator()
|
||||
|
||||
struct Reservation: Equatable {
|
||||
fileprivate let generation: UInt64
|
||||
}
|
||||
|
||||
private weak var activeController: (any ExclusivePlayback)?
|
||||
private weak var latestReservedController: (any ExclusivePlayback)?
|
||||
private var latestReservation = Reservation(generation: 0)
|
||||
|
||||
private init() {}
|
||||
/// Internal so tests can isolate their own exclusivity slot; the app
|
||||
/// uses `shared`.
|
||||
init() {}
|
||||
|
||||
func activate(_ controller: any ExclusivePlayback) {
|
||||
/// Records playback intent without interrupting audio that is already
|
||||
/// audible. Async starters reserve before suspension, then activate only
|
||||
/// after their audio resource is ready.
|
||||
func reserve(_ controller: any ExclusivePlayback) -> Reservation {
|
||||
latestReservation = Reservation(generation: latestReservation.generation &+ 1)
|
||||
latestReservedController = controller
|
||||
return latestReservation
|
||||
}
|
||||
|
||||
/// Immediate activation for synchronous/user-initiated playback.
|
||||
@discardableResult
|
||||
func activate(_ controller: any ExclusivePlayback) -> Reservation {
|
||||
let reservation = reserve(controller)
|
||||
_ = activate(controller, reservation: reservation)
|
||||
return reservation
|
||||
}
|
||||
|
||||
/// Commits an earlier reservation only when it is still the newest
|
||||
/// playback request. This prevents an older async acquire from stealing
|
||||
/// the floor after a newer play gesture.
|
||||
@discardableResult
|
||||
func activate(_ controller: any ExclusivePlayback, reservation: Reservation) -> Bool {
|
||||
guard isCurrent(reservation, for: controller) else { return false }
|
||||
if activeController === controller {
|
||||
return
|
||||
return true
|
||||
}
|
||||
activeController?.pauseForExclusivity()
|
||||
activeController = controller
|
||||
return true
|
||||
}
|
||||
|
||||
func isCurrent(_ reservation: Reservation, for controller: any ExclusivePlayback) -> Bool {
|
||||
latestReservation == reservation && latestReservedController === controller
|
||||
}
|
||||
|
||||
func deactivate(_ controller: any ExclusivePlayback) {
|
||||
if activeController === controller {
|
||||
activeController = nil
|
||||
}
|
||||
if latestReservedController === controller {
|
||||
latestReservedController = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,95 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
|
||||
/// The small surface of `AVAudioRecorder` that `VoiceRecorder` owns. Keeping
|
||||
/// it behind a protocol lets lifecycle races be tested without opening the
|
||||
/// microphone on the test host.
|
||||
protocol VoiceAudioRecording: AnyObject {
|
||||
var isRecording: Bool { get }
|
||||
var isMeteringEnabled: Bool { get set }
|
||||
func prepareToRecord() -> Bool
|
||||
func record(forDuration duration: TimeInterval) -> Bool
|
||||
func stop()
|
||||
}
|
||||
|
||||
extension AVAudioRecorder: VoiceAudioRecording {}
|
||||
|
||||
protocol VoiceAudioRecorderCreating {
|
||||
func makeRecorder(url: URL) throws -> any VoiceAudioRecording
|
||||
}
|
||||
|
||||
private struct SystemVoiceAudioRecorderFactory: VoiceAudioRecorderCreating {
|
||||
func makeRecorder(url: URL) throws -> any VoiceAudioRecording {
|
||||
let settings: [String: Any] = [
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVSampleRateKey: 16_000,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
AVEncoderBitRateKey: 16_000
|
||||
]
|
||||
return try AVAudioRecorder(url: url, settings: settings)
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages audio capture for mesh voice notes with predictable encoding settings.
|
||||
actor VoiceRecorder {
|
||||
enum RecorderError: Error {
|
||||
enum RecorderError: Error, Equatable {
|
||||
case microphoneAccessDenied
|
||||
case recordingInProgress
|
||||
case failedToStartRecording
|
||||
}
|
||||
|
||||
static let shared = VoiceRecorder()
|
||||
|
||||
private let paddingInterval: TimeInterval = 0.5
|
||||
private let maxRecordingDuration: TimeInterval = 120
|
||||
static let minRecordingDuration: TimeInterval = 1
|
||||
|
||||
private var recorder: AVAudioRecorder?
|
||||
/// Identity of one press/hold. Every lifecycle mutation must present the
|
||||
/// same owner that started the recorder, so a stale finish or cancel from
|
||||
/// another hold cannot stop or delete the current recording.
|
||||
final class RecordingOwner: @unchecked Sendable {}
|
||||
|
||||
/// Test-only scheduling seams for lifecycle boundaries that otherwise rely
|
||||
/// on wall-clock sleeps. Production uses the real padding delay.
|
||||
struct TestingHooks: Sendable {
|
||||
let waitForStopPadding: (@Sendable (TimeInterval) async -> Void)?
|
||||
|
||||
init(waitForStopPadding: (@Sendable (TimeInterval) async -> Void)? = nil) {
|
||||
self.waitForStopPadding = waitForStopPadding
|
||||
}
|
||||
}
|
||||
|
||||
private let sessionCoordinator: AudioSessionCoordinator
|
||||
private let recorderFactory: any VoiceAudioRecorderCreating
|
||||
private let permissionGranted: () -> Bool
|
||||
private let paddingInterval: TimeInterval
|
||||
private let maxRecordingDuration: TimeInterval
|
||||
private let outputDirectory: URL?
|
||||
private let testingHooks: TestingHooks
|
||||
|
||||
private var recorder: (any VoiceAudioRecording)?
|
||||
private var currentURL: URL?
|
||||
private var sessionToken: AudioSessionCoordinator.Token?
|
||||
private var activeOwner: RecordingOwner?
|
||||
/// True only while `startRecording()` is suspended in session acquire.
|
||||
/// A second start is rejected instead of superseding the first one.
|
||||
private var startInFlight = false
|
||||
|
||||
init(
|
||||
sessionCoordinator: AudioSessionCoordinator = .shared,
|
||||
recorderFactory: any VoiceAudioRecorderCreating = SystemVoiceAudioRecorderFactory(),
|
||||
permissionGranted: (() -> Bool)? = nil,
|
||||
paddingInterval: TimeInterval = 0.5,
|
||||
maxRecordingDuration: TimeInterval = 120,
|
||||
outputDirectory: URL? = nil,
|
||||
testingHooks: TestingHooks = TestingHooks()
|
||||
) {
|
||||
self.sessionCoordinator = sessionCoordinator
|
||||
self.recorderFactory = recorderFactory
|
||||
self.permissionGranted = permissionGranted ?? Self.hasSystemPermission
|
||||
self.paddingInterval = paddingInterval
|
||||
self.maxRecordingDuration = maxRecordingDuration
|
||||
self.outputDirectory = outputDirectory
|
||||
self.testingHooks = testingHooks
|
||||
}
|
||||
|
||||
// MARK: - Permissions
|
||||
|
||||
@@ -41,82 +115,130 @@ actor VoiceRecorder {
|
||||
// MARK: - Recording Lifecycle
|
||||
|
||||
@discardableResult
|
||||
func startRecording() throws -> URL {
|
||||
if recorder?.isRecording == true {
|
||||
func startRecording(owner: RecordingOwner) async throws -> URL {
|
||||
if activeOwner != nil {
|
||||
throw RecorderError.recordingInProgress
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
guard session.recordPermission == .granted else {
|
||||
guard permissionGranted() else {
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
}
|
||||
#if targetEnvironment(simulator)
|
||||
// allowBluetoothHFP is not available on iOS Simulator
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .default,
|
||||
options: [.defaultToSpeaker, .allowBluetoothA2DP]
|
||||
)
|
||||
#else
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .default,
|
||||
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
|
||||
)
|
||||
#endif
|
||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
|
||||
activeOwner = owner
|
||||
startInFlight = true
|
||||
|
||||
// The acquire suspends while the blocking session IPC runs on the
|
||||
// coordinator's queue (never this actor's thread or main).
|
||||
let token: AudioSessionCoordinator.Token
|
||||
do {
|
||||
token = try await sessionCoordinator.acquire(.capture) { [weak self] in
|
||||
Task { await self?.handleSessionInterruption(for: owner) }
|
||||
}
|
||||
} catch {
|
||||
guard activeOwner === owner else {
|
||||
throw CancellationError()
|
||||
}
|
||||
startInFlight = false
|
||||
activeOwner = nil
|
||||
throw error
|
||||
}
|
||||
#endif
|
||||
|
||||
let outputURL = try makeOutputURL()
|
||||
let settings: [String: Any] = [
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVSampleRateKey: 16_000,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
AVEncoderBitRateKey: 16_000
|
||||
]
|
||||
// Actor reentrancy: release/cancel may have ended this hold while the
|
||||
// blocking session activation was still in progress.
|
||||
guard activeOwner === owner, startInFlight else {
|
||||
sessionCoordinator.release(token)
|
||||
throw CancellationError()
|
||||
}
|
||||
startInFlight = false
|
||||
sessionToken = token
|
||||
|
||||
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
||||
audioRecorder.isMeteringEnabled = true
|
||||
audioRecorder.prepareToRecord()
|
||||
audioRecorder.record(forDuration: maxRecordingDuration)
|
||||
var outputURL: URL?
|
||||
do {
|
||||
let newURL = try makeOutputURL()
|
||||
outputURL = newURL
|
||||
let audioRecorder = try recorderFactory.makeRecorder(url: newURL)
|
||||
audioRecorder.isMeteringEnabled = true
|
||||
guard audioRecorder.prepareToRecord() else {
|
||||
throw RecorderError.failedToStartRecording
|
||||
}
|
||||
guard audioRecorder.record(forDuration: maxRecordingDuration) else {
|
||||
throw RecorderError.failedToStartRecording
|
||||
}
|
||||
|
||||
recorder = audioRecorder
|
||||
currentURL = outputURL
|
||||
return outputURL
|
||||
recorder = audioRecorder
|
||||
currentURL = newURL
|
||||
return newURL
|
||||
} catch {
|
||||
releaseSessionToken()
|
||||
recorder = nil
|
||||
currentURL = nil
|
||||
activeOwner = nil
|
||||
if let outputURL {
|
||||
try? FileManager.default.removeItem(at: outputURL)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func stopRecording() async -> URL? {
|
||||
guard let recorder, recorder.isRecording else {
|
||||
return currentURL
|
||||
func stopRecording(owner: RecordingOwner) async -> URL? {
|
||||
guard activeOwner === owner else { return nil }
|
||||
|
||||
// `finish()` can race a still-suspended start on a direct caller even
|
||||
// though the UI normally routes quick releases through cancel().
|
||||
if startInFlight {
|
||||
activeOwner = nil
|
||||
startInFlight = false
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let activeRecorder = recorder else {
|
||||
let sessionURL = currentURL
|
||||
releaseSessionToken()
|
||||
currentURL = nil
|
||||
activeOwner = nil
|
||||
return sessionURL
|
||||
}
|
||||
|
||||
let sessionURL = currentURL
|
||||
|
||||
try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000))
|
||||
|
||||
recorder.stop()
|
||||
|
||||
// A new session may have started during the sleep — don't touch its state
|
||||
if self.recorder === recorder {
|
||||
cleanupSession()
|
||||
self.recorder = nil
|
||||
currentURL = nil
|
||||
if activeRecorder.isRecording, paddingInterval > 0 {
|
||||
if let waitForStopPadding = testingHooks.waitForStopPadding {
|
||||
await waitForStopPadding(paddingInterval)
|
||||
} else {
|
||||
try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000))
|
||||
}
|
||||
}
|
||||
|
||||
// Cancellation or interruption may have run during the padding sleep.
|
||||
// Only the recorder whose stop began here may be finalized by it.
|
||||
guard activeOwner === owner,
|
||||
let recorder = self.recorder,
|
||||
recorder === activeRecorder
|
||||
else { return nil }
|
||||
|
||||
if activeRecorder.isRecording {
|
||||
activeRecorder.stop()
|
||||
}
|
||||
releaseSessionToken()
|
||||
self.recorder = nil
|
||||
currentURL = nil
|
||||
activeOwner = nil
|
||||
|
||||
return sessionURL
|
||||
}
|
||||
|
||||
func cancelRecording() {
|
||||
func cancelRecording(owner: RecordingOwner) async {
|
||||
guard activeOwner === owner else { return }
|
||||
|
||||
// Invalidate ownership before cleanup. An actor-reentrant start whose
|
||||
// session acquire resumes later will observe the mismatch and release
|
||||
// its token without opening the microphone.
|
||||
activeOwner = nil
|
||||
startInFlight = false
|
||||
if let recorder, recorder.isRecording {
|
||||
recorder.stop()
|
||||
}
|
||||
cleanupSession()
|
||||
releaseSessionToken()
|
||||
if let currentURL {
|
||||
try? FileManager.default.removeItem(at: currentURL)
|
||||
}
|
||||
@@ -124,14 +246,45 @@ actor VoiceRecorder {
|
||||
currentURL = nil
|
||||
}
|
||||
|
||||
/// The audio session was interrupted (call, Siri) or reconfigured: stop
|
||||
/// the recorder but keep `recorder`/`currentURL` so the caller's pending
|
||||
/// `stopRecording()` still returns the partial note.
|
||||
private func handleSessionInterruption(for owner: RecordingOwner) async {
|
||||
// A callback captured for a released token must never stop a newer
|
||||
// recording. Conversely, an interruption delivered while acquire is
|
||||
// still suspended invalidates that acquire before it can open the mic.
|
||||
guard activeOwner === owner else { return }
|
||||
if startInFlight {
|
||||
activeOwner = nil
|
||||
startInFlight = false
|
||||
return
|
||||
}
|
||||
startInFlight = false
|
||||
if let recorder, recorder.isRecording {
|
||||
recorder.stop()
|
||||
}
|
||||
releaseSessionToken()
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private static func hasSystemPermission() -> Bool {
|
||||
#if os(iOS)
|
||||
AVAudioSession.sharedInstance().recordPermission == .granted
|
||||
#elseif os(macOS)
|
||||
AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
||||
#else
|
||||
true
|
||||
#endif
|
||||
}
|
||||
|
||||
private func makeOutputURL() throws -> URL {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||
let fileName = "voice_\(formatter.string(from: Date())).m4a"
|
||||
let fileName = "voice_\(formatter.string(from: Date()))_\(UUID().uuidString).m4a"
|
||||
|
||||
let baseDirectory = try applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true)
|
||||
let baseDirectory = try outputDirectory
|
||||
?? applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: nil)
|
||||
return baseDirectory.appendingPathComponent(fileName)
|
||||
}
|
||||
@@ -146,9 +299,11 @@ actor VoiceRecorder {
|
||||
#endif
|
||||
}
|
||||
|
||||
private func cleanupSession() {
|
||||
#if os(iOS)
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
#endif
|
||||
/// Fire-and-forget: the coordinator hops the blocking deactivation IPC
|
||||
/// onto its own queue.
|
||||
private func releaseSessionToken() {
|
||||
guard let token = sessionToken else { return }
|
||||
sessionToken = nil
|
||||
sessionCoordinator.release(token)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user