Compare commits

..

3 Commits

Author SHA1 Message Date
Derek 545031ef09 Add an audio alert plugin 2021-05-15 23:00:40 -07:00
Derek 0862940ff4 Allow multiple of the same plugins 2021-05-15 22:14:41 -07:00
Derek 5160ae9d79 Fix some more twitch chat bugs 2021-05-15 19:15:21 -07:00
6 changed files with 212 additions and 76 deletions

View File

@ -1,9 +1,10 @@
import websocket import websocket
import time
from multiprocessing import Process, Pipe from multiprocessing import Process, Pipe
from enum import Enum, auto from enum import Enum, auto
from traceback import format_exception from traceback import format_exception
from miniirc import ircv3_message_parser
from miniirc import ircv3_message_parser
from . import AUTHOR_TYPES, Message from . import AUTHOR_TYPES, Message
from .ChatProcess import ChatProcess from .ChatProcess import ChatProcess
@ -14,7 +15,8 @@ class CONTROL_MESSAGES(Enum):
class STATES(Enum): class STATES(Enum):
DISCONNECTED = auto() CONNECTING = auto()
TIMEOUT = auto()
READING = auto() READING = auto()
FAILURE = auto() FAILURE = auto()
@ -39,6 +41,7 @@ class NonBlockingWebsocket(Process):
super().__init__() super().__init__()
self._ws = websocket.WebSocket() self._ws = websocket.WebSocket()
self._pipe, self._caller_pipe = Pipe() self._pipe, self._caller_pipe = Pipe()
self.daemon = True
def send(self, data): def send(self, data):
return self._ws.send(data) return self._ws.send(data)
@ -61,6 +64,7 @@ class NonBlockingWebsocket(Process):
print(f'Failure in {self.__class__.__name__}: {e}') print(f'Failure in {self.__class__.__name__}: {e}')
print(''.join(format_exception(None, e, e.__traceback__))) print(''.join(format_exception(None, e, e.__traceback__)))
self._caller_pipe.close() self._caller_pipe.close()
self._ws.shutdown()
return -1 return -1
@ -76,7 +80,7 @@ class Process(ChatProcess):
self._username = channel_name self._username = channel_name
self._token = oauth_key self._token = oauth_key
self.state = STATES.DISCONNECTED self.state = STATES.CONNECTING
@property @property
def keybinds(self): def keybinds(self):
@ -90,7 +94,7 @@ class Process(ChatProcess):
def on_state_enter(self, new_state): def on_state_enter(self, new_state):
status_messages = { status_messages = {
STATES.READING: 'Connected to channel!', STATES.READING: 'Connected to channel!',
STATES.FAILURE: 'Connection closed - see terminal', STATES.FAILURE: 'Connection closed - see terminal for details',
} }
message = status_messages.get(new_state) message = status_messages.get(new_state)
@ -98,7 +102,7 @@ class Process(ChatProcess):
sys_msg = Message(message, Process.CHAT_NAME, self.__class__.__name__, AUTHOR_TYPES.SYSTEM) sys_msg = Message(message, Process.CHAT_NAME, self.__class__.__name__, AUTHOR_TYPES.SYSTEM)
self._message_queue.put(sys_msg) self._message_queue.put(sys_msg)
def on_disconnected(self, next_state): def on_connecting(self, next_state):
self._ws = NonBlockingWebsocket() self._ws = NonBlockingWebsocket()
self._ws.connect(Process.WEBSOCKET_ADDRESS) self._ws.connect(Process.WEBSOCKET_ADDRESS)
self._ws.start() self._ws.start()
@ -108,29 +112,39 @@ class Process(ChatProcess):
if any('Welcome, GLHF!' in msg for msg in response.splitlines()): if any('Welcome, GLHF!' in msg for msg in response.splitlines()):
self._ws.send(f'JOIN #{self._username.lower()}') self._ws.send(f'JOIN #{self._username.lower()}')
self._ws.send('CAP REQ :twitch.tv/tags') self._ws.send('CAP REQ :twitch.tv/tags')
# Not currently using the data we get back from these, so leaving it to READING state to ignore self._ws.send('CAP REQ :twitch.tv/commands')
return STATES.READING return STATES.READING
else: else:
print(response) print(response)
return STATES.FAILURE return STATES.FAILURE
def on_reading(self, next_state): def on_reading(self, next_state):
if self._ws.poll(0.1): try:
messages = self._ws.recv() if self._ws.poll(0.1):
for message in messages.splitlines(): messages = self._ws.recv()
cmd, hostmask, tags, args = ircv3_message_parser(message) for message in messages.splitlines():
if cmd == 'PRIVMSG': cmd, hostmask, tags, args = ircv3_message_parser(message)
normalized_message = Process.normalize_message(hostmask, tags, args) if cmd == 'PRIVMSG':
self._message_queue.put(normalized_message) normalized_message = Process.normalize_message(hostmask, tags, args)
elif cmd == 'USERNOTICE': self._message_queue.put(normalized_message)
normalized_message = Process.normalize_event(hostmask, tags, args) elif cmd == 'USERNOTICE':
self._message_queue.put(normalized_message) normalized_message = Process.normalize_event(hostmask, tags, args)
elif cmd == 'PING': self._message_queue.put(normalized_message)
self._ws.send(f"PONG {' '.join(args)}") elif cmd == 'PING':
else: self._ws.send(f"PONG {' '.join(args)}")
# HACK: ignoring quite a lot of messages rn elif cmd == 'RECONNECT':
pass return STATES.TIMEOUT
return 0 else:
# HACK: ignoring quite a lot of messages rn
print('Unhandled message', cmd, hostmask, tags, args)
return 0
except websocket._exceptions.WebSocketException as e:
print(e)
return STATES.FAILURE
def on_timeout(self, next_state):
time.sleep(3)
return STATES.CONNECTING
def on_failure(self, next_state): def on_failure(self, next_state):
self._ws.terminate() self._ws.terminate()
@ -155,7 +169,11 @@ class Process(ChatProcess):
badges = Process.parse_badges(tags) badges = Process.parse_badges(tags)
message = Process.parse_message(args) message = Process.parse_message(args)
monitization = tags.get('bits') bits = tags.get('bits')
if bits:
monitization = (f'{bits} bits', round(bits / 100, 2))
else:
monitization = None
if 'broadcaster' in badges: if 'broadcaster' in badges:
author_type = AUTHOR_TYPES.OWNER author_type = AUTHOR_TYPES.OWNER

