475 lines
17 KiB
Python
475 lines
17 KiB
Python
|
import pyogg
|
||
|
|
||
|
import os.path
|
||
|
import warnings
|
||
|
|
||
|
from abc import abstractmethod
|
||
|
from ctypes import c_void_p, POINTER, c_int, pointer, cast, c_char, c_char_p, CFUNCTYPE, c_ubyte
|
||
|
from ctypes import memmove, create_string_buffer, byref
|
||
|
|
||
|
from pyglet.media import StreamingSource
|
||
|
from pyglet.media.codecs import AudioFormat, AudioData, MediaDecoder, StaticSource
|
||
|
from pyglet.util import debug_print
|
||
|
|
||
|
|
||
|
_debug = debug_print('Debug PyOgg codec')
|
||
|
|
||
|
if _debug:
|
||
|
if not pyogg.PYOGG_OGG_AVAIL and not pyogg.PYOGG_VORBIS_AVAIL and not pyogg.PYOGG_VORBIS_FILE_AVAIL:
|
||
|
warnings.warn("PyOgg determined the ogg/vorbis libraries were not available.")
|
||
|
|
||
|
if not pyogg.PYOGG_FLAC_AVAIL:
|
||
|
warnings.warn("PyOgg determined the flac library was not available.")
|
||
|
|
||
|
if not pyogg.PYOGG_OPUS_AVAIL and not pyogg.PYOGG_OPUS_FILE_AVAIL:
|
||
|
warnings.warn("PyOgg determined the opus libraries were not available.")
|
||
|
|
||
|
if not (
|
||
|
pyogg.PYOGG_OGG_AVAIL and not pyogg.PYOGG_VORBIS_AVAIL and not pyogg.PYOGG_VORBIS_FILE_AVAIL) and (
|
||
|
not pyogg.PYOGG_OPUS_AVAIL and not pyogg.PYOGG_OPUS_FILE_AVAIL) and not pyogg.PYOGG_FLAC_AVAIL:
|
||
|
raise ImportError("PyOgg determined no supported libraries were found")
|
||
|
|
||
|
# Some monkey patching PyOgg for FLAC.
|
||
|
if pyogg.PYOGG_FLAC_AVAIL:
|
||
|
# Original in PyOgg: FLAC__StreamDecoderEofCallback = CFUNCTYPE(FLAC__bool, POINTER(FLAC__StreamDecoder), c_void_p)
|
||
|
# FLAC__bool is not valid for this return type (at least for ctypes). Needs to be an int or an error occurs.
|
||
|
FLAC__StreamDecoderEofCallback = CFUNCTYPE(c_int, POINTER(pyogg.flac.FLAC__StreamDecoder), c_void_p)
|
||
|
|
||
|
# Override explicits with c_void_p, so we can support non-seeking FLAC's (CFUNCTYPE does not accept None).
|
||
|
pyogg.flac.libflac.FLAC__stream_decoder_init_stream.restype = pyogg.flac.FLAC__StreamDecoderInitStatus
|
||
|
pyogg.flac.libflac.FLAC__stream_decoder_init_stream.argtypes = [POINTER(pyogg.flac.FLAC__StreamDecoder),
|
||
|
pyogg.flac.FLAC__StreamDecoderReadCallback,
|
||
|
c_void_p, # Seek
|
||
|
c_void_p, # Tell
|
||
|
c_void_p, # Length
|
||
|
c_void_p, # EOF
|
||
|
pyogg.flac.FLAC__StreamDecoderWriteCallback,
|
||
|
pyogg.flac.FLAC__StreamDecoderMetadataCallback,
|
||
|
pyogg.flac.FLAC__StreamDecoderErrorCallback,
|
||
|
c_void_p]
|
||
|
|
||
|
|
||
|
def metadata_callback(self, decoder, metadata, client_data):
|
||
|
self.bits_per_sample = metadata.contents.data.stream_info.bits_per_sample # missing from pyogg
|
||
|
self.total_samples = metadata.contents.data.stream_info.total_samples
|
||
|
self.channels = metadata.contents.data.stream_info.channels
|
||
|
self.frequency = metadata.contents.data.stream_info.sample_rate
|
||
|
|
||
|
|
||
|
# Monkey patch metadata callback to include bits per sample as FLAC may rarely deviate from 16 bit.
|
||
|
pyogg.FlacFileStream.metadata_callback = metadata_callback
|
||
|
|
||
|
|
||
|
class MemoryVorbisObject:
|
||
|
def __init__(self, file):
|
||
|
self.file = file
|
||
|
|
||
|
def read_func_cb(ptr, byte_size, size_to_read, datasource):
|
||
|
data_size = size_to_read * byte_size
|
||
|
data = self.file.read(data_size)
|
||
|
read_size = len(data)
|
||
|
memmove(ptr, data, read_size)
|
||
|
return read_size
|
||
|
|
||
|
def seek_func_cb(datasource, offset, whence):
|
||
|
pos = self.file.seek(offset, whence)
|
||
|
return pos
|
||
|
|
||
|
def close_func_cb(datasource):
|
||
|
return 0
|
||
|
|
||
|
def tell_func_cb(datasource):
|
||
|
return self.file.tell()
|
||
|
|
||
|
self.read_func = pyogg.vorbis.read_func(read_func_cb)
|
||
|
self.seek_func = pyogg.vorbis.seek_func(seek_func_cb)
|
||
|
self.close_func = pyogg.vorbis.close_func(close_func_cb)
|
||
|
self.tell_func = pyogg.vorbis.tell_func(tell_func_cb)
|
||
|
|
||
|
self.callbacks = pyogg.vorbis.ov_callbacks(self.read_func, self.seek_func, self.close_func, self.tell_func)
|
||
|
|
||
|
|
||
|
class UnclosedVorbisFileStream(pyogg.VorbisFileStream):
|
||
|
def __del__(self):
|
||
|
if self.exists:
|
||
|
pyogg.vorbis.ov_clear(byref(self.vf))
|
||
|
self.exists = False
|
||
|
|
||
|
def clean_up(self):
|
||
|
"""PyOgg calls clean_up on end of data. We may want to loop a sound or replay. Prevent this.
|
||
|
Rely on GC (__del__) to clean up objects instead.
|
||
|
"""
|
||
|
return
|
||
|
|
||
|
|
||
|
class UnclosedOpusFileStream(pyogg.OpusFileStream):
|
||
|
def __del__(self):
|
||
|
self.ptr.contents.value = self.ptr_init
|
||
|
|
||
|
del self.ptr
|
||
|
|
||
|
if self.of:
|
||
|
pyogg.opus.op_free(self.of)
|
||
|
|
||
|
def clean_up(self):
|
||
|
pass
|
||
|
|
||
|
|
||
|
class MemoryOpusObject:
|
||
|
def __init__(self, filename, file):
|
||
|
self.file = file
|
||
|
self.filename = filename
|
||
|
|
||
|
def read_func_cb(stream, buffer, size):
|
||
|
data = self.file.read(size)
|
||
|
read_size = len(data)
|
||
|
memmove(buffer, data, read_size)
|
||
|
return read_size
|
||
|
|
||
|
def seek_func_cb(stream, offset, whence):
|
||
|
self.file.seek(offset, whence)
|
||
|
return 0
|
||
|
|
||
|
def tell_func_cb(stream):
|
||
|
pos = self.file.tell()
|
||
|
return pos
|
||
|
|
||
|
def close_func_cb(stream):
|
||
|
return 0
|
||
|
|
||
|
self.read_func = pyogg.opus.op_read_func(read_func_cb)
|
||
|
self.seek_func = pyogg.opus.op_seek_func(seek_func_cb)
|
||
|
self.tell_func = pyogg.opus.op_tell_func(tell_func_cb)
|
||
|
self.close_func = pyogg.opus.op_close_func(close_func_cb)
|
||
|
|
||
|
self.callbacks = pyogg.opus.OpusFileCallbacks(self.read_func, self.seek_func, self.tell_func, self.close_func)
|
||
|
|
||
|
|
||
|
class MemoryOpusFileStream(UnclosedOpusFileStream):
|
||
|
def __init__(self, filename, file):
|
||
|
self.file = file
|
||
|
|
||
|
self.memory_object = MemoryOpusObject(filename, file)
|
||
|
|
||
|
self._dummy_fileobj = c_void_p()
|
||
|
|
||
|
error = c_int()
|
||
|
|
||
|
self.read_buffer = create_string_buffer(pyogg.PYOGG_STREAM_BUFFER_SIZE)
|
||
|
|
||
|
self.ptr_buffer = cast(self.read_buffer, POINTER(c_ubyte))
|
||
|
|
||
|
self.of = pyogg.opus.op_open_callbacks(
|
||
|
self._dummy_fileobj,
|
||
|
byref(self.memory_object.callbacks),
|
||
|
self.ptr_buffer,
|
||
|
0, # Start length
|
||
|
byref(error)
|
||
|
)
|
||
|
|
||
|
if error.value != 0:
|
||
|
raise pyogg.PyOggError(
|
||
|
"file-like object: {} couldn't be processed. Error code : {}".format(filename, error.value))
|
||
|
|
||
|
self.channels = pyogg.opus.op_channel_count(self.of, -1)
|
||
|
|
||
|
self.pcm_size = pyogg.opus.op_pcm_total(self.of, -1)
|
||
|
|
||
|
self.frequency = 48000
|
||
|
|
||
|
self.bfarr_t = pyogg.opus.opus_int16 * (pyogg.PYOGG_STREAM_BUFFER_SIZE * self.channels * 2)
|
||
|
|
||
|
self.buffer = cast(pointer(self.bfarr_t()), pyogg.opus.opus_int16_p)
|
||
|
|
||
|
self.ptr = cast(pointer(self.buffer), POINTER(c_void_p))
|
||
|
|
||
|
self.ptr_init = self.ptr.contents.value
|
||
|
|
||
|
|
||
|
class MemoryVorbisFileStream(UnclosedVorbisFileStream):
|
||
|
def __init__(self, path, file):
|
||
|
buff = create_string_buffer(pyogg.PYOGG_STREAM_BUFFER_SIZE)
|
||
|
|
||
|
self.vf = pyogg.vorbis.OggVorbis_File()
|
||
|
self.memory_object = MemoryVorbisObject(file)
|
||
|
|
||
|
error = pyogg.vorbis.libvorbisfile.ov_open_callbacks(buff, self.vf, None, 0, self.memory_object.callbacks)
|
||
|
if error != 0:
|
||
|
raise pyogg.PyOggError("file couldn't be opened or doesn't exist. Error code : {}".format(error))
|
||
|
|
||
|
info = pyogg.vorbis.ov_info(byref(self.vf), -1)
|
||
|
|
||
|
self.channels = info.contents.channels
|
||
|
|
||
|
self.frequency = info.contents.rate
|
||
|
|
||
|
array = (c_char * (pyogg.PYOGG_STREAM_BUFFER_SIZE * self.channels))()
|
||
|
|
||
|
self.buffer_ = cast(pointer(array), c_char_p)
|
||
|
|
||
|
self.bitstream = c_int()
|
||
|
self.bitstream_pointer = pointer(self.bitstream)
|
||
|
|
||
|
self.exists = True
|
||
|
|
||
|
|
||
|
class UnclosedFLACFileStream(pyogg.FlacFileStream):
|
||
|
def __init__(self, *args, **kw):
|
||
|
super().__init__(*args, **kw)
|
||
|
self.seekable = True
|
||
|
|
||
|
def __del__(self):
|
||
|
if self.decoder:
|
||
|
pyogg.flac.FLAC__stream_decoder_finish(self.decoder)
|
||
|
|
||
|
|
||
|
class MemoryFLACFileStream(UnclosedFLACFileStream):
|
||
|
def __init__(self, path, file):
|
||
|
self.file = file
|
||
|
|
||
|
self.file_size = 0
|
||
|
|
||
|
if getattr(self.file, 'seek', None) and getattr(self.file, 'tell', None):
|
||
|
self.seekable = True
|
||
|
self.file.seek(0, 2)
|
||
|
self.file_size = self.file.tell()
|
||
|
self.file.seek(0)
|
||
|
else:
|
||
|
warnings.warn(f"Warning: {file} file object is not seekable.")
|
||
|
self.seekable = False
|
||
|
|
||
|
self.decoder = pyogg.flac.FLAC__stream_decoder_new()
|
||
|
|
||
|
self.client_data = c_void_p()
|
||
|
|
||
|
self.channels = None
|
||
|
|
||
|
self.frequency = None
|
||
|
|
||
|
self.total_samples = None
|
||
|
|
||
|
self.buffer = None
|
||
|
|
||
|
self.bytes_written = None
|
||
|
|
||
|
self.write_callback_ = pyogg.flac.FLAC__StreamDecoderWriteCallback(self.write_callback)
|
||
|
self.metadata_callback_ = pyogg.flac.FLAC__StreamDecoderMetadataCallback(self.metadata_callback)
|
||
|
self.error_callback_ = pyogg.flac.FLAC__StreamDecoderErrorCallback(self.error_callback)
|
||
|
self.read_callback_ = pyogg.flac.FLAC__StreamDecoderReadCallback(self.read_callback)
|
||
|
|
||
|
if self.seekable:
|
||
|
self.seek_callback_ = pyogg.flac.FLAC__StreamDecoderSeekCallback(self.seek_callback)
|
||
|
self.tell_callback_ = pyogg.flac.FLAC__StreamDecoderTellCallback(self.tell_callback)
|
||
|
self.length_callback_ = pyogg.flac.FLAC__StreamDecoderLengthCallback(self.length_callback)
|
||
|
self.eof_callback_ = FLAC__StreamDecoderEofCallback(self.eof_callback)
|
||
|
else:
|
||
|
self.seek_callback_ = None
|
||
|
self.tell_callback_ = None
|
||
|
self.length_callback_ = None
|
||
|
self.eof_callback_ = None
|
||
|
|
||
|
init_status = pyogg.flac.libflac.FLAC__stream_decoder_init_stream(
|
||
|
self.decoder,
|
||
|
self.read_callback_,
|
||
|
self.seek_callback_,
|
||
|
self.tell_callback_,
|
||
|
self.length_callback_,
|
||
|
self.eof_callback_,
|
||
|
self.write_callback_,
|
||
|
self.metadata_callback_,
|
||
|
self.error_callback_,
|
||
|
self.client_data
|
||
|
)
|
||
|
|
||
|
if init_status: # error
|
||
|
raise pyogg.PyOggError("An error occurred when trying to open '{}': {}".format(
|
||
|
path, pyogg.flac.FLAC__StreamDecoderInitStatusEnum[init_status]))
|
||
|
|
||
|
metadata_status = pyogg.flac.FLAC__stream_decoder_process_until_end_of_metadata(self.decoder)
|
||
|
if not metadata_status: # error
|
||
|
raise pyogg.PyOggError("An error occured when trying to decode the metadata of {}".format(path))
|
||
|
|
||
|
def read_callback(self, decoder, buffer, size, data):
|
||
|
chunk = size.contents.value
|
||
|
data = self.file.read(chunk)
|
||
|
read_size = len(data)
|
||
|
memmove(buffer, data, read_size)
|
||
|
|
||
|
size.contents.value = read_size
|
||
|
|
||
|
if read_size > 0:
|
||
|
return 0 # FLAC__STREAM_DECODER_READ_STATUS_CONTINUE
|
||
|
elif read_size == 0:
|
||
|
return 1 # FLAC__STREAM_DECODER_READ_STATUS_END_OF_STREAM
|
||
|
else:
|
||
|
return 2 # FLAC__STREAM_DECODER_READ_STATUS_ABORT
|
||
|
|
||
|
def seek_callback(self, decoder, offset, data):
|
||
|
pos = self.file.seek(offset, 0)
|
||
|
if pos < 0:
|
||
|
return 1 # FLAC__STREAM_DECODER_SEEK_STATUS_ERROR
|
||
|
else:
|
||
|
return 0 # FLAC__STREAM_DECODER_SEEK_STATUS_OK
|
||
|
|
||
|
def tell_callback(self, decoder, offset, data):
|
||
|
"""Decoder wants to know the current position of the file stream."""
|
||
|
pos = self.file.tell()
|
||
|
if pos < 0:
|
||
|
return 1 # FLAC__STREAM_DECODER_TELL_STATUS_ERROR
|
||
|
else:
|
||
|
offset.contents.value = pos
|
||
|
return 0 # FLAC__STREAM_DECODER_TELL_STATUS_OK
|
||
|
|
||
|
def length_callback(self, decoder, length, data):
|
||
|
"""Decoder wants to know the total length of the stream."""
|
||
|
if self.file_size == 0:
|
||
|
return 1 # FLAC__STREAM_DECODER_LENGTH_STATUS_ERROR
|
||
|
else:
|
||
|
length.contents.value = self.file_size
|
||
|
return 0 # FLAC__STREAM_DECODER_LENGTH_STATUS_OK
|
||
|
|
||
|
def eof_callback(self, decoder, data):
|
||
|
return self.file.tell() >= self.file_size
|
||
|
|
||
|
|
||
|
class PyOggSource(StreamingSource):
|
||
|
def __init__(self, filename, file):
|
||
|
self.filename = filename
|
||
|
self.file = file
|
||
|
self._stream = None
|
||
|
self.sample_size = 16
|
||
|
|
||
|
self._load_source()
|
||
|
|
||
|
self.audio_format = AudioFormat(channels=self._stream.channels, sample_size=self.sample_size,
|
||
|
sample_rate=self._stream.frequency)
|
||
|
|
||
|
@abstractmethod
|
||
|
def _load_source(self):
|
||
|
pass
|
||
|
|
||
|
def get_audio_data(self, num_bytes, compensation_time=0.0):
|
||
|
"""Data returns as c_short_array instead of LP_c_char or c_ubyte, cast each buffer."""
|
||
|
data = self._stream.get_buffer() # Returns buffer, length or None
|
||
|
if data is not None:
|
||
|
buff, length = data
|
||
|
buff_char_p = cast(buff, POINTER(c_char))
|
||
|
return AudioData(buff_char_p[:length], length, 1000, 1000, [])
|
||
|
|
||
|
return None
|
||
|
|
||
|
def __del__(self):
|
||
|
if self._stream:
|
||
|
del self._stream
|
||
|
|
||
|
|
||
|
class PyOggFLACSource(PyOggSource):
|
||
|
|
||
|
def _load_source(self):
|
||
|
if self.file:
|
||
|
self._stream = MemoryFLACFileStream(self.filename, self.file)
|
||
|
else:
|
||
|
self._stream = UnclosedFLACFileStream(self.filename)
|
||
|
|
||
|
self.sample_size = self._stream.bits_per_sample
|
||
|
self._duration = self._stream.total_samples / self._stream.frequency
|
||
|
|
||
|
# Unknown amount of samples. May occur in some sources.
|
||
|
if self._stream.total_samples == 0:
|
||
|
if _debug:
|
||
|
warnings.warn(f"Unknown amount of samples found in {self.filename}. Seeking may be limited.")
|
||
|
self._duration_per_frame = 0
|
||
|
else:
|
||
|
self._duration_per_frame = self._duration / self._stream.total_samples
|
||
|
|
||
|
def seek(self, timestamp):
|
||
|
if self._stream.seekable:
|
||
|
# Convert sample to seconds.
|
||
|
if self._duration_per_frame:
|
||
|
timestamp = max(0.0, min(timestamp, self._duration))
|
||
|
position = int(timestamp / self._duration_per_frame)
|
||
|
else: # If we have no duration, we cannot reliably seek. However, 0.0 is still required to play and loop.
|
||
|
position = 0
|
||
|
seek_succeeded = pyogg.flac.FLAC__stream_decoder_seek_absolute(self._stream.decoder, position)
|
||
|
if seek_succeeded is False:
|
||
|
warnings.warn(f"Failed to seek FLAC file: {self.filename}")
|
||
|
else:
|
||
|
warnings.warn(f"Stream is not seekable for FLAC file: {self.filename}.")
|
||
|
|
||
|
|
||
|
class PyOggVorbisSource(PyOggSource):
|
||
|
|
||
|
def _load_source(self):
|
||
|
if self.file:
|
||
|
self._stream = MemoryVorbisFileStream(self.filename, self.file)
|
||
|
else:
|
||
|
self._stream = UnclosedVorbisFileStream(self.filename)
|
||
|
|
||
|
self._duration = pyogg.vorbis.libvorbisfile.ov_time_total(byref(self._stream.vf), -1)
|
||
|
|
||
|
def get_audio_data(self, num_bytes, compensation_time=0.0):
|
||
|
data = self._stream.get_buffer() # Returns buffer, length or None
|
||
|
|
||
|
if data is not None:
|
||
|
return AudioData(*data, 1000, 1000, [])
|
||
|
|
||
|
return None
|
||
|
|
||
|
def seek(self, timestamp):
|
||
|
seek_succeeded = pyogg.vorbis.ov_time_seek(self._stream.vf, timestamp)
|
||
|
if seek_succeeded != 0:
|
||
|
if _debug:
|
||
|
warnings.warn(f"Failed to seek file {self.filename} - {seek_succeeded}")
|
||
|
|
||
|
|
||
|
class PyOggOpusSource(PyOggSource):
|
||
|
def _load_source(self):
|
||
|
if self.file:
|
||
|
self._stream = MemoryOpusFileStream(self.filename, self.file)
|
||
|
else:
|
||
|
self._stream = UnclosedOpusFileStream(self.filename)
|
||
|
|
||
|
self._duration = self._stream.pcm_size / self._stream.frequency
|
||
|
self._duration_per_frame = self._duration / self._stream.pcm_size
|
||
|
|
||
|
def seek(self, timestamp):
|
||
|
timestamp = max(0.0, min(timestamp, self._duration))
|
||
|
position = int(timestamp / self._duration_per_frame)
|
||
|
error = pyogg.opus.op_pcm_seek(self._stream.of, position)
|
||
|
if error:
|
||
|
warnings.warn(f"Opus stream could not seek properly {error}.")
|
||
|
|
||
|
|
||
|
class PyOggDecoder(MediaDecoder):
|
||
|
vorbis_exts = ('.ogg',) if pyogg.PYOGG_OGG_AVAIL and pyogg.PYOGG_VORBIS_AVAIL and pyogg.PYOGG_VORBIS_FILE_AVAIL else ()
|
||
|
flac_exts = ('.flac',) if pyogg.PYOGG_FLAC_AVAIL else ()
|
||
|
opus_exts = ('.opus',) if pyogg.PYOGG_OPUS_AVAIL and pyogg.PYOGG_OPUS_FILE_AVAIL else ()
|
||
|
exts = vorbis_exts + flac_exts + opus_exts
|
||
|
|
||
|
def get_file_extensions(self):
|
||
|
return PyOggDecoder.exts
|
||
|
|
||
|
def decode(self, file, filename, streaming=True):
|
||
|
name, ext = os.path.splitext(filename)
|
||
|
if ext in PyOggDecoder.vorbis_exts:
|
||
|
source = PyOggVorbisSource
|
||
|
elif ext in PyOggDecoder.flac_exts:
|
||
|
source = PyOggFLACSource
|
||
|
elif ext in PyOggDecoder.opus_exts:
|
||
|
source = PyOggOpusSource
|
||
|
else:
|
||
|
raise Exception("Decoder could not find a suitable source to use with this filetype.")
|
||
|
|
||
|
if streaming:
|
||
|
return source(filename, file)
|
||
|
else:
|
||
|
return StaticSource(source(filename, file))
|
||
|
|
||
|
|
||
|
def get_decoders():
|
||
|
return [PyOggDecoder()]
|
||
|
|
||
|
|
||
|
def get_encoders():
|
||
|
return []
|