Last active 1738749288

Use this script to send vban text requests over a network

Revision 5f83e98c4f84e4f209c1fdc0abf6f22ae24ff733

sendtext.py Raw
1#!/usr/bin/env python3
2
3# MIT License
4#
5# Copyright (c) 2025 Onyx and Iris
6#
7# Permission is hereby granted, free of charge, to any person obtaining a copy
8# of this software and associated documentation files (the "Software"), to deal
9# in the Software without restriction, including without limitation the rights
10# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11# copies of the Software, and to permit persons to whom the Software is
12# furnished to do so, subject to the following conditions:
13#
14# The above copyright notice and this permission notice shall be included in all
15# copies or substantial portions of the Software.
16#
17# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23# SOFTWARE.
24
25"""
26sendtext.py
27
28This script provides functionality to send text messages over a network using the VBAN protocol.
29It includes classes and functions to construct and send VBAN packets with text data, read configuration
30from a TOML file, and handle command-line arguments for customization.
31
32Classes:
33 RTHeader: Represents the VBAN header for a text stream.
34 RequestPacket: Encapsulates a VBAN request packet with text data.
35 VbanSendText: Manages the sending of text messages over VBAN with rate limiting.
36
37Functions:
38 ratelimit: Decorator to enforce a rate limit on a function.
39 conn_from_toml: Reads a TOML configuration file and returns its contents as a dictionary.
40 parse_args: Parses command-line arguments.
41 main: Main function to send text using VbanSendText.
42
43Usage:
44 Run the script with appropriate command-line arguments to send text messages.
45 Example:
46 python sendtext.py --config /path/to/config.toml --log-level DEBUG "strip[0].mute=0 strip[1].mute=0"
47"""
48
49import argparse
50import functools
51import logging
52import socket
53import sys
54import time
55from dataclasses import dataclass, field
56from pathlib import Path
57from typing import Callable
58
59import tomllib
60
61logger = logging.getLogger(__name__)
62
63
64@dataclass
65class RTHeader:
66 name: str
67 bps: int
68 channel: int
69 VBAN_PROTOCOL_TXT = 0x40
70 framecounter: bytes = (0).to_bytes(4, "little")
71 # fmt: off
72 BPS_OPTS: list[int] = field(default_factory=lambda: [
73 0, 110, 150, 300, 600, 1200, 2400, 4800, 9600, 14400, 19200, 31250,
74 38400, 57600, 115200, 128000, 230400, 250000, 256000, 460800, 921600,
75 1000000, 1500000, 2000000, 3000000
76 ])
77 # fmt: on
78
79 def __post_init__(self):
80 if len(self.name) > 16:
81 raise ValueError(
82 f"Stream name got: '{self.name}', want: must be 16 characters or fewer"
83 )
84 try:
85 self.bps_index = self.BPS_OPTS.index(self.bps)
86 except ValueError as e:
87 ERR_MSG = f"Invalid bps: {self.bps}, must be one of {self.BPS_OPTS}"
88 e.add_note(ERR_MSG)
89 raise
90
91 def __sr(self) -> bytes:
92 return (RTHeader.VBAN_PROTOCOL_TXT + self.bps_index).to_bytes(1, "little")
93
94 def __nbc(self) -> bytes:
95 return (self.channel).to_bytes(1, "little")
96
97 def build(self) -> bytes:
98 header = "VBAN".encode("utf-8")
99 header += self.__sr()
100 header += (0).to_bytes(1, "little")
101 header += self.__nbc()
102 header += (0x10).to_bytes(1, "little")
103 header += self.name.encode() + bytes(16 - len(self.name))
104 header += RTHeader.framecounter
105 return header
106
107
108class RequestPacket:
109 def __init__(self, header: RTHeader):
110 self.header = header
111
112 def encode(self, text: str) -> bytes:
113 return self.header.build() + text.encode("utf-8")
114
115 def bump_framecounter(self) -> None:
116 self.header.framecounter = (
117 int.from_bytes(self.header.framecounter, "little") + 1
118 ).to_bytes(4, "little")
119
120 logger.debug(
121 f"framecounter: {int.from_bytes(self.header.framecounter, 'little')}"
122 )
123
124
125def ratelimit(func: Callable) -> Callable:
126 """
127 Decorator to enforce a rate limit on a function.
128 This decorator ensures that the decorated function is not called more frequently
129 than the specified delay. If the function is called before the delay has passed
130 since the last call, it will wait for the remaining time before executing.
131 Args:
132 func (callable): The function to be decorated.
133 Returns:
134 callable: The wrapped function with rate limiting applied.
135 Example:
136 @ratelimit
137 def send_message(self, message):
138 # Function implementation
139 pass
140 """
141
142 @functools.wraps(func)
143 def wrapper(self, *args, **kwargs):
144 now = time.time()
145 if now - self.lastsent < self.delay:
146 time.sleep(self.delay - (now - self.lastsent))
147 self.lastsent = time.time()
148 return func(self, *args, **kwargs)
149
150 return wrapper
151
152
153class VbanSendText:
154 def __init__(self, **kwargs):
155 defaultkwargs = {
156 "host": "localhost",
157 "port": 6980,
158 "streamname": "Command1",
159 "bps": 256000,
160 "channel": 0,
161 "delay": 0.02,
162 }
163 defaultkwargs.update(kwargs)
164 self.__dict__.update(defaultkwargs)
165 self._request = RequestPacket(RTHeader(self.streamname, self.bps, self.channel))
166 self.lastsent = 0
167
168 def __enter__(self):
169 self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
170 return self
171
172 def __exit__(self, exc_type, exc_value, traceback):
173 self._sock.close()
174
175 @ratelimit
176 def sendtext(self, text: str):
177 """
178 Sends a text message to the specified host and port.
179 Args:
180 text (str): The text message to be sent.
181 """
182
183 self._sock.sendto(self._request.encode(text), (self.host, self.port))
184
185 self._request.bump_framecounter()
186
187
188def conn_from_toml(filepath: str = "config.toml") -> dict:
189 """
190 Reads a TOML configuration file and returns its contents as a dictionary.
191 Args:
192 filepath (str): The path to the TOML file. Defaults to "config.toml".
193 Returns:
194 dict: The contents of the TOML file as a dictionary.
195 Example:
196 # config.toml
197 host = "localhost"
198 port = 6980
199 streamname = "Command1"
200 """
201
202 pn = Path(filepath)
203 if not pn.exists():
204 logger.info(
205 f"no {pn} found, using defaults: localhost:6980 streamname: Command1"
206 )
207 return {}
208
209 try:
210 with open(pn, "rb") as f:
211 return tomllib.load(f)
212 except tomllib.TOMLDecodeError as e:
213 raise ValueError(f"Error decoding TOML file: {e}") from e
214
215
216def parse_args() -> argparse.Namespace:
217 """
218 Parse command-line arguments.
219 Returns:
220 argparse.Namespace: Parsed command-line arguments.
221 Command-line arguments:
222 --log-level (str): Set the logging level. Choices are "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL". Default is "INFO".
223 --config (str): Path to config file. Default is "config.toml".
224 -i, --input-file (argparse.FileType): Input file to read from. Default is sys.stdin.
225 text (str, optional): Text to send.
226 """
227
228 parser = argparse.ArgumentParser(description="Voicemeeter VBAN Send Text CLI")
229 parser.add_argument(
230 "--log-level",
231 type=str,
232 choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
233 default="INFO",
234 help="Set the logging level",
235 )
236 parser.add_argument(
237 "--config", type=str, default="config.toml", help="Path to config file"
238 )
239 parser.add_argument(
240 "-i",
241 "--input-file",
242 type=argparse.FileType("r"),
243 default=sys.stdin,
244 )
245 parser.add_argument("text", nargs="?", type=str, help="Text to send")
246 return parser.parse_args()
247
248
249def main(config: dict):
250 """
251 Main function to send text using VbanSendText.
252 Args:
253 config (dict): Configuration dictionary for VbanSendText.
254 Behavior:
255 - If 'args.text' is provided, sends the text and returns.
256 - Otherwise, reads lines from 'args.input_file', strips whitespace, and sends each line.
257 - Stops reading and sending if a line equals "Q".
258 - Logs each line being sent at the debug level.
259 """
260
261 with VbanSendText(**config) as vban:
262 if args.text:
263 vban.sendtext(args.text)
264 return
265
266 for line in args.input_file:
267 line = line.strip()
268 if line.upper() == "Q":
269 break
270
271 logger.debug(f"Sending {line}")
272 vban.sendtext(line)
273
274
275if __name__ == "__main__":
276 args = parse_args()
277
278 logging.basicConfig(level=args.log_level)
279
280 main(conn_from_toml(args.config))
281