import math import pyglet from pyglet.media.drivers.base import AbstractAudioDriver, AbstractAudioPlayer from pyglet.media.drivers.listener import AbstractListener from pyglet.util import debug_print from . import interface _debug = debug_print('debug_media') def _convert_coordinates(coordinates): x, y, z = coordinates return x, y, -z class XAudio2AudioPlayer(AbstractAudioPlayer): # Need to cache these because pyglet API allows update separately, but # DSound requires both to be set at once. _cone_inner_angle = 360 _cone_outer_angle = 360 min_buffer_size = 9600 max_buffer_count = 3 # Max in queue at once, increasing may impact performance depending on buffer size. def __init__(self, driver, xa2_driver, source, player): super(XAudio2AudioPlayer, self).__init__(source, player) # We keep here a strong reference because the AudioDriver is anyway # a singleton object which will only be deleted when the application # shuts down. The AudioDriver does not keep a ref to the AudioPlayer. self.driver = driver self._xa2_driver = xa2_driver # If cleared, we need to check when it's done clearing. self._flushing = False # If deleted, we need to make sure it's done deleting. self._deleted = False # Desired play state (may be actually paused due to underrun -- not # implemented yet). self._playing = False # Theoretical write and play cursors for an infinite buffer. play # cursor is always <= write cursor (when equal, underrun is # happening). self._write_cursor = 0 self._play_cursor = 0 # List of play_cursor, in sort order self._events = [] # List of (cursor, timestamp), in sort order (cursor gives expiry # place of the timestamp) self._timestamps = [] # This will be True if the last buffer has already been submitted. self.buffer_end_submitted = False self._buffers = [] # Current buffers in queue waiting to be played. self._xa2_source_voice = self._xa2_driver.get_source_voice(source, self) self._buffer_size = int(source.audio_format.sample_rate * 2) def on_driver_destroy(self): self.stop() self._xa2_source_voice = None def on_driver_reset(self): self._xa2_source_voice = self._xa2_driver.get_source_voice(self.source, self) # Queue up any buffers that are still in queue but weren't deleted. This does not pickup where the last sample # played, only where the last buffer was submitted. As such it's possible for audio to be replayed if buffer is # large enough. for cx2_buffer in self._buffers: self._xa2_source_voice.submit_buffer(cx2_buffer) def __del__(self): if self._xa2_source_voice: self._xa2_source_voice = None def delete(self): """Called from Player. Docs says to cleanup resources, but other drivers wait for GC to do it?""" if self._xa2_source_voice: self._deleted = True if not self._buffers: self._xa2_driver.return_voice(self._xa2_source_voice) def play(self): assert _debug('XAudio2 play') if not self._playing: self._playing = True if not self._flushing: self._xa2_source_voice.play() assert _debug('return XAudio2 play') def stop(self): assert _debug('XAudio2 stop') if self._playing: self._playing = False self.buffer_end_submitted = False self._xa2_source_voice.stop() assert _debug('return XAudio2 stop') def clear(self): assert _debug('XAudio2 clear') super(XAudio2AudioPlayer, self).clear() self._play_cursor = 0 self._write_cursor = 0 self.buffer_end_submitted = False self._deleted = False if self._buffers: self._flushing = True self._xa2_source_voice.flush() self._buffers.clear() del self._events[:] del self._timestamps[:] def _restart(self, dt): """Prefill audio and attempt to replay audio.""" if self._playing and self._xa2_source_voice: self.refill_source_player() self._xa2_source_voice.play() def refill_source_player(self): """Obtains audio data from the source, puts it into a buffer to submit to the voice. Unlike the other drivers this does not carve pieces of audio from the buffer and slowly consume it. This submits the buffer retrieved from the decoder in it's entirety. """ buffers_queued = self._xa2_source_voice.buffers_queued # Free any buffers that have ended. while len(self._buffers) > buffers_queued: # Clean out any buffers that have played. buffer = self._buffers.pop(0) self._play_cursor += buffer.AudioBytes del buffer # Does this remove AudioData within the buffer? Let GC remove or explicit remove? # We have to wait for all of the buffers we are flushing to end before we restart next buffer. # When voice reaches 0 buffers, it is available for re-use. if self._flushing: if buffers_queued == 0: self._flushing = False # This is required because the next call to play will come before all flushes are done. # Restart at next available opportunity. pyglet.clock.schedule_once(self._restart, 0) return if self._deleted: if buffers_queued == 0: self._deleted = False self._xa2_driver.return_voice(self._xa2_source_voice) return # Wait for the playback to hit 0 buffers before we eos. if self.buffer_end_submitted: if buffers_queued == 0: self._xa2_source_voice.stop() pyglet.app.platform_event_loop.post_event(self.player, 'on_eos') else: current_buffers = [] while buffers_queued < self.max_buffer_count: audio_data = self.source.get_audio_data(self._buffer_size, 0.0) if audio_data: assert _debug( 'Xaudio2: audio data - length: {}, duration: {}, buffer size: {}'.format(audio_data.length, audio_data.duration, self._buffer_size)) if audio_data.length == 0: # Sometimes audio data has 0 length at the front? continue x2_buffer = self._xa2_driver.create_buffer(audio_data) current_buffers.append(x2_buffer) self._write_cursor += x2_buffer.AudioBytes # We've pushed this many bytes into the source player. self._add_audiodata_events(audio_data) self._add_audiodata_timestamp(audio_data) buffers_queued += 1 else: # End of audio data, set last packet as end. self.buffer_end_submitted = True break # We submit the buffers here, just in-case the end of stream was found. for cx2_buffer in current_buffers: self._xa2_source_voice.submit_buffer(cx2_buffer) # Store buffers temporarily, otherwise they get GC'd. self._buffers.extend(current_buffers) self._dispatch_pending_events() def _add_audiodata_events(self, audio_data): for event in audio_data.events: event_cursor = self._write_cursor + event.timestamp * self.source.audio_format.bytes_per_second assert _debug('Adding event', event, 'at', event_cursor) self._events.append((event_cursor, event)) def _add_audiodata_timestamp(self, audio_data): ts_cursor = self._write_cursor + audio_data.length self._timestamps.append( (ts_cursor, audio_data.timestamp + audio_data.duration)) def _dispatch_pending_events(self): pending_events = [] while self._events and self._events[0][0] <= self._play_cursor: _, event = self._events.pop(0) pending_events.append(event) assert _debug('Dispatching pending events: {}'.format(pending_events)) assert _debug('Remaining events: {}'.format(self._events)) for event in pending_events: event._sync_dispatch_to_player(self.player) def _cleanup_timestamps(self): while self._timestamps and self._timestamps[0][0] < self._play_cursor: del self._timestamps[0] def get_time(self): self.update_play_cursor() if self._timestamps: cursor, ts = self._timestamps[0] result = ts + (self._play_cursor - cursor) / float(self.source.audio_format.bytes_per_second) else: result = None return result def set_volume(self, volume): self._xa2_source_voice.volume = volume def set_position(self, position): if self._xa2_source_voice.is_emitter: self._xa2_source_voice.position = _convert_coordinates(position) def set_min_distance(self, min_distance): """Not a true min distance, but similar effect. Changes CurveDistanceScaler default is 1.""" if self._xa2_source_voice.is_emitter: self._xa2_source_voice.distance_scaler = min_distance def set_max_distance(self, max_distance): """No such thing built into xaudio2""" return def set_pitch(self, pitch): self._xa2_source_voice.frequency = pitch def set_cone_orientation(self, cone_orientation): if self._xa2_source_voice.is_emitter: self._xa2_source_voice.cone_orientation = _convert_coordinates(cone_orientation) def set_cone_inner_angle(self, cone_inner_angle): if self._xa2_source_voice.is_emitter: self._cone_inner_angle = int(cone_inner_angle) self._set_cone_angles() def set_cone_outer_angle(self, cone_outer_angle): if self._xa2_source_voice.is_emitter: self._cone_outer_angle = int(cone_outer_angle) self._set_cone_angles() def _set_cone_angles(self): inner = min(self._cone_inner_angle, self._cone_outer_angle) outer = max(self._cone_inner_angle, self._cone_outer_angle) self._xa2_source_voice.set_cone_angles(math.radians(inner), math.radians(outer)) def set_cone_outer_gain(self, cone_outer_gain): if self._xa2_source_voice.is_emitter: self._xa2_source_voice.cone_outside_volume = cone_outer_gain def prefill_audio(self): # Cannot refill during a flush. Schedule will handle it. if not self._flushing: self.refill_source_player() class XAudio2Driver(AbstractAudioDriver): def __init__(self): self._xa2_driver = interface.XAudio2Driver() self._xa2_listener = self._xa2_driver.create_listener() assert self._xa2_driver is not None assert self._xa2_listener is not None def __del__(self): self.delete() def get_performance(self): assert self._xa2_driver is not None return self._xa2_driver.get_performance() def create_audio_player(self, source, player): assert self._xa2_driver is not None return XAudio2AudioPlayer(self, self._xa2_driver, source, player) def get_listener(self): assert self._xa2_driver is not None assert self._xa2_listener is not None return XAudio2Listener(self._xa2_listener, self._xa2_driver) def delete(self): self._xa2_listener = None class XAudio2Listener(AbstractListener): def __init__(self, xa2_listener, xa2_driver): self._xa2_listener = xa2_listener self._xa2_driver = xa2_driver def _set_volume(self, volume): self._volume = volume self._xa2_driver.volume = volume def _set_position(self, position): self._position = position self._xa2_listener.position = _convert_coordinates(position) def _set_forward_orientation(self, orientation): self._forward_orientation = orientation self._set_orientation() def _set_up_orientation(self, orientation): self._up_orientation = orientation self._set_orientation() def _set_orientation(self): self._xa2_listener.orientation = _convert_coordinates(self._forward_orientation) + _convert_coordinates( self._up_orientation)