Source code for wolpie.sound.play_sound

# CREDS: https://pypi.org/project/playsound/

import abc
import logging
import warnings
from contextlib import contextmanager
from pathlib import Path
from platform import system
from typing import Generator

logger = logging.getLogger(__name__)
DEFAULT_DING_FILE = Path(__file__).resolve().parent / "ding.mp3"
SUPPORTED_PLATFORMS = ["Windows"]


class PlaySoundError(Exception):
    pass


class PlaySoundInterface(abc.ABC):
    @abc.abstractmethod
    def _play_sound(
        self,
        sound: str | Path,
        block: bool = True,
    ) -> None:
        """A function that will play `*.mp3` and `*.wav` sound files.

        .. warning::
            This function is only officially supported on Windows.

        Example usage:

        .. code-block:: python

            from wolpie import play_sound
            # or from wolpie.sound import play_sound

            play_sound("path/to/sound/file.mp3")

        .. tip::
            If you want sound files, consider https://pixabay.com/sound-effects/search

        :param str | Path sound: The sound file to play.
        :param bool block: If True, the function will block until the sound has finished playing.
        :raises PlaySoundError: If there was an error playing the sound.
        """
        ...


class WinPlaySound(PlaySoundInterface):
    def _play_sound(
        self,
        sound: str | Path,
        block: bool = True,
    ) -> None:
        _sound = Path(sound)
        if not _sound.exists():
            _err = f"Sound file does not exist: {sound}"
            raise PlaySoundError(_err)
        if _sound.suffix.lower() not in {".mp3", ".wav"}:
            _err = f"Unsupported sound file format: {sound}. Only .mp3 and .wav are supported."
            raise PlaySoundError(_err)

        sound = '"' + str(_sound) + '"'

        from ctypes import create_unicode_buffer, windll, wintypes

        windll.winmm.mciSendStringW.argtypes = [wintypes.LPCWSTR, wintypes.LPWSTR, wintypes.UINT, wintypes.HANDLE]
        windll.winmm.mciGetErrorStringW.argtypes = [wintypes.DWORD, wintypes.LPWSTR, wintypes.UINT]

        def win_command(*command):
            buf_len = 600
            buf = create_unicode_buffer(buf_len)
            command = " ".join(command)
            winmm = windll.winmm
            error_code = int(winmm.mciSendStringW(command, buf, buf_len - 1, 0))
            if error_code:
                error_buffer = create_unicode_buffer(buf_len)
                winmm.mciGetErrorStringW(error_code, error_buffer, buf_len)
                exception_message = (
                    f"\n    Error {error_code} for command:\n        {command[0]}\n    {error_buffer.value}"
                )
                logger.error(exception_message)
                raise PlaySoundError(exception_message)

        try:
            win_command(f"open {sound}")
            win_command("play {}{}".format(sound, " wait" if block else ""))
        finally:
            try:
                win_command(f"close {sound}")
            except PlaySoundError:
                logger.warning("Failed to close the file: {}".format(sound))
                # If it fails, there's nothing more that can be done...
                pass


class NotImplementedPlaySound(PlaySoundInterface):
    def _play_sound(
        self,
        sound: str | Path,
        block: bool = True,
    ) -> None:
        _err = f"play_sound is not implemented for {current_system}. Could not play sound: {sound} with block={block}"
        logger.warning(_err)


match current_system := system():
    case "Windows":
        play_sound = WinPlaySound()._play_sound
    case _:
        warnings.warn(
            message=(
                f"Platform {current_system} is not officially supported. Supported platforms: {SUPPORTED_PLATFORMS}."
                "Playing sounds may not work as expected."
            ),
            stacklevel=2,
        )
        play_sound = NotImplementedPlaySound()._play_sound


[docs] @contextmanager def ding( sound: str | Path = DEFAULT_DING_FILE, ) -> Generator[None, None, None]: """A context manager that plays a "ding" sound when the block of code inside the context manager finishes executing. If if fails to play the "ding", it won't raise any errors. After all.. the whole idea of this context manager is to notify you when a long running function is done. Imagine you wait 2 hours just for the nice sound file to fail. And you have to start from scratch. No thanks. .. warning:: This function is only officially supported on Windows. Uses :func:`play_sound` under the hood. Example usage: .. code-block:: python from wolpie import ding import time # plays the default "ding" sound after the block finishes with ding(): time.sleep(10) # Simulate some long running function # or if you have a cooler ding: with ding("path/to/cooler/ding.mp3"): time.sleep(10) # Simulate some long running function .. tip:: If you want sound files, consider https://pixabay.com/sound-effects/search :param str | Path sound: The sound file to play. Defaults to a built-in "ding" sound. :yield Generator[None, None, None]: Yields nothing. """ try: yield finally: try: play_sound(sound, block=True) except Exception as e: logger.warning(f"Failed to play sound {sound}: {e}")