View File

@ -13,43 +13,15 @@ class AUTHOR_TYPES(Enum):
class Message: class Message:
def __init__(self, text, author_name, author_id, author_type, def __init__(self, text, author_name, author_id, author_type,
author_color=None, monitization=None, for_event=None): author_color=None, monitization=None, for_event=None):
self._text = text self.text = text
self._author_name = author_name self.author_name = author_name
self._author_id = author_id self.author_id = author_id
if not isinstance(author_type, AUTHOR_TYPES): if not isinstance(author_type, AUTHOR_TYPES):
raise ValueError('author_type must be an instance of AUTHOR_TYPES enum') raise ValueError('author_type must be an instance of AUTHOR_TYPES enum')
self._author_type = author_type self.author_type = author_type
self._author_color = author_color self.author_color = author_color
self._monitization = monitization self.monitization_local, self.monitization = monitization or (None, None)
self._for_event = for_event self.for_event = for_event
@property
def text(self):
return self._text
@property
def author_name(self):
return self._author_name
@property
def author_id(self):
return self._author_id
@property
def author_type(self):
return self._author_type
@property
def author_color(self):
return self._author_color
@property
def monitization(self):
return self._monitization
@property
def for_event(self):
return self._for_event
def __repr__(self): def __repr__(self):
return f"{self._author_name} - \"{self._text}\" : author_type = {self._author_type}, monitization = {self._monitization}, for_event = {self._for_event}" return f"{self.author_name} - \"{self.text}\" : author_type = {self.author_type}, monitization = {self.monitization}, for_event = {self.for_event}"

View File

