Parcourir la source

feat(obs): 2025 is the year we go full streamer mode

Joe il y a 1 an
Parent
commit
0be3a8e53b
4 fichiers modifiés avec 354 ajouts et 3 suppressions
  1. 6 0
      .config/brew/Brewfile
  2. 32 3
      .config/yabai/yabai.py
  3. 296 0
      .scripts/obs_client.py
  4. 20 0
      .scripts/tablecast.zsh

+ 6 - 0
.config/brew/Brewfile

@@ -123,6 +123,8 @@ brew "universal-ctags"
 brew "vscode-langservers-extracted"
 # Executes a program periodically, showing output fullscreen
 brew "watch"
+# Command-line client for WebSockets
+brew "websocat"
 # Internet file retriever
 brew "wget"
 # Language Server for Yaml Files
@@ -149,11 +151,15 @@ cask "cursorcerer"
 cask "firefox"
 # Vector graphics editor
 cask "inkscape"
+# Open-source software for live streaming and screen recording
+cask "obs"
 # Knowledge base that works on top of a local folder of plain text Markdown files
 cask "obsidian"
 # Administration and development platform for PostgreSQL
 cask "pgadmin4"
 # Instant messaging application focusing on security
 cask "signal"
+# Virtual audio cable for routing audio from one application to another
+cask "vb-cable"
 # Multimedia player
 cask "vlc"

+ 32 - 3
.config/yabai/yabai.py

@@ -372,6 +372,27 @@ class Yabai(CLIWrapper):
                 f"action=/usr/bin/env python3 {HOME}/.scripts/slack_client.py",
             ]
         )
+        # "Attach" OBS to Google Meet
+        self.message(
+            [
+                "signal",
+                "--add",
+                "event=application_launched",
+                "app=Google Meet",
+                "label=GoogleMeetOBSJoiner",
+                f"action=/usr/bin/env python3 {HOME}/.scripts/obs_client.py Activate Default StartVirtualCam",
+            ]
+        )
+        self.message(
+            [
+                "signal",
+                "--add",
+                "event=application_terminated",
+                "app=Google Meet",
+                "label=GoogleMeetOBSJoinerEnd",
+                f"action=/usr/bin/env python3 {HOME}/.scripts/obs_client.py StopVirtualCam Activate Disabled",
+            ]
+        )
         # Check if dark mode settings have been updated when focusing terminal
         self.message(
             [
@@ -446,7 +467,9 @@ class Yabai(CLIWrapper):
 
         displays = self.get_displays()
         main_display = self.get_main_display(displays)
-        secondary_display = next(d for d in displays if not d.id == main_display.id)
+        secondary_display = next(
+            (d for d in displays if not d.id == main_display.id), None
+        )
 
         current_spaces = self.get_spaces()
 
@@ -459,7 +482,11 @@ class Yabai(CLIWrapper):
             if mapped_space is None:
                 continue
 
-            if mapped_space.display == main_display.index and is_secondary_space:
+            if (
+                mapped_space.display == main_display.index
+                and is_secondary_space
+                and secondary_display
+            ):
                 incorrect_secondary_spaces.add(mapped_space)
             if mapped_space.display != main_display.index and not is_secondary_space:
                 incorrect_main_spaces.add(mapped_space)
@@ -482,7 +509,9 @@ class Yabai(CLIWrapper):
                         "space",
                         space.label,
                         "--display",
-                        secondary_display.label if secondary else main_display.label,
+                        secondary_display.label
+                        if secondary and secondary_display
+                        else main_display.label,
                     ]
                 )
 

+ 296 - 0
.scripts/obs_client.py

