Last active 1738749288

Use this script to send vban text requests over a network

Revision fb9e67635bf46b1a33ba3dd0c2eeaf0191cac246

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
25import argparse
26import functools
27import logging
28import socket
29import sys
30import time
31from dataclasses import dataclass
32from pathlib import Path
33from typing import Callable
34
35import tomllib
36
37logger = logging.getLogger(__name__)
38
39
40@dataclass
41class RTHeader:
42 name: str
43 bps_index: int
44 channel: int
45 VBAN_PROTOCOL_TXT = 0x40
46 framecounter: bytes = (0).to_bytes(4, 'little')
47
48 def __sr(self) -> bytes:
49 return (RTHeader.VBAN_PROTOCOL_TXT + self.bps_index).to_bytes(1, 'little')
50
51 def __nbc(self) -> bytes:
52 return (self.channel).to_bytes(1, 'little')
53
54 def build(self) -> bytes:
55 header = 'VBAN'.encode('utf-8')
56 header += self.__sr()
57 header += (0).to_bytes(1, 'little')
58 header += self.__nbc()
59 header += (0x10).to_bytes(1, 'little')
60 header += self.name.encode() + bytes(16 - len(self.name))
61 header += RTHeader.framecounter
62 return header
63
64
65class RequestPacket:
66 def __init__(self, header: RTHeader):
67 self.header = header
68
69 def encode(self, text: str) -> bytes:
70 return self.header.build() + text.encode('utf-8')
71
72 def bump_framecounter(self) -> None:
73 self.header.framecounter = (
74 int.from_bytes(self.header.framecounter, 'little') + 1
75 ).to_bytes(4, 'little')
76
77 logger.debug(
78 f'framecounter: {int.from_bytes(self.header.framecounter, "little")}'
79 )
80
81
82def ratelimit(func: Callable) -> Callable:
83 """
84 Decorator to enforce a rate limit on a function.
85 This decorator ensures that the decorated function is not called more frequently
86 than the specified delay. If the function is called before the delay has passed
87 since the last call, it will wait for the remaining time before executing.
88 Args:
89 func (callable): The function to be decorated.
90 Returns:
91 callable: The wrapped function with rate limiting applied.
92 Example:
93 @ratelimit
94 def send_message(self, message):
95 # Function implementation
96 pass
97 """
98
99 @functools.wraps(func)
100 def wrapper(self, *args, **kwargs):
101 now = time.time()
102 if now - self.lastsent < self.delay:
103 time.sleep(self.delay - (now - self.lastsent))
104 self.lastsent = time.time()
105 return func(self, *args, **kwargs)
106
107 return wrapper
108
109
110class VbanSendText:
111 # fmt: off
112 BPS_OPTS = [
113 0, 110, 150, 300, 600, 1200, 2400, 4800, 9600, 14400, 19200, 31250,
114 38400, 57600, 115200, 128000, 230400, 250000, 256000, 460800, 921600,
115 1000000, 1500000, 2000000, 3000000
116 ]
117 # fmt: on
118
119 def __init__(self, **kwargs):
120 defaultkwargs = {
121 'host': 'localhost',
122 'port': 6980,
123 'streamname': 'Command1',
124 'bps': 256000,
125 'channel': 0,
126 'delay': 0.02,
127 }
128 defaultkwargs.update(kwargs)
129 self.__dict__.update(defaultkwargs)
130 self._request = RequestPacket(
131 RTHeader(
132 self.streamname, VbanSendText.BPS_OPTS.index(self.bps), self.channel
133 )
134 )
135 self.lastsent = 0
136
137 def __enter__(self):
138 self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
139 return self
140
141 def __exit__(self, exc_type, exc_value, traceback):
142 self._sock.close()
143
144 @ratelimit
145 def sendtext(self, text: str):
146 """
147 Sends a text message to the specified host and port.
148 Args:
149 text (str): The text message to be sent.
150 """
151
152 self._sock.sendto(self._request.encode(text), (self.host, self.port))
153
154 self._request.bump_framecounter()
155
156
157def conn_from_toml(filepath: str = 'config.toml') -> dict:
158 """
159 Reads a TOML configuration file and returns its contents as a dictionary.
160 Args:
161 filepath (str): The path to the TOML file. Defaults to "config.toml".
162 Returns:
163 dict: The contents of the TOML file as a dictionary.
164 Example:
165 # config.toml
166 host = "localhost"
167 port = 6980
168 streamname = "Command1"
169 """
170
171 pn = Path(filepath)
172 if not pn.exists():
173 logger.info(
174 f'no {pn} found, using defaults: localhost:6980 streamname: Command1'
175 )
176 return {}
177
178 try:
179 with open(pn, 'rb') as f:
180 return tomllib.load(f)
181 except tomllib.TOMLDecodeError as e:
182 raise ValueError(f'Error decoding TOML file: {e}') from e
183
184
185def parse_args() -> argparse.Namespace:
186 """
187 Parse command-line arguments.
188 Returns:
189 argparse.Namespace: Parsed command-line arguments.
190 Command-line arguments:
191 --log-level (str): Set the logging level. Choices are "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL". Default is "INFO".
192 --config (str): Path to config file. Default is "config.toml".
193 -i, --input-file (argparse.FileType): Input file to read from. Default is sys.stdin.
194 text (str, optional): Text to send.
195 """
196
197 parser = argparse.ArgumentParser(description='Voicemeeter VBAN Send Text CLI')
198 parser.add_argument(
199 '--log-level',
200 type=str,
201 choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
202 default='INFO',
203 help='Set the logging level',
204 )
205 parser.add_argument(
206 '--config', type=str, default='config.toml', help='Path to config file'
207 )
208 parser.add_argument(
209 '-i',
210 '--input-file',
211 type=argparse.FileType('r'),
212 default=sys.stdin,
213 )
214 parser.add_argument('text', nargs='?', type=str, help='Text to send')
215 return parser.parse_args()
216
217
218def main(config: dict):
219 """
220 Main function to send text using VbanSendText.
221 Args:
222 config (dict): Configuration dictionary for VbanSendText.
223 Behavior:
224 - If 'args.text' is provided, sends the text and returns.
225 - Otherwise, reads lines from 'args.input_file', strips whitespace, and sends each line.
226 - Stops reading and sending if a line equals "Q".
227 - Logs each line being sent at the debug level.
228 """
229
230 with VbanSendText(**config) as vban:
231 if args.text:
232 vban.sendtext(args.text)
233 return
234
235 for line in args.input_file:
236 line = line.strip()
237 if line.upper() == 'Q':
238 break
239
240 logger.debug(f'Sending {line}')
241 vban.sendtext(line)
242
243
244if __name__ == '__main__':
245 args = parse_args()
246
247 logging.basicConfig(level=args.log_level)
248
249 main(conn_from_toml(args.config))
250