mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 06:45:18 +00:00
- Reduce sample rate from 44.1 kHz to 16 kHz (telephony standard) - Lower bitrate from 32 kbps to 20 kbps - Results in ~37% file size reduction (~150 KB/min vs 240 KB/min) - Increases max voice note length from 4.4 to 7 minutes over 1 MiB BLE limit - Maintains excellent voice quality using native AAC-LC codec
170 lines
5.7 KiB
Swift
170 lines
5.7 KiB
Swift
import Foundation
|
|
import AVFoundation
|
|
|
|
/// Manages audio capture for mesh voice notes with predictable encoding settings.
|
|
/// Recording runs on an internal serial queue to avoid AVAudioSession contention.
|
|
final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
|
enum RecorderError: Error {
|
|
case microphoneAccessDenied
|
|
case recorderInitializationFailed
|
|
case recordingInProgress
|
|
}
|
|
|
|
static let shared = VoiceRecorder()
|
|
|
|
private let queue = DispatchQueue(label: "com.bitchat.voice-recorder")
|
|
private let paddingInterval: TimeInterval = 0.5
|
|
|
|
private var recorder: AVAudioRecorder?
|
|
private var currentURL: URL?
|
|
private var stopWorkItem: DispatchWorkItem?
|
|
|
|
private override init() {
|
|
super.init()
|
|
}
|
|
|
|
// MARK: - Permissions
|
|
|
|
@discardableResult
|
|
func requestPermission() async -> Bool {
|
|
#if os(iOS)
|
|
return await withCheckedContinuation { continuation in
|
|
AVAudioSession.sharedInstance().requestRecordPermission { granted in
|
|
continuation.resume(returning: granted)
|
|
}
|
|
}
|
|
#elseif os(macOS)
|
|
return await withCheckedContinuation { continuation in
|
|
AVCaptureDevice.requestAccess(for: .audio) { granted in
|
|
continuation.resume(returning: granted)
|
|
}
|
|
}
|
|
#else
|
|
return true
|
|
#endif
|
|
}
|
|
|
|
// MARK: - Recording Lifecycle
|
|
|
|
func startRecording() throws -> URL {
|
|
try queue.sync {
|
|
if recorder?.isRecording == true {
|
|
throw RecorderError.recordingInProgress
|
|
}
|
|
|
|
#if os(iOS)
|
|
let session = AVAudioSession.sharedInstance()
|
|
guard session.recordPermission == .granted else {
|
|
throw RecorderError.microphoneAccessDenied
|
|
}
|
|
try session.setCategory(
|
|
.playAndRecord,
|
|
mode: .default,
|
|
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
|
|
)
|
|
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
|
#endif
|
|
#if os(macOS)
|
|
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
|
|
throw RecorderError.microphoneAccessDenied
|
|
}
|
|
#endif
|
|
|
|
let outputURL = try makeOutputURL()
|
|
let settings: [String: Any] = [
|
|
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
|
AVSampleRateKey: 16_000,
|
|
AVNumberOfChannelsKey: 1,
|
|
AVEncoderBitRateKey: 20_000
|
|
]
|
|
|
|
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
|
audioRecorder.delegate = self
|
|
audioRecorder.isMeteringEnabled = true
|
|
audioRecorder.prepareToRecord()
|
|
audioRecorder.record()
|
|
|
|
recorder = audioRecorder
|
|
currentURL = outputURL
|
|
stopWorkItem?.cancel()
|
|
stopWorkItem = nil
|
|
return outputURL
|
|
}
|
|
}
|
|
|
|
func stopRecording(completion: @escaping (URL?) -> Void) {
|
|
queue.async { [weak self] in
|
|
guard let self = self, let recorder = self.recorder, recorder.isRecording else {
|
|
completion(self?.currentURL)
|
|
return
|
|
}
|
|
|
|
let item = DispatchWorkItem { [weak self] in
|
|
guard let self = self else { return }
|
|
recorder.stop()
|
|
self.cleanupSession()
|
|
let url = self.currentURL
|
|
self.recorder = nil
|
|
self.currentURL = url
|
|
completion(url)
|
|
}
|
|
self.stopWorkItem = item
|
|
self.queue.asyncAfter(deadline: .now() + self.paddingInterval, execute: item)
|
|
}
|
|
}
|
|
|
|
func cancelRecording() {
|
|
queue.async { [weak self] in
|
|
guard let self = self else { return }
|
|
self.stopWorkItem?.cancel()
|
|
self.stopWorkItem = nil
|
|
if let recorder = self.recorder, recorder.isRecording {
|
|
recorder.stop()
|
|
}
|
|
self.cleanupSession()
|
|
if let url = self.currentURL {
|
|
try? FileManager.default.removeItem(at: url)
|
|
}
|
|
self.recorder = nil
|
|
self.currentURL = nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Metering
|
|
|
|
func currentAveragePower() -> Float {
|
|
queue.sync {
|
|
recorder?.updateMeters()
|
|
return recorder?.averagePower(forChannel: 0) ?? -160
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func makeOutputURL() throws -> URL {
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
|
let fileName = "voice_\(formatter.string(from: Date())).m4a"
|
|
|
|
let baseDirectory = try applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: nil)
|
|
return baseDirectory.appendingPathComponent(fileName)
|
|
}
|
|
|
|
private func applicationFilesDirectory() throws -> URL {
|
|
#if os(iOS)
|
|
return try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
|
.appendingPathComponent("files", isDirectory: true)
|
|
#else
|
|
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
|
return base.appendingPathComponent("files", isDirectory: true)
|
|
#endif
|
|
}
|
|
|
|
private func cleanupSession() {
|
|
#if os(iOS)
|
|
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
|
#endif
|
|
}
|
|
}
|