@ -25,9 +25,8 @@ enabled = false
# # You can get this from https://twitchapps.com/tmi/ # # You can get this from https://twitchapps.com/tmi/
# oauth_key = "oauth:somethinglikethis" # oauth_key = "oauth:somethinglikethis"
[plugin.PhraseCounter] # # Dumps a file with the current count of mesasges containting a phrase, to be read with OBS or similar
# Dumps a file with the current count of mesasges containting a phrase, to be read with OBS or similar # [[plugin.PhraseCounter]]
enabled = false
# # The phrases to detect # # The phrases to detect
# phrases = ["pog"] # phrases = ["pog"]
# # Relative path to dump to # # Relative path to dump to
@ -53,3 +52,30 @@ enabled = false
# # goes here! # # goes here!
# # """ # # """
# template="Pog count: {pog}" # template="Pog count: {pog}"
# # Plays a sound when specific criteria are met
# [[plugin.AudioAlert]]
# # The output device to use (run `python -m plugins.AudioAlert` to get a list of valid devices)
# output = 'ALSA:default'
# # The path to a sound you want to play. Must be in wave format. Depending on your output target, you may have to match channel count and sample rates to the output
# soundpath = '../your-cool-sound.wav'
# # An array of criteria. The sound will play if a message meets any of these criteria.
# # Criteria can have any combination of three selectors:
# # + event: The name of an event linked to the message
# # + monitization: The amount of money attached to the message in USD. Can be an exact value or a range (like '1-10')
# # + regex: A regex to match against the message text. This will partial match, so use start of string and end of string selectors if you want to match the whole message!
# #
# # To play a sound if a user donates exactly 4 dolalrs and twenty cents and said "blaze it" or "blazeit", you'd add
# # criteria = [
# # { monitization = 4.20, regex = 'blaze\s?it' },
# # ]
# # or if you wanted a the same sound to play for either a monitization between 1 and 5 dollars or a sub, you could have
# # critera = [
# # { monitization = '1-5' },
# # { event = 'SUB' },
# # ]
# critera = []
# # How much of the sample to load at a time. Must be a power of two. Increase this if you have glitchy playback!
# buffer_length = 4096
# # Prevent cuttoff by waiting this number of buffers before closing the stream. Depending on your output target, this may not be neccesary
# cutoff_prevention_buffers = 4

18
main.py
View File

@ -32,17 +32,15 @@ if __name__ == '__main__':
print('No chats configured!') print('No chats configured!')
plugins = [] plugins = []
for plugin_name, plugin_config in config.get('plugin', {}).items(): for plugin_name, plugin_configs in config.get('plugin', {}).items():
if not plugin_config['enabled']:
continue
del plugin_config['enabled']
plugin_module = importlib.import_module(f'.{plugin_name}', package='plugins') plugin_module = importlib.import_module(f'.{plugin_name}', package='plugins')
try:
plugin = plugin_module.Plugin(**plugin_config) for plugin_config in plugin_configs:
plugins.append(plugin) try:
except Exception as e: plugin = plugin_module.Plugin(**plugin_config)
print(f'Failed to initalize {plugin_name} - {e}') plugins.append(plugin)
except Exception as e:
print(f'Failed to initalize {plugin_name} - {e}')
# Start the app and subprocesses # Start the app and subprocesses
app = App(chat_processes, plugins) app = App(chat_processes, plugins)

122
plugins/AudioAlert.py Normal file
View File

