mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 19:25:23 +00:00
Add BLE file transfer support and media UX
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
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: 44_100,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
AVEncoderBitRateKey: 32_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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user