Audible cue on errors?

Is there any way have a little “chirp” when a command failed?

I’m using hotkeys for different tools and most often than not I don’t need to pay attention to the command line.
Sometimes command fail without visual cue (for example attempting split at a single point a closed curve while it so zoomed in, and you don’t know it’s closed curve)
An audio cue when something went wrong could help efficiency.

Thank you.

For now, this script seems to work ok, although some commands return “Nothing” for both failure and if nothing changed, this script doesn’t deferential and plays sound for both.

-RunPythonScript "CommandFailSound.py"
launch once to activate event listener, launch again to disable it

CommandFailSound.py
import Rhino
import scriptcontext as sc


VERSION = "26.03.31.174234"
_WATCHER_KEY = "rhino.command_fail_sound.watcher"
_APP_EVENT_NAMES = ("CommandEnded", "EndCommand", "CommandFinished")
_COMMAND_EVENT_NAMES = ("EndCommand", "CommandEnded")
_RESULT_ATTRS = ("CommandResult", "Result", "RunResult")

try:
    _SUCCESS_RESULT_INT = int(Rhino.Commands.Result.Success)
    _CANCEL_RESULT_INT = int(Rhino.Commands.Result.Cancel)
except Exception:
    _SUCCESS_RESULT_INT = None
    _CANCEL_RESULT_INT = None


def _write(msg):
    try:
        Rhino.RhinoApp.WriteLine("[CommandFailSound {0}] {1}".format(VERSION, msg))
    except Exception:
        pass


def _event_candidates():
    app = getattr(Rhino, "RhinoApp", None)
    if app is not None:
        for name in _APP_EVENT_NAMES:
            yield app, name

    command_type = getattr(getattr(Rhino, "Commands", None), "Command", None)
    if command_type is not None:
        for name in _COMMAND_EVENT_NAMES:
            yield command_type, name


def _normalize_hook(hook):
    if isinstance(hook, tuple) and len(hook) == 3:
        return hook

    if hook:
        return (Rhino.RhinoApp, "CommandEnded", hook)

    return None


def _detach_handler(source, event_name, handler):
    try:
        if hasattr(source, event_name):
            event_obj = getattr(source, event_name)
            event_obj -= handler
    except Exception:
        pass


def _detach_current_handler_everywhere():
    for source, event_name in _event_candidates():
        _detach_handler(source, event_name, _on_command_ended)


def _play_sound():
    try:
        import sys

        if sys.platform == "darwin":
            try:
                import subprocess

                subprocess.Popen(
                    ["afplay", "/System/Library/Sounds/Pop.aiff"],
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL,
                )
                return
            except Exception:
                pass

            try:
                import subprocess

                subprocess.Popen(
                    ["osascript", "-e", "beep"],
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL,
                )
                return
            except Exception:
                pass
    except Exception:
        pass

    try:
        import winsound

        winsound.MessageBeep(winsound.MB_ICONEXCLAMATION)
        return
    except Exception:
        pass

    try:
        import System

        System.Media.SystemSounds.Exclamation.Play()
        return
    except Exception:
        pass

    try:
        import System

        System.Console.Beep(1200, 120)
    except Exception:
        pass


def _event_result(event_args):
    for name in _RESULT_ATTRS:
        value = getattr(event_args, name, None)
        if value is None:
            continue

        if callable(value):
            try:
                value = value()
            except Exception:
                continue
        return value

    return None


def _is_failure(result):
    if result is None:
        return False

    try:
        value = int(result)
        if _SUCCESS_RESULT_INT is not None and value == _SUCCESS_RESULT_INT:
            return False
        if _CANCEL_RESULT_INT is not None and value == _CANCEL_RESULT_INT:
            return False
        return True
    except Exception:
        text = str(result).lower()
        if "success" in text:
            return False
        if "cancel" in text:
            return False
        return ("fail" in text) or ("nothing" in text)


def _on_command_ended(sender, event_args):
    try:
        result = _event_result(event_args)
        if _is_failure(result):
            _play_sound()
    except Exception:
        # Never let event handlers throw into Rhino event loop.
        pass


def _install():
    if _normalize_hook(sc.sticky.get(_WATCHER_KEY)):
        _write("Command-fail sound watcher is already enabled.")
        return True

    _detach_current_handler_everywhere()

    for source, event_name in _event_candidates():
        if not hasattr(source, event_name):
            continue

        try:
            event_obj = getattr(source, event_name)
            event_obj += _on_command_ended
            sc.sticky[_WATCHER_KEY] = (source, event_name, _on_command_ended)
            _write(
                "Command-fail sound watcher enabled via {0}.{1}.".format(
                    getattr(source, "__name__", source.__class__.__name__), event_name
                )
            )
            return True
        except Exception:
            continue

    _write("No supported command-end event was found in this Rhino build.")
    return False


def _remove():
    hook = _normalize_hook(sc.sticky.get(_WATCHER_KEY))
    if not hook:
        _write("Command-fail sound watcher is not enabled.")
        _detach_current_handler_everywhere()
        return False

    source, event_name, handler = hook

    _detach_handler(source, event_name, handler)
    _detach_current_handler_everywhere()

    sc.sticky.pop(_WATCHER_KEY, None)
    _write("Command-fail sound watcher disabled.")
    return True


def ToggleCommandFailSound():
    if _normalize_hook(sc.sticky.get(_WATCHER_KEY)):
        _remove()
    else:
        _install()


if __name__ == "__main__":
    ToggleCommandFailSound()

CommandFailSound.py (5.5 KB)