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.

181 lines
5.2 KiB

import Foundation
#if canImport(JLAudioUnitKit)
import JLAudioUnitKit
#endif
/// Streaming OPUS decoder + encoder built on top of `JLAudioUnitKit`.
///
/// Mirrors the Android `audio/OpusStreamCodec.kt` (decoder side). The codec
/// pushes 16 kHz / 16-bit / mono / 20 ms PCM frames through the supplied
/// callback, matching the Android contract used by `RcspTranslationRuntime`.
///
/// Important: `JLOpusDecoder.opusDecoderInputData(_:)` accepts **one OPUS
/// packet at a time**. The headset advertises packets at 40 bytes for 16k/20ms;
/// pass the byte stream directly — the decoder does not buffer. If the
/// upstream feed combines multiple packets in one chunk, we slice along
/// `packetSize` to keep the decoder happy.
final class OpusStreamDecoder: NSObject {
let sampleRate: Int
let channels: Int
let packetSize: Int
private let onPcm: (Data) -> Void
private let onError: (Int, String?) -> Void
#if canImport(JLAudioUnitKit)
private var decoder: JLOpusDecoder?
#endif
private var started = false
private let lock = NSLock()
init(
channels: Int = 1,
packetSize: Int = 40,
sampleRate: Int = 16000,
onPcm: @escaping (Data) -> Void,
onError: @escaping (Int, String?) -> Void = { _, _ in }
) {
self.channels = channels
self.packetSize = packetSize
self.sampleRate = sampleRate
self.onPcm = onPcm
self.onError = onError
}
func start() {
lock.lock(); defer { lock.unlock() }
if started { return }
#if canImport(JLAudioUnitKit)
let format = JLOpusFormat.defaultFormats()
format.sampleRate = Int32(sampleRate)
format.channels = Int32(channels)
format.frameDuration = 20
// The headset's stream is the JieLi-flavored OPUS (no header). The
// demo's `defaultFormats` already disables the header for the headset
// direction, so we keep that default.
format.dataSize = Int32(packetSize)
decoder = JLOpusDecoder(decoder: format, delegate: self)
#endif
started = true
}
func stop() {
lock.lock(); defer { lock.unlock() }
if !started { return }
started = false
#if canImport(JLAudioUnitKit)
decoder?.opusOnRelease()
decoder = nil
#endif
}
/// Feed one or more OPUS packets concatenated. The decoder is fed one
/// `packetSize`-sized chunk at a time when the input length is a multiple
/// of `packetSize`; otherwise the entire buffer is forwarded as-is and
/// the decoder does its own resync.
func feedEncoded(_ data: Data) {
#if canImport(JLAudioUnitKit)
guard started, let dec = decoder else { return }
if packetSize > 0 && data.count > packetSize && data.count % packetSize == 0 {
var offset = 0
while offset < data.count {
let end = min(offset + packetSize, data.count)
dec.opusDecoderInputData(data.subdata(in: offset..<end))
offset = end
}
} else {
dec.opusDecoderInputData(data)
}
#endif
}
}
#if canImport(JLAudioUnitKit)
extension OpusStreamDecoder: JLOpusDecoderDelegate {
func opusDecoder(_ decoder: JLOpusDecoder, data: Data?, error: Error?) {
if let err = error {
onError((err as NSError).code, err.localizedDescription)
return
}
guard let pcm = data, !pcm.isEmpty else { return }
onPcm(pcm)
}
}
#endif
/// Streaming OPUS encoder used to repackage TTS PCM for the headset's
/// downlink path. Buffers PCM chunks and emits encoded packets through the
/// callback.
final class OpusStreamEncoder: NSObject {
let sampleRate: Int
let channels: Int
private let onEncoded: (Data) -> Void
private let onError: (Int, String?) -> Void
#if canImport(JLAudioUnitKit)
private var encoder: JLOpusEncoder?
#endif
private var started = false
private let lock = NSLock()
init(
channels: Int = 1,
sampleRate: Int = 16000,
onEncoded: @escaping (Data) -> Void,
onError: @escaping (Int, String?) -> Void = { _, _ in }
) {
self.sampleRate = sampleRate
self.channels = channels
self.onEncoded = onEncoded
self.onError = onError
}
func start() {
lock.lock(); defer { lock.unlock() }
if started { return }
#if canImport(JLAudioUnitKit)
// Use the JieLi default config (no OGG header — same as the headset
// expects, matches the demo's `JLOpusEncodeConfig.default()`).
let cfg = JLOpusEncodeConfig.default()
cfg.sampleRate = Int32(sampleRate)
cfg.channels = Int32(channels)
cfg.frameDuration = 20
encoder = JLOpusEncoder(format: cfg, delegate: self)
#endif
started = true
}
func stop() {
lock.lock(); defer { lock.unlock() }
if !started { return }
started = false
#if canImport(JLAudioUnitKit)
encoder?.opusOnRelease()
encoder = nil
#endif
}
/// Feed one or more raw PCM bytes (16-bit LE, `channels` interleaved when >1).
func feedPcm(_ pcm: Data) {
#if canImport(JLAudioUnitKit)
guard started, let enc = encoder else { return }
enc.opusEncode(pcm)
#endif
}
}
#if canImport(JLAudioUnitKit)
extension OpusStreamEncoder: JLOpusEncoderDelegate {
func opusEncoder(_ encoder: JLOpusEncoder, data: Data?, error: Error?) {
if let err = error {
onError((err as NSError).code, err.localizedDescription)
return
}
guard let opus = data, !opus.isEmpty else { return }
onEncoded(opus)
}
}
#endif