Skip to content

feat(Ogg): add Ogg Opus playback support - #139

Open
hischampion wants to merge 3 commits into
dimitris-c:mainfrom
hischampion:feature/ogg-opus
Open

hischampion wants to merge 3 commits into
dimitris-c:mainfrom
hischampion:feature/ogg-opus

Conversation

@hischampion

Copy link
Copy Markdown

Closes #137.

Opus streams don't play: openFileStream sends every kAudioFileOggType stream to OggVorbisStreamProcessor, and VorbisFileBridge returns rc=-132 (OV_ENOTVORBIS). audio/ogg is a container type shared by Vorbis, Opus, Speex and FLAC, and Navidrome serves its Opus transcodes as audio/ogg, so Cassette, a Navidrome client built on this library, gets silence. This branch picks the codec from the first Ogg page and adds a libopusfile bridge next to the Vorbis one.

Up front: I don't have a Mac, so this has never run on a device, and it was authored heavily with Claude Opus. CI is green (macOS build + test, iOS build, ASan), the bridge decodes bit-identically to ffmpeg, and valgrind is clean, but OggStreamProcessor needs a live AVAudioEngine and only a device can vouch for it. If that's not something you want to carry, say so and I'll leave it on the branch.

What changes

  • Codec selection moves from openFileStream to first data, since neither the file hint nor the Content-Type can tell Opus from Vorbis. OggCodecSniffer reads the first page for OpusHead vs \x01vorbis.
  • OggVorbisStreamProcessorOggStreamProcessor, driving an injected OggAudioDecoder. Renderer loop, ring-buffer waits and packet accounting are untouched; pushed as a rename so the diff reads as one.
  • OpusFileBridge wraps libopusfile, mirroring VorbisFileBridge. Dependency: sbooth/opus-binary-xcframework 0.3.0, same author and packaging as the ogg/vorbis xcframeworks already in Package.resolved.
  • Unsupported Ogg codecs (Speex, FLAC) now report kAudioFileStreamError_UnsupportedDataFormat instead of failing silently inside libvorbisfile.
  • Opus always decodes at 48 kHz. OpusHead.input_sample_rate describes the source material, not the output; using it as the output rate pitch-shifts.
  • OFOpen rewinds the ring buffer when op_open_callbacks fails. A failed open is normal while the header is still arriving, but opusfile consumes bytes first, so without the rewind every retry starts mid-stream and returns OP_ENOTFORMAT forever (any header over the 16 KB open threshold, e.g. embedded cover art).

Commits

  1. feat(Ogg) — the Opus support, self-contained.
  2. refactor(AudioCodecs) — one shared ring buffer instead of the copy OpusFileBridge.c arrived with; PCM out of both bridges is byte-identical before and after. Droppable.
  3. ci — iOS build and ASan run. Droppable.

Tests

20 cases, none needing audio hardware:

  • sniffer: real Opus and Vorbis fixtures; a multi-segment first page where the lacing table shifts the payload; FLAC, Speex and non-Ogg report unsupported; truncated input asks for more bytes instead of guessing.
  • bridge: mono and stereo decode to the right rate, channels and length; stereo fixture is 440 Hz left / 660 Hz right, so a broken deinterleave reads as the wrong frequency rather than silence; a fixture declaring input_sample_rate: 44100 still decodes at 48 kHz; recovery from a failed open on a >16 KB header; Vorbis and junk are rejected.

Not covered: OggStreamProcessor itself, for the device reason above.

Scott added 3 commits August 25, 2026 14:54
AudioFileStreamProcessor routed every kAudioFileOggType stream to the
libvorbisfile processor. Ogg is a container, not a codec, and audio/ogg is
shared by Vorbis, Opus, Speex and FLAC-in-Ogg, so Opus reached libvorbisfile
and was rejected. Navidrome, for one, serves Opus transcodes as audio/ogg.

Codec selection cannot happen at openFileStream time, because both the file
hint and the Content-Type say only "Ogg". It now happens on first data, by
reading the first Ogg page.

- OggVorbisStreamProcessor becomes OggStreamProcessor driving an injected
  OggAudioDecoder. The renderer loop, ring-buffer waits and packet accounting
  are unchanged; only decoder references were renamed.
- OggCodecSniffer reads the first page to tell OpusHead from \x01vorbis,
  accounting for the lacing table when locating the payload.
- OpusFileBridge wraps libopusfile, mirroring VorbisFileBridge.
- Unsupported Ogg codecs now report kAudioFileStreamError_UnsupportedDataFormat
  instead of failing silently inside libvorbisfile.

Two details worth flagging for review:

Opus always decodes at 48 kHz. OpusHead.input_sample_rate describes the
material that was encoded, not the output, so it must not be used as the
output rate; a fixture that declares 44100 guards against that regression.

OFOpen rewinds the ring buffer when op_open_callbacks fails. A failed open is
the normal case while the header is still arriving, but it consumes bytes
first, so without the rewind every retry starts mid-stream and returns
OP_ENOTFORMAT forever. This is reachable with a header larger than the
16 KB open threshold - an Opus file with embedded cover art, for instance.
The same flaw exists in the Vorbis path today and is left alone here.

Tests cover the sniffer and the C bridge, both of which are pure computation
and need no audio hardware. OggStreamProcessor itself still requires a device.
OpusFileBridge.c arrived with a near-verbatim copy of the ring buffer in
VorbisFileBridge.c: the same struct, the same rb_read/rb_write, and the same
create/destroy/push/available/mark-EOF wrappers under a different prefix. Two
copies drift, so this moves the buffer into OggRingBuffer.c and leaves both
bridges holding only their codec-specific callbacks.

Behaviour is unchanged. Decoding the test fixtures through both bridges
produces byte-for-byte identical PCM before and after (705600 bytes of Vorbis,
768000 of Opus), and the codec bridges keep their existing public API, so
nothing on the Swift side moves.

This commit is self-contained and can be dropped if you would rather not have
the Vorbis bridge touched in the same PR as the Opus addition.
The existing job runs swift build and swift test, which compile the macOS
slice. This PR adds a binary xcframework dependency and a second C bridge, so
two gaps are worth closing:

- build-ios compiles against the iOS slices of the ogg/vorbis/opus
  xcframeworks, which swift build never touches.
- test-asan runs the suite under Address Sanitizer. The codec bridges are C
  operating on raw pointers, and ASan is what makes an overrun there fail
  loudly instead of silently corrupting audio.

Both are additive; the existing job is unchanged.
@hischampion
hischampion marked this pull request as ready for review September 17, 2026 22:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Opus Support

1 participant