Fetch voice from Lyrebird

This commit is contained in:
Ruben van de Ven 2019-02-18 20:38:54 +01:00
parent 46941e45a5
commit 79424ccfbf
7 changed files with 186 additions and 34 deletions

View File

@ -20,6 +20,7 @@ import json
import logging import logging
import queue import queue
import threading import threading
from hugvey.voice import VoiceStorage
logger = logging.getLogger("command") logger = logging.getLogger("command")
@ -73,6 +74,7 @@ class CentralCommand(object):
self.loadLanguages() self.loadLanguages()
self.panopticon = Panopticon(self, self.config) self.panopticon = Panopticon(self, self.config)
def loadLanguages(self): def loadLanguages(self):
@ -122,6 +124,7 @@ class CentralCommand(object):
""" """
prepare command to be picked up by the sender prepare command to be picked up by the sender
""" """
logging.debug(f"COmmand {hv_id}: {msg}")
if threading.current_thread().getName() != 'MainThread': if threading.current_thread().getName() != 'MainThread':
# Threading nightmares! Adding to queue from other thread/loop (not sure which is the isse) # Threading nightmares! Adding to queue from other thread/loop (not sure which is the isse)
# won't trigger asyncios queue.get() so we have to do this thread # won't trigger asyncios queue.get() so we have to do this thread
@ -226,9 +229,9 @@ class CentralCommand(object):
self.tasks = {} # collect tasks so we can cancel in case of error self.tasks = {} # collect tasks so we can cancel in case of error
self.tasks['eventListener'] = self.loop.create_task( self.tasks['eventListener'] = self.loop.create_task(
self.eventListener()) self.catchException(self.eventListener()))
self.tasks['commandSender'] = self.loop.create_task( self.tasks['commandSender'] = self.loop.create_task(
self.commandSender()) self.catchException(self.commandSender()))
# we want the web interface in a separate thread # we want the web interface in a separate thread
self.panopticon_thread = threading.Thread( self.panopticon_thread = threading.Thread(
@ -239,6 +242,14 @@ class CentralCommand(object):
def stop(self): def stop(self):
self.isRunning.clear() self.isRunning.clear()
async def catchException(self, awaitable):
try:
print(awaitable)
await awaitable
except Exception as e:
logger.exception(e)
logger.critical(f"Hugvey restart might be required but not implemented yet")
class HugveyState(object): class HugveyState(object):
@ -309,6 +320,7 @@ class HugveyState(object):
async def catchException(self, awaitable): async def catchException(self, awaitable):
try: try:
print(awaitable)
await awaitable await awaitable
except Exception as e: except Exception as e:
logger.exception(e) logger.exception(e)
@ -436,7 +448,8 @@ class HugveyState(object):
await self.isRunning.wait() await self.isRunning.wait()
# new story instance on each run # new story instance on each run
self.story = Story(self) port = self.command.config['web']['port']
self.story = Story(self, port)
startMsgId = self.startMsgId startMsgId = self.startMsgId
self.startMsgId = None # use only once, reset before 'run' self.startMsgId = None # use only once, reset before 'run'
self.logger.warn(f"Starting from {startMsgId}") self.logger.warn(f"Starting from {startMsgId}")

View File

@ -11,6 +11,7 @@ def getTopic(hugvey_id):
def zmqSend(socket, hugvey_id, msg): def zmqSend(socket, hugvey_id, msg):
logger.info("SEND: {}".format(msg))
msgData = json.dumps(msg) msgData = json.dumps(msg)
topic = getTopic(hugvey_id) topic = getTopic(hugvey_id)
logger.info("Send 0mq to {} containing {}".format(topic, msg)) logger.info("Send 0mq to {} containing {}".format(topic, msg))

View File

@ -16,6 +16,7 @@ import asyncio
import json import json
from urllib.parse import urlparse from urllib.parse import urlparse
from hugvey import central_command from hugvey import central_command
from hugvey.voice import VoiceStorage
logger = logging.getLogger("panopticon") logger = logging.getLogger("panopticon")
@ -165,15 +166,41 @@ def getUploadHandler(central_command):
return UploadHandler return UploadHandler
def getVoiceHandler(voiceStorage):
class VoiceHandler(tornado.web.RequestHandler):
async def get(self):
# TODO: we should be using ZMQ here...
text = self.get_argument('text')
isVariable = True if int(self.get_argument('variable')) >0 else False
fn = await voiceStorage.requestFile(text, isVariable)
if not fn:
raise Exception(f"No Filename for text: {text}")
if int(self.get_argument('filename')) == 1:
self.set_header("Content-Type","text/plain")
self.write(fn)
else:
self.set_header("Content-Type","audio/wave")
with open(fn, 'rb') as fp:
self.write(fp.read())
self.finish()
return VoiceHandler
class Panopticon(object): class Panopticon(object):
def __init__(self, central_command, config): def __init__(self, central_command, config):
self.command = central_command self.command = central_command
self.config = config self.config = config
voice_dir = os.path.join(self.config['web']['files_dir'], 'voices')
self.voiceStorage = VoiceStorage(voice_dir, self.config['voice']['token'])
self.application = tornado.web.Application([ self.application = tornado.web.Application([
(r"/ws", getWebSocketHandler(self.command)), (r"/ws", getWebSocketHandler(self.command)),
(r"/local/(.*)", NonCachingStaticFileHandler, (r"/local/(.*)", NonCachingStaticFileHandler,
{"path": config['web']['files_dir']}), {"path": config['web']['files_dir']}),
(r"/upload", getUploadHandler(self.command)), (r"/upload", getUploadHandler(self.command)),
(r"/voice", getVoiceHandler(self.voiceStorage)),
(r"/(.*)", tornado.web.StaticFileHandler, (r"/(.*)", tornado.web.StaticFileHandler,
{"path": web_dir, "default_filename": 'index.html'}), {"path": web_dir, "default_filename": 'index.html'}),
], debug=True) ], debug=True)

View File

@ -3,7 +3,9 @@ import time
import logging import logging
import re import re
import asyncio import asyncio
import urllib.parse
from .communication import LOG_BS from .communication import LOG_BS
from tornado.httpclient import AsyncHTTPClient, HTTPRequest
logger = logging.getLogger("narrative") logger = logging.getLogger("narrative")
@ -54,6 +56,9 @@ class Message(object):
""" """
self.variables = re.findall('\$(\w+)', self.text) self.variables = re.findall('\$(\w+)', self.text)
def hasVariables(self) -> bool:
return len(self.variables) > 0
def setReply(self, reply): def setReply(self, reply):
self.reply = reply self.reply = reply
@ -82,6 +87,25 @@ class Message(object):
'time': None if self.reply is None else [u.startTime for u in self.reply.utterances], 'time': None if self.reply is None else [u.startTime for u in self.reply.utterances],
'replyText': None if self.reply is None else [u.text for u in self.reply.utterances] 'replyText': None if self.reply is None else [u.text for u in self.reply.utterances]
} }
async def getAudioFilePath(self,story):
client = AsyncHTTPClient()
queryString = urllib.parse.urlencode({
'text': self.text,
'filename': 1,
'variable': 1 if self.hasVariables() else 0
})
request = HTTPRequest(
url = f"http://localhost:{story.panopticon_port}/voice?{queryString}",
method="GET"
)
logger.log(LOG_BS, request.url)
response = await client.fetch(request)
if response.code != 200:
logger.critical(f"Error when fetching filename: {response.code} for {queryString}")
return None
return response.body.decode().strip()
class Reply(object): class Reply(object):
@ -363,9 +387,10 @@ class Story(object):
# TODO should we separate 'narrative' (the graph) from the story (the # TODO should we separate 'narrative' (the graph) from the story (the
# current user flow) # current user flow)
def __init__(self, hugvey_state): def __init__(self, hugvey_state, panopticon_port):
super(Story, self).__init__() super(Story, self).__init__()
self.hugvey = hugvey_state self.hugvey = hugvey_state
self.panopticon_port = panopticon_port
self.events = [] # queue of received events self.events = [] # queue of received events
self.commands = [] # queue of commands to send self.commands = [] # queue of commands to send
@ -485,7 +510,7 @@ class Story(object):
if self.isRunning: if self.isRunning:
self.isRunning = False self.isRunning = False
def _processPendingEvents(self): async def _processPendingEvents(self):
# Gather events: # Gather events:
nr = len(self.events) nr = len(self.events)
for i in range(nr): for i in range(nr):
@ -497,7 +522,7 @@ class Story(object):
# a client connected. Should only happen in the beginning or in case of error # a client connected. Should only happen in the beginning or in case of error
# that is, until we have a 'reset' or 'start' event. # that is, until we have a 'reset' or 'start' event.
# reinitiate current message # reinitiate current message
self.setCurrentMessage(self.currentMessage) await self.setCurrentMessage(self.currentMessage)
if e['event'] == "playbackFinish": if e['event'] == "playbackFinish":
if e['msgId'] == self.currentMessage.id: if e['msgId'] == self.currentMessage.id:
@ -518,7 +543,7 @@ class Story(object):
logger.warn("Interrupt message, replay {}".format(self.previousReply.forMessage.id)) logger.warn("Interrupt message, replay {}".format(self.previousReply.forMessage.id))
self.currentReply = self.previousReply self.currentReply = self.previousReply
self.previousReply.forMessage.interruptCount += 1 self.previousReply.forMessage.interruptCount += 1
self.currentMessage = self.setCurrentMessage(self.previousReply.forMessage) self.currentMessage = await self.setCurrentMessage(self.previousReply.forMessage)
# log if somebody starts speaking # log if somebody starts speaking
# TODO: implement interrupt # TODO: implement interrupt
@ -532,7 +557,7 @@ class Story(object):
utterance.setFinished(self.timer.getElapsed()) utterance.setFinished(self.timer.getElapsed())
def _processDirections(self, directions): async def _processDirections(self, directions):
for direction in directions: for direction in directions:
for condition in direction.conditions: for condition in direction.conditions:
if condition.isMet(self): if condition.isMet(self):
@ -542,7 +567,7 @@ class Story(object):
self.addToLog(condition) self.addToLog(condition)
self.addToLog(direction) self.addToLog(direction)
self.currentMessage.setFinished(self.timer.getElapsed()) self.currentMessage.setFinished(self.timer.getElapsed())
self.setCurrentMessage(direction.msgTo) await self.setCurrentMessage(direction.msgTo)
return direction return direction
def addToLog(self, node): def addToLog(self, node):
@ -563,13 +588,13 @@ class Story(object):
await self.timer.isRunning.wait() # wait for un-pause await self.timer.isRunning.wait() # wait for un-pause
for i in range(len(self.events)): for i in range(len(self.events)):
self._processPendingEvents() await self._processPendingEvents()
if self.currentMessage.id not in self.directionsPerMsg: if self.currentMessage.id not in self.directionsPerMsg:
self.finish() self.finish()
directions = self.getCurrentDirections() directions = self.getCurrentDirections()
self._processDirections(directions) await self._processDirections(directions)
# TODO create timer event # TODO create timer event
# self.commands.append({'msg':'TEST!'}) # self.commands.append({'msg':'TEST!'})
@ -581,7 +606,7 @@ class Story(object):
logger.info("Stop renderer") logger.info("Stop renderer")
def setCurrentMessage(self, message): async def setCurrentMessage(self, message):
if self.currentMessage and not self.lastMsgFinishTime: if self.currentMessage and not self.lastMsgFinishTime:
logger.info("Interrupt playback {}".format(self.currentMessage.id)) logger.info("Interrupt playback {}".format(self.currentMessage.id))
# message is playing # message is playing
@ -606,18 +631,12 @@ class Story(object):
message.id, message.text)) message.id, message.text))
self.addToLog(message) self.addToLog(message)
# TODO: prep events & timer etc. # TODO: prep events & timer etc.
if message.audioFile: # TODO: preload file paths if no variables are set, or once these are loaded
self.hugvey.sendCommand({ self.hugvey.sendCommand({
'action': 'play', 'action': 'play',
'file': message.audioFile, 'file': await message.getAudioFilePath(self),
'id': message.id, 'id': message.id,
}) })
else:
self.hugvey.sendCommand({
'action': 'play',
'msg': message.text,
'id': message.id,
})
logger.debug("Pending directions: ") logger.debug("Pending directions: ")
@ -640,7 +659,7 @@ class Story(object):
startMsg = self.get(customStartMsgId) startMsg = self.get(customStartMsgId)
else: else:
startMsg = self.startMessage startMsg = self.startMessage
self.setCurrentMessage(startMsg) await self.setCurrentMessage(startMsg)
await self._renderer() await self._renderer()
def isFinished(self): def isFinished(self):

