Skip to content

Commit

Permalink
Improve seek in ogg/opus files
Browse files Browse the repository at this point in the history
The file parser had problems seeking in files with "large" ogg pages.

RFC 3533 says:
  Pages are of variable size, usually 4-8 kB, maximum 65307 bytes.

If ogg pages were larger than 9 kB the parser would fail. Ogg and Opus
files that I've encoded recently seem to use page sizes ~10-12 kB.
  • Loading branch information
robho committed Apr 29, 2023
1 parent 2e0339b commit 5c5deba
Show file tree
Hide file tree
Showing 8 changed files with 136 additions and 124 deletions.
3 changes: 3 additions & 0 deletions Changes
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ Revision history for Audio::Scan

Note: Bug numbers refer to bugs at http://bugs.slimdevices.com

1.07 unreleased
- ogg/opus: Improve/fix seek.

1.06 2022-11-10
- opus: Fix parsing large comment headers (such as large embedded images)

Expand Down
4 changes: 3 additions & 1 deletion Makefile.PL
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ use ExtUtils::MakeMaker qw(WriteMakefile);
use File::Spec::Functions;
use Getopt::Long;

my (@INC, @LIBS);
my (@CCFLAGS, @INC, @LIBS);

push @INC, '-Iinclude', '-Isrc';
if( $^O eq 'MSWin32' ) {
push @LIBS, '-Lwin32/ -lzlib';
}
else {
push @CCFLAGS, '-Wdeclaration-after-statement';
push @LIBS, '-lz';
}

