%PDF- %PDF-
| Direktori : /usr/share/ibus-table/engine/ |
| Current File : //usr/share/ibus-table/engine/it_sound.py |
# -*- coding: utf-8 -*-
# vim:et sts=4 sw=4
#
# ibus-table - The Tables engine for IBus
#
# Copyright (c) 2023 Mike FABIAN <mfabian@redhat.com>
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>
'''
Module to play simple error sounds
'''
from typing import Optional
from typing import Any
import sys
import os
import logging
import threading
import wave
import shutil
import subprocess
import mimetypes
LOGGER = logging.getLogger('ibus-table')
IMPORT_PYGAME_MIXER_SUCCESSFUL = False
try:
import pygame.mixer
IMPORT_PYGAME_MIXER_SUCCESSFUL = True
except (ImportError,):
IMPORT_PYGAME_MIXER_SUCCESSFUL = False
IMPORT_PYAUDIO_SUCCESSFUL = False
try:
import pyaudio # type: ignore
IMPORT_PYAUDIO_SUCCESSFUL = True
except (ImportError,):
IMPORT_PYAUDIO_SUCCESSFUL = False
IMPORT_SIMPLEAUDIO_SUCCESSFUL = False
try:
import simpleaudio # type: ignore
IMPORT_SIMPLEAUDIO_SUCCESSFUL = True
except (ImportError,):
IMPORT_SIMPLEAUDIO_SUCCESSFUL = False
class SoundObject:
'''
Class to play sounds
When pygames is used, this can play .wav and .mp3 files.
When pyaudio is used, only .wav files work.
'''
def __init__(self,
path_to_sound_file: str,
audio_backend: str = 'automatic') -> None:
self._path_to_sound_file: str = path_to_sound_file
self._wav_file: Optional[wave.Wave_read] = None
self._paudio: Optional[pyaudio.PyAudio] = None
self._play_pyaudio_thread: Optional[threading.Thread] = None
self._simpleaudio_wave_o: Optional[simpleaudio.WaveObject] = None
self._simpleaudio_play_o: Optional[simpleaudio.shiny.PlayObject] = None
self._aplay_binary: Optional[str] = None
self._aplay_stdin = b''
self._aplay_process: Optional[Any] = None
self._play_aplay_thread: Optional[threading.Thread] = None
self._supported_audio_backends = ('automatic', 'pygame', 'simpleaudio', 'aplay', 'pyaudio')
self._requested_audio_backend = audio_backend
self._audio_backend = ''
if not os.path.isfile(self._path_to_sound_file):
LOGGER.info('Sound file %s does not exist.', path_to_sound_file)
return
if not os.access(self._path_to_sound_file, os.R_OK):
LOGGER.info('Sound file %s not readable.', path_to_sound_file)
return
if not self._requested_audio_backend in self._supported_audio_backends:
LOGGER.error('Audio backend %s not supported, use one of %s',
audio_backend, self._supported_audio_backends)
return
self._audio_backend = getattr(self, f'_init_{self._requested_audio_backend}')()
if self._audio_backend:
LOGGER.info('Using audio backend %s', self._audio_backend)
else:
LOGGER.error('Could not init audio backend %s', self._requested_audio_backend)
def _init_automatic(self) -> str:
# Try 'pygame' first if possible it seems to be the best:
if self._init_pygame():
return 'pygame'
# Try 'simpleaudio' for Python < 3.12.0, it used to work well
# and has no dependencies. But it is broken in Fedora 39,
# see: https://bugzilla.redhat.com/show_bug.cgi?id=2237680
# probably because Fedora 39 has Python 3.12.0rc2:
if ((sys.version_info.major,
sys.version_info.minor,
sys.version_info.micro) < (3, 12, 0)):
if self._init_simpleaudio():
return 'simpleaudio'
# Try 'aplay', it seems reliable:
if self._init_aplay():
return 'aplay'
# Try 'pyaudio' as a last resort:
# Broken for Python >= 3.10 if not updated to pyaudio >= 0.2.12
# See: https://stackoverflow.com/questions/70344884)
# Sometimes it seems to hang. Not often, but when this happens this is really bad
if (IMPORT_PYAUDIO_SUCCESSFUL
and
(((sys.version_info.major,
sys.version_info.minor,
sys.version_info.micro) < (3, 10, 0))
or
(pyaudio.__version__
and
tuple(int(x) for x in pyaudio.__version__.split('.')) >= (0, 2, 12)))):
if self._init_pyaudio():
return 'pyaudio'
# Nothing more to try ☹
return ''
def _init_pygame(self) -> str:
if not IMPORT_PYGAME_MIXER_SUCCESSFUL:
return ''
try:
pygame.mixer.init()
if pygame.mixer.get_init():
pygame.mixer.music.load(self._path_to_sound_file)
return 'pygame'
except Exception as error: # pylint: disable=broad-except
LOGGER.exception(
'pygame: cannot load sound file %s: %s',
error.__class__.__name__, error)
return ''
def _init_pyaudio(self) -> str:
if not IMPORT_PYAUDIO_SUCCESSFUL:
return ''
(mime_type, encoding) = mimetypes.guess_type(self._path_to_sound_file)
if mime_type not in ('audio/x-wav',):
LOGGER.error(
'File %s has mime type %s and is not supported by simpleaudio',
self._path_to_sound_file, mime_type)
return ''
try:
self._wav_file = wave.open(self._path_to_sound_file, 'rb')
self._paudio = pyaudio.PyAudio()
self._stop_event_paudio: threading.Event = threading.Event()
LOGGER.info('portaudio version = %s',
pyaudio.get_portaudio_version_text())
return 'pyaudio'
except Exception as error: # pylint: disable=broad-except
LOGGER.exception(
'pyaudio: cannot init wave object %s: %s',
error.__class__.__name__, error)
return ''
def _init_simpleaudio(self) -> str:
if not IMPORT_SIMPLEAUDIO_SUCCESSFUL:
return ''
(mime_type, encoding) = mimetypes.guess_type(self._path_to_sound_file)
if mime_type not in ('audio/x-wav',):
LOGGER.error(
'File %s has mime type %s and is not supported by simpleaudio',
self._path_to_sound_file, mime_type)
return ''
try:
self._simpleaudio_wave_o = (
simpleaudio.WaveObject.from_wave_file(self._path_to_sound_file))
return 'simpleaudio'
except Exception as error: # pylint: disable=broad-except
LOGGER.exception(
'Initializing error sound object failed: %s: %s',
error.__class__.__name__, error)
return ''
def _init_aplay(self) -> str:
(mime_type, encoding) = mimetypes.guess_type(self._path_to_sound_file)
if mime_type not in ('audio/x-wav',):
LOGGER.error(
'File %s has mime type %s and is not supported by aplay',
self._path_to_sound_file, mime_type)
return ''
self._aplay_binary = shutil.which('aplay')
if not self._aplay_binary:
return ''
with open (self._path_to_sound_file, mode='rb') as aplay_input:
self._aplay_stdin = aplay_input.read()
if self._aplay_stdin:
return 'aplay'
return ''
def __del__(self) -> None:
if self._paudio:
self._paudio.terminate()
if self._wav_file:
self._wav_file.close()
def _play_pyaudio_thread_function(self, stop_event: threading.Event) -> None:
if not self._wav_file:
LOGGER.error('wave.open(%s, \'rb\') did not work.',
self._path_to_sound_file)
return
if not self._paudio:
LOGGER.error('pyaudio.PyAudio() did not work.')
return
LOGGER.info('Playing sound with pyaudio ...')
stream = self._paudio.open(
format=self._paudio.get_format_from_width(
self._wav_file.getsampwidth()),
channels=self._wav_file.getnchannels(),
rate=self._wav_file.getframerate(),
output=True)
chunk_size = 1024
self._wav_file.rewind()
data = self._wav_file.readframes(chunk_size)
while data and not stop_event.is_set():
try:
if not stream.is_active():
LOGGER.error('pyaudio stream is_active() is False')
break
stream.write(data)
data = self._wav_file.readframes(chunk_size)
except (SystemError, OSError) as error:
LOGGER.exception(
'Unexpected error playing wave object %s: %s',
error.__class__.__name__, error)
LOGGER.error('If you see the '
'"SystemError: PY_SSIZE_T_CLEAN macro '
'must be defined for \'#\' formats" '
'message here, updating to pyaudio >= 0.2.12 '
'will probably fix the problem.'
'See https://stackoverflow.com/questions/70344884')
break
stream.stop_stream()
stream.close()
LOGGER.info('Done playing sound with pyaudio.')
def _play_pyaudio(self) -> None:
self._stop_event_paudio.clear()
self._play_pyaudio_thread = threading.Thread(
daemon=True,
target=self._play_pyaudio_thread_function,
args=(self._stop_event_paudio,))
self._play_pyaudio_thread.start()
def _is_playing_pyaudio(self) -> bool:
if not self._play_pyaudio_thread:
return False
return self._play_pyaudio_thread.is_alive()
def _stop_pyaudio(self) -> None:
if not self._play_pyaudio_thread:
return
if (self._play_pyaudio_thread.is_alive()
and not self._stop_event_paudio.is_set()):
self._stop_event_paudio.set()
self._play_pyaudio_thread.join()
self._stop_event_paudio.clear()
def _wait_done_pyaudio(self) -> None:
if not self._play_pyaudio_thread:
return
if self._play_pyaudio_thread.is_alive():
self._play_pyaudio_thread.join()
def _play_simpleaudio(self) -> None:
if not self._simpleaudio_wave_o:
return
try:
self._simpleaudio_play_o = self._simpleaudio_wave_o.play()
except Exception as error: # pylint: disable=broad-except
LOGGER.exception(
'Initializing error sound object failed: %s: %s',
error.__class__.__name__, error)
def _is_playing_simpleaudio(self) -> bool:
if not self._simpleaudio_play_o:
return False
return bool(self._simpleaudio_play_o.is_playing())
def _stop_simpleaudio(self) -> None:
if not self._simpleaudio_play_o:
return
self._simpleaudio_play_o.stop()
# wait until it is really stopped, otherwise a call to
# __is_playing_simpleaudio() might still return True:
self._simpleaudio_play_o.wait_done()
def _wait_done_simpleaudio(self) -> None:
if not self._simpleaudio_play_o:
return
self._simpleaudio_play_o.wait_done()
@staticmethod
def _play_pygame() -> None:
pygame.mixer.music.rewind()
pygame.mixer.music.play()
@staticmethod
def _is_playing_pygame() -> bool:
return pygame.mixer.music.get_busy()
@staticmethod
def _stop_pygame() -> None:
pygame.mixer.music.stop()
@staticmethod
def _wait_done_pygame() -> None:
while pygame.mixer.music.get_busy():
pass
def _play_aplay_thread_function(self) -> None:
if not self._aplay_binary:
return
try:
self._aplay_process = subprocess.Popen('aplay', shell=False,
stdin=subprocess.PIPE,
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
encoding=None,
errors=None,
text=None)
except (OSError, ValueError) as error:
LOGGER.exception(
'cannot start aplay process %s: %s',
error.__class__.__name__, error)
return
try:
self._aplay_process.communicate(input=self._aplay_stdin,
timeout=1000)
except subprocess.TimeoutExpired as error:
LOGGER.exception(
'timeout piping sound file into aplay process%s: %s',
error.__class__.__name__, error)
self._aplay_process.kill()
return
try:
self._aplay_process.terminate()
except Exception as error:
LOGGER.exception(
'cannot terminate aplay process %s: %s',
error.__class__.__name__, error)
try:
LOGGER.info('Trying to kill aplay process')
self._aplay_process.kill()
LOGGER.info('aplay process killed')
except Exception as error:
LOGGER.exception(
'cannot kill aplay process%s: %s',
error.__class__.__name__, error)
def _play_aplay(self) -> None:
self._play_aplay_thread = threading.Thread(
daemon=True,
target=self._play_aplay_thread_function)
self._play_aplay_thread.start()
def _is_playing_aplay(self) -> bool:
if not self._play_aplay_thread:
return False
return self._play_aplay_thread.is_alive()
def _stop_aplay(self) -> None:
if not self._play_aplay_thread:
return
if (self._play_aplay_thread.is_alive()
and self._aplay_process
and self._aplay_process.poll() is None):
try:
self._aplay_process.terminate()
except Exception as error:
LOGGER.exception(
'cannot terminate aplay process %s: %s',
error.__class__.__name__, error)
try:
LOGGER.info('Trying to kill aplay process')
self._aplay_process.kill()
except Exception as error:
LOGGER.exception(
'cannot kill aplay process%s: %s',
error.__class__.__name__, error)
if self._play_aplay_thread.is_alive():
self._play_aplay_thread.join(timeout=0.1)
if self._play_aplay_thread.is_alive():
LOGGER.error('timeout stopping aplay thread')
def _wait_done_aplay(self) -> None:
if not self._play_aplay_thread:
return
if self._play_aplay_thread.is_alive():
self._play_aplay_thread.join()
def play(self) -> None:
'''Play the sound'''
if not self._audio_backend:
LOGGER.error('Could not init any audio backend %s',
self._requested_audio_backend)
return
getattr(self, f'_play_{self._audio_backend}')()
def is_playing(self) -> bool:
'''Check whether the sound is currently playing'''
if not self._audio_backend:
LOGGER.error('Could not init any audio backend %s',
self._requested_audio_backend)
return False
return bool(getattr(self, f'_is_playing_{self._audio_backend}')())
def stop(self) -> None:
'''Stop playing of the sound'''
if not self._audio_backend:
LOGGER.error('Could not init any audio backend %s',
self._requested_audio_backend)
return
getattr(self, f'_stop_{self._audio_backend}')()
def wait_done(self) -> None:
'''Wait until the sound has been fully played'''
if not self._audio_backend:
LOGGER.error('Could not init any audio backend %s',
self._requested_audio_backend)
return
getattr(self, f'_wait_done_{self._audio_backend}')()
def run_tests() -> None:
'''Run some simple tests'''
audio_backend = 'automatic'
# Testing a short sound:
sound_object = SoundObject(
#'/home/mfabian/sounds/japanese/今回もよろしくお願いします.wav',
'/usr/share/ibus-table/data/coin9.wav',
#'/home/mfabian/sounds/japanese/今回もよろしくお願いします.mp3',
audio_backend=audio_backend)
sound_object.play()
sound_object.wait_done()
sound_object.play()
sound_object.wait_done()
# Testing stopping in between with a longer sound file:
import time # pylint: disable=import-outside-toplevel
sound_object = SoundObject(
'/home/mfabian/sounds/japanese/今回もよろしくお願いします.wav',
audio_backend=audio_backend)
sound_object.play()
LOGGER.info('Sleeping ...')
time.sleep(1)
LOGGER.info('is playing %s', sound_object.is_playing())
sound_object.stop()
LOGGER.info('is playing %s', sound_object.is_playing())
time.sleep(4)
if __name__ == "__main__":
LOG_HANDLER = logging.StreamHandler(stream=sys.stderr)
LOGGER.setLevel(logging.DEBUG)
LOGGER.addHandler(LOG_HANDLER)
run_tests()