Merge pull request #289 from jodal/feature/spotify-to-use-gst-time

Make Spotify backend use GStreamer for time position and seek
This commit is contained in:
Stein Magnus Jodal 2012-12-24 03:08:55 -08:00
commit 5f1a750504
3 changed files with 72 additions and 94 deletions

View File

@ -39,13 +39,18 @@ class Audio(pykka.ThreadingActor):
super(Audio, self).__init__()
self._playbin = None
self._mixer = None
self._mixer_track = None
self._mixer_scale = None
self._software_mixing = False
self._appsrc = None
self._volume_set = None
self._appsrc = None
self._appsrc_caps = None
self._appsrc_seek_data_callback = None
self._appsrc_seek_data_id = None
self._notify_source_signal_id = None
self._about_to_finish_id = None
self._message_signal_id = None
@ -77,25 +82,35 @@ class Audio(pykka.ThreadingActor):
'notify::source', self._on_new_source)
def _on_about_to_finish(self, element):
self._appsrc = None
source, self._appsrc = self._appsrc, None
if source is None:
return
self._appsrc_caps = None
if self._appsrc_seek_data_id is not None:
source.disconnect(self._appsrc_seek_data_id)
self._appsrc_seek_data_id = None
def _on_new_source(self, element, pad):
uri = element.get_property('uri')
if not uri or not uri.startswith('appsrc://'):
return
# These caps matches the audio data provided by libspotify
default_caps = gst.Caps(
b'audio/x-raw-int, endianness=(int)1234, channels=(int)2, '
b'width=(int)16, depth=(int)16, signed=(boolean)true, '
b'rate=(int)44100')
source = element.get_property('source')
source.set_property('caps', default_caps)
# GStreamer does not like unicode
source.set_property('caps', self._appsrc_caps)
source.set_property('format', b'time')
source.set_property('stream-type', b'seekable')
self._appsrc_seek_data_id = source.connect(
'seek-data', self._appsrc_on_seek_data)
self._appsrc = source
def _appsrc_on_seek_data(self, appsrc, time_in_ns):
time_in_ms = time_in_ns // gst.MSECOND
if self._appsrc_seek_data_callback is not None:
self._appsrc_seek_data_callback(time_in_ms)
return True
def _teardown_playbin(self):
if self._about_to_finish_id:
self._playbin.disconnect(self._about_to_finish_id)
@ -242,6 +257,25 @@ class Audio(pykka.ThreadingActor):
"""
self._playbin.set_property('uri', uri)
def set_appsrc(self, caps, seek_data=None):
"""
Switch to using appsrc for getting audio to be played.
You *MUST* call :meth:`prepare_change` before calling this method.
:param caps: GStreamer caps string describing the audio format to
expect
:type caps: string
:param seek_data: callback for when data from a new position is needed
to continue playback
:type seek_data: callable which takes time position in ms
"""
if isinstance(caps, unicode):
caps = caps.encode('utf-8')
self._appsrc_caps = gst.Caps(caps)
self._appsrc_seek_data_callback = seek_data
self._playbin.set_property('uri', 'appsrc://')
def emit_data(self, buffer_):
"""
Call this to deliver raw audio data to be played.
@ -274,13 +308,11 @@ class Audio(pykka.ThreadingActor):
:rtype: int
"""
if self._playbin.get_state()[1] == gst.STATE_NULL:
return 0
try:
position = self._playbin.query_position(gst.FORMAT_TIME)[0]
return position // gst.MSECOND
except gst.QueryError, e:
logger.error('time_position failed: %s', e)
except gst.QueryError:
logger.debug('Position query failed')
return 0
def set_position(self, position):
@ -291,12 +323,9 @@ class Audio(pykka.ThreadingActor):
:type position: int
:rtype: :class:`True` if successful, else :class:`False`
"""
self._playbin.get_state() # block until state changes are done
handeled = self._playbin.seek_simple(
return self._playbin.seek_simple(
gst.Format(gst.FORMAT_TIME), gst.SEEK_FLAG_FLUSH,
position * gst.MSECOND)
self._playbin.get_state() # block until seek is done
return handeled
def start_playback(self):
"""

View File

@ -1,113 +1,58 @@
from __future__ import unicode_literals
import logging
import time
import functools
from spotify import Link, SpotifyError
from mopidy.backends import base
from mopidy.core import PlaybackState
logger = logging.getLogger('mopidy.backends.spotify')
def seek_data_callback(spotify_backend, time_position):
logger.debug('seek_data_callback(%d) called', time_position)
spotify_backend.playback.on_seek_data(time_position)
class SpotifyPlaybackProvider(base.BasePlaybackProvider):
def __init__(self, *args, **kwargs):
super(SpotifyPlaybackProvider, self).__init__(*args, **kwargs)
self._timer = TrackPositionTimer()
def pause(self):
self._timer.pause()
return super(SpotifyPlaybackProvider, self).pause()
# These GStreamer caps matches the audio data provided by libspotify
_caps = (
'audio/x-raw-int, endianness=(int)1234, channels=(int)2, '
'width=(int)16, depth=(int)16, signed=(boolean)true, '
'rate=(int)44100')
def play(self, track):
if track.uri is None:
return False
spotify_backend = self.backend.actor_ref.proxy()
seek_data_callback_bound = functools.partial(
seek_data_callback, spotify_backend)
try:
self.backend.spotify.session.load(
Link.from_string(track.uri).as_track())
self.backend.spotify.session.play(1)
self.audio.prepare_change()
self.audio.set_uri('appsrc://')
self.audio.set_appsrc(
self._caps,
seek_data=seek_data_callback_bound)
self.audio.start_playback()
self.audio.set_metadata(track)
self._timer.play()
return True
except SpotifyError as e:
logger.info('Playback of %s failed: %s', track.uri, e)
return False
def resume(self):
time_position = self.get_time_position()
self._timer.resume()
self.audio.prepare_change()
result = self.seek(time_position)
self.audio.start_playback()
return result
def seek(self, time_position):
self.backend.spotify.session.seek(time_position)
self._timer.seek(time_position)
return True
def stop(self):
self.backend.spotify.session.play(0)
return super(SpotifyPlaybackProvider, self).stop()
def get_time_position(self):
# XXX: The default implementation of get_time_position hangs/times out
# when used with the Spotify backend and GStreamer appsrc. If this can
# be resolved, we no longer need to use a wall clock based time
# position for Spotify playback.
return self._timer.get_time_position()
class TrackPositionTimer(object):
"""
Keeps track of time position in a track using the wall clock and playback
events.
To not introduce a reverse dependency on the playback controller, this
class keeps track of playback state itself.
"""
def __init__(self):
self._state = PlaybackState.STOPPED
self._accumulated = 0
self._started = 0
def play(self):
self._state = PlaybackState.PLAYING
self._accumulated = 0
self._started = self._wall_time()
def pause(self):
self._state = PlaybackState.PAUSED
self._accumulated += self._wall_time() - self._started
def resume(self):
self._state = PlaybackState.PLAYING
def seek(self, time_position):
self._started = self._wall_time()
self._accumulated = time_position
def get_time_position(self):
if self._state == PlaybackState.PLAYING:
time_since_started = self._wall_time() - self._started
return self._accumulated + time_since_started
elif self._state == PlaybackState.PAUSED:
return self._accumulated
elif self._state == PlaybackState.STOPPED:
return 0
def _wall_time(self):
return int(time.time() * 1000)
def on_seek_data(self, time_position):
logger.debug('playback.on_seek_data(%d) called', time_position)
self.backend.spotify.next_buffer_timestamp = time_position
self.backend.spotify.session.seek(time_position)

View File

@ -46,6 +46,7 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager):
self.backend_ref = backend_ref
self.connected = threading.Event()
self.next_buffer_timestamp = None
self.container_manager = None
self.playlist_manager = None
@ -121,6 +122,9 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager):
}
buffer_ = gst.Buffer(bytes(frames))
buffer_.set_caps(gst.caps_from_string(capabilites))
if self.next_buffer_timestamp is not None:
buffer_.timestamp = self.next_buffer_timestamp * gst.MSECOND
self.next_buffer_timestamp = None
if self.audio.emit_data(buffer_).get():
return num_frames