Expand All @@ -23,6 +24,7 @@ WriteMakefile(
PREREQ_PM => { 'Test::Warn' => 0 },
ABSTRACT_FROM => 'lib/Audio/Scan.pm',
AUTHOR => 'Andy Grundman <[email protected]>',
CCFLAGS => "$Config::Config{ccflags} " . join(' ', @CCFLAGS),
INC => join(' ', @INC),
LIBS => [ join(' ', @LIBS) ],
depend => { 'Scan.c' => "$inc_files $src_files" },
Expand Down
2 changes: 2 additions & 0 deletions include/ogg.h
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
* Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
*/

#define OGG_HEADER_SIZE 28
#define OGG_MAX_PAGE_SIZE 65307
#define OGG_BLOCK_SIZE 4500

int get_ogg_metadata(PerlIO *infile, char *file, HV *info, HV *tags);
Expand Down
2 changes: 1 addition & 1 deletion lib/Audio/Scan.pm
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package Audio::Scan;

use strict;

our $VERSION = '1.06';
our $VERSION = '1.07';

require XSLoader;
XSLoader::load('Audio::Scan', $VERSION);
Expand Down
181 changes: 75 additions & 106 deletions src/ogg.c
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ _ogg_parse(PerlIO *infile, char *file, HV *info, HV *tags, uint8_t seeking)
off_t audio_size; // total size of audio without tags
off_t audio_offset = 0; // offset to audio

unsigned char ogghdr[28];
unsigned char ogghdr[OGG_HEADER_SIZE];
char header_type;
int serialno;
int final_serialno;
Expand Down Expand Up @@ -96,14 +96,14 @@ _ogg_parse(PerlIO *infile, char *file, HV *info, HV *tags, uint8_t seeking)

while (1) {
// Grab 28-byte Ogg header
if ( !_check_buf(infile, &ogg_buf, 28, OGG_BLOCK_SIZE) ) {
if ( !_check_buf(infile, &ogg_buf, OGG_HEADER_SIZE, OGG_BLOCK_SIZE) ) {
err = -1;
goto out;
}

buffer_get(&ogg_buf, ogghdr, 28);
buffer_get(&ogg_buf, ogghdr, OGG_HEADER_SIZE);

audio_offset += 28;
audio_offset += OGG_HEADER_SIZE;

// check that the first four bytes are 'OggS'
if ( ogghdr[0] != 'O' || ogghdr[1] != 'g' || ogghdr[2] != 'g' || ogghdr[3] != 'S' ) {
Expand Down Expand Up @@ -149,7 +149,7 @@ _ogg_parse(PerlIO *infile, char *file, HV *info, HV *tags, uint8_t seeking)
DEBUG_TRACE("Missing page(s) in Ogg file: %s\n", file);
}

DEBUG_TRACE("OggS page %d / packet %d at %d\n", pagenum, packets, (int)(audio_offset - 28));
DEBUG_TRACE("OggS page %d / packet %d at %d\n", pagenum, packets, (int)(audio_offset - OGG_HEADER_SIZE));
DEBUG_TRACE(" granule_pos: %llu\n", granule_pos);

// If the granule_pos > 0, we have reached the end of headers and
Expand Down Expand Up @@ -266,7 +266,7 @@ _ogg_parse(PerlIO *infile, char *file, HV *info, HV *tags, uint8_t seeking)
buffer_clear(&ogg_buf);

// audio_offset is 28 less because we read the Ogg header
audio_offset -= 28;
audio_offset -= OGG_HEADER_SIZE;

// from the first packet past the comments
my_hv_store( info, "audio_offset", newSViv(audio_offset) );
Expand Down Expand Up @@ -523,7 +523,7 @@ ogg_find_frame(PerlIO *infile, char *file, int offset)
samplerate = SvIV( *(my_hv_fetch( info, "samplerate" )) );

// Determine target sample we're looking for
target_sample = ((offset - 1) / 10) * (samplerate / 100);
target_sample = (uint64_t)offset * samplerate / 1000;
DEBUG_TRACE("Looking for target sample %llu\n", target_sample);

frame_offset = _ogg_binary_search_sample(infile, file, info, target_sample);
Expand All @@ -543,150 +543,119 @@ _ogg_binary_search_sample(PerlIO *infile, char *file, HV *info, uint64_t target_
unsigned char *bptr;
unsigned int buf_size;
int frame_offset = -1;
int prev_frame_offset = -1;
int best_frame_offset = -1;
uint64_t granule_pos = 0;
uint64_t prev_granule_pos = 0;
uint32_t cur_serialno;
off_t low;
off_t high;
off_t mid;
int i;

off_t audio_offset = SvIV( *(my_hv_fetch( info, "audio_offset" )) );
off_t file_size = SvIV( *(my_hv_fetch( info, "file_size" )) );
uint32_t serialno = SvIV( *(my_hv_fetch( info, "serial_number" )) );

// Binary search the entire file
// Define the binary search range
low = audio_offset;
high = file_size;
high = file_size - OGG_HEADER_SIZE;

// We need enough for at least 2 packets
buffer_init(&buf, OGG_BLOCK_SIZE * 2);
buffer_init(&buf, OGG_MAX_PAGE_SIZE + OGG_HEADER_SIZE);

while (low <= high) {
off_t packet_offset;
while (high > low) {
off_t mid;
off_t page_start_offset = -1;
uint32_t cur_serialno;
int i; // Used by macro CONVERT_INT32LE

mid = low + ((high - low) / 2);
if (high - low > OGG_HEADER_SIZE) {
mid = low + ((high - low) / 2);
} else {
mid = low; // Fast-forward search
}

DEBUG_TRACE(" Searching for sample %llu between %d and %d (mid %d)\n", target_sample, (int)low, (int)high, (int)mid);
DEBUG_TRACE(" Searching for sample %llu between %d and %d (mid %d)\n", target_sample, low, high, mid);

if (mid > file_size - 28) {
if (mid > file_size - OGG_HEADER_SIZE) {
DEBUG_TRACE(" Reached end of file, aborting\n");
frame_offset = -1;
goto out;
}

if ( (PerlIO_seek(infile, mid, SEEK_SET)) == -1 ) {
if (PerlIO_seek(infile, mid, SEEK_SET) == -1) {
frame_offset = -1;
goto out;
}

if ( !_check_buf(infile, &buf, 28, OGG_BLOCK_SIZE * 2) ) {
buffer_clear(&buf);

// Worst case is:
// ....OggS...<OGG_MAX_PAGE_SIZE>...OggS
// ^-mid ^-high
//
// To handle this, read OGG_HEADER_SIZE bytes extra after 'high'
// so that we find the header that starts just before 'high'.
if (!_check_buf(infile, &buf, OGG_HEADER_SIZE,
MIN(OGG_MAX_PAGE_SIZE, high - mid) + OGG_HEADER_SIZE)) {
frame_offset = -1;
goto out;
}

bptr = buffer_ptr(&buf);
buf_size = buffer_len(&buf);

// Find all packets within this buffer, we need at least 2 packets
// to figure out what samples we have
while (buf_size >= 4) {
// Save info from previous packet
prev_frame_offset = frame_offset;
prev_granule_pos = granule_pos;

while (
buf_size >= 4
&&
(bptr[0] != 'O' || bptr[1] != 'g' || bptr[2] != 'g' || bptr[3] != 'S')
) {
bptr++;
buf_size--;
}

for (bptr = buffer_ptr(&buf), buf_size = buffer_len(&buf); ; ++bptr, --buf_size) {
if (buf_size < 4) {
// No more packets found in buffer
break;
}

// Remember how far into the buffer this packet is
packet_offset = buffer_len(&buf) - buf_size;

frame_offset = mid + packet_offset;

// Make sure we have at least the Ogg header
if ( !_check_buf(infile, &buf, 28, 28) ) {
frame_offset = -1;
goto out;
// no page start found!?
break;
}

// Read granule_pos for this packet
bptr = buffer_ptr(&buf);
bptr += packet_offset + 6;
granule_pos = (uint64_t)CONVERT_INT32LE(bptr);
bptr += 4;
granule_pos |= (uint64_t)CONVERT_INT32LE(bptr) << 32;
bptr += 4;
buf_size -= 14;

// Also read serial number, if this ever changes within a file it is a chained
// file and we can't seek
cur_serialno = CONVERT_INT32LE(bptr);

if (serialno != cur_serialno) {
DEBUG_TRACE(" serial number changed to %x, aborting seek\n", cur_serialno);
frame_offset = -1;
goto out;
if (bptr[0] != 'O' || bptr[1] != 'g' || bptr[2] != 'g' || bptr[3] != 'S') {
continue;
}

DEBUG_TRACE(" frame offset: %d, prev_frame_offset: %d, granule_pos: %llu, prev_granule_pos %llu\n",
frame_offset, prev_frame_offset, granule_pos, prev_granule_pos
);
page_start_offset = buffer_len(&buf) - buf_size;
frame_offset = mid + page_start_offset;
break;
}

// Break out after reading 2 packets
if (granule_pos && prev_granule_pos) {
break;
}
if (page_start_offset < 0) {
DEBUG_TRACE(" Nothing found in upper half, searching lower\n");
high = mid;
continue;
}

// Now, we know the first (prev_granule_pos + 1) and last (granule_pos) samples
// in the packet starting at frame_offset
DEBUG_TRACE(" checking frame at %d\n", frame_offset);

if ((prev_granule_pos + 1) <= target_sample && granule_pos >= target_sample) {
// found frame
DEBUG_TRACE(" found frame at %d\n", frame_offset);
// Read granule_pos for this packet
bptr = buffer_ptr(&buf);
bptr += page_start_offset + 6;
granule_pos = (uint64_t)CONVERT_INT32LE(bptr);
bptr += 4;
granule_pos |= (uint64_t)CONVERT_INT32LE(bptr) << 32;
bptr += 4;

// Also read serial number, if this ever changes within a file it is a chained
// file and we can't seek
cur_serialno = CONVERT_INT32LE(bptr);
if (serialno != cur_serialno) {
DEBUG_TRACE(" serial number changed to %x, aborting seek\n", cur_serialno);
frame_offset = -1;
goto out;
}

if (target_sample < (prev_granule_pos + 1)) {
// Special case when very first frame has the sample
if (prev_frame_offset == audio_offset) {
DEBUG_TRACE(" first frame has target sample\n");
frame_offset = prev_frame_offset;
break;
}

high = mid - 1;
DEBUG_TRACE(" high = %d\n", (int)high);
if (granule_pos > target_sample) {
best_frame_offset = frame_offset;
DEBUG_TRACE(" searching lower\n");
high = mid;
}
else if (granule_pos < target_sample) {
DEBUG_TRACE(" searching higher\n");
low = frame_offset + OGG_HEADER_SIZE;
}
else {
low = mid + 1;
DEBUG_TRACE(" low = %d\n", (int)low);
DEBUG_TRACE(" found frame at %d\n", frame_offset);
best_frame_offset = frame_offset;
break;
}

// XXX this can be pretty inefficient in some cases

// Reset and binary search again
buffer_clear(&buf);

frame_offset = -1;
granule_pos = 0;
}

frame_offset = best_frame_offset;

out:
buffer_free(&buf);

return frame_offset;
}

Loading

0 comments on commit 5c5deba

Please sign in to comment.