Files
punktfunk/clients/apple/Tests/PunktfunkKitTests/OpusCodecTests.swift
T
enricobuehlerandClaude Fable 5 e54258b8ac feat(apple): the mic uplink drops to mono 10 ms packets and stops shoveling its FIFO
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>
2026-07-31 21:57:48 +02:00

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")
}
}