Files
bitchat/bitchat/Features/voice/VoiceRecorder.swift
T
jackandislam d7ca99ae5d Optimize voice note codec to 16 kHz / 20 kbps for smaller file sizes
- 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
2025-10-15 17:37:19 +01:00

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
}
}