430 lines
14 KiB
Python
430 lines
14 KiB
Python
# This file is part of audioread.
|
|
# Copyright 2011, Adrian Sampson.
|
|
#
|
|
# Permission is hereby granted, free of charge, to any person obtaining
|
|
# a copy of this software and associated documentation files (the
|
|
# "Software"), to deal in the Software without restriction, including
|
|
# without limitation the rights to use, copy, modify, merge, publish,
|
|
# distribute, sublicense, and/or sell copies of the Software, and to
|
|
# permit persons to whom the Software is furnished to do so, subject to
|
|
# the following conditions:
|
|
#
|
|
# The above copyright notice and this permission notice shall be
|
|
# included in all copies or substantial portions of the Software.
|
|
|
|
"""Use Gstreamer to decode audio files.
|
|
|
|
To read an audio file, pass it to the constructor for GstAudioFile()
|
|
and then iterate over the contents:
|
|
|
|
>>> f = GstAudioFile('something.mp3')
|
|
>>> try:
|
|
>>> for block in f:
|
|
>>> ...
|
|
>>> finally:
|
|
>>> f.close()
|
|
|
|
Note that there are a few complications caused by Gstreamer's
|
|
asynchronous architecture. This module spawns its own Gobject main-
|
|
loop thread; I'm not sure how that will interact with other main
|
|
loops if your program has them. Also, in order to stop the thread
|
|
and terminate your program normally, you need to call the close()
|
|
method on every GstAudioFile you create. Conveniently, the file can be
|
|
used as a context manager to make this simpler:
|
|
|
|
>>> with GstAudioFile('something.mp3') as f:
|
|
>>> for block in f:
|
|
>>> ...
|
|
|
|
Iterating a GstAudioFile yields strings containing short integer PCM
|
|
data. You can also read the sample rate and channel count from the
|
|
file:
|
|
|
|
>>> with GstAudioFile('something.mp3') as f:
|
|
>>> print f.samplerate
|
|
>>> print f.channels
|
|
>>> print f.duration
|
|
"""
|
|
|
|
import gi
|
|
gi.require_version('Gst', '1.0')
|
|
from gi.repository import GLib, Gst
|
|
|
|
import sys
|
|
import threading
|
|
import os
|
|
import queue
|
|
from urllib.parse import quote
|
|
|
|
from .exceptions import DecodeError
|
|
from .base import AudioFile
|
|
|
|
QUEUE_SIZE = 10
|
|
BUFFER_SIZE = 10
|
|
SENTINEL = '__GSTDEC_SENTINEL__'
|
|
|
|
|
|
# Exceptions.
|
|
|
|
class GStreamerError(DecodeError):
|
|
pass
|
|
|
|
|
|
class UnknownTypeError(GStreamerError):
|
|
"""Raised when Gstreamer can't decode the given file type."""
|
|
def __init__(self, streaminfo):
|
|
super().__init__(
|
|
"can't decode stream: " + streaminfo
|
|
)
|
|
self.streaminfo = streaminfo
|
|
|
|
|
|
class FileReadError(GStreamerError):
|
|
"""Raised when the file can't be read at all."""
|
|
pass
|
|
|
|
|
|
class NoStreamError(GStreamerError):
|
|
"""Raised when the file was read successfully but no audio streams
|
|
were found.
|
|
"""
|
|
def __init__(self):
|
|
super().__init__('no audio streams found')
|
|
|
|
|
|
class MetadataMissingError(GStreamerError):
|
|
"""Raised when GStreamer fails to report stream metadata (duration,
|
|
channels, or sample rate).
|
|
"""
|
|
pass
|
|
|
|
|
|
class IncompleteGStreamerError(GStreamerError):
|
|
"""Raised when necessary components of GStreamer (namely, the
|
|
principal plugin packages) are missing.
|
|
"""
|
|
def __init__(self):
|
|
super().__init__(
|
|
'missing GStreamer base plugins'
|
|
)
|
|
|
|
|
|
# Managing the Gobject main loop thread.
|
|
|
|
_shared_loop_thread = None
|
|
_loop_thread_lock = threading.RLock()
|
|
|
|
Gst.init(None)
|
|
|
|
def get_loop_thread():
|
|
"""Get the shared main-loop thread.
|
|
"""
|
|
global _shared_loop_thread
|
|
with _loop_thread_lock:
|
|
if not _shared_loop_thread:
|
|
# Start a new thread.
|
|
_shared_loop_thread = MainLoopThread()
|
|
_shared_loop_thread.start()
|
|
return _shared_loop_thread
|
|
|
|
|
|
class MainLoopThread(threading.Thread):
|
|
"""A daemon thread encapsulating a Gobject main loop.
|
|
"""
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.loop = GLib.MainLoop.new(None, False)
|
|
self.daemon = True
|
|
|
|
def run(self):
|
|
self.loop.run()
|
|
|
|
|
|
# The decoder.
|
|
|
|
class GstAudioFile(AudioFile):
|
|
"""Reads raw audio data from any audio file that Gstreamer
|
|
knows how to decode.
|
|
|
|
>>> with GstAudioFile('something.mp3') as f:
|
|
>>> print f.samplerate
|
|
>>> print f.channels
|
|
>>> print f.duration
|
|
>>> for block in f:
|
|
>>> do_something(block)
|
|
|
|
Iterating the object yields blocks of 16-bit PCM data. Three
|
|
pieces of stream information are also available: samplerate (in Hz),
|
|
number of channels, and duration (in seconds).
|
|
|
|
It's very important that the client call close() when it's done
|
|
with the object. Otherwise, the program is likely to hang on exit.
|
|
Alternatively, of course, one can just use the file as a context
|
|
manager, as shown above.
|
|
"""
|
|
def __init__(self, path):
|
|
self.running = False
|
|
self.finished = False
|
|
|
|
# Set up the Gstreamer pipeline.
|
|
self.pipeline = Gst.Pipeline()
|
|
|
|
self.dec = Gst.ElementFactory.make("uridecodebin", None)
|
|
self.conv = Gst.ElementFactory.make("audioconvert", None)
|
|
self.sink = Gst.ElementFactory.make("appsink", None)
|
|
|
|
if self.dec is None or self.conv is None or self.sink is None:
|
|
# uridecodebin, audioconvert, or appsink is missing. We need
|
|
# gst-plugins-base.
|
|
raise IncompleteGStreamerError()
|
|
|
|
# Register for bus signals.
|
|
bus = self.pipeline.get_bus()
|
|
bus.add_signal_watch()
|
|
bus.connect("message::eos", self._message)
|
|
bus.connect("message::error", self._message)
|
|
|
|
# Configure the input.
|
|
uri = 'file://' + quote(os.path.abspath(path))
|
|
self.dec.set_property("uri", uri)
|
|
# The callback to connect the input.
|
|
self.dec.connect("pad-added", self._pad_added)
|
|
self.dec.connect("no-more-pads", self._no_more_pads)
|
|
# And a callback if decoding fails.
|
|
self.dec.connect("unknown-type", self._unkown_type)
|
|
|
|
# Configure the output.
|
|
# We want short integer data.
|
|
self.sink.set_property(
|
|
'caps',
|
|
Gst.Caps.from_string('audio/x-raw, format=(string)S16LE'),
|
|
)
|
|
# TODO set endianness?
|
|
# Set up the characteristics of the output. We don't want to
|
|
# drop any data (nothing is real-time here); we should bound
|
|
# the memory usage of the internal queue; and, most
|
|
# importantly, setting "sync" to False disables the default
|
|
# behavior in which you consume buffers in real time. This way,
|
|
# we get data as soon as it's decoded.
|
|
self.sink.set_property('drop', False)
|
|
self.sink.set_property('max-buffers', BUFFER_SIZE)
|
|
self.sink.set_property('sync', False)
|
|
# The callback to receive decoded data.
|
|
self.sink.set_property('emit-signals', True)
|
|
self.sink.connect("new-sample", self._new_sample)
|
|
|
|
# We'll need to know when the stream becomes ready and we get
|
|
# its attributes. This semaphore will become available when the
|
|
# caps are received. That way, when __init__() returns, the file
|
|
# (and its attributes) will be ready for reading.
|
|
self.ready_sem = threading.Semaphore(0)
|
|
self.caps_handler = self.sink.get_static_pad("sink").connect(
|
|
"notify::caps", self._notify_caps
|
|
)
|
|
|
|
# Link up everything but the decoder (which must be linked only
|
|
# when it becomes ready).
|
|
self.pipeline.add(self.dec)
|
|
self.pipeline.add(self.conv)
|
|
self.pipeline.add(self.sink)
|
|
|
|
self.conv.link(self.sink)
|
|
|
|
# Set up the queue for data and run the main thread.
|
|
self.queue = queue.Queue(QUEUE_SIZE)
|
|
self.thread = get_loop_thread()
|
|
|
|
# This wil get filled with an exception if opening fails.
|
|
self.read_exc = None
|
|
|
|
# Return as soon as the stream is ready!
|
|
self.running = True
|
|
self.got_caps = False
|
|
self.pipeline.set_state(Gst.State.PLAYING)
|
|
self.ready_sem.acquire()
|
|
if self.read_exc:
|
|
# An error occurred before the stream became ready.
|
|
self.close(True)
|
|
raise self.read_exc
|
|
|
|
# Gstreamer callbacks.
|
|
|
|
def _notify_caps(self, pad, args):
|
|
"""The callback for the sinkpad's "notify::caps" signal.
|
|
"""
|
|
# The sink has started to receive data, so the stream is ready.
|
|
# This also is our opportunity to read information about the
|
|
# stream.
|
|
self.got_caps = True
|
|
info = pad.get_current_caps().get_structure(0)
|
|
|
|
# Stream attributes.
|
|
self.channels = info.get_int('channels')[1]
|
|
self.samplerate = info.get_int('rate')[1]
|
|
|
|
# Query duration.
|
|
success, length = pad.get_peer().query_duration(Gst.Format.TIME)
|
|
if success:
|
|
self.duration = length / 1000000000
|
|
else:
|
|
self.read_exc = MetadataMissingError('duration not available')
|
|
|
|
# Allow constructor to complete.
|
|
self.ready_sem.release()
|
|
|
|
_got_a_pad = False
|
|
|
|
def _pad_added(self, element, pad):
|
|
"""The callback for GstElement's "pad-added" signal.
|
|
"""
|
|
# Decoded data is ready. Connect up the decoder, finally.
|
|
name = pad.query_caps(None).to_string()
|
|
if name.startswith('audio/x-raw'):
|
|
nextpad = self.conv.get_static_pad('sink')
|
|
if not nextpad.is_linked():
|
|
self._got_a_pad = True
|
|
pad.link(nextpad)
|
|
|
|
def _no_more_pads(self, element):
|
|
"""The callback for GstElement's "no-more-pads" signal.
|
|
"""
|
|
# Sent when the pads are done adding (i.e., there are no more
|
|
# streams in the file). If we haven't gotten at least one
|
|
# decodable stream, raise an exception.
|
|
if not self._got_a_pad:
|
|
self.read_exc = NoStreamError()
|
|
self.ready_sem.release() # No effect if we've already started.
|
|
|
|
def _new_sample(self, sink):
|
|
"""The callback for appsink's "new-sample" signal.
|
|
"""
|
|
if self.running:
|
|
# New data is available from the pipeline! Dump it into our
|
|
# queue (or possibly block if we're full).
|
|
buf = sink.emit('pull-sample').get_buffer()
|
|
|
|
# We can't use Gst.Buffer.extract() to read the data as it crashes
|
|
# when called through PyGObject. We also can't use
|
|
# Gst.Buffer.extract_dup() because we have no way in Python to free
|
|
# the memory that it returns. Instead we get access to the actual
|
|
# data via Gst.Memory.map().
|
|
mem = buf.get_all_memory()
|
|
success, info = mem.map(Gst.MapFlags.READ)
|
|
if success:
|
|
if isinstance(info.data, memoryview):
|
|
# We need to copy the data as the memoryview is released
|
|
# when we call mem.unmap()
|
|
data = bytes(info.data)
|
|
else:
|
|
# GStreamer Python bindings <= 1.16 return a copy of the
|
|
# data as bytes()
|
|
data = info.data
|
|
mem.unmap(info)
|
|
self.queue.put(data)
|
|
else:
|
|
raise GStreamerError("Unable to map buffer memory while reading the file.")
|
|
return Gst.FlowReturn.OK
|
|
|
|
def _unkown_type(self, uridecodebin, decodebin, caps):
|
|
"""The callback for decodebin's "unknown-type" signal.
|
|
"""
|
|
# This is called *before* the stream becomes ready when the
|
|
# file can't be read.
|
|
streaminfo = caps.to_string()
|
|
if not streaminfo.startswith('audio/'):
|
|
# Ignore non-audio (e.g., video) decode errors.
|
|
return
|
|
self.read_exc = UnknownTypeError(streaminfo)
|
|
self.ready_sem.release()
|
|
|
|
def _message(self, bus, message):
|
|
"""The callback for GstBus's "message" signal (for two kinds of
|
|
messages).
|
|
"""
|
|
if not self.finished:
|
|
if message.type == Gst.MessageType.EOS:
|
|
# The file is done. Tell the consumer thread.
|
|
self.queue.put(SENTINEL)
|
|
if not self.got_caps:
|
|
# If the stream ends before _notify_caps was called, this
|
|
# is an invalid file.
|
|
self.read_exc = NoStreamError()
|
|
self.ready_sem.release()
|
|
|
|
elif message.type == Gst.MessageType.ERROR:
|
|
gerror, debug = message.parse_error()
|
|
if 'not-linked' in debug:
|
|
self.read_exc = NoStreamError()
|
|
elif 'No such file' in debug:
|
|
self.read_exc = IOError('resource not found')
|
|
else:
|
|
self.read_exc = FileReadError(debug)
|
|
self.ready_sem.release()
|
|
|
|
# Iteration.
|
|
|
|
def __next__(self):
|
|
# Wait for data from the Gstreamer callbacks.
|
|
val = self.queue.get()
|
|
if val == SENTINEL:
|
|
# End of stream.
|
|
raise StopIteration
|
|
return val
|
|
|
|
def __iter__(self):
|
|
return self
|
|
|
|
# Cleanup.
|
|
def close(self, force=False):
|
|
"""Close the file and clean up associated resources.
|
|
|
|
Calling `close()` a second time has no effect.
|
|
"""
|
|
if self.running or force:
|
|
self.running = False
|
|
self.finished = True
|
|
|
|
# Unregister for signals, which we registered for above with
|
|
# `add_signal_watch`. (Without this, GStreamer leaks file
|
|
# descriptors.)
|
|
self.pipeline.get_bus().remove_signal_watch()
|
|
|
|
# Stop reading the file.
|
|
self.dec.set_property("uri", None)
|
|
# Block spurious signals.
|
|
self.sink.get_static_pad("sink").disconnect(self.caps_handler)
|
|
|
|
# Make space in the output queue to let the decoder thread
|
|
# finish. (Otherwise, the thread blocks on its enqueue and
|
|
# the interpreter hangs.)
|
|
try:
|
|
self.queue.get_nowait()
|
|
except queue.Empty:
|
|
pass
|
|
|
|
# Halt the pipeline (closing file).
|
|
self.pipeline.set_state(Gst.State.NULL)
|
|
|
|
def __del__(self):
|
|
self.close()
|
|
|
|
# Context manager.
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
self.close()
|
|
return False
|
|
|
|
|
|
# Smoke test.
|
|
if __name__ == '__main__':
|
|
for path in sys.argv[1:]:
|
|
path = os.path.abspath(os.path.expanduser(path))
|
|
with GstAudioFile(path) as f:
|
|
print(f.channels)
|
|
print(f.samplerate)
|
|
print(f.duration)
|
|
for s in f:
|
|
print(len(s), ord(s[0]))
|