407 lines
14 KiB
Swift
407 lines
14 KiB
Swift
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
|
|
}
|
|
}
|