You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
Musubi/Packages/MusubiKit/Sources/Intercom/Media/TalkbackEncoder.swift

91 lines
4.2 KiB

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<UInt32>.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<Float>) -> [([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<Float>
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<Feed>.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)))
}
}

Powered by TurnKey Linux.