diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 70bfbfa..fcbbace 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -21,3 +21,33 @@ jobs: run: swift build - name: Run tests run: swift test --parallel + + build-ios: + # swift build compiles the macOS slice only. The codec dependencies ship as + # xcframeworks, so linking against their iOS slices is a separate risk and + # is not covered by the job above. + name: Build for iOS + runs-on: macOS-latest + env: + DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer + steps: + - uses: actions/checkout@v2 + - name: Build for iOS device + run: | + xcodebuild build \ + -scheme AudioStreaming \ + -destination 'generic/platform=iOS' \ + -skipPackagePluginValidation + + test-asan: + # The Ogg codec bridges are C: a ring buffer, raw pointer arithmetic, and + # callbacks driven by libvorbisfile/libopusfile. Address Sanitizer is what + # turns a silent overrun there into a failing test. + name: Test under Address Sanitizer + runs-on: macOS-latest + env: + DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer + steps: + - uses: actions/checkout@v2 + - name: Run tests with ASan + run: swift test --sanitize=address diff --git a/AudioCodecs/OggRingBuffer.c b/AudioCodecs/OggRingBuffer.c new file mode 100644 index 0000000..225ee2b --- /dev/null +++ b/AudioCodecs/OggRingBuffer.c @@ -0,0 +1,134 @@ +// +// OggRingBuffer.c +// AudioCodecs +// + +#include "OggRingBuffer.h" + +#include +#include + +size_t ogg_rb_write_locked(struct OggRingBuffer *s, const uint8_t *src, size_t len) { + size_t written = 0; + while (written < len) { + size_t free_space = s->cap - s->size; + if (free_space == 0) break; + size_t chunk = s->cap - s->tail; + if (chunk > len - written) chunk = len - written; + if (chunk > free_space) chunk = free_space; + memcpy(s->buf + s->tail, src + written, chunk); + s->tail = (s->tail + chunk) % s->cap; + s->size += chunk; + written += chunk; + } + return written; +} + +size_t ogg_rb_read_locked(struct OggRingBuffer *s, uint8_t *dst, size_t len) { + size_t read = 0; + while (read < len && s->size > 0) { + size_t chunk = s->cap - s->head; + if (chunk > s->size) chunk = s->size; + if (chunk > len - read) chunk = len - read; + memcpy(dst + read, s->buf + s->head, chunk); + s->head = (s->head + chunk) % s->cap; + s->size -= chunk; + read += chunk; + } + return read; +} + +struct OggRingBuffer *ogg_rb_create(size_t capacity_bytes) { + struct OggRingBuffer *s = (struct OggRingBuffer *)calloc(1, sizeof(struct OggRingBuffer)); + if (!s) return NULL; + s->buf = (uint8_t *)malloc(capacity_bytes); + if (!s->buf) { free(s); return NULL; } + s->cap = capacity_bytes; + pthread_mutex_init(&s->m, NULL); + pthread_cond_init(&s->cv, NULL); + return s; +} + +void ogg_rb_destroy(struct OggRingBuffer *s) { + if (!s) return; + pthread_mutex_destroy(&s->m); + pthread_cond_destroy(&s->cv); + free(s->buf); + free(s); +} + +size_t ogg_rb_available(struct OggRingBuffer *s) { + if (!s) return 0; + pthread_mutex_lock(&s->m); + size_t sz = s->size; + pthread_mutex_unlock(&s->m); + return sz; +} + +void ogg_rb_push(struct OggRingBuffer *s, const uint8_t *data, size_t len) { + if (!s || !data || len == 0) return; + + pthread_mutex_lock(&s->m); + size_t written_total = 0; + while (written_total < len) { + size_t w = ogg_rb_write_locked(s, data + written_total, len - written_total); + written_total += w; + if (written_total < len) { + // Buffer full, wait for consumer to read + pthread_cond_wait(&s->cv, &s->m); + } + } + s->total_pushed += (long long)len; + pthread_cond_broadcast(&s->cv); + pthread_mutex_unlock(&s->m); +} + +void ogg_rb_mark_eof(struct OggRingBuffer *s) { + if (!s) return; + pthread_mutex_lock(&s->m); + s->eof = 1; + pthread_cond_broadcast(&s->cv); + pthread_mutex_unlock(&s->m); +} + +size_t ogg_rb_take(struct OggRingBuffer *s, uint8_t *dst, size_t len) { + if (!s || !dst || len == 0) return 0; + + size_t got = 0; + pthread_mutex_lock(&s->m); + // Read what's available NOW - don't block waiting for more data. + while (got < len && s->size > 0) { + size_t chunk = ogg_rb_read_locked(s, dst + got, len - got); + if (chunk == 0) break; + s->pos += (long long)chunk; + got += chunk; + // Allow producer to push more + pthread_cond_broadcast(&s->cv); + } + pthread_mutex_unlock(&s->m); + return got; +} + +long long ogg_rb_position(struct OggRingBuffer *s) { + if (!s) return -1; + pthread_mutex_lock(&s->m); + long long p = s->pos; + pthread_mutex_unlock(&s->m); + return p; +} + +int ogg_rb_rewind_to(struct OggRingBuffer *s, long long saved_pos) { + if (!s) return 0; + int ok = 0; + pthread_mutex_lock(&s->m); + long long consumed = s->pos - saved_pos; + if (consumed > 0 && (size_t)consumed <= s->cap - s->size) { + s->head = (s->head + s->cap - ((size_t)consumed % s->cap)) % s->cap; + s->size += (size_t)consumed; + s->pos = saved_pos; + ok = 1; + } + pthread_cond_broadcast(&s->cv); + pthread_mutex_unlock(&s->m); + return ok; +} diff --git a/AudioCodecs/OggRingBuffer.h b/AudioCodecs/OggRingBuffer.h new file mode 100644 index 0000000..a8da932 --- /dev/null +++ b/AudioCodecs/OggRingBuffer.h @@ -0,0 +1,61 @@ +// +// OggRingBuffer.h +// AudioCodecs +// +// Shared blocking ring buffer for the Ogg codec bridges. +// +// Both libvorbisfile and libopusfile are pull-based: they call a read callback +// when they want bytes. The streaming layer is push-based. This buffer bridges +// the two, blocking the producer when it fills and handing the consumer +// whatever is available without blocking. +// +// Internal to the AudioCodecs target — not part of the public umbrella header. +// + +#ifndef OGG_RING_BUFFER_H +#define OGG_RING_BUFFER_H + +#include +#include +#include + +// Fields are exposed rather than opaque because the codec bridges' seek +// callbacks reposition the buffer directly. +struct OggRingBuffer { + uint8_t *buf; + size_t cap, head, tail, size; + int eof; + long long pos; // Current read position in the stream + long long total_pushed; // Total bytes pushed into the buffer + pthread_mutex_t m; + pthread_cond_t cv; +}; + +struct OggRingBuffer *ogg_rb_create(size_t capacity_bytes); +void ogg_rb_destroy(struct OggRingBuffer *s); + +// Bytes currently buffered. +size_t ogg_rb_available(struct OggRingBuffer *s); + +// Appends `len` bytes, blocking while the buffer is full. +void ogg_rb_push(struct OggRingBuffer *s, const uint8_t *data, size_t len); + +void ogg_rb_mark_eof(struct OggRingBuffer *s); + +// Consumes up to `len` bytes into `dst` and advances the stream position. +// Returns what was available now; does not wait for more. +size_t ogg_rb_take(struct OggRingBuffer *s, uint8_t *dst, size_t len); + +// Current stream position, for callers that need to rewind later. +long long ogg_rb_position(struct OggRingBuffer *s); + +// Returns the buffer to `saved_pos`, undoing consumption since that point. +// Only valid while no producer has overwritten the reclaimed region. +// Returns 1 if the rewind happened, 0 if it was not safe. +int ogg_rb_rewind_to(struct OggRingBuffer *s, long long saved_pos); + +// Unlocked primitives, for callers already holding the lock. +size_t ogg_rb_write_locked(struct OggRingBuffer *s, const uint8_t *src, size_t len); +size_t ogg_rb_read_locked(struct OggRingBuffer *s, uint8_t *dst, size_t len); + +#endif // OGG_RING_BUFFER_H diff --git a/AudioCodecs/OpusFileBridge.c b/AudioCodecs/OpusFileBridge.c new file mode 100644 index 0000000..10ca672 --- /dev/null +++ b/AudioCodecs/OpusFileBridge.c @@ -0,0 +1,226 @@ +#include "include/OpusFileBridge.h" + +#include +#include +#include + +#include "OggRingBuffer.h" + +// Ring buffer + opusfile callback shim. +// +// Deliberately mirrors VorbisFileBridge.c so the two Ogg codecs behave +// identically from the Swift side. The only structural difference is the +// callback signatures: opusfile uses a byte-count read (op_read_func) rather +// than libvorbisfile's fread-style (size, nmemb) pair. + +// The ring buffer lives in OggRingBuffer.c, shared with VorbisFileBridge.c. +// These wrappers keep the OF* API surface the Swift layer expects. + +OFStreamRef OFStreamCreate(size_t capacity_bytes) { + return (OFStreamRef)ogg_rb_create(capacity_bytes); +} + +void OFStreamDestroy(OFStreamRef sr) { + ogg_rb_destroy((struct OggRingBuffer *)sr); +} + +size_t OFStreamAvailableBytes(OFStreamRef sr) { + return ogg_rb_available((struct OggRingBuffer *)sr); +} + +void OFStreamPush(OFStreamRef sr, const uint8_t *data, size_t len) { + ogg_rb_push((struct OggRingBuffer *)sr, data, len); +} + +void OFStreamMarkEOF(OFStreamRef sr) { + ogg_rb_mark_eof((struct OggRingBuffer *)sr); +} + +// A decoder handle: the opusfile object plus a scratch buffer. +// +// libopusfile offers only an interleaved read (there is no equivalent of +// libvorbisfile's ov_read_float), so producing deinterleaved output needs a +// staging buffer. Allocating it per read would put a malloc in the render +// path, so the handle owns it and grows it at most once per buffer size. +struct OFFile { + OggOpusFile *of; + float *scratch; + size_t scratch_floats; +}; + +// Returns a scratch buffer of at least `floats_needed` floats, or NULL on +// allocation failure. Steady state performs no allocation. +static float *of_scratch(struct OFFile *f, size_t floats_needed) { + if (f->scratch && f->scratch_floats >= floats_needed) return f->scratch; + float *grown = (float *)realloc(f->scratch, floats_needed * sizeof(float)); + if (!grown) return NULL; + f->scratch = grown; + f->scratch_floats = floats_needed; + return grown; +} + +// MARK: - opusfile callbacks + +// op_read_func: returns bytes read, 0 on EOF, <0 on error. +// Non-blocking: returns whatever is available now, exactly like the Vorbis shim. +static int read_cb(void *stream, unsigned char *ptr, int nbytes) { + if (!stream || nbytes <= 0) return 0; + // got == 0 with eof set signals EOF to opusfile; got == 0 without eof is a + // short read, which opusfile also treats as end-of-stream. The Swift layer + // gates calls on availableBytes() to avoid the latter. + return (int)ogg_rb_take((struct OggRingBuffer *)stream, ptr, (size_t)nbytes); +} + +static int close_cb(void *stream) { + (void)stream; + return 0; +} + +static opus_int64 tell_cb(void *stream) { + struct OggRingBuffer *s = (struct OggRingBuffer *)stream; + if (!s) return -1; + return (opus_int64)s->pos; +} + +int OFOpen(OFStreamRef sr, OFFileRef *out_of) { + struct OggRingBuffer *s = (struct OggRingBuffer *)sr; + if (!s || !out_of) return -1; + + OpusFileCallbacks cbs; + cbs.read = read_cb; + cbs.seek = NULL; // Non-seekable streaming (seeking handled at Swift level) + cbs.tell = tell_cb; + cbs.close = close_cb; + + // A failed open is the normal case while the header is still arriving, so + // it must not damage the stream. op_open_callbacks consumes bytes through + // read_cb before it discovers the header is short, which advances the ring + // buffer past data the next attempt still needs — without the rewind below + // the retry sees a mid-stream position and every subsequent attempt fails + // with OP_ENOTFORMAT, so the track never plays. + // + // read_cb is the only consumer and it advances s->pos by exactly the bytes + // it took, so the delta is the amount to give back. Callers serialise open + // against push (OpusFileDecoder holds decoderLock across both), so no + // producer can have overwritten the reclaimed region. + long long saved_pos = ogg_rb_position(s); + + int err = 0; + OggOpusFile *of = op_open_callbacks((void *)s, &cbs, NULL, 0, &err); + if (!of) { + ogg_rb_rewind_to(s, saved_pos); + return err != 0 ? err : -1; + } + + struct OFFile *f = (struct OFFile *)calloc(1, sizeof(struct OFFile)); + if (!f) { + op_free(of); + return -1; + } + f->of = of; + + *out_of = (OFFileRef)f; + return 0; +} + +void OFClear(OFFileRef fr) { + struct OFFile *f = (struct OFFile *)fr; + if (!f) return; + if (f->of) op_free(f->of); + free(f->scratch); + free(f); +} + +int OFGetInfo(OFFileRef fr, OFStreamInfo *out_info) { + struct OFFile *f = (struct OFFile *)fr; + if (!f || !f->of || !out_info) return -1; + OggOpusFile *of = f->of; + + const OpusHead *head = op_head(of, -1); + if (!head) return -1; + + // opusfile always decodes to 48 kHz regardless of the original input rate. + // head->input_sample_rate is informational only and must NOT be used as the + // output rate — doing so is the classic Opus pitch-shift bug. + out_info->sample_rate = 48000; + out_info->channels = op_channel_count(of, -1); + + // op_pcm_total requires a seekable stream; HTTP sources report -1 here and + // the Swift layer falls back to a bitrate-based duration estimate. + opus_int64 total = op_pcm_total(of, -1); + if (total >= 0) { + out_info->total_pcm_samples = (long long)total; + out_info->duration_seconds = (double)total / 48000.0; + } else { + out_info->total_pcm_samples = -1; + out_info->duration_seconds = -1; + } + + // op_bitrate() also requires a seekable stream. For live/HTTP sources fall + // back to the instantaneous estimate, which is 0 until packets decode. + opus_int32 br = op_bitrate(of, -1); + if (br <= 0) br = op_bitrate_instant(of); + out_info->bitrate_nominal = br > 0 ? (long)br : 0; + + return 0; +} + +long OFReadInterleavedFloat(OFFileRef fr, float *dst, int max_frames, int channels) { + struct OFFile *f = (struct OFFile *)fr; + if (!f || !f->of || !dst || max_frames <= 0 || channels <= 0) return -1; + + // op_read_float takes the buffer size in TOTAL floats, not frames. + int li = 0; + int frames = op_read_float(f->of, dst, max_frames * channels, &li); + if (frames < 0) return (long)frames; // OP_* error code + return (long)frames; // 0 == EOF +} + +long OFReadFloatDeinterleaved(OFFileRef fr, float **dst, int max_frames, int channels) { + struct OFFile *f = (struct OFFile *)fr; + if (!f || !f->of || !dst || max_frames <= 0 || channels <= 0) return -1; + + float *scratch = of_scratch(f, (size_t)max_frames * (size_t)channels); + if (!scratch) return -1; + + int li = 0; + int frames = op_read_float(f->of, scratch, max_frames * channels, &li); + if (frames <= 0) return (long)frames; + + // op_read_float reports the channel count of the link it just decoded; a + // chained stream can change it mid-file. Deinterleave with the stride the + // data actually has, not the one the caller asked for, or the output is + // garbled rather than merely wrong-length. + int decoded_channels = op_channel_count(f->of, li); + if (decoded_channels <= 0) decoded_channels = channels; + + for (int c = 0; c < channels; ++c) { + float *out = dst[c]; + if (!out) continue; + if (c < decoded_channels) { + for (int fr_i = 0; fr_i < frames; ++fr_i) { + out[fr_i] = scratch[fr_i * decoded_channels + c]; + } + } else { + // Caller wants more channels than this link carries; silence the rest. + for (int fr_i = 0; fr_i < frames; ++fr_i) out[fr_i] = 0.0f; + } + } + + return (long)frames; +} + +int OFSeekTime(OFFileRef fr, double time_seconds) { + struct OFFile *f = (struct OFFile *)fr; + if (!f || !f->of) return -1; + if (!op_seekable(f->of)) return -1; + // opusfile seeks by sample position at the fixed 48 kHz output rate. + opus_int64 target = (opus_int64)(time_seconds * 48000.0); + return op_pcm_seek(f->of, target); +} + +int OFIsSeekable(OFFileRef fr) { + struct OFFile *f = (struct OFFile *)fr; + if (!f || !f->of) return 0; + return op_seekable(f->of); +} diff --git a/AudioCodecs/VorbisFileBridge.c b/AudioCodecs/VorbisFileBridge.c index 3645eb5..012ca7e 100644 --- a/AudioCodecs/VorbisFileBridge.c +++ b/AudioCodecs/VorbisFileBridge.c @@ -2,146 +2,47 @@ #include #include -#include #include -struct VFRemoteStream { - uint8_t *buf; - size_t cap, head, tail, size; - int eof; - long long pos; // Current read position in the stream - long long total_pushed; // Total bytes pushed into the buffer - pthread_mutex_t m; - pthread_cond_t cv; -}; +#include "OggRingBuffer.h" -// Simple ring buffer write -static size_t rb_write(struct VFRemoteStream *s, const uint8_t *src, size_t len) { - size_t written = 0; - while (written < len) { - size_t free_space = s->cap - s->size; - if (free_space == 0) break; - size_t chunk = s->cap - s->tail; - if (chunk > len - written) chunk = len - written; - if (chunk > free_space) chunk = free_space; - memcpy(s->buf + s->tail, src + written, chunk); - s->tail = (s->tail + chunk) % s->cap; - s->size += chunk; - written += chunk; - } - return written; -} - -// Simple ring buffer read -static size_t rb_read(struct VFRemoteStream *s, uint8_t *dst, size_t len) { - size_t read = 0; - while (read < len && s->size > 0) { - size_t chunk = s->cap - s->head; - if (chunk > s->size) chunk = s->size; - if (chunk > len - read) chunk = len - read; - memcpy(dst + read, s->buf + s->head, chunk); - s->head = (s->head + chunk) % s->cap; - s->size -= chunk; - read += chunk; - } - return read; -} +// The ring buffer lives in OggRingBuffer.c, shared with OpusFileBridge.c. +// These wrappers keep the VF* API surface the Swift layer expects. -// Create a stream buffer VFStreamRef VFStreamCreate(size_t capacity_bytes) { - struct VFRemoteStream *s = (struct VFRemoteStream *)calloc(1, sizeof(struct VFRemoteStream)); - if (!s) return NULL; - s->buf = (uint8_t *)malloc(capacity_bytes); - if (!s->buf) { free(s); return NULL; } - s->cap = capacity_bytes; - pthread_mutex_init(&s->m, NULL); - pthread_cond_init(&s->cv, NULL); - return s; + return (VFStreamRef)ogg_rb_create(capacity_bytes); } -// Destroy a stream buffer void VFStreamDestroy(VFStreamRef sr) { - struct VFRemoteStream *s = (struct VFRemoteStream *)sr; - if (!s) return; - pthread_mutex_destroy(&s->m); - pthread_cond_destroy(&s->cv); - free(s->buf); - free(s); + ogg_rb_destroy((struct OggRingBuffer *)sr); } -// Get available bytes in the buffer size_t VFStreamAvailableBytes(VFStreamRef sr) { - struct VFRemoteStream *s = (struct VFRemoteStream *)sr; - if (!s) return 0; - pthread_mutex_lock(&s->m); - size_t sz = s->size; - pthread_mutex_unlock(&s->m); - return sz; + return ogg_rb_available((struct OggRingBuffer *)sr); } -// Push data into the stream void VFStreamPush(VFStreamRef sr, const uint8_t *data, size_t len) { - struct VFRemoteStream *s = (struct VFRemoteStream *)sr; - if (!s || !data || len == 0) return; - - pthread_mutex_lock(&s->m); - size_t written_total = 0; - while (written_total < len) { - size_t w = rb_write(s, data + written_total, len - written_total); - written_total += w; - if (written_total < len) { - // Buffer full, wait for consumer to read - pthread_cond_wait(&s->cv, &s->m); - } - } - s->total_pushed += (long long)len; - pthread_cond_broadcast(&s->cv); - pthread_mutex_unlock(&s->m); + ogg_rb_push((struct OggRingBuffer *)sr, data, len); } -// Mark the stream as EOF void VFStreamMarkEOF(VFStreamRef sr) { - struct VFRemoteStream *s = (struct VFRemoteStream *)sr; - if (!s) return; - pthread_mutex_lock(&s->m); - s->eof = 1; - pthread_cond_broadcast(&s->cv); - pthread_mutex_unlock(&s->m); + ogg_rb_mark_eof((struct OggRingBuffer *)sr); } // libvorbisfile callbacks // Read callback for libvorbisfile static size_t read_cb(void *ptr, size_t size, size_t nmemb, void *datasrc) { - struct VFRemoteStream *s = (struct VFRemoteStream *)datasrc; - size_t want_bytes = size * nmemb; - size_t got = 0; - - pthread_mutex_lock(&s->m); - // Read what's available NOW - don't block waiting for more data - while (got < want_bytes && s->size > 0) { - size_t chunk = rb_read(s, (uint8_t *)ptr + got, want_bytes - got); - s->pos += (long long)chunk; - got += chunk; - - if (chunk == 0) break; - // Allow producer to push more - pthread_cond_broadcast(&s->cv); - } - - // If nothing available and EOF, we're done - if (got == 0 && s->eof) { - // Return 0 to signal EOF to libvorbisfile - } - - pthread_mutex_unlock(&s->m); - - return size ? (got / size) : 0; + if (!datasrc || size == 0) return 0; + // Read what's available NOW - don't block waiting for more data. Returning + // 0 signals EOF to libvorbisfile. + size_t got = ogg_rb_take((struct OggRingBuffer *)datasrc, (uint8_t *)ptr, size * nmemb); + return got / size; } // Seek callback - seek within the ring buffer static int seek_cb(void *datasrc, ogg_int64_t offset, int whence) { - struct VFRemoteStream *s = (struct VFRemoteStream *)datasrc; + struct OggRingBuffer *s = (struct OggRingBuffer *)datasrc; if (!s) return -1; pthread_mutex_lock(&s->m); @@ -215,13 +116,13 @@ static int close_cb(void *datasrc) { // Tell callback - return current position static long tell_cb(void *datasrc) { - struct VFRemoteStream *s = (struct VFRemoteStream *)datasrc; + struct OggRingBuffer *s = (struct OggRingBuffer *)datasrc; return (long)s->pos; } // Open a vorbis file using callbacks int VFOpen(VFStreamRef sr, VFFileRef *out_vf) { - struct VFRemoteStream *s = (struct VFRemoteStream *)sr; + struct OggRingBuffer *s = (struct OggRingBuffer *)sr; if (!s || !out_vf) return -1; OggVorbis_File *vf = (OggVorbis_File *)malloc(sizeof(OggVorbis_File)); diff --git a/AudioCodecs/include/AudioCodecs.h b/AudioCodecs/include/AudioCodecs.h index ee189d9..b29d1f7 100644 --- a/AudioCodecs/include/AudioCodecs.h +++ b/AudioCodecs/include/AudioCodecs.h @@ -9,5 +9,6 @@ #define AudioCodecs_h #import "VorbisFileBridge.h" +#import "OpusFileBridge.h" #endif /* AudioCodecs_h */ diff --git a/AudioCodecs/include/OpusFileBridge.h b/AudioCodecs/include/OpusFileBridge.h new file mode 100644 index 0000000..25a1736 --- /dev/null +++ b/AudioCodecs/include/OpusFileBridge.h @@ -0,0 +1,65 @@ +#ifndef OPUS_FILE_BRIDGE_H +#define OPUS_FILE_BRIDGE_H + +#include +#include + +// Opaque refs for Swift-friendly API. +// Mirrors VorbisFileBridge.h so OggStreamProcessor can drive either decoder. +typedef void * OFStreamRef; +typedef void * OFFileRef; + +#ifdef __cplusplus +extern "C" { +#endif + +// Stream info structure +typedef struct { + int sample_rate; // Always 48000 for Opus (opusfile always outputs 48 kHz) + int channels; + long long total_pcm_samples; // -1 if unknown (non-seekable stream) + double duration_seconds; // < 0 if unknown + long bitrate_nominal; // instantaneous bitrate in bits/sec, or 0 if unknown +} OFStreamInfo; + +// Stream lifecycle +OFStreamRef OFStreamCreate(size_t capacity_bytes); +void OFStreamDestroy(OFStreamRef s); +size_t OFStreamAvailableBytes(OFStreamRef s); + +// Feeding data +void OFStreamPush(OFStreamRef s, const uint8_t *data, size_t len); +void OFStreamMarkEOF(OFStreamRef s); + +// Decoder lifecycle +// Returns 0 on success, negative on error (opusfile OP_* codes) +int OFOpen(OFStreamRef s, OFFileRef *out_of); +void OFClear(OFFileRef of); + +// Query info; returns 0 on success +int OFGetInfo(OFFileRef of, OFStreamInfo *out_info); + +// Read deinterleaved float32 PCM frames into caller-provided channel pointers. +// `dst` is an array of `channels` pointers, each with room for max_frames floats. +// Returns frames read per channel, 0 on EOF, <0 on error. +// +// NOTE: opusfile has no deinterleaved read (unlike ov_read_float), so this +// deinterleaves internally into the caller's buffers. Callers must not assume +// the returned data is owned by the decoder — it is written into `dst`. +long OFReadFloatDeinterleaved(OFFileRef of, float **dst, int max_frames, int channels); + +// Read interleaved float32 PCM frames into dst (room for max_frames * channels floats). +// Returns frames read per channel, 0 on EOF, <0 on error. +long OFReadInterleavedFloat(OFFileRef of, float *dst, int max_frames, int channels); + +// Seek to a specific time in seconds; returns 0 on success, <0 on error +int OFSeekTime(OFFileRef of, double time_seconds); + +// Check if the stream is seekable; returns 1 if seekable, 0 if not +int OFIsSeekable(OFFileRef of); + +#ifdef __cplusplus +} +#endif + +#endif // OPUS_FILE_BRIDGE_H diff --git a/AudioStreaming/OggAudio/OggAudioDecoder.swift b/AudioStreaming/OggAudio/OggAudioDecoder.swift new file mode 100644 index 0000000..9cac9b8 --- /dev/null +++ b/AudioStreaming/OggAudio/OggAudioDecoder.swift @@ -0,0 +1,69 @@ +// +// OggAudioDecoder.swift +// AudioStreaming +// + +import AVFoundation +import Foundation + +/// The decoder interface `OggStreamProcessor` drives. +/// +/// Ogg is a container, not a codec: a `kAudioFileOggType` stream can carry +/// Vorbis, Opus, FLAC, or Speex. The renderer plumbing is identical for all of +/// them, so it lives in `OggStreamProcessor` and the codec-specific work sits +/// behind this protocol. +/// +/// `VorbisFileDecoder` (libvorbisfile) and `OpusFileDecoder` (libopusfile) +/// both conform. +protocol OggAudioDecoder: AnyObject { + /// Human-readable codec name, used only for log messages. + var codecName: String { get } + + /// Output sample rate in Hz. Zero until `openIfNeeded()` succeeds. + var sampleRate: Int { get } + /// Output channel count. Zero until `openIfNeeded()` succeeds. + var channels: Int { get } + /// Total duration in seconds, or a negative value when unknown (streaming). + var durationSeconds: Double { get } + /// Total PCM samples per channel, or -1 when unknown (streaming). + var totalPcmSamples: Int64 { get } + /// Nominal or instantaneous bitrate in bits/sec, or 0 when unknown. + var nominalBitrate: Int { get } + /// Deinterleaved float32 format matching `sampleRate` / `channels`. + var processingFormat: AVAudioFormat? { get } + + /// Bitrate estimates used for duration calculation when the container + /// reports neither a total sample count nor a nominal bitrate. + var fallbackBitrateStereo: Double { get } + var fallbackBitrateMono: Double { get } + + /// Allocate the ring buffer. + func create(capacityBytes: Int) + /// Release the decoder and ring buffer. + func destroy() + /// Feed compressed bytes. + func push(_ data: Data) + /// Bytes currently sitting in the ring buffer. + func availableBytes() -> Int + /// Signal that no more data is coming. + func markEOF() + /// Open the decoder once enough bytes have arrived. Throws while still short. + func openIfNeeded() throws + /// Decode into `buffer`. Returns frames written; 0 or negative means no data. + func readFrames(into buffer: AVAudioPCMBuffer, frameCount: Int) -> Int + /// Tear down and return to the pre-`create` state. + func reset() +} + +extension OggAudioDecoder { + // Vorbis-era defaults; OpusFileDecoder overrides with lower values since + // Opus is typically encoded at 96-128 kbps rather than 160-192. + var fallbackBitrateStereo: Double { 160_000 } + var fallbackBitrateMono: Double { 96_000 } +} + +// `VorbisFileDecoder` already exposes every member above with matching +// signatures, so conformance is declaration-only. +extension VorbisFileDecoder: OggAudioDecoder { + var codecName: String { "Vorbis" } +} diff --git a/AudioStreaming/OggAudio/OpusFileDecoder.swift b/AudioStreaming/OggAudio/OpusFileDecoder.swift new file mode 100644 index 0000000..8fc4dcc --- /dev/null +++ b/AudioStreaming/OggAudio/OpusFileDecoder.swift @@ -0,0 +1,219 @@ +// +// OpusFileDecoder.swift +// AudioStreaming +// + +import AudioCodecs +import AVFoundation +import Foundation +import OSLog + +/// A decoder for Ogg Opus streams using libopusfile. +/// +/// Structurally a mirror of `VorbisFileDecoder`. Two things differ: +/// +/// 1. Opus always decodes to 48 kHz. `OpusHead.input_sample_rate` describes the +/// material that was *encoded*, not the output, and using it as the output +/// rate produces a pitch-shifted stream. +/// 2. libopusfile has no deinterleaved read (no equivalent of `ov_read_float`), +/// so the C bridge deinterleaves into caller-owned buffers. That means this +/// decoder writes straight into the `AVAudioPCMBuffer` channel pointers +/// rather than memcpy'ing from decoder-owned memory. +final class OpusFileDecoder { + // Core properties + private var stream: OFStreamRef? + private var of: OFFileRef? + + // Audio format properties + private(set) var sampleRate: Int = 0 + private(set) var channels: Int = 0 + private(set) var durationSeconds: Double = -1 + private(set) var totalPcmSamples: Int64 = -1 + private(set) var nominalBitrate: Int = 0 + private(set) var processingFormat: AVAudioFormat? + + // Thread safety + private let decoderLock = NSLock() + + /// Create the stream buffer with specified capacity + /// - Parameter capacityBytes: Size of the ring buffer in bytes + func create(capacityBytes: Int) { + decoderLock.lock() + defer { decoderLock.unlock() } + + stream = OFStreamCreate(capacityBytes) + } + + /// Clean up resources + func destroy() { + decoderLock.lock() + defer { decoderLock.unlock() } + + if let of = of { OFClear(of) } + if let stream = stream { OFStreamDestroy(stream) } + of = nil + stream = nil + } + + deinit { + destroy() + } + + /// Push data into the stream buffer + /// - Parameter data: The Ogg Opus data to decode + func push(_ data: Data) { + decoderLock.lock() + defer { decoderLock.unlock() } + + data.withUnsafeBytes { rawBuf in + guard let base = rawBuf.baseAddress?.assumingMemoryBound(to: UInt8.self), + rawBuf.count > 0, + let stream = stream else { return } + + OFStreamPush(stream, base, rawBuf.count) + } + } + + /// Get the number of bytes currently available in the stream buffer + func availableBytes() -> Int { + decoderLock.lock() + defer { decoderLock.unlock() } + + guard let stream = stream else { return 0 } + return Int(OFStreamAvailableBytes(stream)) + } + + /// Mark the end of the stream + func markEOF() { + decoderLock.lock() + defer { decoderLock.unlock() } + + if let stream = stream { + OFStreamMarkEOF(stream) + } + } + + /// Try to open the Opus file if enough data is available + /// - Throws: Error if opening fails + func openIfNeeded() throws { + decoderLock.lock() + defer { decoderLock.unlock() } + + guard of == nil, let stream = stream else { return } + + var outOF: OFFileRef? + let rc = OFOpen(stream, &outOF) + if rc < 0 { + // OP_ENOTFORMAT / OP_EBADHEADER on a short read is expected — the + // caller retries as more bytes arrive. + Logger.error("Failed to open Opus file (\(rc))", category: .audioRendering) + throw NSError(domain: "OpusFileDecoder", code: Int(rc), + userInfo: [NSLocalizedDescriptionKey: "Failed to open Opus file"]) + } + + of = outOF + + var info = OFStreamInfo() + if OFGetInfo(outOF, &info) == 0 { + sampleRate = Int(info.sample_rate) + channels = Int(info.channels) + totalPcmSamples = Int64(info.total_pcm_samples) + durationSeconds = info.duration_seconds + nominalBitrate = Int(info.bitrate_nominal) + + let layoutTag: AudioChannelLayoutTag + switch channels { + case 1: layoutTag = kAudioChannelLayoutTag_Mono + case 2: layoutTag = kAudioChannelLayoutTag_Stereo + default: layoutTag = kAudioChannelLayoutTag_Unknown | UInt32(channels) + } + + guard let channelLayout = AVAudioChannelLayout(layoutTag: layoutTag) else { + Logger.error("Failed to build channel layout for \(channels) channels", + category: .audioRendering) + return + } + + processingFormat = AVAudioFormat( + commonFormat: .pcmFormatFloat32, + sampleRate: Double(sampleRate), + interleaved: false, + channelLayout: channelLayout + ) + } else { + Logger.error("Failed to get Opus stream info", category: .audioRendering) + } + } + + /// Read decoded frames into an AVAudioPCMBuffer + /// - Returns: Number of frames read; a small run of silent frames when no + /// data is available, matching `VorbisFileDecoder` so the renderer never + /// sees a zero-frame read as end-of-track. + func readFrames(into buffer: AVAudioPCMBuffer, frameCount: Int) -> Int { + decoderLock.lock() + defer { decoderLock.unlock() } + + guard let of = of, + buffer.format.channelCount > 0, + let floatChannelData = buffer.floatChannelData else { + return generateSilentFrames(into: buffer, frameCount: frameCount) + } + + let maxFrames = min(frameCount, Int(buffer.frameCapacity)) + let channelCount = min(Int(buffer.format.channelCount), channels) + guard channelCount > 0, maxFrames > 0 else { + return generateSilentFrames(into: buffer, frameCount: frameCount) + } + + // Hand the bridge the buffer's own channel pointers. It deinterleaves + // directly into them, so there is no second copy. + var channelPointers = [UnsafeMutablePointer?]() + channelPointers.reserveCapacity(channelCount) + for ch in 0.. Int in + guard let base = ptr.baseAddress else { return -1 } + return Int(OFReadFloatDeinterleaved(of, base, Int32(maxFrames), Int32(channelCount))) + } + + if framesRead <= 0 { + return generateSilentFrames(into: buffer, frameCount: frameCount) + } + + return framesRead + } + + /// Generate silent frames when no real audio data is available. + /// Prevents the renderer from treating a starved buffer as EOF. + private func generateSilentFrames(into buffer: AVAudioPCMBuffer, frameCount: Int) -> Int { + guard let floatChannelData = buffer.floatChannelData, + channels > 0 else { return 1 } + + let framesToGenerate = min(128, frameCount) + + for ch in 0.. OggStreamProcessor? { + let decoder: any OggAudioDecoder + switch codec { + case .vorbis: decoder = VorbisFileDecoder() + case .opus: decoder = OpusFileDecoder() + case .unsupported: return nil + } + let processor = OggStreamProcessor( + playerContext: playerContext, + rendererContext: rendererContext, + outputAudioFormat: outputAudioFormat, + decoder: decoder + ) + processor.processorCallback = { [weak self] effect in self?.fileStreamCallback?(effect) } + return processor } /// Opens the `AudioFileStream` @@ -79,12 +93,14 @@ final class AudioFileStreamProcessor { /// - Returns: An `OSStatus` value indicating if an error occurred or not. func openFileStream(with fileHint: AudioFileTypeID) -> OSStatus { - // Check if this is an Ogg Vorbis file + // Ogg container: defer decoder selection until the first page arrives. if fileHint == kAudioFileOggType { - isProcessingOggVorbis = true + isProcessingOgg = true + oggProcessor = nil + oggHeadBuffer.removeAll(keepingCapacity: true) return noErr } else { - isProcessingOggVorbis = false + isProcessingOgg = false let data = UnsafeMutableRawPointer.from(object: self) return AudioFileStreamOpen(data, _propertyListenerProc, _propertyPacketsProc, fileHint, &audioFileStream) } @@ -92,9 +108,11 @@ final class AudioFileStreamProcessor { /// Closes the currently open `AudioFileStream` instance, if opened. func closeFileStreamIfNeeded() { - if isProcessingOggVorbis { - isProcessingOggVorbis = false - oggVorbisProcessor.cleanup() + if isProcessingOgg { + isProcessingOgg = false + oggProcessor?.cleanup() + oggProcessor = nil + oggHeadBuffer.removeAll(keepingCapacity: false) return } @@ -114,9 +132,36 @@ final class AudioFileStreamProcessor { func parseFileStreamBytes(data: Data) -> OSStatus { guard !data.isEmpty else { return 0 } - // Check if we're processing Ogg Vorbis - if isProcessingOggVorbis { - return oggVorbisProcessor.parseOggVorbisData(data: data) + // Ogg: pick the decoder from the first page, then forward everything. + if isProcessingOgg { + if let processor = oggProcessor { + return processor.parseOggData(data: data) + } + + oggHeadBuffer.append(data) + guard let codec = OggCodecSniffer.sniff(oggHeadBuffer) else { + // Not enough bytes to decide yet. Guard against a stream that + // never resolves rather than buffering without bound. + if oggHeadBuffer.count > OggCodecSniffer.maxHeaderLength { + Logger.debug("Ogg codec undetermined after \(oggHeadBuffer.count) bytes", + category: .generic) + isProcessingOgg = false + return OSStatus(kAudioFileStreamError_UnsupportedFileType) + } + return noErr + } + + guard let processor = makeOggProcessor(for: codec) else { + Logger.debug("Unsupported codec in Ogg container", category: .generic) + isProcessingOgg = false + return OSStatus(kAudioFileStreamError_UnsupportedDataFormat) + } + + oggProcessor = processor + // Replay the buffered head so the decoder sees the stream from byte 0. + let buffered = oggHeadBuffer + oggHeadBuffer.removeAll(keepingCapacity: false) + return processor.parseOggData(data: buffered) } guard let stream = audioFileStream else { return 0 } @@ -137,8 +182,8 @@ final class AudioFileStreamProcessor { /// /// - Returns: An `OSStatus` value indicating if an error occurred or not. func flushRemainingPackets() -> OSStatus { - // Ogg Vorbis doesn't need flushing (handled internally) - if isProcessingOggVorbis { + // Ogg doesn't need flushing (handled internally) + if isProcessingOgg { return noErr } @@ -159,9 +204,9 @@ final class AudioFileStreamProcessor { return } - // If processing Ogg Vorbis, use the Ogg Vorbis processor - if isProcessingOggVorbis { - oggVorbisProcessor.processSeek() + // If processing Ogg, use the Ogg processor + if isProcessingOgg { + oggProcessor?.processSeek() return } diff --git a/AudioStreaming/Streaming/AudioPlayer/Processors/OggVorbisStreamProcessor.swift b/AudioStreaming/Streaming/AudioPlayer/Processors/OggStreamProcessor.swift similarity index 88% rename from AudioStreaming/Streaming/AudioPlayer/Processors/OggVorbisStreamProcessor.swift rename to AudioStreaming/Streaming/AudioPlayer/Processors/OggStreamProcessor.swift index 884f467..9e6950f 100644 --- a/AudioStreaming/Streaming/AudioPlayer/Processors/OggVorbisStreamProcessor.swift +++ b/AudioStreaming/Streaming/AudioPlayer/Processors/OggStreamProcessor.swift @@ -1,5 +1,5 @@ // -// OggVorbisStreamProcessor.swift +// OggStreamProcessor.swift // AudioStreaming // // Created on 25/10/2025. @@ -10,8 +10,15 @@ import AVFoundation import CoreAudio import OSLog -/// A processor for Ogg Vorbis audio streams using libvorbisfile -final class OggVorbisStreamProcessor { +/// A processor for Ogg audio streams. +/// +/// Ogg is a container, so the renderer plumbing here is codec-agnostic: it +/// drives whatever `OggAudioDecoder` it was handed. `VorbisFileDecoder` wraps +/// libvorbisfile and `OpusFileDecoder` wraps libopusfile. +/// +/// Previously named `OggVorbisStreamProcessor`, when Vorbis was the only Ogg +/// codec supported. +final class OggStreamProcessor { /// The callback to notify when processing is complete or an error occurs var processorCallback: ((FileStreamProcessorEffect) -> Void)? @@ -23,9 +30,8 @@ final class OggVorbisStreamProcessor { /// By reducing the bitrate slightly, we increase the calculated duration to match reality. private let oggContainerOverheadFactor: Double = 0.96 // 4% overhead - /// Fallback bitrate estimates when nominal bitrate is unavailable - private let fallbackBitrateStereo: Double = 160_000 // 160 kbps for stereo - private let fallbackBitrateMono: Double = 96_000 // 96 kbps for mono + // Fallback bitrate estimates now come from the decoder, since sensible + // values differ per codec (Vorbis 160/96 kbps, Opus 128/64 kbps). // MARK: - Properties @@ -33,7 +39,7 @@ final class OggVorbisStreamProcessor { private let rendererContext: AudioRendererContext private let outputAudioFormat: AudioStreamBasicDescription - private let vfDecoder = VorbisFileDecoder() + private let decoder: any OggAudioDecoder private var isInitialized = false // Audio converter for format conversion @@ -52,17 +58,20 @@ final class OggVorbisStreamProcessor { // MARK: - Initialization - /// Initialize the OggVorbisStreamProcessor + /// Initialize the OggStreamProcessor /// - Parameters: /// - playerContext: The audio player context /// - rendererContext: The audio renderer context /// - outputAudioFormat: The output audio format + /// - decoder: The codec-specific decoder to drive init(playerContext: AudioPlayerContext, rendererContext: AudioRendererContext, - outputAudioFormat: AudioStreamBasicDescription) { + outputAudioFormat: AudioStreamBasicDescription, + decoder: any OggAudioDecoder) { self.playerContext = playerContext self.rendererContext = rendererContext self.outputAudioFormat = outputAudioFormat + self.decoder = decoder } deinit { @@ -76,40 +85,40 @@ final class OggVorbisStreamProcessor { audioConverter = nil // Destroy and reset the decoder - vfDecoder.destroy() + decoder.destroy() isInitialized = false totalFramesProcessed = 0 } // MARK: - Data Processing - /// Parse Ogg Vorbis data - /// - Parameter data: The Ogg Vorbis data to parse + /// Parse Ogg data + /// - Parameter data: The Ogg data to parse /// - Returns: An OSStatus indicating success or failure - func parseOggVorbisData(data: Data) -> OSStatus { + func parseOggData(data: Data) -> OSStatus { guard let entry = playerContext.audioReadingEntry else { return 0 } dataChunkCount += 1 if !isInitialized { - vfDecoder.create(capacityBytes: 2_097_152) + decoder.create(capacityBytes: 2_097_152) isInitialized = true totalFramesProcessed = 0 } - vfDecoder.push(data) + decoder.push(data) if !entry.audioStreamState.processedDataFormat { - let availableBytes = vfDecoder.availableBytes() + let availableBytes = decoder.availableBytes() if availableBytes >= 16384 { do { - try vfDecoder.openIfNeeded() + try decoder.openIfNeeded() - if vfDecoder.sampleRate > 0 && vfDecoder.channels > 0 { + if decoder.sampleRate > 0 && decoder.channels > 0 { setupAudioFormat() - if pcmBuffer == nil, let processingFormat = vfDecoder.processingFormat { + if pcmBuffer == nil, let processingFormat = decoder.processingFormat { pcmBuffer = AVAudioPCMBuffer(pcmFormat: processingFormat, frameCapacity: UInt32(frameCount)) } } @@ -199,7 +208,7 @@ final class OggVorbisStreamProcessor { } } - let availableBytes = vfDecoder.availableBytes() + let availableBytes = decoder.availableBytes() if availableBytes < 4096 { consecutiveNoFrames += 1 if consecutiveNoFrames >= 3 { @@ -234,7 +243,7 @@ final class OggVorbisStreamProcessor { return OSStatus(-1) } - let framesRead = vfDecoder.readFrames(into: pcmBuffer, frameCount: frameCount) + let framesRead = decoder.readFrames(into: pcmBuffer, frameCount: frameCount) if framesRead <= 0 { return OSStatus(-1) @@ -249,10 +258,10 @@ final class OggVorbisStreamProcessor { // MARK: - Audio Format Setup - // Setup audio format using the processingFormat from VorbisFileDecoder + // Setup audio format using the processingFormat from the decoder private func setupAudioFormat() { guard let entry = playerContext.audioReadingEntry, - let processingFormat = vfDecoder.processingFormat else { return } + let processingFormat = decoder.processingFormat else { return } entry.lock.lock() @@ -261,21 +270,21 @@ final class OggVorbisStreamProcessor { // Store the format in the entry entry.audioStreamFormat = asbd - entry.sampleRate = Float(vfDecoder.sampleRate) - entry.packetDuration = Double(1) / Double(vfDecoder.sampleRate) + entry.sampleRate = Float(decoder.sampleRate) + entry.packetDuration = Double(1) / Double(decoder.sampleRate) // For streaming Ogg files, totalPcmSamples may not be available (returns error code) // In that case, use bitrate-based duration calculation with container overhead correction - if vfDecoder.totalPcmSamples > 0 { + if decoder.totalPcmSamples > 0 { // We have total samples - use packet offset for accurate duration - entry.audioStreamState.dataPacketOffset = UInt64(vfDecoder.totalPcmSamples) + entry.audioStreamState.dataPacketOffset = UInt64(decoder.totalPcmSamples) } else { // Streaming - use bitrate for duration estimation - if vfDecoder.nominalBitrate > 0 { - entry.audioStreamState.bitRate = Double(vfDecoder.nominalBitrate) * oggContainerOverheadFactor + if decoder.nominalBitrate > 0 { + entry.audioStreamState.bitRate = Double(decoder.nominalBitrate) * oggContainerOverheadFactor } else { - // Fallback: use typical bitrates for Vorbis quality - let estimatedBitrate = vfDecoder.channels == 2 ? fallbackBitrateStereo : fallbackBitrateMono + // Fallback: use typical bitrates for this codec + let estimatedBitrate = decoder.channels == 2 ? decoder.fallbackBitrateStereo : decoder.fallbackBitrateMono entry.audioStreamState.bitRate = estimatedBitrate * oggContainerOverheadFactor } } @@ -438,10 +447,10 @@ final class OggVorbisStreamProcessor { /// Process a seek request /// - /// Seeking is not supported for Ogg Vorbis streams. + /// Seeking is not supported for Ogg streams. /// For HTTP streams, seeking is extremely difficult because: /// 1. Need to find Ogg page boundaries - /// 2. Need Vorbis headers to initialize decoder + /// 2. Need codec headers to initialize decoder /// 3. Headers are only at the beginning of the file /// /// Note: Future enhancement could support seeking in local files diff --git a/AudioStreaming/Streaming/Helpers/AudioFileType.swift b/AudioStreaming/Streaming/Helpers/AudioFileType.swift index 742bb6f..1881e23 100644 --- a/AudioStreaming/Streaming/Helpers/AudioFileType.swift +++ b/AudioStreaming/Streaming/Helpers/AudioFileType.swift @@ -38,6 +38,7 @@ let fileTypesFromMimeType: [String: AudioFileTypeID] = "video/3gp2": kAudioFile3GP2Type, "audio/flac": kAudioFileFLACType, "audio/ogg": kAudioFileOggType, + "audio/opus": kAudioFileOggType, "audio/vorbis": kAudioFileOggType, "application/ogg": kAudioFileOggType ] @@ -66,6 +67,7 @@ let fileTypesFromFileExtension: [String: AudioFileTypeID] = "flac": kAudioFileFLACType, "ogg": kAudioFileOggType, "oga": kAudioFileOggType, + "opus": kAudioFileOggType, ] func audioFileType(fileExtension: String) -> AudioFileTypeID { diff --git a/AudioStreaming/Streaming/Helpers/OggCodecSniffer.swift b/AudioStreaming/Streaming/Helpers/OggCodecSniffer.swift new file mode 100644 index 0000000..8e57bee --- /dev/null +++ b/AudioStreaming/Streaming/Helpers/OggCodecSniffer.swift @@ -0,0 +1,81 @@ +// +// OggCodecSniffer.swift +// AudioStreaming +// + +import Foundation + +/// Identifies which codec an Ogg bitstream carries. +/// +/// This exists because the transport layer cannot tell us. Ogg is a container, +/// and the registered MIME type `audio/ogg` is shared by Vorbis, Opus, Speex, +/// and FLAC-in-Ogg. Navidrome, for instance, serves an Opus transcode as +/// `audio/ogg` (`resources/mime_types.yaml`), so a Content-Type check alone +/// routes Opus into the Vorbis decoder, which rejects it. +/// +/// The only reliable discriminator is the first packet of the first page. +enum OggCodec: Equatable { + case vorbis + case opus + /// Recognised as Ogg, but the codec is not one we decode. + case unsupported +} + +enum OggCodecSniffer { + /// Bytes needed in the worst realistic case: 27-byte page header plus a + /// 255-entry segment table plus the 8-byte codec magic. + static let maxHeaderLength = 27 + 255 + 8 + + /// Bytes needed for a typical first page (single segment). + static let typicalHeaderLength = 36 + + /// Identifies the codec from the start of an Ogg bitstream. + /// + /// - Returns: the codec, or `nil` when `bytes` is not yet long enough to + /// decide. `nil` means "feed me more", `.unsupported` means "give up". + static func sniff(_ bytes: [UInt8]) -> OggCodec? { + // Ogg page header layout (RFC 3533 §6): + // 0..3 capture pattern "OggS" + // 4 stream structure version + // 5 header type flag + // 6..13 granule position + // 14..17 bitstream serial number + // 18..21 page sequence number + // 22..25 CRC checksum + // 26 number of page segments + // 27.. segment table (one byte per segment) + // then packet data + guard bytes.count >= 27 else { return nil } + + func matches(_ ascii: String, at offset: Int) -> Bool { + let pattern = Array(ascii.utf8) + guard bytes.count >= offset + pattern.count else { return false } + return Array(bytes[offset..= payloadOffset + 8 else { return nil } + + if matches("OpusHead", at: payloadOffset) { + return .opus + } + if bytes[payloadOffset] == 0x01, matches("vorbis", at: payloadOffset + 1) { + return .vorbis + } + + // Ogg FLAC ("\x7FFLAC"), Speex ("Speex "), Theora, etc. + return .unsupported + } + + /// Convenience overload for the streaming path. + static func sniff(_ data: Data) -> OggCodec? { + sniff([UInt8](data.prefix(maxHeaderLength))) + } +} diff --git a/AudioStreamingTests/Codecs/OpusFileBridgeTests.swift b/AudioStreamingTests/Codecs/OpusFileBridgeTests.swift new file mode 100644 index 0000000..54d3b23 --- /dev/null +++ b/AudioStreamingTests/Codecs/OpusFileBridgeTests.swift @@ -0,0 +1,239 @@ +// +// OpusFileBridgeTests.swift +// AudioStreamingTests +// + +import AudioCodecs +import XCTest +@testable import AudioStreaming + +/// Exercises the libopusfile C bridge directly. +/// +/// The bridge is pure computation over bytes — no audio hardware and no +/// `AVAudioEngine` — so unlike `OggStreamProcessor` it is fully testable in CI. +/// These tests cover the ring buffer, both callback signatures, the +/// deinterleave, and the 48 kHz output rule. +final class OpusFileBridgeTests: XCTestCase { + /// Matches `OggStreamProcessor`'s ring buffer size. + private let capacity = 2 * 1024 * 1024 + /// Matches the byte count `OggStreamProcessor` waits for before opening. + private let openGate = 16384 + + // MARK: - Helpers + + private func fixture(_ name: String, _ ext: String) throws -> Data { + let url = try XCTUnwrap( + Bundle.module.url(forResource: "ogg-fixtures/\(name)", withExtension: ext), + "missing fixture \(name).\(ext)" + ) + return try Data(contentsOf: url) + } + + private struct Decoded { + var info: OFStreamInfo + var frames: Int + var peak: Float + var channels: [[Float]] + var openAttempts: Int + var openedAfterBytes: Int + } + + /// Feeds `data` through the bridge the way `OpusFileDecoder` does. + /// + /// - Parameter chunkSize: when non-nil, pushes in chunks and retries the + /// open once `openGate` bytes are buffered — the streaming path. When + /// nil, pushes everything before opening — the cached-file path. + private func decode(_ data: Data, chunkSize: Int? = nil) throws -> Decoded { + let stream = try XCTUnwrap(OFStreamCreate(capacity), "OFStreamCreate returned nil") + defer { OFStreamDestroy(stream) } + + var file: OFFileRef? + var attempts = 0 + var openedAfter = 0 + + func push(_ slice: Data) { + slice.withUnsafeBytes { raw in + guard let base = raw.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return } + OFStreamPush(stream, base, raw.count) + } + } + + if let chunkSize { + var offset = 0 + while offset < data.count { + let end = min(offset + chunkSize, data.count) + push(data[offset..= openGate { + attempts += 1 + if OFOpen(stream, &file) == 0 { openedAfter = offset } + } + } + OFStreamMarkEOF(stream) + } else { + push(data) + OFStreamMarkEOF(stream) + attempts = 1 + if OFOpen(stream, &file) == 0 { openedAfter = data.count } + } + + let of = try XCTUnwrap(file, "stream never opened after \(attempts) attempt(s)") + defer { OFClear(of) } + + var info = OFStreamInfo() + XCTAssertEqual(OFGetInfo(of, &info), 0, "OFGetInfo failed") + + let channelCount = Int(info.channels) + XCTAssertGreaterThan(channelCount, 0) + + let blockFrames = 4096 + var scratch: [UnsafeMutablePointer] = (0...allocate(capacity: blockFrames) + } + defer { scratch.forEach { $0.deallocate() } } + + var collected = [[Float]](repeating: [], count: channelCount) + var peak: Float = 0 + var total = 0 + + while total < 48000 * 60 { // hard stop so a bug cannot hang CI + var pointers: [UnsafeMutablePointer?] = scratch.map { $0 } + let got = pointers.withUnsafeMutableBufferPointer { buf -> Int in + guard let base = buf.baseAddress else { return -1 } + return Int(OFReadFloatDeinterleaved(of, base, Int32(blockFrames), Int32(channelCount))) + } + if got <= 0 { break } + for channel in 0.. Double { + guard samples.count > 1 else { return 0 } + var crossings = 0 + for i in 1.. [UInt8] { + var bytes = Array(capture.utf8) + bytes.append(contentsOf: [UInt8](repeating: 0, count: 22)) // through byte 25 + bytes.append(UInt8(segments.count)) // byte 26 + bytes.append(contentsOf: segments) // segment table + bytes.append(contentsOf: payload) + return bytes + } + + private func fixtureBytes(_ name: String, _ ext: String) throws -> [UInt8] { + let url = try XCTUnwrap(Bundle.module.url(forResource: "ogg-fixtures/\(name)", withExtension: ext)) + return [UInt8](try Data(contentsOf: url)) + } + + // MARK: - Real files + + func testIdentifiesRealOpusFile() throws { + XCTAssertEqual(OggCodecSniffer.sniff(try fixtureBytes("opus-tone-stereo-48k", "opus")), .opus) + } + + func testIdentifiesRealVorbisFile() throws { + XCTAssertEqual(OggCodecSniffer.sniff(try fixtureBytes("vorbis-tone-stereo-44k", "ogg")), .vorbis) + } + + /// The whole point of the sniffer: these two are both served as `audio/ogg` + /// and must not be confused with one another. + func testDistinguishesOpusFromVorbis() throws { + let opus = try fixtureBytes("opus-tone-stereo-48k", "opus") + let vorbis = try fixtureBytes("vorbis-tone-stereo-44k", "ogg") + XCTAssertEqual(OggCodecSniffer.sniff(opus), .opus) + XCTAssertEqual(OggCodecSniffer.sniff(vorbis), .vorbis) + } + + // MARK: - Synthetic pages + + func testSingleSegmentPages() { + XCTAssertEqual(OggCodecSniffer.sniff(page(segments: [19], payload: opusHead)), .opus) + XCTAssertEqual(OggCodecSniffer.sniff(page(segments: [30], payload: vorbisIdentification)), .vorbis) + } + + /// The reason the payload offset is computed rather than hardcoded: every + /// lacing entry shifts the codec magic by one more byte. + func testMultiSegmentPagesShiftThePayloadOffset() { + XCTAssertEqual(OggCodecSniffer.sniff(page(segments: [255, 255, 255, 19], payload: opusHead)), .opus) + XCTAssertEqual(OggCodecSniffer.sniff(page(segments: [255, 30], payload: vorbisIdentification)), .vorbis) + XCTAssertEqual(OggCodecSniffer.sniff(page(segments: [UInt8](repeating: 1, count: 255), payload: opusHead)), .opus) + } + + func testMaxHeaderLengthCoversTheLargestPossibleFirstPage() { + // 27-byte header + 255-entry lacing table + 8-byte codec magic. + XCTAssertEqual(OggCodecSniffer.maxHeaderLength, 290) + let worstCase = page(segments: [UInt8](repeating: 1, count: 255), payload: opusHead) + XCTAssertLessThanOrEqual(OggCodecSniffer.maxHeaderLength, worstCase.count) + } + + // MARK: - Codecs we do not decode + + func testUnsupportedOggCodecsAreReportedNotGuessed() { + let flac = [UInt8(0x7F)] + Array("FLAC".utf8) + [UInt8](repeating: 0, count: 20) + let speex = Array("Speex ".utf8) + [UInt8](repeating: 0, count: 20) + XCTAssertEqual(OggCodecSniffer.sniff(page(segments: [1], payload: flac)), .unsupported) + XCTAssertEqual(OggCodecSniffer.sniff(page(segments: [1], payload: speex)), .unsupported) + } + + func testNonOggDataIsUnsupported() { + let mp3 = Array("ID3\u{4}".utf8) + [UInt8](repeating: 0, count: 40) + XCTAssertEqual(OggCodecSniffer.sniff(mp3), .unsupported) + } + + // MARK: - Partial input + + /// `nil` means "feed me more" and must never be confused with a decision; + /// answering early would route the stream to the wrong decoder. + func testTruncatedInputAsksForMoreBytes() { + XCTAssertNil(OggCodecSniffer.sniff([UInt8]())) + XCTAssertNil(OggCodecSniffer.sniff([UInt8](repeating: 0, count: 26))) + XCTAssertNil(OggCodecSniffer.sniff(page(segments: [19], payload: []))) + + let full = page(segments: [255, 255, 255, 19], payload: opusHead) + XCTAssertNil(OggCodecSniffer.sniff(Array(full.prefix(29))), "truncated inside the segment table") + XCTAssertNil(OggCodecSniffer.sniff(Array(full.prefix(27 + 4 + 7))), "one byte short of the magic") + } + + func testDataOverloadMatchesArrayOverload() { + let bytes = page(segments: [19], payload: opusHead) + XCTAssertEqual(OggCodecSniffer.sniff(Data(bytes)), OggCodecSniffer.sniff(bytes)) + XCTAssertNil(OggCodecSniffer.sniff(Data())) + } + + /// The sniffer only ever inspects the head of the stream, so handing it a + /// whole file must be no different from handing it the first page. + func testOnlyTheHeadOfTheStreamMatters() throws { + let whole = try fixtureBytes("opus-tone-stereo-48k", "opus") + let head = Array(whole.prefix(OggCodecSniffer.maxHeaderLength)) + XCTAssertEqual(OggCodecSniffer.sniff(whole), OggCodecSniffer.sniff(head)) + } +} diff --git a/Package.swift b/Package.swift index 5249157..299fe0e 100644 --- a/Package.swift +++ b/Package.swift @@ -17,7 +17,9 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/sbooth/ogg-binary-xcframework", exact: "0.1.2"), - .package(url: "https://github.com/sbooth/vorbis-binary-xcframework", exact: "0.1.2") + .package(url: "https://github.com/sbooth/vorbis-binary-xcframework", exact: "0.1.2"), + // Ships libopus AND libopusfile () + .package(url: "https://github.com/sbooth/opus-binary-xcframework", exact: "0.3.0") ], targets: [ // C target for audio codec bridges @@ -25,7 +27,8 @@ let package = Package( name: "AudioCodecs", dependencies: [ .product(name: "ogg", package: "ogg-binary-xcframework"), - .product(name: "vorbis", package: "vorbis-binary-xcframework") + .product(name: "vorbis", package: "vorbis-binary-xcframework"), + .product(name: "opus", package: "opus-binary-xcframework") ], path: "AudioCodecs", publicHeadersPath: "include", @@ -45,7 +48,8 @@ let package = Package( dependencies: [ "AudioCodecs", .product(name: "ogg", package: "ogg-binary-xcframework"), - .product(name: "vorbis", package: "vorbis-binary-xcframework") + .product(name: "vorbis", package: "vorbis-binary-xcframework"), + .product(name: "opus", package: "opus-binary-xcframework") ], path: "AudioStreaming", exclude: ["AudioStreaming.h", "Streaming/OggVorbis", "Info.plist"], @@ -54,13 +58,17 @@ let package = Package( .testTarget( name: "AudioStreamingTests", dependencies: [ - "AudioStreaming" + "AudioStreaming", + // OpusFileBridgeTests drives the C bridge directly. + "AudioCodecs" ], path: "AudioStreamingTests", exclude: ["Info.plist", "Streaming/output"], resources: [ // Test resources for metadata stream processor tests - .copy("Streaming/Metadata Stream Processor/raw-audio-streams") + .copy("Streaming/Metadata Stream Processor/raw-audio-streams"), + // Ogg Opus / Vorbis fixtures for the codec tests + .copy("Codecs/ogg-fixtures") ] ) ]