433 lines
16 KiB
Python
433 lines
16 KiB
Python
# ----------------------------------------------------------------------------
|
|
# pyglet
|
|
# Copyright (c) 2006-2008 Alex Holkner
|
|
# Copyright (c) 2008-2021 pyglet contributors
|
|
# All rights reserved.
|
|
#
|
|
# Redistribution and use in source and binary forms, with or without
|
|
# modification, are permitted provided that the following conditions
|
|
# are met:
|
|
#
|
|
# * Redistributions of source code must retain the above copyright
|
|
# notice, this list of conditions and the following disclaimer.
|
|
# * Redistributions in binary form must reproduce the above copyright
|
|
# notice, this list of conditions and the following disclaimer in
|
|
# the documentation and/or other materials provided with the
|
|
# distribution.
|
|
# * Neither the name of pyglet nor the names of its
|
|
# contributors may be used to endorse or promote products
|
|
# derived from this software without specific prior written
|
|
# permission.
|
|
#
|
|
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
|
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
|
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
|
|
# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
|
|
# COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
|
|
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
|
|
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
|
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
|
|
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
|
|
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
|
# POSSIBILITY OF SUCH DAMAGE.
|
|
# ----------------------------------------------------------------------------
|
|
|
|
import ctypes
|
|
import math
|
|
|
|
import pyglet
|
|
from . import interface
|
|
from pyglet.util import debug_print
|
|
from pyglet.media.events import MediaEvent
|
|
from pyglet.media.drivers.base import AbstractAudioDriver, AbstractAudioPlayer
|
|
from pyglet.media.drivers.listener import AbstractListener
|
|
|
|
_debug = debug_print('debug_media')
|
|
|
|
|
|
def _convert_coordinates(coordinates):
|
|
x, y, z = coordinates
|
|
return x, y, -z
|
|
|
|
|
|
def _gain2db(gain):
|
|
"""
|
|
Convert linear gain in range [0.0, 1.0] to 100ths of dB.
|
|
|
|
Power gain = P1/P2
|
|
dB = 10 log(P1/P2)
|
|
dB * 100 = 1000 * log(power gain)
|
|
"""
|
|
if gain <= 0:
|
|
return -10000
|
|
return max(-10000, min(int(1000 * math.log10(min(gain, 1))), 0))
|
|
|
|
|
|
def _db2gain(db):
|
|
"""Convert 100ths of dB to linear gain."""
|
|
return math.pow(10.0, float(db)/1000.0)
|
|
|
|
|
|
class DirectSoundAudioPlayer(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
|
|
|
|
def __init__(self, driver, ds_driver, source, player):
|
|
super(DirectSoundAudioPlayer, 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._ds_driver = ds_driver
|
|
|
|
# Desired play state (may be actually paused due to underrun -- not
|
|
# implemented yet).
|
|
self._playing = False
|
|
|
|
# Up to one audio data may be buffered if too much data was received
|
|
# from the source that could not be written immediately into the
|
|
# buffer. See refill().
|
|
self._audiodata_buffer = None
|
|
|
|
# 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
|
|
|
|
# Cursor position of end of data. Silence is written after
|
|
# eos for one buffer size.
|
|
self._eos_cursor = None
|
|
|
|
# Indexes into DSound circular buffer. Complications ensue wrt each
|
|
# other to avoid writing over the play cursor. See get_write_size and
|
|
# write().
|
|
self._play_cursor_ring = 0
|
|
self._write_cursor_ring = 0
|
|
|
|
# List of (play_cursor, MediaEvent), in sort order
|
|
self._events = []
|
|
|
|
# List of (cursor, timestamp), in sort order (cursor gives expiry
|
|
# place of the timestamp)
|
|
self._timestamps = []
|
|
|
|
audio_format = source.audio_format
|
|
|
|
# DSound buffer
|
|
self._ds_buffer = self._ds_driver.create_buffer(audio_format)
|
|
self._buffer_size = self._ds_buffer.buffer_size
|
|
|
|
self._ds_buffer.current_position = 0
|
|
|
|
self.refill(self._buffer_size)
|
|
|
|
def __del__(self):
|
|
# We decrease the IDirectSound refcount
|
|
self.driver._ds_driver._native_dsound.Release()
|
|
|
|
def delete(self):
|
|
pyglet.clock.unschedule(self._check_refill)
|
|
|
|
def play(self):
|
|
assert _debug('DirectSound play')
|
|
pyglet.clock.schedule_interval(self._check_refill, 0.1)
|
|
|
|
if not self._playing:
|
|
self._get_audiodata() # prebuffer if needed
|
|
self._playing = True
|
|
self._ds_buffer.play()
|
|
|
|
assert _debug('return DirectSound play')
|
|
|
|
def stop(self):
|
|
assert _debug('DirectSound stop')
|
|
pyglet.clock.unschedule(self._check_refill)
|
|
|
|
if self._playing:
|
|
self._playing = False
|
|
self._ds_buffer.stop()
|
|
|
|
assert _debug('return DirectSound stop')
|
|
|
|
def clear(self):
|
|
assert _debug('DirectSound clear')
|
|
super(DirectSoundAudioPlayer, self).clear()
|
|
self._ds_buffer.current_position = 0
|
|
self._play_cursor_ring = self._write_cursor_ring = 0
|
|
self._play_cursor = self._write_cursor
|
|
self._eos_cursor = None
|
|
self._audiodata_buffer = None
|
|
del self._events[:]
|
|
del self._timestamps[:]
|
|
|
|
def _check_refill(self, dt):
|
|
write_size = self.get_write_size()
|
|
if write_size > self.min_buffer_size:
|
|
self.refill(write_size)
|
|
|
|
def refill(self, write_size):
|
|
while write_size > 0:
|
|
assert _debug('refill, write_size =', write_size)
|
|
audio_data = self._get_audiodata()
|
|
|
|
if audio_data is not None:
|
|
assert _debug('write', audio_data.length)
|
|
length = min(write_size, audio_data.length)
|
|
self.write(audio_data, length)
|
|
write_size -= length
|
|
else:
|
|
assert _debug('write silence')
|
|
self.write(None, write_size)
|
|
write_size = 0
|
|
|
|
def _has_underrun(self):
|
|
return (self._eos_cursor is not None
|
|
and self._play_cursor > self._eos_cursor)
|
|
|
|
def _dispatch_new_event(self, event_name):
|
|
MediaEvent(0, event_name)._sync_dispatch_to_player(self.player)
|
|
|
|
def _get_audiodata(self):
|
|
if self._audiodata_buffer is None or self._audiodata_buffer.length == 0:
|
|
self._get_new_audiodata()
|
|
|
|
return self._audiodata_buffer
|
|
|
|
def _get_new_audiodata(self):
|
|
assert _debug('Getting new audio data buffer.')
|
|
# Pass a reference of ourself to allow the audio decoding to get time
|
|
# information for synchronization.
|
|
compensation_time = self.get_audio_time_diff()
|
|
self._audiodata_buffer = self.source.get_audio_data(self._buffer_size, compensation_time)
|
|
|
|
if self._audiodata_buffer is not None:
|
|
assert _debug('New audio data available: {} bytes'.format(self._audiodata_buffer.length))
|
|
|
|
if self._eos_cursor is not None:
|
|
self._move_write_cursor_after_eos()
|
|
|
|
self._add_audiodata_events(self._audiodata_buffer)
|
|
self._add_audiodata_timestamp(self._audiodata_buffer)
|
|
self._eos_cursor = None
|
|
elif self._eos_cursor is None:
|
|
assert _debug('No more audio data.')
|
|
self._eos_cursor = self._write_cursor
|
|
|
|
def _move_write_cursor_after_eos(self):
|
|
# Set the write cursor back to eos_cursor or play_cursor to prevent gaps
|
|
if self._play_cursor < self._eos_cursor:
|
|
cursor_diff = self._write_cursor - self._eos_cursor
|
|
assert _debug('Moving cursor back', cursor_diff)
|
|
self._write_cursor = self._eos_cursor
|
|
self._write_cursor_ring -= cursor_diff
|
|
self._write_cursor_ring %= self._buffer_size
|
|
|
|
else:
|
|
cursor_diff = self._play_cursor - self._eos_cursor
|
|
assert _debug('Moving cursor back', cursor_diff)
|
|
self._write_cursor = self._play_cursor
|
|
self._write_cursor_ring -= cursor_diff
|
|
self._write_cursor_ring %= self._buffer_size
|
|
|
|
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 update_play_cursor(self):
|
|
play_cursor_ring = self._ds_buffer.current_position.play_cursor
|
|
if play_cursor_ring < self._play_cursor_ring:
|
|
# Wrapped around
|
|
self._play_cursor += self._buffer_size - self._play_cursor_ring
|
|
self._play_cursor_ring = 0
|
|
self._play_cursor += play_cursor_ring - self._play_cursor_ring
|
|
self._play_cursor_ring = play_cursor_ring
|
|
|
|
self._dispatch_pending_events()
|
|
self._cleanup_timestamps()
|
|
self._check_underrun()
|
|
|
|
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 _check_underrun(self):
|
|
if self._playing and self._has_underrun():
|
|
assert _debug('underrun, stopping')
|
|
self.stop()
|
|
self._dispatch_new_event('on_eos')
|
|
|
|
def get_write_size(self):
|
|
self.update_play_cursor()
|
|
|
|
play_cursor = self._play_cursor
|
|
write_cursor = self._write_cursor
|
|
|
|
return self._buffer_size - max(write_cursor - play_cursor, 0)
|
|
|
|
def write(self, audio_data, length):
|
|
# Pass audio_data=None to write silence
|
|
if length == 0:
|
|
return 0
|
|
|
|
write_ptr = self._ds_buffer.lock(self._write_cursor_ring, length)
|
|
assert 0 < length <= self._buffer_size
|
|
assert length == write_ptr.audio_length_1.value + write_ptr.audio_length_2.value
|
|
|
|
if audio_data:
|
|
ctypes.memmove(write_ptr.audio_ptr_1, audio_data.data, write_ptr.audio_length_1.value)
|
|
audio_data.consume(write_ptr.audio_length_1.value, self.source.audio_format)
|
|
if write_ptr.audio_length_2.value > 0:
|
|
ctypes.memmove(write_ptr.audio_ptr_2, audio_data.data, write_ptr.audio_length_2.value)
|
|
audio_data.consume(write_ptr.audio_length_2.value, self.source.audio_format)
|
|
else:
|
|
if self.source.audio_format.sample_size == 8:
|
|
c = 0x80
|
|
else:
|
|
c = 0
|
|
ctypes.memset(write_ptr.audio_ptr_1, c, write_ptr.audio_length_1.value)
|
|
if write_ptr.audio_length_2.value > 0:
|
|
ctypes.memset(write_ptr.audio_ptr_2, c, write_ptr.audio_length_2.value)
|
|
self._ds_buffer.unlock(write_ptr)
|
|
|
|
self._write_cursor += length
|
|
self._write_cursor_ring += length
|
|
self._write_cursor_ring %= self._buffer_size
|
|
|
|
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._ds_buffer.volume = _gain2db(volume)
|
|
|
|
def set_position(self, position):
|
|
if self._ds_buffer.is3d:
|
|
self._ds_buffer.position = _convert_coordinates(position)
|
|
|
|
def set_min_distance(self, min_distance):
|
|
if self._ds_buffer.is3d:
|
|
self._ds_buffer.min_distance = min_distance
|
|
|
|
def set_max_distance(self, max_distance):
|
|
if self._ds_buffer.is3d:
|
|
self._ds_buffer.max_distance = max_distance
|
|
|
|
def set_pitch(self, pitch):
|
|
frequency = int(pitch * self.source.audio_format.sample_rate)
|
|
self._ds_buffer.frequency = frequency
|
|
|
|
def set_cone_orientation(self, cone_orientation):
|
|
if self._ds_buffer.is3d:
|
|
self._ds_buffer.cone_orientation = _convert_coordinates(cone_orientation)
|
|
|
|
def set_cone_inner_angle(self, cone_inner_angle):
|
|
if self._ds_buffer.is3d:
|
|
self._cone_inner_angle = int(cone_inner_angle)
|
|
self._set_cone_angles()
|
|
|
|
def set_cone_outer_angle(self, cone_outer_angle):
|
|
if self._ds_buffer.is3d:
|
|
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._ds_buffer.set_cone_angles(inner, outer)
|
|
|
|
def set_cone_outer_gain(self, cone_outer_gain):
|
|
if self._ds_buffer.is3d:
|
|
volume = _gain2db(cone_outer_gain)
|
|
self._ds_buffer.cone_outside_volume = volume
|
|
|
|
def prefill_audio(self):
|
|
write_size = self.get_write_size()
|
|
self.refill(write_size)
|
|
|
|
|
|
class DirectSoundDriver(AbstractAudioDriver):
|
|
def __init__(self):
|
|
self._ds_driver = interface.DirectSoundDriver()
|
|
self._ds_listener = self._ds_driver.create_listener()
|
|
|
|
assert self._ds_driver is not None
|
|
assert self._ds_listener is not None
|
|
|
|
def __del__(self):
|
|
self.delete()
|
|
|
|
def create_audio_player(self, source, player):
|
|
assert self._ds_driver is not None
|
|
# We increase IDirectSound refcount for each AudioPlayer instantiated
|
|
# This makes sure the AudioPlayer still has a valid _native_dsound to
|
|
# clean-up itself during tear-down.
|
|
self._ds_driver._native_dsound.AddRef()
|
|
return DirectSoundAudioPlayer(self, self._ds_driver, source, player)
|
|
|
|
def get_listener(self):
|
|
assert self._ds_driver is not None
|
|
assert self._ds_listener is not None
|
|
return DirectSoundListener(self._ds_listener, self._ds_driver.primary_buffer)
|
|
|
|
def delete(self):
|
|
# Make sure the _ds_listener is deleted before the _ds_driver
|
|
self._ds_listener = None
|
|
|
|
|
|
class DirectSoundListener(AbstractListener):
|
|
def __init__(self, ds_listener, ds_buffer):
|
|
self._ds_listener = ds_listener
|
|
self._ds_buffer = ds_buffer
|
|
|
|
def _set_volume(self, volume):
|
|
self._volume = volume
|
|
self._ds_buffer.volume = _gain2db(volume)
|
|
|
|
def _set_position(self, position):
|
|
self._position = position
|
|
self._ds_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._ds_listener.orientation = (_convert_coordinates(self._forward_orientation)
|
|
+ _convert_coordinates(self._up_orientation))
|