#!/usr/bin/env python3 """Fake MPRIS players for visual tests of media widgets. usage: fake_mpris.py PROFILE (0, 1, 2, ... see PROFILES) Each process owns org.mpris.MediaPlayer2.fake, answers the calls a bar makes (GetAll, PlayPause, Next, Previous, Seek) and, when the profile says so, plays through `paplay` as its own child, so mixers that match sound streams to players by process ancestry find it: "silence" (zeros) or "synth" (a beat, a chord and a sweep, for visualisers and equalizers). Set FAKE_MPRIS_SINK to a null sink (`pactl load-module module-null-sink sink_name=...`) so the synth is never audible. Covers are generated PNGs in a folder with a space in its name (exercises percent-encoded file:// links). Profiles cover: long title + playing, >1 h paused podcast, live stream without cover or sound, synth. Needs PyGObject (gi) and paplay. SIGTERM stops paplay too. """ import array import math import os import random import signal import struct import subprocess import sys import threading import time import urllib.parse import zlib from gi.repository import Gio, GLib HERE = os.path.dirname(os.path.abspath(__file__)) RATE = 44100 # identity, playing, sound ("silence", "synth" or None), tracks of (title, artist, seconds, cover colours or None) PROFILES = [ ("Lo-Fi Lab", True, "silence", [ ("Late Night Tape Hiss (Extended Rain Mix) feat. Somebody With A Long Name", "Nujabes Tribute Collective", 214, ((255, 140, 60), (200, 40, 140))), ("Second Track", "Lo-Fi Lab Band", 180, ((250, 210, 70), (60, 160, 120))), ]), ("Podcast Deck", False, "silence", [ ("Episode 142: Shipping Small", "The Lazy Dev Show", 3725, ((40, 190, 200), (20, 30, 90))), ]), ("Radio Stream", True, None, [ ("Live from the Basement", "Night FM", 0, None), ]), ("Synth Test", True, "synth", [ ("Chest Pain (I Love)", "Malcolm Todd", 200, ((120, 110, 190), (30, 20, 60))), ]), ] def synth(out, stop): """Endless s16le stereo: a kick every 0.5 s, hats, a breathing chord and a slow sweep.""" frame = 0 chunk = 2048 while not stop.is_set(): samples = array.array("h") for _ in range(chunk): t = frame / RATE beat = t % 0.5 kick = math.sin(2 * math.pi * (50 + 90 * math.exp(-beat * 30)) * beat) * math.exp(-beat * 8) hat = (random.random() * 2 - 1) * math.exp(-(t % 0.25) * 60) * 0.25 chord = sum(math.sin(2 * math.pi * f * t) for f in (220.0, 277.2, 329.6)) / 3 * (0.3 + 0.2 * math.sin(t)) sweep = math.sin(2 * math.pi * (400 + 3000 * (0.5 + 0.5 * math.sin(t * 0.7))) * t) * 0.15 value = int(max(-1.0, min(1.0, 0.5 * kick + hat + chord + sweep)) * 20000) samples.append(value) samples.append(value) frame += 1 try: out.write(samples.tobytes()) out.flush() except (BrokenPipeError, ValueError): return INTERFACES = """ """ def png(path, colours, size=256): """A diagonal two-colour gradient with a lighter disc, written with zlib only.""" (r0, g0, b0), (r1, g1, b1) = colours rows = [] for y in range(size): row = bytearray(b"\x00") for x in range(size): t = (x + y) / (2 * size - 2) disc = (x - size * 0.62) ** 2 + (y - size * 0.38) ** 2 < (size * 0.22) ** 2 lift = 60 if disc else 0 row += bytes(min(255, int(a + (b - a) * t) + lift) for a, b in ((r0, r1), (g0, g1), (b0, b1))) rows.append(bytes(row)) def chunk(kind, data): return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data)) header = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0) with open(path, "wb") as out: out.write(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + chunk(b"IDAT", zlib.compress(b"".join(rows))) + chunk(b"IEND", b"")) class Player: def __init__(self, index): self.index = index self.identity, self.playing, sound, self.tracks = PROFILES[index] self.track = 0 self.offset = 0.0 # seconds into the track at `since` self.since = time.monotonic() self.sound = None self.stop_synth = threading.Event() if sound: command = ["paplay", "--raw", "--format=s16le", f"--rate={RATE}", "--channels=2", f"--client-name={self.identity}"] if os.environ.get("FAKE_MPRIS_SINK"): command.append(f"--device={os.environ['FAKE_MPRIS_SINK']}") if sound == "silence": self.sound = subprocess.Popen(command + ["/dev/zero"]) else: # paplay reads stdin when no file is given self.sound = subprocess.Popen(command, stdin=subprocess.PIPE) threading.Thread(target=synth, args=(self.sound.stdin, self.stop_synth), daemon=True).start() covers = os.path.join(HERE, "fake covers") os.makedirs(covers, exist_ok=True) self.covers = [] for number, (_, _, _, colours) in enumerate(self.tracks): if colours is None: self.covers.append("") continue path = os.path.join(covers, f"player{index}-track{number}.png") if not os.path.exists(path): png(path, colours) self.covers.append("file://" + urllib.parse.quote(path)) def position(self): length = self.tracks[self.track][2] moved = time.monotonic() - self.since if self.playing else 0.0 seconds = self.offset + moved return min(seconds, length) if length else seconds def rebase(self, seconds): self.offset = max(0.0, seconds) self.since = time.monotonic() def metadata(self): title, artist, length, _ = self.tracks[self.track] data = { "mpris:trackid": GLib.Variant("o", f"/org/mpris/MediaPlayer2/fake/track{self.track}"), "xesam:title": GLib.Variant("s", title), "xesam:artist": GLib.Variant("as", [artist]), "mpris:artUrl": GLib.Variant("s", self.covers[self.track]), } if length: data["mpris:length"] = GLib.Variant("x", length * 1_000_000) return GLib.Variant("a{sv}", data) def get(self, _connection, _sender, _path, _interface, name): constant = { "Identity": GLib.Variant("s", self.identity), "CanQuit": GLib.Variant("b", False), "CanRaise": GLib.Variant("b", False), "HasTrackList": GLib.Variant("b", False), "SupportedUriSchemes": GLib.Variant("as", []), "SupportedMimeTypes": GLib.Variant("as", []), "Rate": GLib.Variant("d", 1.0), "Volume": GLib.Variant("d", 1.0), } if name in constant: return constant[name] if name == "PlaybackStatus": return GLib.Variant("s", "Playing" if self.playing else "Paused") if name == "Metadata": return self.metadata() if name == "Position": return GLib.Variant("x", int(self.position() * 1_000_000)) return GLib.Variant("b", True) # every Can* capability def call(self, _connection, _sender, _path, _interface, method, parameters, invocation): if method in ("PlayPause", "Play", "Pause", "Stop"): here = self.position() self.playing = {"PlayPause": not self.playing, "Play": True}.get(method, False) self.rebase(here) elif method in ("Next", "Previous"): step = 1 if method == "Next" else -1 self.track = (self.track + step) % len(self.tracks) self.rebase(0) elif method == "Seek": self.rebase(self.position() + parameters.unpack()[0] / 1_000_000) elif method == "SetPosition": self.rebase(parameters.unpack()[1] / 1_000_000) print(f"{self.identity}: {method} {parameters.unpack() if parameters else ''} " f"-> {'playing' if self.playing else 'paused'} at {self.position():.1f}s", flush=True) invocation.return_value(None) def stop(self, *_): self.stop_synth.set() if self.sound: self.sound.terminate() sys.exit(0) def main(): player = Player(int(sys.argv[1])) signal.signal(signal.SIGTERM, player.stop) signal.signal(signal.SIGINT, player.stop) node = Gio.DBusNodeInfo.new_for_xml(INTERFACES) def acquired(connection, _name): for interface in node.interfaces: connection.register_object("/org/mpris/MediaPlayer2", interface, player.call, player.get, None) Gio.bus_own_name(Gio.BusType.SESSION, f"org.mpris.MediaPlayer2.fake{player.index}", Gio.BusNameOwnerFlags.NONE, acquired, None, None) # GLib's loop ignores Python signal handlers unless it wakes up now and then GLib.timeout_add(250, lambda: True) GLib.MainLoop().run() if __name__ == "__main__": main()