File size: 15,057 Bytes
b8370f5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 | import AVFAudio
#if os(macOS)
import AVFoundation
#endif
import Foundation
import Observation
import OpenClawKit
private let voiceNoteMaximumDurationSeconds: TimeInterval = 180
/// Audio capture operations driven by the voice-note recorder state machine.
@MainActor
public protocol VoiceNoteAudioCapture: AnyObject {
/// Requests microphone access when recording is first used.
func requestPermission() async -> Bool
/// Starts writing an audio recording to the supplied URL.
func start(url: URL) throws
/// Stops capture and returns the recorded duration in seconds.
func stop() -> TimeInterval
/// Stops and discards the active capture.
func cancel()
/// Reports capture loss after a recording has started.
func setFailureHandler(_ handler: @escaping @MainActor () -> Void)
/// Latest capture level in 0...1 for the live waveform, when supported.
func currentLevel() -> Double?
}
extension VoiceNoteAudioCapture {
/// Metering is a UI nicety; test captures may skip it.
public func currentLevel() -> Double? {
nil
}
}
/// A completed voice-note recording ready to stage as a chat attachment.
public struct OpenClawVoiceNoteRecording: Equatable, Sendable {
public let fileURL: URL
public let durationSeconds: TimeInterval
public init(fileURL: URL, durationSeconds: TimeInterval) {
self.fileURL = fileURL
self.durationSeconds = durationSeconds
}
}
/// Main-actor voice-note recorder with explicit permission and capture states.
@MainActor
@Observable
public final class OpenClawVoiceNoteRecorder {
public enum State: Equatable {
case idle
case requestingPermission
case recording(startedAt: Date, fileURL: URL)
case finished(recording: OpenClawVoiceNoteRecording)
case staging(recording: OpenClawVoiceNoteRecording)
case failed(message: String)
}
public static let maximumDurationSeconds = voiceNoteMaximumDurationSeconds
public private(set) var state: State = .idle
public private(set) var elapsedSeconds: TimeInterval = 0
/// Live capture level in 0...1 while recording; drives the recording waveform.
public private(set) var level: Double = 0
@ObservationIgnored public var onRecordingActiveChanged: (@MainActor (Bool) -> Void)?
@ObservationIgnored private let capture: any VoiceNoteAudioCapture
@ObservationIgnored private let durationLimit: TimeInterval
@ObservationIgnored private let timerIntervalNanoseconds: UInt64
@ObservationIgnored private let now: () -> Date
@ObservationIgnored private var timerTask: Task<Void, Never>?
@ObservationIgnored private var captureAdmissionHandler: @MainActor () -> Bool = { true }
/// Creates a recorder backed by the system audio recorder.
public convenience init() {
self.init(capture: OpenClawVoiceNoteAudioCapture())
}
/// Creates a recorder with an injectable audio capture seam.
public init(
capture: any VoiceNoteAudioCapture,
durationLimit: TimeInterval = OpenClawVoiceNoteRecorder.maximumDurationSeconds,
timerIntervalNanoseconds: UInt64 = 100_000_000,
now: @escaping () -> Date = Date.init)
{
self.capture = capture
self.durationLimit = durationLimit
self.timerIntervalNanoseconds = timerIntervalNanoseconds
self.now = now
self.capture.setFailureHandler { [weak self] in
self?.captureFailed()
}
}
deinit {
self.timerTask?.cancel()
}
public var isRecording: Bool {
if case .recording = self.state { return true }
return false
}
public var isRequestingPermission: Bool {
self.state == .requestingPermission
}
/// True from permission request until the completed recording is staged.
public var ownsPendingChatAttachment: Bool {
switch self.state {
case .requestingPermission, .recording, .finished, .staging:
true
case .idle, .failed:
false
}
}
/// Installs the app's synchronous microphone-ownership gate. The check and
/// transition to requesting permission run in one MainActor turn.
public func setCaptureAdmissionHandler(_ handler: @escaping @MainActor () -> Bool) {
self.captureAdmissionHandler = handler
}
public var errorMessage: String? {
guard case let .failed(message) = self.state else { return nil }
return message
}
public var completedRecording: OpenClawVoiceNoteRecording? {
guard case let .finished(recording) = self.state else { return nil }
return recording
}
/// Claims a finished recording exactly once while its captured chat stages it.
func claimCompletedRecording() -> OpenClawVoiceNoteRecording? {
guard case let .finished(recording) = self.state else { return nil }
self.state = .staging(recording: recording)
return recording
}
/// Releases chat ownership after the claimed recording was consumed.
func completeStaging(_ recording: OpenClawVoiceNoteRecording) {
guard self.state == .staging(recording: recording) else { return }
self.state = .idle
self.elapsedSeconds = 0
}
/// Requests permission if needed and starts a new recording.
@discardableResult
public func start() async -> Bool {
guard self.state == .idle || self.errorMessage != nil else { return false }
guard self.captureAdmissionHandler() else {
self.fail(message: String(localized: "Another feature is using the microphone."))
return false
}
self.elapsedSeconds = 0
self.state = .requestingPermission
guard await self.capture.requestPermission() else {
self.fail(message: String(localized: "Microphone access is required. Enable it in Settings."))
return false
}
guard self.state == .requestingPermission else { return false }
let fileURL = self.makeTemporaryFileURL()
self.onRecordingActiveChanged?(true)
do {
try self.capture.start(url: fileURL)
} catch {
try? FileManager.default.removeItem(at: fileURL)
self.capture.cancel()
self.onRecordingActiveChanged?(false)
self.fail(message: String(
format: String(localized: "Could not start recording: %@"),
error.localizedDescription))
return false
}
self.state = .recording(startedAt: self.now(), fileURL: fileURL)
self.startTimer()
return true
}
/// Finishes the active recording and publishes its attachment handoff.
@discardableResult
public func finish() -> OpenClawVoiceNoteRecording? {
guard case let .recording(_, fileURL) = self.state else { return nil }
self.timerTask?.cancel()
self.timerTask = nil
let duration = max(0, self.capture.stop())
let recording = OpenClawVoiceNoteRecording(fileURL: fileURL, durationSeconds: duration)
self.elapsedSeconds = duration
self.level = 0
self.state = .finished(recording: recording)
self.onRecordingActiveChanged?(false)
return recording
}
/// Cancels permission or capture and removes any temporary audio file.
public func cancel() {
// The chat view model owns the file after claiming the handoff.
if case .staging = self.state { return }
let fileURL: URL? = switch self.state {
case let .recording(_, fileURL):
fileURL
case let .finished(recording):
recording.fileURL
default:
nil
}
self.timerTask?.cancel()
self.timerTask = nil
self.capture.cancel()
if let fileURL {
try? FileManager.default.removeItem(at: fileURL)
}
let wasRecording = self.isRecording
self.elapsedSeconds = 0
self.level = 0
self.state = .idle
if wasRecording {
self.onRecordingActiveChanged?(false)
}
}
private func startTimer() {
self.timerTask?.cancel()
self.timerTask = Task { [weak self] in
guard let self else { return }
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: self.timerIntervalNanoseconds)
guard !Task.isCancelled else { return }
guard case let .recording(startedAt, _) = self.state else { return }
self.elapsedSeconds = max(0, self.now().timeIntervalSince(startedAt))
if let captureLevel = self.capture.currentLevel() {
// Light smoothing keeps the 10 Hz meter poll from stepping visibly.
self.level = (self.level * 0.55) + (captureLevel * 0.45)
}
if self.elapsedSeconds >= self.durationLimit {
self.finish()
return
}
}
}
}
private func fail(message: String) {
self.timerTask?.cancel()
self.timerTask = nil
let wasRecording = self.isRecording
self.elapsedSeconds = 0
self.level = 0
self.state = .failed(message: message)
if wasRecording {
self.onRecordingActiveChanged?(false)
}
}
private func captureFailed() {
guard case let .recording(_, fileURL) = self.state else { return }
self.capture.cancel()
try? FileManager.default.removeItem(at: fileURL)
self.fail(message: String(localized: "Recording was interrupted. Try again."))
}
private func makeTemporaryFileURL() -> URL {
FileManager.default.temporaryDirectory
.appendingPathComponent("voice-note-\(UUID().uuidString).m4a")
}
}
/// AVAudioRecorder-backed AAC voice-note capture.
@MainActor
public final class OpenClawVoiceNoteAudioCapture: NSObject, VoiceNoteAudioCapture, AVAudioRecorderDelegate {
private var recorder: AVAudioRecorder?
private var ownsAudioSession = false
private var failureHandler: (@MainActor () -> Void)?
override public init() {
super.init()
#if os(iOS)
NotificationCenter.default.addObserver(
self,
selector: #selector(self.audioSessionInterrupted(_:)),
name: AVAudioSession.interruptionNotification,
object: AVAudioSession.sharedInstance())
#endif
}
deinit {
NotificationCenter.default.removeObserver(self)
}
public func setFailureHandler(_ handler: @escaping @MainActor () -> Void) {
self.failureHandler = handler
}
public func requestPermission() async -> Bool {
#if os(iOS)
switch AVAudioApplication.shared.recordPermission {
case .granted:
return true
case .denied:
return false
case .undetermined:
return await withCheckedContinuation { continuation in
AVAudioApplication.requestRecordPermission { granted in
continuation.resume(returning: granted)
}
}
@unknown default:
return false
}
#elseif os(macOS)
switch AVCaptureDevice.authorizationStatus(for: .audio) {
case .authorized:
return true
case .denied, .restricted:
return false
case .notDetermined:
return await AVCaptureDevice.requestAccess(for: .audio)
@unknown default:
return false
}
#else
return false
#endif
}
public func start(url: URL) throws {
do {
#if os(iOS)
let session = AVAudioSession.sharedInstance()
try session.setCategory(.record, mode: .measurement, options: [.allowBluetoothHFP])
try session.setActive(true)
self.ownsAudioSession = true
#endif
let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 24000,
AVNumberOfChannelsKey: 1,
AVEncoderBitRateKey: 32000,
AVEncoderAudioQualityKey: AVAudioQuality.medium.rawValue,
]
let recorder = try AVAudioRecorder(url: url, settings: settings)
recorder.delegate = self
recorder.isMeteringEnabled = true
guard recorder.record() else {
throw NSError(
domain: "OpenClawVoiceNoteAudioCapture",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Audio recorder refused to start"])
}
self.recorder = recorder
} catch {
self.deactivateAudioSession()
throw error
}
}
public func stop() -> TimeInterval {
guard let recorder = self.recorder else { return 0 }
let duration = recorder.currentTime
recorder.stop()
self.recorder = nil
self.deactivateAudioSession()
return duration
}
public func cancel() {
self.recorder?.stop()
self.recorder = nil
self.deactivateAudioSession()
}
public func currentLevel() -> Double? {
guard let recorder, recorder.isRecording else { return nil }
recorder.updateMeters()
return TalkAudioLevel.normalized(decibels: Double(recorder.averagePower(forChannel: 0)))
}
public nonisolated func audioRecorderEncodeErrorDidOccur(_ recorder: AVAudioRecorder, error: (any Error)?) {
Task { @MainActor [weak self] in
self?.captureDidFail()
}
}
#if os(iOS)
@objc private nonisolated func audioSessionInterrupted(_ notification: Notification) {
guard
let rawType = notification.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
rawType == AVAudioSession.InterruptionType.began.rawValue
else { return }
Task { @MainActor [weak self] in
self?.captureDidFail()
}
}
#endif
private func captureDidFail() {
guard self.recorder != nil else { return }
self.recorder?.stop()
self.recorder = nil
self.deactivateAudioSession()
self.failureHandler?()
}
private func deactivateAudioSession() {
#if os(iOS)
guard self.ownsAudioSession else { return }
self.ownsAudioSession = false
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
#endif
}
}
func openClawVoiceNoteDurationLabel(_ durationSeconds: TimeInterval) -> String {
guard durationSeconds.isFinite else { return "0:00" }
let boundedDuration = min(
max(0, durationSeconds),
voiceNoteMaximumDurationSeconds)
let totalSeconds = Int(boundedDuration)
return String(format: "%d:%02d", totalSeconds / 60, totalSeconds % 60)
}
|