Last active 1738749288

Use this script to send vban text requests over a network

Revision 3081d19218a5ad04ce859ce0d0392b9e35e78c89

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 if self._sock:
174 self._sock.close()
175 self._sock = None
176
177 @ratelimit
178 def sendtext(self, text: str):
179 """
180 Sends a text message to the specified host and port.
181 Args:
182 text (str): The text message to be sent.
183 """
184
185 self._sock.sendto(self._request.encode(text), (self.host, self.port))
186
187 self._request.bump_framecounter()
188
189
190def conn_from_toml(filepath: str = "config.toml") -> dict:
191 """
192 Reads a TOML configuration file and returns its contents as a dictionary.
193 Args:
194 filepath (str): The path to the TOML file. Defaults to "config.toml".
195 Returns:
196 dict: The contents of the TOML file as a dictionary.
197 Example:
198 # config.toml
199 host = "localhost"
200 port = 6980
201 streamname = "Command1"
202 """
203
204 pn = Path(filepath)
205 if not pn.exists():
206 logger.info(
207 f"no {pn} found, using defaults: localhost:6980 streamname: Command1"
208 )
209 return {}
210
211 try:
212 with open(pn, "rb") as f:
213 return tomllib.load(f)
214 except tomllib.TOMLDecodeError as e:
215 raise ValueError(f"Error decoding TOML file: {e}") from e
216
217
218def parse_args() -> argparse.Namespace:
219 """
220 Parse command-line arguments.
221 Returns:
222 argparse.Namespace: Parsed command-line arguments.
223 Command-line arguments:
224 --log-level (str): Set the logging level. Choices are "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL". Default is "INFO".
225 --config (str): Path to config file. Default is "config.toml".
226 -i, --input-file (argparse.FileType): Input file to read from. Default is sys.stdin.
227 text (str, optional): Text to send.
228 """
229
230 parser = argparse.ArgumentParser(description="Voicemeeter VBAN Send Text CLI")
231 parser.add_argument(
232 "--log-level",
233 type=str,
234 choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
235 default="INFO",
236 help="Set the logging level",
237 )
238 parser.add_argument(
239 "--config", type=str, default="config.toml", help="Path to config file"
240 )
241 parser.add_argument(
242 "-i",
243 "--input-file",
244 type=argparse.FileType("r"),
245 default=sys.stdin,
246 )
247 parser.add_argument("text", nargs="?", type=str, help="Text to send")
248 return parser.parse_args()
249
250
251def main(config: dict):
252 """
253 Main function to send text using VbanSendText.
254 Args:
255 config (dict): Configuration dictionary for VbanSendText.
256 Behavior:
257 - If 'args.text' is provided, sends the text and returns.
258 - Otherwise, reads lines from 'args.input_file', strips whitespace, and sends each line.
259 - Stops reading and sending if a line equals "Q".
260 - Logs each line being sent at the debug level.
261 """
262
263 with VbanSendText(**config) as vban:
264 if args.text:
265 vban.sendtext(args.text)
266 return
267
268 for line in args.input_file:
269 line = line.strip()
270 if line.upper() == "Q":
271 break
272
273 logger.debug(f"Sending {line}")
274 vban.sendtext(line)
275
276
277if __name__ == "__main__":
278 args = parse_args()
279
280 logging.basicConfig(level=args.log_level)
281
282 main(conn_from_toml(args.config))
283