The capture path carried three latencies that had nothing to do with the network: a 2048-frame tap request (42.7 ms bursts before the first sample could even be sliced), 20 ms Opus framing, and a mono signal duplicated into both stereo channels because the encoder was configured like the downlink. CoreAudio's Opus encoder takes mFramesPerPacket=480 and mChannelsPerFrame=1 just fine — probed empirically: the converter truly emits 10 ms mono CELT packets (TOC config 30), one per 480-frame chunk, and the stereo-shaped decoder upmixes them with the tone intact — so the uplink now asks for 10 ms tap buffers and encodes 48 kbps mono 10 ms packets, with 960 kept only as an init-time fallback. The host decodes any Opus frame ≤120 ms, so nothing changes on the wire's far end. The tap thread also stops burning cycles per callback: the mono fold and resampler scratch buffers are allocated once (regrown only if a larger device quantum ever arrives), and the chunk slicer walks a head index instead of removeFirst — which memmoved the entire backlog for every packet on a render-adjacent thread. iOS additionally asks the session for 5 ms IO quanta at 48 kHz when the mic is on (best-effort; the hardware decides). Verified: swift build (macOS arm64), swift test OpusCodecTests + AudioChannelFoldTests, and an iOS arm64 cross-build; the 480/mono behavior confirmed by TOC inspection on macOS 15. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
86 lines
3.8 KiB
Swift
86 lines
3.8 KiB
Swift
// The Opus codec through CoreAudio (kAudioFormatOpus): a real encode → decode round
|
|
// trip. This is the load-bearing assumption of the whole audio feature (no bundled
|
|
// libopus) — if AVAudioConverter can't handle raw Opus packets, fail HERE, not in the
|
|
// app.
|
|
|
|
import AVFoundation
|
|
import XCTest
|
|
|
|
@testable import PunktfunkKit
|
|
|
|
final class OpusCodecTests: XCTestCase {
|
|
/// Encode a 440 Hz mono tone (the uplink's shape), decode it back through the
|
|
/// STEREO-configured decoder (the host-plane shape — Opus upmixes mono packets), and
|
|
/// require the result to be recognizably the same signal (Opus is lossy — check
|
|
/// correlation, not bytes).
|
|
func testEncodeDecodeRoundTripPreservesTone() throws {
|
|
let encoder = try OpusEncoder()
|
|
let decoder = try OpusDecoder(framesPerPacket: UInt32(encoder.framesPerPacket))
|
|
let pcmFormat = encoder.pcmFormat
|
|
|
|
let frames = encoder.framesPerPacket
|
|
var packets: [Data] = []
|
|
var phase: Float = 0
|
|
let step = 2 * Float.pi * 440 / 48_000
|
|
|
|
// 1 s of tone, whatever packet duration the encoder chose (10 ms → 100 chunks).
|
|
let chunks = Int(48_000 / frames)
|
|
for _ in 0..<chunks {
|
|
let buf = AVAudioPCMBuffer(pcmFormat: pcmFormat, frameCapacity: frames)!
|
|
buf.frameLength = frames
|
|
let p = buf.floatChannelData![0] // mono: one plane
|
|
for f in 0..<Int(frames) {
|
|
p[f] = sin(phase) * 0.5
|
|
phase += step
|
|
}
|
|
packets.append(contentsOf: try encoder.encode(buf))
|
|
}
|
|
XCTAssertGreaterThanOrEqual(
|
|
packets.count, chunks - 5, "encoder must emit ~one packet per buffer")
|
|
XCTAssertTrue(packets.allSatisfy { !$0.isEmpty })
|
|
|
|
var decoded: [Float] = []
|
|
let out = AVAudioPCMBuffer(pcmFormat: decoder.pcmFormat, frameCapacity: 5760)!
|
|
for packet in packets {
|
|
let n = try decoder.decode(packet, into: out)
|
|
let p = out.floatChannelData![0]
|
|
for f in 0..<Int(n) {
|
|
decoded.append(p[f * 2]) // left channel
|
|
}
|
|
}
|
|
XCTAssertGreaterThan(decoded.count, 40_000, "~1 s of 48 kHz audio back out")
|
|
|
|
// The decoded signal must contain a strong 440 Hz component: correlate against
|
|
// quadrature reference tones (phase-agnostic), skipping the codec warm-up.
|
|
let skip = 4800
|
|
var inPhase: Float = 0
|
|
var quadrature: Float = 0
|
|
var energy: Float = 0
|
|
for (i, s) in decoded[skip...].enumerated() {
|
|
let t = Float(i) * step
|
|
inPhase += s * sin(t)
|
|
quadrature += s * cos(t)
|
|
energy += s * s
|
|
}
|
|
let n = Float(decoded.count - skip)
|
|
let correlation = (inPhase * inPhase + quadrature * quadrature).squareRoot() / n
|
|
let rms = (energy / n).squareRoot()
|
|
XCTAssertGreaterThan(rms, 0.2, "decoded audio is not silence")
|
|
// A clean sine at amplitude a yields correlation a/2 (≈0.25 here); noise ≈ 0.
|
|
XCTAssertGreaterThan(correlation, 0.15, "440 Hz tone must survive the round trip")
|
|
}
|
|
|
|
/// The host's audio plane is 5 ms (240-frame) packets — make sure a 240-frame
|
|
/// decoder accepts packets that small (encoder-side we can't force 5 ms out of
|
|
/// CoreAudio, so this decodes the 20 ms packets with a mismatched nominal fpp,
|
|
/// which the packet descriptions override).
|
|
func testDecoderHandlesDTXAndOversizedPackets() throws {
|
|
let decoder = try OpusDecoder(framesPerPacket: 240)
|
|
let out = AVAudioPCMBuffer(pcmFormat: decoder.pcmFormat, frameCapacity: 5760)!
|
|
XCTAssertEqual(try decoder.decode(Data(), into: out), 0, "DTX decodes to silence/0")
|
|
XCTAssertThrowsError(
|
|
try decoder.decode(Data(repeating: 0, count: 2000), into: out),
|
|
"oversized packet must throw, not crash")
|
|
}
|
|
}
|