import AVFoundation import CoreMedia import Foundation @objc(RecordPermission) public class RecordPermission: NSObject { @objc public static func requestRecordPermission(_ callback: @escaping (Bool) -> Void) { AVAudioSession.sharedInstance().requestRecordPermission { granted in callback(granted) } } } @objc(AudioRecorderManager) public class AudioRecorderManager: NSObject { @objc public static let shared = AudioRecorderManager() private enum State { case idle, recording, stopped } private var state: State = .idle private let processingQueue = DispatchQueue(label: "com.xhjcn.lock.lite.audio.processing.queue") private var recordingCallback: ((Data?, Bool, String) -> Void)? private var audioEngine: AVAudioEngine? private var audioConverter: AVAudioConverter? private var outputBuffer: AVAudioCompressedBuffer? private var flvMuxer: FLVMuxer? @objc public func initAudio(_ callback: @escaping (Bool, String) -> Void) { callback(true, "init success") } @objc public func startRecord(_ callback: @escaping (Data?, Bool, String) -> Void) { processingQueue.async { [weak self] in guard let self = self else { return } guard self.state == .idle || self.state == .stopped else { self.dispatchError(callback: callback, message: "Recording is already in progress.") return } self.recordingCallback = callback if AVAudioSession.sharedInstance().recordPermission != .granted { self.dispatchError(callback: callback, message: "Microphone permission not granted.") return } do { try self.setupAudioSession() try self.setupAudioEngine() guard let converter = self.audioConverter else { self.cleanUp() self.dispatchError(callback: callback, message: "Failed to setup audio engine components.") return } self.flvMuxer = FLVMuxer(sampleRate: converter.outputFormat.sampleRate, channels: Double(converter.outputFormat.channelCount)) var initialData = Data() let header = self.flvMuxer!.getHeader() initialData.append(header) let metaTag = self.flvMuxer!.getMetaTag() initialData.append(metaTag) let audioConfigTag = self.flvMuxer!.getAudioSpecificConfigTag(config: nil) initialData.append(audioConfigTag) let inputNode = self.audioEngine!.inputNode let inputFormat = inputNode.outputFormat(forBus: 0) inputNode.installTap(onBus: 0, bufferSize: 1024, format: inputFormat) { [weak self] (buffer, _) in self?.processAudioBuffer(buffer) } DispatchQueue.main.async { self.dispatchData(initialData) DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { self.processingQueue.async { [weak self] in guard let self = self, let engine = self.audioEngine else { return } do { engine.prepare() try engine.start() self.state = .recording self.dispatchSuccess(message: "Recording started successfully.") } catch { self.cleanUp() self.dispatchError(callback: self.recordingCallback ?? {_,_,_ in }, message: "Failed to start engine: \(error.localizedDescription)") } } } } } catch { self.cleanUp() self.dispatchError(callback: callback, message: "Failed to start recording: \(error.localizedDescription)") } } } @objc public func stopRecord(_ callback: @escaping (Bool, String, String) -> Void) { processingQueue.async { [weak self] in guard let self = self else { return } guard self.state == .recording else { callback(false, "Not recording.", "") return } self.cleanUp() self.state = .stopped callback(true, "Recording stopped.", "") } } @objc public func releaseRecord(_ callback: @escaping (Bool, String) -> Void) { processingQueue.async { [weak self] in guard let self = self else { return } self.cleanUp() self.state = .idle callback(true, "Record released.") } } private func setupAudioSession() throws { let audioSession = AVAudioSession.sharedInstance() try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker, .allowBluetooth]) try audioSession.setActive(true) } private func setupAudioEngine() throws { audioEngine = AVAudioEngine() guard let engine = audioEngine else { throw NSError(domain: "AudioRecorderManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to create AVAudioEngine"]) } let inputNode = engine.inputNode let inputFormat = inputNode.outputFormat(forBus: 0) guard inputFormat.sampleRate > 0 else { throw NSError(domain: "AudioRecorderManager", code: -2, userInfo: [NSLocalizedDescriptionKey: "Input node has an invalid sample rate."]) } let outputFormatSettings: [String: Any] = [ AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: 16000, AVNumberOfChannelsKey: inputFormat.channelCount, AVEncoderBitRateKey: 48000, AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue ] guard let outputFormat = AVAudioFormat(settings: outputFormatSettings) else { throw NSError(domain: "AudioRecorderManager", code: -3, userInfo: [NSLocalizedDescriptionKey: "Failed to create output format."]) } guard let converter = AVAudioConverter(from: inputFormat, to: outputFormat) else { throw NSError(domain: "AudioRecorderManager", code: -4, userInfo: [NSLocalizedDescriptionKey: "Failed to create audio converter."]) } converter.bitRate = 48000 converter.bitRateStrategy = AVAudioBitRateStrategy_Constant self.audioConverter = converter self.outputBuffer = AVAudioCompressedBuffer( format: converter.outputFormat, packetCapacity: 1, maximumPacketSize: converter.maximumOutputPacketSize ) } private func processAudioBuffer(_ pcmBuffer: AVAudioPCMBuffer) { guard state == .recording, let converter = self.audioConverter, let outputBuffer = self.outputBuffer, let flvMuxer = self.flvMuxer else { return } outputBuffer.packetCount = 0 outputBuffer.byteLength = 0 var error: NSError? let status = converter.convert(to: outputBuffer, error: &error) { _, outStatus in outStatus.pointee = .haveData return pcmBuffer } if status == .haveData, outputBuffer.byteLength > 0 { let aacData = Data(bytes: outputBuffer.data, count: Int(outputBuffer.byteLength)) let aacTag = flvMuxer.getAACTag(data: aacData) dispatchData(aacTag) } else if let error = error { processingQueue.async { [weak self] in guard let self = self, self.state == .recording else { return } let errorMessage = "AAC Conversion Error: \(error.localizedDescription)" self.dispatchError(callback: self.recordingCallback ?? { _, _, _ in }, message: errorMessage) self.cleanUp() self.state = .stopped } } } private func cleanUp() { audioEngine?.stop() audioEngine?.inputNode.removeTap(onBus: 0) try? AVAudioSession.sharedInstance().setActive(false) audioEngine = nil audioConverter = nil outputBuffer = nil flvMuxer = nil recordingCallback = nil } private func dispatchData(_ data: Data) { DispatchQueue.main.async { [weak self] in self?.recordingCallback?(data, false, "") } } private func dispatchError(callback: @escaping (Data?, Bool, String) -> Void, message: String) { DispatchQueue.main.async { callback(nil, false, message) } } private func dispatchSuccess(message: String) { DispatchQueue.main.async { [weak self] in self?.recordingCallback?(nil, true, message) } } } private class FLVMuxer { private var startTime: CFTimeInterval private var sampleRate: Double private var channels: Double init(sampleRate: Double = 44100.0, channels: Double = 1.0) { self.startTime = CACurrentMediaTime() self.sampleRate = sampleRate self.channels = channels } func getHeader() -> Data { return Data([ 0x46, 0x4C, 0x56, 0x01, 0x04, 0x00, 0x00, 0x00, 0x09, 0x00, 0x00, 0x00, 0x00 ]) } func getMetaTag() -> Data { var scriptData = Data() scriptData.append(amfString("onMetaData")) let properties: [(String, Any)] = [ ("audiocodecid", 10.0), ("audiosamplerate", self.sampleRate), ("audiochannels", self.channels), ("stereo", self.channels > 1), ("creator", "starlock-record") ] scriptData.append(amfECMAArray(properties)) let tag = createTag(type: .script, timestamp: 0, body: scriptData) return tag } func getAudioSpecificConfigTag(config: Data?) -> Data { let audioObjectType: UInt8 = 2 guard let frequencyIndex = getSamplingFrequencyIndex(sampleRate: self.sampleRate) else { let defaultConfig = Data([0x12, 0x08]) let body = Data([0xAE, 0x00]) + defaultConfig return createTag(type: .audio, timestamp: 0, body: body) } let channelConfig = UInt8(self.channels) var configByte1: UInt8 = 0 configByte1 |= (audioObjectType << 3) configByte1 |= (frequencyIndex >> 1) var configByte2: UInt8 = 0 configByte2 |= ((frequencyIndex & 0x01) << 7) configByte2 |= (channelConfig << 3) let audioSpecificConfig = Data([configByte1, configByte2]) let body = Data([0xAE, 0x00]) + audioSpecificConfig let tag = createTag(type: .audio, timestamp: 0, body: body) return tag } func getAACTag(data: Data) -> Data { let elapsed = CACurrentMediaTime() - self.startTime let timestamp = UInt32(elapsed * 1000) let body = Data([0xAE, 0x01]) + data return createTag(type: .audio, timestamp: timestamp, body: body) } private func createTag(type: TagType, timestamp: UInt32, body: Data) -> Data { let dataSize = UInt32(body.count) let tagHeaderSize: UInt32 = 11 var header = Data(capacity: Int(tagHeaderSize)) header.append(type.rawValue) header.append(UInt8((dataSize >> 16) & 0xFF)) header.append(UInt8((dataSize >> 8) & 0xFF)) header.append(UInt8(dataSize & 0xFF)) header.append(UInt8((timestamp >> 16) & 0xFF)) header.append(UInt8((timestamp >> 8) & 0xFF)) header.append(UInt8(timestamp & 0xFF)) header.append(UInt8((timestamp >> 24) & 0xFF)) header.append(contentsOf: [0x00, 0x00, 0x00]) var completeTag = Data() completeTag.append(header) completeTag.append(body) let fullTagSize = UInt32(header.count + body.count) completeTag.append(contentsOf: fullTagSize.bytes) return completeTag } private func getSamplingFrequencyIndex(sampleRate: Double) -> UInt8? { let rates: [Double: UInt8] = [ 96000: 0, 88200: 1, 64000: 2, 48000: 3, 44100: 4, 32000: 5, 24000: 6, 22050: 7, 16000: 8, 12000: 9, 11025: 10, 8000: 11, 7350: 12 ] return rates[sampleRate] } private enum TagType: UInt8 { case audio = 0x08, video = 0x09, script = 0x12 } private func amfString(_ str: String) -> Data { var data = Data([0x02]) let bytes = [UInt8](str.utf8) data.append(UInt8((bytes.count >> 8) & 0xFF)) data.append(UInt8(bytes.count & 0xFF)) data.append(contentsOf: bytes) return data } private func amfNumber(_ val: Double) -> Data { var data = Data([0x00]) data.append(contentsOf: val.bitPattern.bytes) return data } private func amfBool(_ val: Bool) -> Data { return Data([0x01, val ? 0x01 : 0x00]) } private func amfECMAArray(_ properties: [(String, Any)]) -> Data { var data = Data([0x08]) let countBytes = UInt32(properties.count).bytes data.append(contentsOf: countBytes) for (key, value) in properties { data.append(amfString(key).dropFirst(1)) if let numValue = value as? Double { data.append(amfNumber(numValue)) } else if let boolValue = value as? Bool { data.append(amfBool(boolValue)) } else if let stringValue = value as? String { data.append(amfString(stringValue)) } } data.append(contentsOf: [0x00, 0x00, 0x09]) return data } } fileprivate extension FixedWidthInteger { var bytes: [UInt8] { withUnsafeBytes(of: self.bigEndian) { Array($0) } } } @objc(UTSConversionHelper) public class UTSConversionHelper: NSObject { @objc public static func dataToNSArray(_ data: Data) -> NSArray { let byteArray = [UInt8](data) let nsArray = NSMutableArray() for byte in byteArray { nsArray.add(NSNumber(value: byte)) } return nsArray } }