163 lines
4.0 KiB
Markdown
163 lines
4.0 KiB
Markdown
# Croaker
|
|
|
|
Croaker is a Linux desktop audio player controlled from a TCP server. It is designed specifically to play background music during TTRPG sessions.
|
|
|
|
### Features
|
|
|
|
* Audio playback using VLC
|
|
* Playlists are built using symlinks
|
|
* Randomizes playlist order the first time it is cached
|
|
* Always plays `_theme.mp3` first upon switching to a playlist, if it exists
|
|
* Controlled by issuing commands over a TCP socket
|
|
|
|
### Requirements
|
|
|
|
* A functioning shoutcast / icecast server
|
|
* Python >= 3.11
|
|
* python3.11-dev
|
|
|
|
|
|
## What? Why?
|
|
|
|
I run an online D&D game. For years I have provided my players with an internet radio station playing the session background music. The first version was built using liquidsoap and icecast. The second version replaced liquidsoap with a custom streamer implementation (which is still available on the `shoutcast` branch, warts and all).
|
|
|
|
Both of these solutions were functional but high maintenance, and I wanted something simpler both for me and my players.
|
|
|
|
This version of Croaker usees VLC (via python-vlc) to play audio locally, and pops up a read-only desktop interface to display what is playing. I share this app using screen sharing during our online games, and control it using my DM tools.
|
|
|
|
*Now that is a powerful yak! -- Aesop Rock (misquoted)*
|
|
|
|
|
|
## Quick Start (Server)
|
|
|
|
This assumes you have a functioning icecast2/whatever installation already.
|
|
|
|
```
|
|
% mkdir -p ~/.dnd/croaker
|
|
% croaker setup > ~/.dnd/croaker/defaults
|
|
% vi ~/.dnd/croaker/defaults # adjust to taste
|
|
% croaker add session_start /music/session_start.mp3
|
|
% croaker add battle /music/battle/*.mp3
|
|
```
|
|
|
|
Now start the server, which will begin streaming the `session_start` playlist:
|
|
|
|
## Controlling The Server
|
|
|
|
```
|
|
% croaker start
|
|
INFO Daemonizing controller on (localhost, 8003); pidfile and logs in ~/.dnd/croaker
|
|
```
|
|
|
|
Connnect to the command & control server:
|
|
|
|
```bash
|
|
% telnet localhost 8003
|
|
Trying 127.0.0.1...
|
|
Connected to croaker.local.
|
|
Escape character is '^]'.
|
|
|
|
help
|
|
|
|
PLAY PLAYLIST - Load and play the specified playlist.
|
|
LIST [PLAYLIST] - List all lplaylists or the contents of a single playlist.
|
|
BACK - Return to the previous track in the playlist
|
|
FFWD - Skip to the next track in the playlist.
|
|
HELP - Display command help.
|
|
KTHX - Close the current connection.
|
|
STOP - Stop the current track and stream silence.
|
|
STFU - Terminate the Croaker server.
|
|
```
|
|
|
|
List available playlists:
|
|
|
|
```
|
|
list
|
|
|
|
battle
|
|
adventure
|
|
session_start
|
|
```
|
|
|
|
Switch to battle music -- roll initiative!
|
|
```
|
|
play battle
|
|
OK
|
|
```
|
|
|
|
Skip this track and move on to the next:
|
|
|
|
```
|
|
ffwd
|
|
OK
|
|
```
|
|
|
|
Stop the music:
|
|
|
|
```
|
|
stop
|
|
OK
|
|
```
|
|
|
|
Disconnect:
|
|
|
|
```
|
|
kthx
|
|
KBAI
|
|
Connection closed by foreign host.
|
|
```
|
|
|
|
## Python Client Implementation
|
|
|
|
Here's a sample client using Ye Olde Socket Library:
|
|
|
|
```python
|
|
import socket
|
|
from dataclasses import dataclass
|
|
from functools import cached_property
|
|
|
|
|
|
@dataclass
|
|
class CroakerClient():
|
|
host: str
|
|
port: int
|
|
|
|
@cached_property
|
|
def playlists(self):
|
|
return self.send("LIST").split("\n")
|
|
|
|
def list(self, *args):
|
|
if not args:
|
|
return self.playlists
|
|
return self.send(f"LIST {args[0]}")
|
|
|
|
def play(self, *args):
|
|
if not args:
|
|
return "Error: Must specify the playlist to play."
|
|
return self.send(f"PLAY {args[0]}")
|
|
|
|
def ffwd(self, *args):
|
|
return self.send("FFWD")
|
|
|
|
def stop(self, *args):
|
|
return self.send("STOP")
|
|
|
|
def send(self, msg: str):
|
|
BUFSIZE = 4096
|
|
data = bytearray()
|
|
with socket.create_connection((self.host, self.port)) as sock:
|
|
sock.sendall(f"{msg}\n".encode())
|
|
while True:
|
|
buf = sock.recv(BUFSIZE)
|
|
data.extend(buf)
|
|
if len(buf) < BUFSIZE:
|
|
break
|
|
sock.sendall(b'KTHX\n')
|
|
return data.decode()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
client = CroakerClient(host='localhost', port=1234)
|
|
client.play('session_start')
|
|
```
|