sendtext.py
· 11 KiB · Python
原始檔案
#!/usr/bin/env python3
# MIT License
#
# Copyright (c) 2025 Onyx and Iris
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""
sendtext.py
This script provides functionality to send text messages over a network using the VBAN protocol.
It includes classes and functions to construct and send VBAN packets with text data, read configuration
from a TOML file, and handle command-line arguments for customization.
Classes:
RTHeader: A dataclass representing the header of a VBAN packet, with methods to convert it to bytes.
Functions:
ratelimit(func): A decorator to enforce a rate limit on a function.
send(sock, args, cmd, framecounter): Sends a text message using the provided socket and packet.
parse_args(): Parses command-line arguments and returns them as an argparse.Namespace object.
main(args): Main function to send text using VbanSendText.
Usage:
Run the script with appropriate command-line arguments to send text messages.
Example:
To send a single message:
python sendtext.py --host localhost --port 6980 --streamname Command1 --bps 256000 --channel 1 "strip[0].mute=1;strip[1].mute=1;strip[2].mute=1"
To send multiple messages:
python sendtext.py --host localhost --port 6980 --streamname Command1 --bps 256000 --channel 1 "strip[0].mute=1;strip[1].mute=1;strip[2].mute=1" "bus[0].mute=1;bus[1].mute=1;bus[2].mute=1"
To send commands stored in a text file:
bash:
xargs ./sendtext.py --host localhost --port 6980 --streamname Command1 --bps 256000 --channel 1 < commands.txt
powershell:
./sendtext.py --host localhost --port 6980 --streamname Command1 --bps 256000 --channel 1 (Get-Content commands.txt)
Messages to Matrix are also possible:
python sendtext.py --host localhost --port 6980 --streamname Command1 --bps 256000 --channel 1 "Point(ASIO128.IN[2],ASIO128.OUT[1]).dBGain = -8"
"""
import argparse
import functools
import logging
import socket
import time
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
@dataclass
class RTHeader:
"""RTHeader represents the header of a VBAN packet for sending text messages.
It includes fields for the stream name, bits per second, channel, and frame counter,
as well as methods to convert these fields into the appropriate byte format for transmission.
Attributes:
name (str): The name of the VBAN stream (max 16 characters).
bps (int): The bits per second for the VBAN stream, must be one of the predefined options.
channel (int): The channel number for the VBAN stream.
framecounter (int): A counter for the frames being sent, default is 0.
BPS_OPTS (list[int]): A list of valid bits per second options for VBAN streams.
Methods:
__post_init__(): Validates the stream name length and bits per second value.
vban(): Returns the VBAN header as bytes.
sr(): Returns the sample rate byte based on the bits per second index.
nbs(): Returns the number of bits per sample byte (currently set to 0).
nbc(): Returns the number of channels byte based on the channel attribute.
bit(): Returns the bit depth byte (currently set to 0x10).
streamname(): Returns the stream name as bytes, padded to 16 bytes.
to_bytes(name, bps, channel, framecounter): Class method to create a byte representation of the RTHeader with the given parameters.
Raises:
ValueError: If the stream name exceeds 16 characters or if the bits per second value is not in the predefined options.
"""
name: str
bps: int
channel: int
framecounter: int = 0
# fmt: off
BPS_OPTS: list[int] = field(default_factory=lambda: [
0, 110, 150, 300, 600, 1200, 2400, 4800, 9600, 14400, 19200, 31250,
38400, 57600, 115200, 128000, 230400, 250000, 256000, 460800, 921600,
1000000, 1500000, 2000000, 3000000
])
# fmt: on
def __post_init__(self):
if len(self.name) > 16:
raise ValueError(
f"Stream name got: '{self.name}', want: must be 16 characters or fewer"
)
try:
self.bps_index = self.BPS_OPTS.index(self.bps)
except ValueError as e:
ERR_MSG = f"Invalid bps: {self.bps}, must be one of {self.BPS_OPTS}"
e.add_note(ERR_MSG)
raise
@property
def vban(self) -> bytes:
return b"VBAN"
@property
def sr(self) -> bytes:
return (0x40 + self.bps_index).to_bytes(1, "little")
@property
def nbs(self) -> bytes:
return (0).to_bytes(1, "little")
@property
def nbc(self) -> bytes:
return (self.channel).to_bytes(1, "little")
@property
def bit(self) -> bytes:
return (0x10).to_bytes(1, "little")
@property
def streamname(self) -> bytes:
return self.name.encode() + bytes(16 - len(self.name))
@classmethod
def to_bytes(cls, name: str, bps: int, channel: int, framecounter: int) -> bytes:
header = cls(name=name, bps=bps, channel=channel, framecounter=framecounter)
data = bytearray()
data.extend(header.vban)
data.extend(header.sr)
data.extend(header.nbs)
data.extend(header.nbc)
data.extend(header.bit)
data.extend(header.streamname)
data.extend(header.framecounter.to_bytes(4, "little"))
return bytes(data)
def ratelimit(func):
"""
Decorator to enforce a rate limit on a function.
This decorator extracts the rate limit value from the 'args.ratelimit' parameter
of the decorated function and ensures that the function is not called more
frequently than the specified rate limit.
Args:
func: The function to be rate limited. Must accept 'args' as a parameter
with a 'ratelimit' attribute.
Returns:
The decorated function with rate limiting applied.
"""
last_call_time = [0.0] # Use list to make it mutable in closure
@functools.wraps(func)
def wrapper(*args, **kwargs):
_, args_namespace, _, _ = args
ratelimit = args_namespace.ratelimit
current_time = time.time()
time_since_last_call = current_time - last_call_time[0]
if time_since_last_call < ratelimit:
sleep_time = ratelimit - time_since_last_call
logger.debug(f"Rate limiting: sleeping for {sleep_time:.3f} seconds")
time.sleep(sleep_time)
last_call_time[0] = time.time()
return func(*args, **kwargs)
return wrapper
@ratelimit
def send(
sock: socket.socket, args: argparse.Namespace, cmd: str, framecounter: int
) -> None:
"""
Send a text message using the provided socket and packet.
Args:
sock (socket.socket): The socket to use for sending the message.
args (argparse.Namespace): The command-line arguments containing the stream name, bits per second, and channel information.
cmd (str): The text command to send.
framecounter (int): The frame counter to include in the VBAN header.
Returns:
None
"""
raw_packet = RTHeader.to_bytes(
name=args.streamname,
bps=args.bps,
channel=args.channel,
framecounter=framecounter,
) + cmd.encode("utf-8")
logger.debug("Sending packet: %s", raw_packet)
sock.sendto(raw_packet, (args.host, args.port))
def parse_args() -> argparse.Namespace:
"""
Parse command-line arguments.
Returns:
argparse.Namespace: Parsed command-line arguments.
Command-line arguments:
--host, -H: VBAN host to send to (default: localhost)
--port, -P: VBAN port to send to (default: 6980)
--streamname, -s: VBAN stream name (default: Command1)
--bps, -b: Bits per second for VBAN stream (default: 256000)
--channel, -c: Channel number for VBAN stream (default: 1)
text: Text to send (positional argument)
"""
parser = argparse.ArgumentParser(description="Voicemeeter VBAN Send Text CLI")
parser.add_argument(
"--host",
"-H",
type=str,
default="localhost",
help="VBAN host to send to (default: localhost)",
)
parser.add_argument(
"--port",
"-P",
type=int,
default=6980,
help="VBAN port to send to (default: 6980)",
)
parser.add_argument(
"--streamname",
"-s",
type=str,
default="Command1",
help="VBAN stream name (default: Command1)",
)
parser.add_argument(
"--bps",
"-b",
type=int,
default=256000,
help="Bits per second for VBAN stream (default: 256000)",
)
parser.add_argument(
"--channel",
"-c",
type=int,
default=1,
help="Channel number for VBAN stream (default: 1)",
)
parser.add_argument(
"--ratelimit",
"-r",
type=float,
default=0.02,
help="Minimum time in seconds between sending messages (default: 0.02)",
)
parser.add_argument(
"--loglevel",
"-l",
type=str,
default="info",
choices=["debug", "info", "warning", "error", "critical"],
help="Set the logging level (default: info)",
)
parser.add_argument(
"cmds", nargs="+", type=str, help="Text to send (positional argument)"
)
return parser.parse_args()
def main(args: argparse.Namespace):
"""
Main function to send text using VBAN.
Args:
args (argparse.Namespace): Parsed command-line arguments.
Behavior:
Creates a UDP socket and sends each command in 'args.cmds' to the specified VBAN host and port using the 'send' function, with rate limiting applied.
"""
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
for n, cmd in enumerate(args.cmds):
logger.debug(f"Sending: {cmd}")
send(sock, args, cmd, n)
if __name__ == "__main__":
args = parse_args()
logging.basicConfig(level=getattr(logging, args.loglevel.upper()))
main(args)
| 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 | """ |
| 26 | sendtext.py |
| 27 | |
| 28 | This script provides functionality to send text messages over a network using the VBAN protocol. |
| 29 | It includes classes and functions to construct and send VBAN packets with text data, read configuration |
| 30 | from a TOML file, and handle command-line arguments for customization. |
| 31 | |
| 32 | Classes: |
| 33 | RTHeader: A dataclass representing the header of a VBAN packet, with methods to convert it to bytes. |
| 34 | |
| 35 | Functions: |
| 36 | ratelimit(func): A decorator to enforce a rate limit on a function. |
| 37 | send(sock, args, cmd, framecounter): Sends a text message using the provided socket and packet. |
| 38 | parse_args(): Parses command-line arguments and returns them as an argparse.Namespace object. |
| 39 | main(args): Main function to send text using VbanSendText. |
| 40 | |
| 41 | Usage: |
| 42 | Run the script with appropriate command-line arguments to send text messages. |
| 43 | Example: |
| 44 | To send a single message: |
| 45 | python sendtext.py --host localhost --port 6980 --streamname Command1 --bps 256000 --channel 1 "strip[0].mute=1;strip[1].mute=1;strip[2].mute=1" |
| 46 | To send multiple messages: |
| 47 | python sendtext.py --host localhost --port 6980 --streamname Command1 --bps 256000 --channel 1 "strip[0].mute=1;strip[1].mute=1;strip[2].mute=1" "bus[0].mute=1;bus[1].mute=1;bus[2].mute=1" |
| 48 | To send commands stored in a text file: |
| 49 | bash: |
| 50 | xargs ./sendtext.py --host localhost --port 6980 --streamname Command1 --bps 256000 --channel 1 < commands.txt |
| 51 | powershell: |
| 52 | ./sendtext.py --host localhost --port 6980 --streamname Command1 --bps 256000 --channel 1 (Get-Content commands.txt) |
| 53 | Messages to Matrix are also possible: |
| 54 | python sendtext.py --host localhost --port 6980 --streamname Command1 --bps 256000 --channel 1 "Point(ASIO128.IN[2],ASIO128.OUT[1]).dBGain = -8" |
| 55 | """ |
| 56 | |
| 57 | import argparse |
| 58 | import functools |
| 59 | import logging |
| 60 | import socket |
| 61 | import time |
| 62 | from dataclasses import dataclass, field |
| 63 | |
| 64 | logger = logging.getLogger(__name__) |
| 65 | |
| 66 | |
| 67 | @dataclass |
| 68 | class RTHeader: |
| 69 | """RTHeader represents the header of a VBAN packet for sending text messages. |
| 70 | It includes fields for the stream name, bits per second, channel, and frame counter, |
| 71 | as well as methods to convert these fields into the appropriate byte format for transmission. |
| 72 | |
| 73 | Attributes: |
| 74 | name (str): The name of the VBAN stream (max 16 characters). |
| 75 | bps (int): The bits per second for the VBAN stream, must be one of the predefined options. |
| 76 | channel (int): The channel number for the VBAN stream. |
| 77 | framecounter (int): A counter for the frames being sent, default is 0. |
| 78 | BPS_OPTS (list[int]): A list of valid bits per second options for VBAN streams. |
| 79 | |
| 80 | Methods: |
| 81 | __post_init__(): Validates the stream name length and bits per second value. |
| 82 | vban(): Returns the VBAN header as bytes. |
| 83 | sr(): Returns the sample rate byte based on the bits per second index. |
| 84 | nbs(): Returns the number of bits per sample byte (currently set to 0). |
| 85 | nbc(): Returns the number of channels byte based on the channel attribute. |
| 86 | bit(): Returns the bit depth byte (currently set to 0x10). |
| 87 | streamname(): Returns the stream name as bytes, padded to 16 bytes. |
| 88 | to_bytes(name, bps, channel, framecounter): Class method to create a byte representation of the RTHeader with the given parameters. |
| 89 | |
| 90 | Raises: |
| 91 | ValueError: If the stream name exceeds 16 characters or if the bits per second value is not in the predefined options. |
| 92 | """ |
| 93 | |
| 94 | name: str |
| 95 | bps: int |
| 96 | channel: int |
| 97 | framecounter: int = 0 |
| 98 | # fmt: off |
| 99 | BPS_OPTS: list[int] = field(default_factory=lambda: [ |
| 100 | 0, 110, 150, 300, 600, 1200, 2400, 4800, 9600, 14400, 19200, 31250, |
| 101 | 38400, 57600, 115200, 128000, 230400, 250000, 256000, 460800, 921600, |
| 102 | 1000000, 1500000, 2000000, 3000000 |
| 103 | ]) |
| 104 | # fmt: on |
| 105 | |
| 106 | def __post_init__(self): |
| 107 | if len(self.name) > 16: |
| 108 | raise ValueError( |
| 109 | f"Stream name got: '{self.name}', want: must be 16 characters or fewer" |
| 110 | ) |
| 111 | try: |
| 112 | self.bps_index = self.BPS_OPTS.index(self.bps) |
| 113 | except ValueError as e: |
| 114 | ERR_MSG = f"Invalid bps: {self.bps}, must be one of {self.BPS_OPTS}" |
| 115 | e.add_note(ERR_MSG) |
| 116 | raise |
| 117 | |
| 118 | @property |
| 119 | def vban(self) -> bytes: |
| 120 | return b"VBAN" |
| 121 | |
| 122 | @property |
| 123 | def sr(self) -> bytes: |
| 124 | return (0x40 + self.bps_index).to_bytes(1, "little") |
| 125 | |
| 126 | @property |
| 127 | def nbs(self) -> bytes: |
| 128 | return (0).to_bytes(1, "little") |
| 129 | |
| 130 | @property |
| 131 | def nbc(self) -> bytes: |
| 132 | return (self.channel).to_bytes(1, "little") |
| 133 | |
| 134 | @property |
| 135 | def bit(self) -> bytes: |
| 136 | return (0x10).to_bytes(1, "little") |
| 137 | |
| 138 | @property |
| 139 | def streamname(self) -> bytes: |
| 140 | return self.name.encode() + bytes(16 - len(self.name)) |
| 141 | |
| 142 | @classmethod |
| 143 | def to_bytes(cls, name: str, bps: int, channel: int, framecounter: int) -> bytes: |
| 144 | header = cls(name=name, bps=bps, channel=channel, framecounter=framecounter) |
| 145 | |
| 146 | data = bytearray() |
| 147 | data.extend(header.vban) |
| 148 | data.extend(header.sr) |
| 149 | data.extend(header.nbs) |
| 150 | data.extend(header.nbc) |
| 151 | data.extend(header.bit) |
| 152 | data.extend(header.streamname) |
| 153 | data.extend(header.framecounter.to_bytes(4, "little")) |
| 154 | return bytes(data) |
| 155 | |
| 156 | |
| 157 | def ratelimit(func): |
| 158 | """ |
| 159 | Decorator to enforce a rate limit on a function. |
| 160 | |
| 161 | This decorator extracts the rate limit value from the 'args.ratelimit' parameter |
| 162 | of the decorated function and ensures that the function is not called more |
| 163 | frequently than the specified rate limit. |
| 164 | |
| 165 | Args: |
| 166 | func: The function to be rate limited. Must accept 'args' as a parameter |
| 167 | with a 'ratelimit' attribute. |
| 168 | |
| 169 | Returns: |
| 170 | The decorated function with rate limiting applied. |
| 171 | """ |
| 172 | last_call_time = [0.0] # Use list to make it mutable in closure |
| 173 | |
| 174 | @functools.wraps(func) |
| 175 | def wrapper(*args, **kwargs): |
| 176 | _, args_namespace, _, _ = args |
| 177 | ratelimit = args_namespace.ratelimit |
| 178 | |
| 179 | current_time = time.time() |
| 180 | time_since_last_call = current_time - last_call_time[0] |
| 181 | |
| 182 | if time_since_last_call < ratelimit: |
| 183 | sleep_time = ratelimit - time_since_last_call |
| 184 | logger.debug(f"Rate limiting: sleeping for {sleep_time:.3f} seconds") |
| 185 | time.sleep(sleep_time) |
| 186 | |
| 187 | last_call_time[0] = time.time() |
| 188 | return func(*args, **kwargs) |
| 189 | |
| 190 | return wrapper |
| 191 | |
| 192 | |
| 193 | @ratelimit |
| 194 | def send( |
| 195 | sock: socket.socket, args: argparse.Namespace, cmd: str, framecounter: int |
| 196 | ) -> None: |
| 197 | """ |
| 198 | Send a text message using the provided socket and packet. |
| 199 | |
| 200 | Args: |
| 201 | sock (socket.socket): The socket to use for sending the message. |
| 202 | args (argparse.Namespace): The command-line arguments containing the stream name, bits per second, and channel information. |
| 203 | cmd (str): The text command to send. |
| 204 | framecounter (int): The frame counter to include in the VBAN header. |
| 205 | |
| 206 | Returns: |
| 207 | None |
| 208 | """ |
| 209 | |
| 210 | raw_packet = RTHeader.to_bytes( |
| 211 | name=args.streamname, |
| 212 | bps=args.bps, |
| 213 | channel=args.channel, |
| 214 | framecounter=framecounter, |
| 215 | ) + cmd.encode("utf-8") |
| 216 | logger.debug("Sending packet: %s", raw_packet) |
| 217 | sock.sendto(raw_packet, (args.host, args.port)) |
| 218 | |
| 219 | |
| 220 | def parse_args() -> argparse.Namespace: |
| 221 | """ |
| 222 | Parse command-line arguments. |
| 223 | Returns: |
| 224 | argparse.Namespace: Parsed command-line arguments. |
| 225 | Command-line arguments: |
| 226 | --host, -H: VBAN host to send to (default: localhost) |
| 227 | --port, -P: VBAN port to send to (default: 6980) |
| 228 | --streamname, -s: VBAN stream name (default: Command1) |
| 229 | --bps, -b: Bits per second for VBAN stream (default: 256000) |
| 230 | --channel, -c: Channel number for VBAN stream (default: 1) |
| 231 | text: Text to send (positional argument) |
| 232 | """ |
| 233 | |
| 234 | parser = argparse.ArgumentParser(description="Voicemeeter VBAN Send Text CLI") |
| 235 | parser.add_argument( |
| 236 | "--host", |
| 237 | "-H", |
| 238 | type=str, |
| 239 | default="localhost", |
| 240 | help="VBAN host to send to (default: localhost)", |
| 241 | ) |
| 242 | parser.add_argument( |
| 243 | "--port", |
| 244 | "-P", |
| 245 | type=int, |
| 246 | default=6980, |
| 247 | help="VBAN port to send to (default: 6980)", |
| 248 | ) |
| 249 | parser.add_argument( |
| 250 | "--streamname", |
| 251 | "-s", |
| 252 | type=str, |
| 253 | default="Command1", |
| 254 | help="VBAN stream name (default: Command1)", |
| 255 | ) |
| 256 | parser.add_argument( |
| 257 | "--bps", |
| 258 | "-b", |
| 259 | type=int, |
| 260 | default=256000, |
| 261 | help="Bits per second for VBAN stream (default: 256000)", |
| 262 | ) |
| 263 | parser.add_argument( |
| 264 | "--channel", |
| 265 | "-c", |
| 266 | type=int, |
| 267 | default=1, |
| 268 | help="Channel number for VBAN stream (default: 1)", |
| 269 | ) |
| 270 | parser.add_argument( |
| 271 | "--ratelimit", |
| 272 | "-r", |
| 273 | type=float, |
| 274 | default=0.02, |
| 275 | help="Minimum time in seconds between sending messages (default: 0.02)", |
| 276 | ) |
| 277 | parser.add_argument( |
| 278 | "--loglevel", |
| 279 | "-l", |
| 280 | type=str, |
| 281 | default="info", |
| 282 | choices=["debug", "info", "warning", "error", "critical"], |
| 283 | help="Set the logging level (default: info)", |
| 284 | ) |
| 285 | parser.add_argument( |
| 286 | "cmds", nargs="+", type=str, help="Text to send (positional argument)" |
| 287 | ) |
| 288 | return parser.parse_args() |
| 289 | |
| 290 | |
| 291 | def main(args: argparse.Namespace): |
| 292 | """ |
| 293 | Main function to send text using VBAN. |
| 294 | Args: |
| 295 | args (argparse.Namespace): Parsed command-line arguments. |
| 296 | Behavior: |
| 297 | Creates a UDP socket and sends each command in 'args.cmds' to the specified VBAN host and port using the 'send' function, with rate limiting applied. |
| 298 | """ |
| 299 | |
| 300 | with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: |
| 301 | for n, cmd in enumerate(args.cmds): |
| 302 | logger.debug(f"Sending: {cmd}") |
| 303 | send(sock, args, cmd, n) |
| 304 | |
| 305 | |
| 306 | if __name__ == "__main__": |
| 307 | args = parse_args() |
| 308 | |
| 309 | logging.basicConfig(level=getattr(logging, args.loglevel.upper())) |
| 310 | |
| 311 | main(args) |
| 312 |