80
hugvey/voice.py Normal file
View File

@ -0,0 +1,80 @@
import os
import time
import json
import logging
import threading
from requests_threads import AsyncSession
from hashlib import sha1
import asyncio
from tornado.httpclient import AsyncHTTPClient, HTTPRequest
logger = logging.getLogger("voice")
class VoiceStorage(object):
"""
Store & keep voices that are not part of the story json
"""
def __init__(self, cache_dir, token):
self.cache_dir = cache_dir
if not os.path.exists(self.cache_dir):
raise Exception(f"Cache dir does not exists: {self.cache_dir}")
# self.request_session = AsyncSession(n=5)
self.pendingRequests = {}
self.token = token
def getId(self, text):
return sha1(text.encode()).hexdigest()
def getFilename(self, text, isVariable=False):
subdir = 'static' if not isVariable else 'variable'
id = self.getId(text)
prefix = id[:2]
storageDir = os.path.join(self.cache_dir, subdir, prefix)
fn = os.path.join(storageDir, f"{id}.wav")
return fn
async def requestFile(self, text, isVariable=False) -> str:
id = self.getId(text)
fn = self.getFilename(text)
if os.path.exists(fn):
return fn
if id in self.pendingRequests:
await self.pendingRequests[id].wait()
if os.path.exists(fn):
return fn
return None
dirname = os.path.dirname(fn)
if not os.path.exists(dirname):
logger.debug(f"create directory for file: {dirname}")
os.makedirs(dirname, exist_ok=True)
self.pendingRequests[id] = asyncio.Event()
http_client = AsyncHTTPClient()
request = HTTPRequest(
method="POST",
url="https://avatar.lyrebird.ai/api/v0/generate",
body=json.dumps({"text": text}),
headers={"authorization": f"Bearer {self.token}"}
)
try:
response = await http_client.fetch(request)
except Exception as e:
logger.exception(e)
self.pendingRequests[id].set()
return None
else:
if response.code != 200:
logger.critical(f"No proper response! {response.code}")
self.pendingRequests[id].set()
return None
logger.debug(f"Wrote body: {response.code}")
with open(fn, "wb") as f:
f.write(response.body)
self.pendingRequests[id].set()
print(type(fn), fn)
return fn