@ -0,0 +1,122 @@
import re
from contextlib import redirect_stdout
import sys
import pyaudio as pya
import soundfile
import numpy as np
from .PluginBase import PluginBase
class Plugin(PluginBase):
def __init__(self, critera=None, soundpath=None, output=None, buffer_length=1024, cutoff_prevention_buffers=0):
super().__init__()
self._criteria = critera
self._soundpath = soundfile
self._output = output
self._cutoff_prevent_length = cutoff_prevention_buffers
self._buffer_length = buffer_length
self._sound = soundfile.SoundFile(soundpath)
self._stream = None
self._cutoff_prevent_count = 0
def tick(self, dt):
pass
def handle_event(self, event):
pass
def on_message(self, message):
if self.should_alert(message):
self._play()
return message
def should_alert(self, message):
for criterion in self._criteria:
if criterion.get('event'):
if not (message.for_event is not None and message.for_event.name == criterion['event']):
continue
if criterion.get('monitization'):
if '-' in criterion['monitization']:
lower_bound, upper_bound = (float(bound) for bound in criterion['monitization'].split('-'))
if not (message.monitization is not None and message.monitization >= lower_bound and message.monitization <= upper_bound):
continue
else:
if not (message.monitization is not None and message.monitization == float(criterion['monitization'])):
continue
if criterion.get('regex'):
if re.search(criterion['regex'], message.text) is None:
continue
return True
def prepare_stream(self):
print('Starting portaudio - prepare for debug spam!')
pyaudio = pya.PyAudio()
print('-' * 10)
if self._output is not None:
if ':' in self._output:
host_api_name, output_name = self._output.split(':', 1)
else:
host_api_name = self._output
output_name = None
for i in range(pyaudio.get_host_api_count()):
host_api_info = pyaudio.get_host_api_info_by_index(i)
if host_api_info['name'] == host_api_name:
if output_name is None:
output_index = host_api_info['defaultOutputDevice']
break
else:
for j in range(host_api_info['deviceCount']):
device_info = pyaudio.get_device_info_by_host_api_device_index(i, j)
if device_info['name'] == output_name:
output_index = device_info['index']
break
else:
raise ValueError(f'Could not find requested output device: {output_name}')
break
else:
raise ValueError(f'Could not find requested audio API: {host_api_name}')
else:
output_index = None
self._stream = pyaudio.open(
output_device_index=output_index,
format=pya.paFloat32,
channels=self._sound.channels,
rate=self._sound.samplerate,
frames_per_buffer=self._buffer_length,
output=True,
stream_callback=self._read_callback)
def _play(self):
self._sound.seek(0)
self._cutoff_prevent_count = 0
if self._stream is None:
self.prepare_stream()
if not self._stream.is_active():
self._stream.stop_stream()
self._stream.start_stream()
def _read_callback(self, in_data, frame_count, time_info, status):
if self._sound.tell() == self._sound.frames:
self._cutoff_prevent_count += 1
return np.zeros((frame_count, self._sound.channels)), pya.paContinue if self._cutoff_prevent_count < self._cutoff_prevent_length else pya.paComplete
return self._sound.read(frames=frame_count, dtype='float32', fill_value=0), pya.paContinue
if __name__ == '__main__':
p = pya.PyAudio()
print('-' * 10)
for i in range(p.get_host_api_count()):
host_api_info = p.get_host_api_info_by_index(i)
for j in range(host_api_info['deviceCount']):
device_info = p.get_device_info_by_host_api_device_index(i, j)
if device_info['maxOutputChannels'] > 0:
print(f"{host_api_info['name']}:{device_info['name']}")

View File

@ -10,19 +10,19 @@ from chats import AUTHOR_TYPES
from . import BG_COLOR, XTERM_COLORS from . import BG_COLOR, XTERM_COLORS
from .TextFragment import TextFragment from .TextFragment import TextFragment
hex_color_regex = re.compile('^#[a-f0-9]{6}$') hex_color_regex = re.compile('^#[a-fA-F0-9]{6}$')
def closest_xterm(target_color): def closest_xterm(target_color):
r, g, b = (int(target_color[i:i+2], 16) for i in range(1, len(target_color), 2)) r, g, b = (int(target_color.lower()[i:i+2], 16) for i in range(1, len(target_color), 2))
distances = [] distances = []
for xterm_color in XTERM_COLORS: for index, xterm_color in enumerate(XTERM_COLORS):
xr, xg, xb = xterm_color xr, xg, xb = xterm_color
distance = math.sqrt(abs(r - xr) ** 2 + abs(g - xg) ** 2 + abs(b - xb) ** 2) distance = math.sqrt(abs(r - xr) ** 2 + abs(g - xg) ** 2 + abs(b - xb) ** 2)
if distance == 0: if distance == 0:
return xterm_color return xterm_color
distances.append((distance, xterm_color)) distances.append((distance, index))
return min(distances)[1] return min(distances)[1]