Compare commits
6 commits
6f2128beb4
...
1a1dfc7d2a
Author | SHA1 | Date | |
---|---|---|---|
1a1dfc7d2a | |||
65d527bdfa | |||
e67b31daf8 | |||
7ebf0b48a4 | |||
7685170714 | |||
1bc693a4eb |
11 changed files with 468 additions and 1629 deletions
|
@ -8,20 +8,21 @@ authors = [
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"click",
|
"click",
|
||||||
"kdl-py",
|
"kdl-py",
|
||||||
"quart",
|
"quart==0.18.*",
|
||||||
"werkzeug",
|
"werkzeug==2.3.7",
|
||||||
"hypercorn",
|
"hypercorn",
|
||||||
"websockets",
|
"websockets==11.0.3",
|
||||||
|
"aioprocessing",
|
||||||
"aioscheduler",
|
"aioscheduler",
|
||||||
"pyaudio",
|
"pyaudio==0.2.*",
|
||||||
"librosa",
|
"librosa==0.8.*",
|
||||||
"pytsmod",
|
"pytsmod",
|
||||||
"numpy",
|
"numpy",
|
||||||
"multipledispatch",
|
"multipledispatch",
|
||||||
"blessed",
|
"blessed",
|
||||||
"appdirs",
|
"appdirs",
|
||||||
"maya",
|
"maya",
|
||||||
"httpx",
|
"httpx>=0.28.1",
|
||||||
]
|
]
|
||||||
requires-python = ">=3.10,<3.11"
|
requires-python = ">=3.10,<3.11"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
@ -29,17 +30,18 @@ license = {text = "GPLv2"}
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
tts = [
|
tts = [
|
||||||
"coqui-tts",
|
"TTS==0.9.*",
|
||||||
|
"torch==1.13.*",
|
||||||
]
|
]
|
||||||
phrasecounter = ["num2words"]
|
phrasecounter = ["num2words"]
|
||||||
jail = ["owoify-py"]
|
jail = ["owoify-py==2.*"]
|
||||||
twitch = ["miniirc"]
|
twitch = ["miniirc"]
|
||||||
midi = [
|
midi = [
|
||||||
"mido",
|
"mido",
|
||||||
"python-rtmidi",
|
"python-rtmidi",
|
||||||
]
|
]
|
||||||
obs = ["simpleobsws"]
|
obs = ["simpleobsws"]
|
||||||
osc = ["python-osc"]
|
osc = ["python-osc>=1.9.0"]
|
||||||
yt-dlp = ["yt-dlp"]
|
yt-dlp = ["yt-dlp"]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|
|
@ -61,10 +61,6 @@ def cli(loglevel, show_time=False):
|
||||||
logging.getLogger('hypercorn.access').setLevel(logging.WARN)
|
logging.getLogger('hypercorn.access').setLevel(logging.WARN)
|
||||||
logging.getLogger('httpx').setLevel(logging.WARN)
|
logging.getLogger('httpx').setLevel(logging.WARN)
|
||||||
logging.getLogger('httpcore').setLevel(logging.INFO)
|
logging.getLogger('httpcore').setLevel(logging.INFO)
|
||||||
logging.getLogger('torio._extension.utils').setLevel(logging.WARN)
|
|
||||||
logging.getLogger('matplotlib').setLevel(logging.INFO)
|
|
||||||
logging.getLogger('fsspec').setLevel(logging.INFO)
|
|
||||||
logging.getLogger('TTS').setLevel(logging.INFO if loglevel == logging.DEBUG else logging.WARN)
|
|
||||||
# Quiet warnings
|
# Quiet warnings
|
||||||
if loglevel > logging.DEBUG:
|
if loglevel > logging.DEBUG:
|
||||||
warnings.filterwarnings("ignore")
|
warnings.filterwarnings("ignore")
|
||||||
|
|
|
@ -8,6 +8,7 @@ import pyaudio as pya
|
||||||
import librosa
|
import librosa
|
||||||
import pytsmod as tsm
|
import pytsmod as tsm
|
||||||
import soundfile
|
import soundfile
|
||||||
|
from aioprocessing import AioEvent
|
||||||
|
|
||||||
# HACK: Redirect stderr to /dev/null to silence portaudio boot
|
# HACK: Redirect stderr to /dev/null to silence portaudio boot
|
||||||
devnull = os.open(os.devnull, os.O_WRONLY)
|
devnull = os.open(os.devnull, os.O_WRONLY)
|
||||||
|
@ -52,9 +53,9 @@ class Clip:
|
||||||
|
|
||||||
def stretch(self, speed, keep_pitch=True):
|
def stretch(self, speed, keep_pitch=True):
|
||||||
if keep_pitch:
|
if keep_pitch:
|
||||||
stretched = tsm.wsola(self._stereo_transpose(self.raw), 1 / speed)
|
stretched = tsm.wsola(self._stereo_transpose(self.raw), speed)
|
||||||
else:
|
else:
|
||||||
stretched = librosa.resample(self._stereo_transpose(self.raw), self.samplerate * speed, self.samplerate, fix=False, scale=True)
|
stretched = librosa.resample(self._stereo_transpose(self.raw), self.samplerate * (1 / speed), self.samplerate, fix=False, scale=True)
|
||||||
self.raw = np.ascontiguousarray(self._stereo_transpose(stretched), dtype='float32')
|
self.raw = np.ascontiguousarray(self._stereo_transpose(stretched), dtype='float32')
|
||||||
|
|
||||||
def save(self, filename):
|
def save(self, filename):
|
||||||
|
@ -66,8 +67,7 @@ class Stream:
|
||||||
self.clip = clip
|
self.clip = clip
|
||||||
self.pos = 0
|
self.pos = 0
|
||||||
self.playing = False
|
self.playing = False
|
||||||
self.loop = asyncio.get_event_loop()
|
self._end_event = AioEvent()
|
||||||
self._end_event = asyncio.Event()
|
|
||||||
self._stream = pyaudio.open(
|
self._stream = pyaudio.open(
|
||||||
output_device_index=output_index,
|
output_device_index=output_index,
|
||||||
format=pya.paFloat32,
|
format=pya.paFloat32,
|
||||||
|
@ -85,11 +85,16 @@ class Stream:
|
||||||
if not self._stream.is_active():
|
if not self._stream.is_active():
|
||||||
self._stream.start_stream()
|
self._stream.start_stream()
|
||||||
|
|
||||||
async def play(self):
|
def play(self):
|
||||||
|
self._end_event.clear()
|
||||||
|
self._play()
|
||||||
|
self._end_event.wait(timeout=self.clip.length)
|
||||||
|
|
||||||
|
async def aplay(self):
|
||||||
self._end_event.clear()
|
self._end_event.clear()
|
||||||
self._play()
|
self._play()
|
||||||
try:
|
try:
|
||||||
await self._end_event.wait()
|
await self._end_event.coro_wait(timeout=self.clip.length)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
self.playing = False
|
self.playing = False
|
||||||
self._stream.stop_stream()
|
self._stream.stop_stream()
|
||||||
|
@ -112,7 +117,7 @@ class Stream:
|
||||||
|
|
||||||
if self.pos >= self.clip.raw.shape[0]:
|
if self.pos >= self.clip.raw.shape[0]:
|
||||||
self.playing = False
|
self.playing = False
|
||||||
self.loop.call_soon_threadsafe(self._end_event.set)
|
self._end_event.set()
|
||||||
|
|
||||||
return buffer, pya.paContinue
|
return buffer, pya.paContinue
|
||||||
|
|
||||||
|
|
|
@ -139,7 +139,7 @@ def parse_kdl_deep(path, relativeto=None):
|
||||||
if relativeto:
|
if relativeto:
|
||||||
path = os.path.normpath(os.path.join(relativeto, path))
|
path = os.path.normpath(os.path.join(relativeto, path))
|
||||||
|
|
||||||
with open(path, 'r', encoding='utf-8') as f:
|
with open(path, 'r') as f:
|
||||||
try:
|
try:
|
||||||
config = kdl.parse(f.read(), kdl_parse_config)
|
config = kdl.parse(f.read(), kdl_parse_config)
|
||||||
for node in config.nodes:
|
for node in config.nodes:
|
||||||
|
|
|
@ -3,6 +3,7 @@ import asyncio
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import os.path
|
||||||
import pathlib
|
import pathlib
|
||||||
import sys
|
import sys
|
||||||
import signal
|
import signal
|
||||||
|
@ -66,9 +67,6 @@ class MainProcess:
|
||||||
# Save sys.path since some config will clobber it
|
# Save sys.path since some config will clobber it
|
||||||
self._initial_syspath = sys.path
|
self._initial_syspath = sys.path
|
||||||
|
|
||||||
if os.name == 'nt':
|
|
||||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
|
||||||
|
|
||||||
def _unload_plugin(self, plugin_name):
|
def _unload_plugin(self, plugin_name):
|
||||||
plugin = self.plugins[plugin_name]
|
plugin = self.plugins[plugin_name]
|
||||||
plugin.close()
|
plugin.close()
|
||||||
|
@ -246,15 +244,12 @@ class MainProcess:
|
||||||
sys.path = self._initial_syspath
|
sys.path = self._initial_syspath
|
||||||
|
|
||||||
async def _discount_repl(self):
|
async def _discount_repl(self):
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
# REVIEW: Not a good UX at the moment (as new logs clobber the terminal entry)
|
# REVIEW: Not a good UX at the moment (as new logs clobber the terminal entry)
|
||||||
while True:
|
async for line in reader:
|
||||||
line = await loop.run_in_executor(None, sys.stdin.readline)
|
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
logger.debug(f'Got terminal input: {line}')
|
if line == b'reload':
|
||||||
if line == 'reload':
|
|
||||||
self.reload_ev.set()
|
self.reload_ev.set()
|
||||||
elif line == 'quit':
|
elif line == b'quit':
|
||||||
self.shutdown_ev.set()
|
self.shutdown_ev.set()
|
||||||
|
|
||||||
async def run(self):
|
async def run(self):
|
||||||
|
@ -266,6 +261,8 @@ class MainProcess:
|
||||||
try:
|
try:
|
||||||
# System setup
|
# System setup
|
||||||
## Make stdin handler
|
## Make stdin handler
|
||||||
|
reader = asyncio.StreamReader()
|
||||||
|
await loop.connect_read_pipe(lambda: asyncio.StreamReaderProtocol(reader), sys.stdin)
|
||||||
self.cli_task = loop.create_task(self._discount_repl())
|
self.cli_task = loop.create_task(self._discount_repl())
|
||||||
## Init websocket server (external end of the event bus)
|
## Init websocket server (external end of the event bus)
|
||||||
self.bus_server = WebsocketServerProcess(self.event_queue, *self.bus_conf)
|
self.bus_server = WebsocketServerProcess(self.event_queue, *self.bus_conf)
|
||||||
|
|
|
@ -29,17 +29,6 @@ class OvtkBlueprint(quart.Blueprint):
|
||||||
endpoint = self.name + endpoint
|
endpoint = self.name + endpoint
|
||||||
return quart.url_for(endpoint, *args, **kwargs)
|
return quart.url_for(endpoint, *args, **kwargs)
|
||||||
|
|
||||||
def render(self, name, **kwargs):
|
|
||||||
"""render_template that prefers the plugin-specific templates"""
|
|
||||||
full = self.template_folder / name
|
|
||||||
if os.path.exists(full):
|
|
||||||
template_string = None
|
|
||||||
with open(full, 'r') as template_file:
|
|
||||||
template_string = template_file.read()
|
|
||||||
return quart.render_template_string(template_string, **kwargs)
|
|
||||||
else:
|
|
||||||
return quart.render_template(name, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class PluginBase(ABC):
|
class PluginBase(ABC):
|
||||||
plugins = {}
|
plugins = {}
|
||||||
|
|
|
@ -54,9 +54,9 @@ class AudioAlert(PluginBase):
|
||||||
|
|
||||||
|
|
||||||
if wait:
|
if wait:
|
||||||
await stream.play()
|
await stream.aplay()
|
||||||
else:
|
else:
|
||||||
task = asyncio.create_task(stream.play())
|
task = asyncio.create_task(stream.aplay())
|
||||||
task.add_done_callback(self.tasks.discard)
|
task.add_done_callback(self.tasks.discard)
|
||||||
self.tasks.add(task)
|
self.tasks.add(task)
|
||||||
|
|
||||||
|
|
|
@ -52,8 +52,8 @@ class TextToSpeechPlugin(PluginBase):
|
||||||
config_path = override_conf_path
|
config_path = override_conf_path
|
||||||
|
|
||||||
self.synthesizer = Synthesizer(
|
self.synthesizer = Synthesizer(
|
||||||
tts_checkpoint=model_path,
|
model_path,
|
||||||
tts_config_path=config_path,
|
config_path,
|
||||||
vocoder_checkpoint=vocoder_path,
|
vocoder_checkpoint=vocoder_path,
|
||||||
vocoder_config=vocoder_config_path,
|
vocoder_config=vocoder_config_path,
|
||||||
use_cuda=self.cuda,
|
use_cuda=self.cuda,
|
||||||
|
@ -66,26 +66,22 @@ class TextToSpeechPlugin(PluginBase):
|
||||||
task.cancel()
|
task.cancel()
|
||||||
|
|
||||||
def make_tts_wav(self, text, filename=None):
|
def make_tts_wav(self, text, filename=None):
|
||||||
# Force punctuation (keeps the models from acting unpredictably)
|
|
||||||
text = text.strip()
|
|
||||||
if not any([text.endswith(punc) for punc in '.!?:']):
|
|
||||||
text += '.'
|
|
||||||
|
|
||||||
if filename is None:
|
if filename is None:
|
||||||
filename = os.path.join(self.cache_dir, f'{uuid.uuid1()}.wav')
|
filename = os.path.join(self.cache_dir, f'{uuid.uuid1()}.wav')
|
||||||
|
|
||||||
self.logger.info(f'Generating TTS "{text}"...')
|
|
||||||
if self.speaker_wav:
|
if self.speaker_wav:
|
||||||
wav = self.synthesizer.tts(text, None, 'en', self.speaker_wav)
|
wav = self.synthesizer.tts(text, None, 'en', self.speaker_wav)
|
||||||
else:
|
else:
|
||||||
wav = self.synthesizer.tts(text)
|
wav = self.synthesizer.tts(text)
|
||||||
|
|
||||||
self.synthesizer.save_wav(wav, filename)
|
self.synthesizer.save_wav(wav, filename)
|
||||||
self.logger.info(f'Done - saved as {filename}')
|
|
||||||
return filename
|
return filename
|
||||||
|
|
||||||
async def run(self, text, *args, _ctx={}, wait=False, **kwargs):
|
async def run(self, text, *args, _ctx={}, wait=False, **kwargs):
|
||||||
try:
|
try:
|
||||||
|
# Force punctuation (keep AI from spinning off into random noises)
|
||||||
|
if not any([text.endswith(punc) for punc in '.!?:']):
|
||||||
|
text += '.'
|
||||||
# Do TTS processing in a thread to avoid blocking main loop
|
# Do TTS processing in a thread to avoid blocking main loop
|
||||||
filename = await asyncio.get_running_loop().run_in_executor(None, self.make_tts_wav, text)
|
filename = await asyncio.get_running_loop().run_in_executor(None, self.make_tts_wav, text)
|
||||||
|
|
||||||
|
@ -94,7 +90,7 @@ class TextToSpeechPlugin(PluginBase):
|
||||||
stream = Stream(clip, self.output_index)
|
stream = Stream(clip, self.output_index)
|
||||||
async def play():
|
async def play():
|
||||||
try:
|
try:
|
||||||
await stream.play()
|
await stream.aplay()
|
||||||
finally:
|
finally:
|
||||||
stream.close()
|
stream.close()
|
||||||
os.remove(os.path.join(self.cache_dir, filename))
|
os.remove(os.path.join(self.cache_dir, filename))
|
||||||
|
|
|
@ -34,7 +34,7 @@ class ScenePlugin(PluginBase):
|
||||||
|
|
||||||
self.blueprint.add_url_rule('/', 'ctrlpanel', self.ui_ctrlpanel)
|
self.blueprint.add_url_rule('/', 'ctrlpanel', self.ui_ctrlpanel)
|
||||||
self.blueprint.add_url_rule('/<name>/<cmd>', 'api-sceneset', self.ui_setscene)
|
self.blueprint.add_url_rule('/<name>/<cmd>', 'api-sceneset', self.ui_setscene)
|
||||||
self.blueprint.add_url_rule('/monitor', 'monitor', self.ui_monitor_ws, websocket=True)
|
self.blueprint.add_url_rule('/monitor', 'monitor', self.ui_monitor_ws, is_websocket=True)
|
||||||
|
|
||||||
async def run(self, name, _children=None, _ctx={}, active=None, group=None, oneshot=False, **kwargs):
|
async def run(self, name, _children=None, _ctx={}, active=None, group=None, oneshot=False, **kwargs):
|
||||||
if _children is None:
|
if _children is None:
|
||||||
|
@ -138,7 +138,7 @@ class ScenePlugin(PluginBase):
|
||||||
|
|
||||||
async def ui_ctrlpanel(self):
|
async def ui_ctrlpanel(self):
|
||||||
groups = self._get_state()
|
groups = self._get_state()
|
||||||
return await self.blueprint.render('index.html', init_state=json.dumps(groups))
|
return await quart.render_template('index.html', init_state=json.dumps(groups))
|
||||||
|
|
||||||
async def ui_setscene(self, name=None, cmd=None):
|
async def ui_setscene(self, name=None, cmd=None):
|
||||||
active = cmd == 'activate'
|
active = cmd == 'activate'
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
<html lang="en" dir="ltr">
|
<html lang="en" dir="ltr">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Scene control</title>
|
<title>Test page</title>
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
{
|
{
|
||||||
"imports": { "vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js" }
|
"imports": { "vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js" }
|
||||||
|
|
Loading…
Add table
Reference in a new issue