最后活跃于 1738749288

Use this script to send vban text requests over a network

修订 bc9e3541a09fa1ff1e6a0de3cfa7beadf02fbe9e

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