@@ -0,0 +1,296 @@
+#!/usr/bin/env python3
+
+# A little control script for OBS, using `websocat`.
+# Managing Python environments is _much_ harder than installing a binary with Homebrew,
+# hence why this used `websocat` and not the Python `websockets` library.
+#
+# Protocol documentation:
+# https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md
+
+# pyright: basic, reportAny=false, reportExplicitAny=false, reportUnusedCallResult=false
+
+from logging import DEBUG, ERROR, basicConfig, debug, error
+from os import environ
+from subprocess import (
+    DEVNULL,
+    CalledProcessError,
+    Popen,
+    PIPE,
+    check_output,
+)
+from sys import argv, stdout
+from time import sleep
+from typing import cast
+from uuid import uuid4
+from json import JSONDecodeError, dumps, loads
+from signal import SIGALRM, signal, alarm
+
+OBS_WS_HOST = environ.get("OBS_WS_HOST", "localhost")
+OBS_WS_PORT = environ.get("OBS_WS_PORT", "4455")
+
+
+class WebsocketWrapper:
+    def __init__(self, host: str, port: str, timeout: int = 60) -> None:
+        self._conn_string = f"ws://{host}:{port}"
+        self._process = Popen(
+            ["websocat", self._conn_string], stdout=PIPE, stderr=PIPE, stdin=PIPE
+        )
+        if self._process.stdout is not None and self._process.stdin is not None:
+            self._stdout = self._process.stdout
+            self._stdin = self._process.stdin
+        else:
+            raise Exception("Invalid process IO")
+
+        # Websockets are tricky, and so are process timeouts - this little hack
+        # kills this script if it lives too long (without worrying about threads)
+        def handler(*_):
+            error(f"{self.__class__.__name__} timed out")
+            self._process.kill()
+            exit(1)
+
+        signal(SIGALRM, handler)
+        alarm(timeout)
+
+        self._identify()
+
+    def _receive_message(self):
+        read = self._stdout.readline()
+        decoded = read.decode()
+        content = loads(decoded)
+        debug(f"READ: {decoded}")
+        return content
+
+    def _send_message(self, message: str | dict):
+        if type(message) == "dict":
+            message = dumps(message)
+        self._stdin.write((cast(str, message) + "\n").encode())
+        self._stdin.flush()
+        debug(f"WRITE: {message}")
+        return message
+
+    def _identify(self):
+        self._receive_message()
+        self._send_message(
+            dumps({"op": 1, "d": {"rpcVersion": 1, "eventSubscriptions": 0}})
+        )
+        self._receive_message()
+
+    def request(self, request_type: str, request_data: dict):
+        self._send_message(
+            dumps(
+                {
+                    "op": 6,
+                    "d": {
+                        "requestType": request_type,
+                        "requestId": uuid4().__str__(),
+                        "requestData": request_data,
+                    },
+                }
+            )
+        )
+        return self._receive_message()
+
+
+class OBS:
+    _default_name = "Default"
+    _disabled_name = "Disabled"
+    _tablecast_name = "Tablecast"
+
+    def __init__(self, host: str, port: str) -> None:
+        self._ensure_profiles = {
+            self._disabled_name: {
+                "General": {"Name": self._disabled_name},
+                "Output": {
+                    "Mode": "Simple",
+                    "Reconnect": False,
+                },
+                "SimpleOutput": {
+                    "VBitrate": 200,
+                    "ABitrate": 20,
+                    "Preset": "veryfast",
+                },
+                "Video": {
+                    "BaseCX": 32,
+                    "BaseCY": 32,
+                    "OutputCX": 32,
+                    "OutputCY": 32,
+                    "FPSType": 2,
+                    "FPSCommon": 30,
+                    "FPSInt": 30,
+                    "FPSNum": 1,
+                    "FPSDen": 10,
+                    "ScaleType": "bicubic",
+                    "ColorFormat": "RGB",
+                    "ColorSpace": "sRGB",
+                    "ColorRange": "Partial",
+                },
+            },
+            self._tablecast_name: {
+                "General": {
+                    "Name": self._tablecast_name,
+                },
+                "Output": {"Mode": "Advanced"},
+                "AdvOut": {
+                    "ApplyServiceSettings": "true",
+                    "UseRescale": "false",
+                    "Encoder": "com.apple.videotoolbox.videoencoder.ave.avc",
+                    "AudioEncoder": "ffmpeg_aac",
+                    "RecSplitFileType": "Time",
+                    "FFFormat": "rtsp",
+                    "FFVEncoderId": "27",
+                    "FFVEncoder": "h264_videotoolbox",
+                    "FFAEncoderId": "86076",
+                    "FFAEncoder": "libopus",
+                    "FFExtension": "FFURL=rtsp://localhost:8554/mystream",
+                    "FFVCustom": "bf=0",
+                    "RescaleFilter": "3",
+                },
+                "Video": {
+                    "BaseCX": "4096",
+                    "BaseCY": "2304",
+                    "OutputCX": "4096",
+                    "OutputCY": "2304",
+                    "FPSType": "0",
+                    "FPSCommon": "24 NTSC",
+                },
+            },
+            self._default_name: {},
+        }
+        self._ensure_collections = {
+            self._disabled_name: {},
+            self._default_name: {},
+            self._tablecast_name: {},
+        }
+
+        # Start OBS if it doesn't appear to be started already
+        started = False
+        if (
+            len([o for o in check_output(["ps", "aux"]).split(b"\n") if b"OBS" in o])
+            == 0
+        ):
+            Popen(
+                [
+                    "obs",
+                    "--minimize-to-tray",
+                    "--disable-updater",
+                    "--disable-missing-files-check",
+                    "--disable-shutdown-check",
+                    "--profile",
+                    self._disabled_name,
+                    "--collection",
+                    self._disabled_name,
+                ],
+                start_new_session=True,
+                stdin=DEVNULL,
+                stdout=DEVNULL,
+                stderr=DEVNULL,
+            )
+            started = True
+            sleep(5)
+
+        self._websocket = WebsocketWrapper(host, port, 60 if not started else 10 * 60)
+
+        if started:
+            # Create "ensured" items on startup
+            self._ensure()
+            self.request("SetCurrentProfile", {"profileName": self._disabled_name})
+            self.request(
+                "SetCurrentSceneCollection",
+                {"sceneCollectionName": self._disabled_name},
+            )
+
+    def _ensure(self):
+        for profile in set(self._ensure_collections.keys()).difference(
+            set(self.request("GetProfileList", {})["profiles"])
+        ):
+            self.request("CreateProfile", {"profileName": profile})
+            for category in self._ensure_profiles[profile]:
+                for key in self._ensure_profiles[profile][category]:
+                    self.request(
+                        "SetProfileParameter",
+                        {
+                            "parameterCategory": category,
+                            "parameterName": key,
+                            "parameterValue": str(
+                                self._ensure_profiles[profile][category][key]
+                            ),
+                        },
+                    )
+        for collection in set(self._ensure_profiles.keys()).difference(
+            set(self.request("GetSceneCollectionList", {})["sceneCollections"])
+        ):
+            self.request("CreateSceneCollection", {"sceneCollectionName": collection})
+
+    def request(self, request_type: str, request_data: dict):
+        result = self._websocket.request(request_type, request_data)["d"]
+        if (
+            "requestStatus" in result
+            and "code" in result["requestStatus"]
+            and result["requestStatus"]["code"] == 207
+        ):
+            sleep(0.1)
+            return self.request(request_type, request_data)
+        if request_type == "StartVirtualCam":
+            sleep(3)
+        if "responseData" in result:
+            return result["responseData"]
+        return result
+
+    def batch(self, requests: list[tuple[str,] | tuple[str, dict]]):
+        for request in requests:
+            if len(request) == 1:
+                yield self.request(request[0], {})
+            else:
+                yield self.request(request[0], request[1])
+
+    def parse(self, args: list[str]):
+        requests: list[tuple[str,] | tuple[str, dict]] = []
+        current: str | None = None
+        for item in args:
+            if current == "Activate":
+                if (
+                    item in self._ensure_collections.keys()
+                    and item in self._ensure_profiles.keys()
+                ):
+                    requests.extend(
+                        (
+                            ("SetCurrentProfile", {"profileName": item}),
+                            (
+                                "SetCurrentSceneCollection",
+                                {"sceneCollectionName": item},
+                            ),
+                        )
+                    )
+                else:
+                    error(f"Cannot activate '{item}'")
+                current = None
+            elif current is not None:
+                try:
+                    requests.append((current, loads(item)))
+                    current = None
+                except JSONDecodeError:
+                    requests.append((current,))
+                    current = item
+            else:
+                current = item
+        if current is not None:
+            requests.append((current,))
+        debug(requests)
+        for response in self.batch(requests):
+            yield response
+
+
+if __name__ == "__main__":
+    basicConfig(level=ERROR)
+    debug(f"Called with parameters {argv}")
+
+    for program in ["websocat"]:
+        try:
+            _ = check_output([program, "--version"], stderr=DEVNULL)
+        except CalledProcessError:
+            pass
+        except:
+            error(f"Aborted, as `{program}` doesn't appear to be callable")
+
+    for response in OBS(OBS_WS_HOST, OBS_WS_PORT).parse(argv[1:]):
+        stdout.write(dumps(response) + "\n")

+ 20 - 0
.scripts/tablecast.zsh

@@ -0,0 +1,20 @@
+#!/bin/zsh
+# OBS -> mediamtx -> VLC -> External Renderer (e.g. Chromecast)
+set -e
+set -x
+
+brew services start mediamtx
+
+obs_client.py \
+    SetCurrentProfile '{"profileName": "Tablecast"}' \
+    SetCurrentSceneCollection '{"sceneCollectionName": "Tablecast"}' \
+    SetStreamServiceSettings '{"streamServiceType": "rtmp_custom", "streamServiceSettings": {"server": "rtmp://localhost", "key": "mystream"}}' \
+    StartStream
+
+sleep 5 # Give the stream a moment to start
+vlc --network-caching=50 rtsp://localhost:8554/mystream
+
+obs_client.py \
+    StopStream \
+    SetCurrentProfile '{"profileName": "Disabled"}' \
+    SetCurrentSceneCollection '{"sceneCollectionName": "Disabled"}'