1// VoiceRecorder.swift
2// SophaxChat
3//
4// Hold-to-record PTT voice message recorder using AVAudioRecorder.
5// Records AAC-LC M4A at 16 kHz / 24 kbps (~4 KB/s) targeting ≤ 512 KB.
6
7import AVFoundation
8import SwiftUI
9
10@MainActor
11final class VoiceRecorder: NSObject, ObservableObject {
12
13 @Published var isRecording: Bool = false
14
15 private var recorder: AVAudioRecorder?
16 private var startTime: Date?
17 private var tempURL: URL?
18 private var completion: ((Data?, Double) -> Void)?
19
20 // MARK: - Public API
21
22 func start() {
23 guard !isRecording else { return }
24
25 let url = FileManager.default.temporaryDirectory
26 .appendingPathComponent(UUID().uuidString)
27 .appendingPathExtension("m4a")
28 tempURL = url
29
30 let settings: [String: Any] = [
31 AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
32 AVSampleRateKey: 16_000,
33 AVNumberOfChannelsKey: 1,
34 AVEncoderAudioQualityKey: AVAudioQuality.medium.rawValue,
35 AVEncoderBitRateKey: 24_000
36 ]
37
38 do {
39 #if !targetEnvironment(macCatalyst)
40 try AVAudioSession.sharedInstance().setCategory(.playAndRecord,
41 mode: .default,
42 options: [.defaultToSpeaker, .allowBluetoothA2DP])
43 try AVAudioSession.sharedInstance().setActive(true)
44 #endif
45 recorder = try AVAudioRecorder(url: url, settings: settings)
46 recorder?.delegate = self
47 recorder?.record()
48 startTime = Date()
49 isRecording = true
50 } catch {
51 isRecording = false
52 }
53 }
54
55 /// Stops recording and returns the audio data + duration via the completion handler.
56 /// Called on the main actor; completion is also delivered on main actor.
57 func stop(completion: @escaping (Data?, Double) -> Void) {
58 guard isRecording, let rec = recorder, let start = startTime else {
59 completion(nil, 0)
60 return
61 }
62 self.completion = completion
63 let duration = Date().timeIntervalSince(start)
64 rec.stop()
65 // Delegate -audioRecorderDidFinishRecording fires next; we deliver there.
66 // Store duration so delegate can use it.
67 storedDuration = duration
68 isRecording = false
69 }
70
71 // MARK: - Private
72
73 private var storedDuration: Double = 0
74
75 private func deliver() {
76 defer {
77 recorder = nil
78 startTime = nil
79 storedDuration = 0
80 tempURL = nil
81 completion = nil
82 #if !targetEnvironment(macCatalyst)
83 try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
84 #endif
85 }
86
87 guard let url = tempURL, let cb = completion else { return }
88 let data = try? Data(contentsOf: url)
89 try? FileManager.default.removeItem(at: url)
90 cb(data, storedDuration)
91 }
92}
93
94// MARK: - AVAudioRecorderDelegate
95
96extension VoiceRecorder: AVAudioRecorderDelegate {
97 nonisolated func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) {
98 Task { @MainActor [weak self] in
99 self?.deliver()
100 }
101 }
102
103 nonisolated func audioRecorderEncodeErrorDidOccur(_ recorder: AVAudioRecorder, error: Error?) {
104 Task { @MainActor [weak self] in
105 self?.isRecording = false
106 self?.completion?(nil, 0)
107 self?.completion = nil
108 self?.recorder = nil
109 }
110 }
111}