Compare commits
No commits in common. "545031ef09a33e578608f09bf4dc1d12f400addd" and "19211278c8bd52c2973dc3899c3c4180c654571d" have entirely different histories.
545031ef09
...
19211278c8
|
@ -1,11 +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
|
||||||
|
|
||||||
|
@ -15,8 +14,7 @@ class CONTROL_MESSAGES(Enum):
|
||||||
|
|
||||||
|
|
||||||
class STATES(Enum):
|
class STATES(Enum):
|
||||||
CONNECTING = auto()
|
DISCONNECTED = auto()
|
||||||
TIMEOUT = auto()
|
|
||||||
READING = auto()
|
READING = auto()
|
||||||
FAILURE = auto()
|
FAILURE = auto()
|
||||||
|
|
||||||
|
@ -41,7 +39,6 @@ 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)
|
||||||
|
@ -64,7 +61,6 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
@ -80,7 +76,7 @@ class Process(ChatProcess):
|
||||||
self._username = channel_name
|
self._username = channel_name
|
||||||
self._token = oauth_key
|
self._token = oauth_key
|
||||||
|
|
||||||
self.state = STATES.CONNECTING
|
self.state = STATES.DISCONNECTED
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def keybinds(self):
|
def keybinds(self):
|
||||||
|
@ -94,7 +90,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 for details',
|
STATES.FAILURE: 'Connection closed - see terminal',
|
||||||
}
|
}
|
||||||
|
|
||||||
message = status_messages.get(new_state)
|
message = status_messages.get(new_state)
|
||||||
|
@ -102,7 +98,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_connecting(self, next_state):
|
def on_disconnected(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()
|
||||||
|
@ -112,39 +108,29 @@ 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')
|
||||||
self._ws.send('CAP REQ :twitch.tv/commands')
|
# Not currently using the data we get back from these, so leaving it to READING state to ignore
|
||||||
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):
|
||||||
try:
|
if self._ws.poll(0.1):
|
||||||
if self._ws.poll(0.1):
|
messages = self._ws.recv()
|
||||||
messages = self._ws.recv()
|
for message in messages.splitlines():
|
||||||
for message in messages.splitlines():
|
cmd, hostmask, tags, args = ircv3_message_parser(message)
|
||||||
cmd, hostmask, tags, args = ircv3_message_parser(message)
|
if cmd == 'PRIVMSG':
|
||||||
if cmd == 'PRIVMSG':
|
normalized_message = Process.normalize_message(hostmask, tags, args)
|
||||||
normalized_message = Process.normalize_message(hostmask, tags, args)
|
self._message_queue.put(normalized_message)
|
||||||
self._message_queue.put(normalized_message)
|
elif cmd == 'USERNOTICE':
|
||||||
elif cmd == 'USERNOTICE':
|
normalized_message = Process.normalize_event(hostmask, tags, args)
|
||||||
normalized_message = Process.normalize_event(hostmask, tags, args)
|
self._message_queue.put(normalized_message)
|
||||||
self._message_queue.put(normalized_message)
|
elif cmd == 'PING':
|
||||||
elif cmd == 'PING':
|
self._ws.send(f"PONG {' '.join(args)}")
|
||||||
self._ws.send(f"PONG {' '.join(args)}")
|
else:
|
||||||
elif cmd == 'RECONNECT':
|
# HACK: ignoring quite a lot of messages rn
|
||||||
return STATES.TIMEOUT
|
pass
|
||||||
else:
|
return 0
|
||||||
# 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()
|
||||||
|
@ -169,11 +155,7 @@ class Process(ChatProcess):
|
||||||
badges = Process.parse_badges(tags)
|
badges = Process.parse_badges(tags)
|
||||||
message = Process.parse_message(args)
|
message = Process.parse_message(args)
|
||||||
|
|
||||||
bits = tags.get('bits')
|
monitization = 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
|
||||||
|
|
|
@ -13,15 +13,43 @@ 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_local, self.monitization = monitization or (None, None)
|
self._monitization = monitization
|
||||||
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}"
|
||||||
|
|
32
config.toml
32
config.toml
|
@ -25,8 +25,9 @@ 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"
|
||||||
|
|
||||||
# # Dumps a file with the current count of mesasges containting a phrase, to be read with OBS or similar
|
[plugin.PhraseCounter]
|
||||||
# [[plugin.PhraseCounter]]
|
# Dumps a file with the current count of mesasges containting a phrase, to be read with OBS or similar
|
||||||
|
enabled = false
|
||||||
# # The phrases to detect
|
# # The phrases to detect
|
||||||
# phrases = ["pog"]
|
# phrases = ["pog"]
|
||||||
# # Relative path to dump to
|
# # Relative path to dump to
|
||||||
|
@ -52,30 +53,3 @@ 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
18
main.py
|
@ -32,15 +32,17 @@ if __name__ == '__main__':
|
||||||
print('No chats configured!')
|
print('No chats configured!')
|
||||||
|
|
||||||
plugins = []
|
plugins = []
|
||||||
for plugin_name, plugin_configs in config.get('plugin', {}).items():
|
for plugin_name, plugin_config in config.get('plugin', {}).items():
|
||||||
plugin_module = importlib.import_module(f'.{plugin_name}', package='plugins')
|
if not plugin_config['enabled']:
|
||||||
|
continue
|
||||||
|
del plugin_config['enabled']
|
||||||
|
|
||||||
for plugin_config in plugin_configs:
|
plugin_module = importlib.import_module(f'.{plugin_name}', package='plugins')
|
||||||
try:
|
try:
|
||||||
plugin = plugin_module.Plugin(**plugin_config)
|
plugin = plugin_module.Plugin(**plugin_config)
|
||||||
plugins.append(plugin)
|
plugins.append(plugin)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f'Failed to initalize {plugin_name} - {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)
|
||||||
|
|
|
@ -1,122 +0,0 @@
|
||||||
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']}")
|
|
|
@ -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-fA-F0-9]{6}$')
|
hex_color_regex = re.compile('^#[a-f0-9]{6}$')
|
||||||
|
|
||||||
|
|
||||||
def closest_xterm(target_color):
|
def closest_xterm(target_color):
|
||||||
r, g, b = (int(target_color.lower()[i:i+2], 16) for i in range(1, len(target_color), 2))
|
r, g, b = (int(target_color[i:i+2], 16) for i in range(1, len(target_color), 2))
|
||||||
|
|
||||||
distances = []
|
distances = []
|
||||||
for index, xterm_color in enumerate(XTERM_COLORS):
|
for xterm_color in 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, index))
|
distances.append((distance, xterm_color))
|
||||||
return min(distances)[1]
|
return min(distances)[1]
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue