import AudioToolbox import Foundation import OSLog /// Encodes mono 48 kHz Float32 into 20 ms Opus packets with AudioToolbox. /// Not thread-safe; the caller feeds it from one thread. final class TalkbackEncoder { static let sampleRate = 48_000.0 static let frameSamples = 960 // 20 ms private var converter: AudioConverterRef? private var pending: [Float] = [] private var timestamp: UInt32 = .random(in: 0...UInt32.max) private let log = Logger(subsystem: "studio.zielke.musubi", category: "intercom") init?(bitRate: UInt32 = 32_000) { var input = AudioStreamBasicDescription( mSampleRate: Self.sampleRate, mFormatID: kAudioFormatLinearPCM, mFormatFlags: kAudioFormatFlagIsFloat | kAudioFormatFlagIsPacked, mBytesPerPacket: 4, mFramesPerPacket: 1, mBytesPerFrame: 4, mChannelsPerFrame: 1, mBitsPerChannel: 32, mReserved: 0) var output = AudioStreamBasicDescription( mSampleRate: Self.sampleRate, mFormatID: kAudioFormatOpus, mFormatFlags: 0, mBytesPerPacket: 0, mFramesPerPacket: UInt32(Self.frameSamples), mBytesPerFrame: 0, mChannelsPerFrame: 1, mBitsPerChannel: 0, mReserved: 0) let status = AudioConverterNew(&input, &output, &converter) guard status == noErr, let converter else { log.error("Opus encoder unavailable: \(status)") return nil } var rate = bitRate AudioConverterSetProperty(converter, kAudioConverterEncodeBitRate, UInt32(MemoryLayout.size), &rate) } deinit { if let converter { AudioConverterDispose(converter) } } /// Appends mono samples at 48 kHz; returns (packet, RTP timestamp) for every full 20 ms. func append(_ samples: UnsafeBufferPointer) -> [([UInt8], UInt32)] { pending += samples var out: [([UInt8], UInt32)] = [] while pending.count >= Self.frameSamples { let frame = Array(pending.prefix(Self.frameSamples)) pending.removeFirst(Self.frameSamples) if let packet = encode(frame) { out.append((packet, timestamp)) } timestamp &+= UInt32(Self.frameSamples) } return out } /// Input for one converter call; owns stable memory the converter may read after the callback returns. private final class Feed { let samples: UnsafeMutablePointer let count: Int var consumed = false init(_ frame: [Float]) { count = frame.count samples = .allocate(capacity: count) samples.initialize(from: frame, count: count) } deinit { samples.deallocate() } } private func encode(_ frame: [Float]) -> [UInt8]? { guard let converter else { return nil } let feed = Feed(frame) var packetSize: UInt32 = 1500 var outBytes = [UInt8](repeating: 0, count: Int(packetSize)) var packets: UInt32 = 1 var description = AudioStreamPacketDescription() let status = withExtendedLifetime(feed) { outBytes.withUnsafeMutableBytes { raw -> OSStatus in var list = AudioBufferList(mNumberBuffers: 1, mBuffers: AudioBuffer(mNumberChannels: 1, mDataByteSize: packetSize, mData: raw.baseAddress)) let status = AudioConverterFillComplexBuffer(converter, { _, ioPackets, ioData, _, userData in let feed = Unmanaged.fromOpaque(userData!).takeUnretainedValue() guard !feed.consumed else { ioPackets.pointee = 0 return 1 // no more data for this call } feed.consumed = true ioPackets.pointee = UInt32(feed.count) ioData.pointee.mBuffers.mData = UnsafeMutableRawPointer(feed.samples) ioData.pointee.mBuffers.mDataByteSize = UInt32(feed.count * 4) ioData.pointee.mBuffers.mNumberChannels = 1 return noErr }, Unmanaged.passUnretained(feed).toOpaque(), &packets, &list, &description) packetSize = list.mBuffers.mDataByteSize return status } } guard (status == noErr || status == 1), packets == 1, packetSize > 0 else { return nil } return Array(outBytes.prefix(Int(packetSize))) } }