View File

@ -7,6 +7,7 @@ voice:
port: 4444 port: 4444
chunk: 2972 chunk: 2972
google_credentials: "../test_googlespeech/My First Project-0c7833e0d5fa.json" google_credentials: "../test_googlespeech/My First Project-0c7833e0d5fa.json"
token: "oauth_SOMETHING"
hugveys: 25 hugveys: 25
languages: languages:
- code: en-GB - code: en-GB

View File

@ -264,6 +264,11 @@ class Graph {
// used eg. after a condition creation. // used eg. after a condition creation.
this.showMsg( this.selectedMsg ); this.showMsg( this.selectedMsg );
} }
getAudioUrlForMsg(msg) {
let isVariable = msg['text'].includes('$') ? '1' : '0';
return `http://localhost:8888/voice?text=${encodeURIComponent(msg['text'])}&variable=${isVariable}&filename=0`;
}
showMsg( msg ) { showMsg( msg ) {
let msgEl = document.getElementById( 'msg' ); let msgEl = document.getElementById( 'msg' );
@ -286,20 +291,19 @@ class Graph {
if ( msg['start'] == true ) { if ( msg['start'] == true ) {
startAttributes['checked'] = 'checked'; startAttributes['checked'] = 'checked';
} }
let audioSrcEl = crel('source', {'src': this.getAudioUrlForMsg(msg)});
let audioSpan = crel( let audioSpan = crel(
'span', 'span',
{ {
'title': msg['audio'] ? msg['audio']['file'] : "", 'title': msg['audio'] ? msg['audio']['file'] : "",
'class': "label-value", 'class': "label-value",
}, },
msg['audio'] ? msg['audio']['original_name'] : "" crel(
'audio', {'controls': 'controls'},
audioSrcEl
)
); );
if(msg['audio']) {
audioSpan.appendChild(crel(
'audio', {'controls': 'controls'},
crel('source', {'src':msg['audio']['file']})
));
}
let msgInfoEl = crel( 'div', { 'class': 'msg__info' }, let msgInfoEl = crel( 'div', { 'class': 'msg__info' },
crel('div', { crel('div', {
'class':'btn btn--delete', 'class':'btn btn--delete',
@ -319,7 +323,10 @@ class Graph {
'name': msg['@id'] + '-text', 'name': msg['@id'] + '-text',
'value': msg['text'], 'value': msg['text'],
'on': { 'on': {
'change': this.getEditEventListener() 'change': this.getEditEventListener(function(){
audioSrcEl.src = panopticon.graph.getAudioUrlForMsg(msg);
audioSrcEl.parentElement.load();
})
} }
} ) } )
), ),
@ -855,7 +862,7 @@ class Graph {
* Use wrapper method, because for event handlers 'this' will refer to * Use wrapper method, because for event handlers 'this' will refer to
* the input object * the input object
*/ */
getEditEventListener() { getEditEventListener(callback) {
let graph = this; let graph = this;
let el = function( e ) { let el = function( e ) {
console.info("Changed", e); console.info("Changed", e);
@ -880,6 +887,10 @@ class Graph {
// node[field] = e.srcElement.value; // node[field] = e.srcElement.value;
graph.build(); graph.build();
if(typeof callback !== 'undefined'){
callback();
}
} }
return el; return el;
} }