Running a very basic Story for the hugveys

This commit is contained in:
Ruben van de Ven 2019-01-22 08:59:45 +01:00
parent f5f08fc103
commit 1ea85dd490
13 changed files with 12828 additions and 164 deletions

View file

@ -17,13 +17,33 @@ from hugvey.story import Story
from hugvey.voice.google import GoogleVoiceClient
from hugvey.voice.player import Player
from hugvey.voice.streamer import AudioStreamer
import queue
logger = logging.getLogger("command")
# def exceptionEmitter(a):
# print(a)
# def decorate(func):
# print('decorate')
# async def call(*args, **kwargs):
# print('call')
# # pre(func, *args, **kwargs)
# try:
# result = await func(*args, **kwargs)
# except Exception as e:
# logger.critical(e, "in", func)
# raise e
# # post(func, *args, **kwargs)
# return result
# return call
# return decorate
class CentralCommand(object):
"""docstring for CentralCommand."""
def __init__(self, debug_mode = False):
def __init__(self, debug_mode=False):
self.debug = debug_mode
self.eventQueue = asyncio.Queue()
self.commandQueue = asyncio.Queue()
@ -31,39 +51,67 @@ class CentralCommand(object):
self.hugveys = {}
self.ctx = Context.instance()
self.hugveyLock = asyncio.Lock()
self.start_time = time.time()
def loadConfig(self, filename):
if hasattr(self, 'config'):
raise Exception("Overriding config not supported yet")
with open(filename, 'r') as fp:
logger.debug('Load config from {}'.format(filename))
self.config = yaml.safe_load(fp)
self.hugvey_ids = [i+1 for i in range(self.config['hugveys'])]
self.hugvey_ids = [i + 1 for i in range(self.config['hugveys'])]
# load languages:
self.languages = {}
for lang in self.config['languages']:
with open(lang['file'], 'r') as fp:
self.languages[lang['code']] = yaml.load(fp)
self.panopticon = Panopticon(self, self.config)
def getHugveyStatus(self, hv_id):
status = {'id': hv_id}
if not hv_id in self.hugveys:
status['status'] = 'off'
return status
hv = self.hugveys[hv_id]
status['status'] = 'running' if hv.isRunning.is_set() else 'paused'
status['language'] = hv.language_code
status['msg'] = hv.story.currentMessage.id
status['counts'] = hv.story.getStoryCounts()
status['finished'] = hv.story.isFinished()
return status
def getStatusSummary(self):
status = {
'uptime': time.time() - self.start_time,
'languages': self.config['languages'],
'hugveys': [],
}
for hv_id in self.hugvey_ids:
status['hugveys'].append(self.getHugveyStatus(hv_id))
return status
def commandHugvey(self, hv_id, msg):
"""
prepare command to be picked up by the sender
"""
if threading.current_thread().getName() != 'MainThread':
# 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 safe, in the right loop
self.loop.call_soon_threadsafe( self._queueCommand, hv_id, msg )
# won't trigger asyncios queue.get() so we have to do this thread
# safe, in the right loop
self.loop.call_soon_threadsafe(self._queueCommand, hv_id, msg)
else:
self._queueCommand(hv_id, msg)
def _queueCommand(self, hv_id, msg):
self.commandQueue.put_nowait((hv_id, msg))
# if msg['action'] == 'play':
@ -72,155 +120,166 @@ class CentralCommand(object):
# 'msg': "This is an interrption",
# 'id': 'test',
# }))
def commandAllHugveys(self, msg):
for hv_id in self.hugvey_ids:
self.commandHugvey(hv_id, msg)
def commandAllActiveHugveys(self, msg):
for hv_id in self.hugveys:
self.commandHugvey(hv_id, msg)
async def commandSender(self):
s = self.ctx.socket(zmq.PUB)
s.bind(self.config['events']['cmd_address'])
self.commandAllHugveys({'action': 'show_yourself'})
# sleep to allow pending connections to connect
await asyncio.sleep(1)
logger.info("Ready to publish commands on: {}".format(self.config['events']['cmd_address']))
logger.debug('Already {} items in queue'.format(self.commandQueue.qsize()))
logger.info("Ready to publish commands on: {}".format(
self.config['events']['cmd_address']))
logger.debug('Already {} items in queue'.format(
self.commandQueue.qsize()))
while self.isRunning.is_set():
hv_id, cmd = await self.commandQueue.get()
logger.info('Got command to send: {} {}'.format(hv_id, cmd))
zmqSend(s, hv_id, cmd)
logger.warn('Stopping command sender')
s.close()
async def instantiateHugvey(self, hugvey_id, msg):
'''
Start a HugveyState, according to a show_yourself reply
'event': 'connection',
'id': self.hugvey_id,
'host': socket.gethostname(),
'ip': self.getIp(),
'''
async with self.hugveyLock: # lock to prevent duplicates on creation
async with self.hugveyLock: # lock to prevent duplicates on creation
if not hugvey_id in self.hugveys:
logger.info(f'Instantiate hugvey #{hugvey_id}')
print('a')
h = HugveyState(hugvey_id, self)
h.config(msg['host'],msg['ip'])
print('a')
h.config(msg['host'], msg['ip'])
print('b')
self.hugveys[hugvey_id] = h
thread = threading.Thread(target=h.start, name=f"hugvey#{hugvey_id}")
thread = threading.Thread(
target=h.start, name=f"hugvey#{hugvey_id}")
thread.start()
print('c')
else:
logger.info(f'Reconfigure hugvey #{hugvey_id}')
# (re)configure exisitng hugveys
h.config(msg['host'],msg['ip'])
h.config(msg['host'], msg['ip'])
async def eventListener(self):
s = self.ctx.socket(zmq.SUB)
s.bind(self.config['events']['listen_address'])
logger.info("Listen for events on: {}".format(self.config['events']['listen_address']))
logger.info("Listen for events on: {}".format(
self.config['events']['listen_address']))
for id in self.hugvey_ids:
s.subscribe(getTopic(id))
while self.isRunning.is_set():
hugvey_id, msg = await zmqReceive(s)
if hugvey_id not in self.hugvey_ids:
logger.critical("Message from alien Hugvey: {}".format(hugvey_id))
continue
elif hugvey_id not in self.hugveys:
if msg['event'] == 'connection':
# Create a hugvey
await self.instantiateHugvey(hugvey_id, msg)
try:
hugvey_id, msg = await zmqReceive(s)
if hugvey_id not in self.hugvey_ids:
logger.critical(
"Message from alien Hugvey: {}".format(hugvey_id))
continue
elif hugvey_id not in self.hugveys:
if msg['event'] == 'connection':
# Create a hugvey
await self.instantiateHugvey(hugvey_id, msg)
else:
logger.warning(
"Message from uninstantiated Hugvey {}".format(hugvey_id))
logger.debug("Message contains: {}".format(msg))
continue
else:
logger.warning("Message from uninstantiated Hugvey {}".format(hugvey_id))
logger.debug("Message contains: {}".format(msg))
continue
else:
await self.hugveys[hugvey_id].eventQueue.put(msg)
pass
# def getPanopticon(self):
# self.panopticon =
await self.hugveys[hugvey_id].eventQueue.put(msg)
except Exception as e:
logger.critical(f"Exception while running event loop:")
logger.exception(e)
def start(self):
self.isRunning.set()
self.loop = asyncio.get_event_loop()
# self.panopticon_loop = asyncio.new_event_loop()
self.tasks = {} # collect tasks so we can cancel in case of error
self.tasks['eventListener'] = self.loop.create_task(self.eventListener())
self.tasks['commandSender'] = self.loop.create_task(self.commandSender())
print(threading.current_thread())
self.tasks = {} # collect tasks so we can cancel in case of error
self.tasks['eventListener'] = self.loop.create_task(
self.eventListener())
self.tasks['commandSender'] = self.loop.create_task(
self.commandSender())
# we want the web interface in a separate thread
self.panopticon_thread = threading.Thread(target=self.panopticon.start, name="Panopticon")
self.panopticon_thread = threading.Thread(
target=self.panopticon.start, name="Panopticon")
self.panopticon_thread.start()
print(threading.current_thread())
self.loop.run_forever()
def stop(self):
self.isRunning.clear()
class HugveyState(object):
"""Represents the state of a Hugvey client on the server.
Manages server connections & voice parsing etc.
"""
def __init__(self, id: int, command: CentralCommand):
self.id = id
self.command = command
self.logger = logging.getLogger(f"hugvey{self.id}")
self.loop = asyncio.new_event_loop()
self.isConfigured = False
self.isRunning = threading.Event()
self.eventQueue = None
self.language_code = 'en-GB'
self.story = Story(self)
self.story.setStoryData(self.command.languages[self.language_code])
def config(self, hostname, ip):
self.ip = ip
self.hostname = hostname
self.logger.info(f"Hugvey {self.id} at {self.ip}, host: {self.hostname}")
self.logger.info(
f"Hugvey {self.id} at {self.ip}, host: {self.hostname}")
if self.isConfigured == True:
# a reconfiguration/reconnection
pass
self.isConfigured = True
def sendCommand(self, msg):
"""
Send message or command to hugvey
@param msg: The message to be sent. Probably a dict()
"""
self.command.commandHugvey(self.id, msg)
def start(self):
"""
Start the tasks
"""
# stop on isRunning.is_set() or wait()
# self.loop.create_task(self.processAudio())
tasks = asyncio.gather(
self.catchException(self.processAudio()),
self.catchException(self.handleEvents()),
self.catchException(self.playStory()),
loop=self.loop)
self.loop.run_until_complete(tasks)
# asyncio.run_coroutine_threadsafe(self._start(), self.loop)
self.isRunning.set()
async def catchException(self, awaitable):
try:
@ -228,7 +287,7 @@ class HugveyState(object):
except Exception as e:
logger.exception(e)
logger.critical(f"Hugvey restart required but not implemented yet")
# TODO: restart
def queueEvent(self, msg):
@ -243,51 +302,74 @@ class HugveyState(object):
self.story.events.append(msg)
async def handleEvents(self):
self.eventQueue = asyncio.Queue() # start event queue here, to avoid loop issues
self.eventQueue = asyncio.Queue() # start event queue here, to avoid loop issues
while self.command.isRunning.is_set():
event = await self.eventQueue.get()
self.logger.info("Received: {}".format(event))
if event['event'] =='language':
if event['event'] == 'language':
self.setLanguage(event['code'])
if event['event'] == 'pause':
self.pause()
if event['event'] == 'restart':
self.restart()
if event['event'] == 'resume':
self.resume()
self.eventQueue = None
def setLanguage(self, language_code):
if language_code not in self.command.languages:
raise Exception("Invalid language {}".format(language_code))
self.language_code = language_code
self.google.setLanguage(language_code)
self.story.reset()
self.story.setStoryData(self.command.languages[language_code])
def pause(self):
self.google.pause()
self.story.pause()
self.isRunning.clear()
def resume(self):
self.google.resume()
self.story.resume()
self.isRunning.set()
def restart(self):
self.story.reset()
self.resume()
self.isRunning.set()
async def playStory(self):
await self.story.start()
async def processAudio(self):
'''
Start the audio streamer service
'''
self.logger.info("Start audio stream")
streamer = AudioStreamer(
self.command.config['voice']['chunk'],
self.command.config['voice']['chunk'],
self.ip,
int(self.command.config['voice']['port']))
if self.command.debug:
self.logger.warn("Debug on: Connecting Audio player")
self.player = Player(self.command.config['voice']['src_rate'], self.command.config['voice']['out_rate'])
self.player = Player(
self.command.config['voice']['src_rate'], self.command.config['voice']['out_rate'])
streamer.addConsumer(self.player)
self.logger.info("Start Speech")
self.google = GoogleVoiceClient(
hugvey=self,
src_rate=self.command.config['voice']['src_rate'],
credential_file=self.command.config['voice']['google_credentials'],
language_code=self.language_code
)
)
streamer.addConsumer(self.google)
await streamer.run()

View file

@ -11,28 +11,73 @@ import tornado.ioloop
import os
from pytz.reference import Central
import asyncio
import json
logger = logging.getLogger("panopticon")
web_dir = os.path.join(os.path.split(__file__)[0], '..','www')
web_dir = os.path.join(os.path.split(__file__)[0], '..', 'www')
print(web_dir)
class WebSocketHandler(tornado.websocket.WebSocketHandler):
connections = set()
# the client connected
def open(self):
self.connections.add(self)
print("New client connected")
def getWebSocketHandler(central_command):
class WebSocketHandler(tornado.websocket.WebSocketHandler):
connections = set()
# the client sent the message
def on_message(self, message):
[con.write_message(message) for con in self.connections]
# the client connected
def open(self):
self.connections.add(self)
logger.info("New client connected")
# client disconnected
def on_close(self):
self.connections.remove(self)
print("Client disconnected")
# the client sent the message
def on_message(self, message):
try:
msg = json.loads(message)
if msg['action'] == 'init':
self.msgInit()
if msg['action'] == 'get_status':
self.msgStatus()
if msg['action'] == 'resume':
self.msgResume(msg['hugvey'])
if msg['action'] == 'pause':
self.msgPause(msg['hugvey'])
if msg['action'] == 'restart':
self.msgRestart(msg['hugvey'])
except Exception as e:
self.send({'alert': 'Invalid request: {}'.format(e)})
def send(self, message):
j = json.dumps(message)
[con.write_message(j) for con in self.connections]
# client disconnected
def on_close(self):
self.connections.remove(self)
logger.info("Client disconnected")
def getStatusMsg(self):
msg = central_command.getStatusSummary()
msg['action'] = 'status'
return msg
def msgStatus(self):
self.send(self.getStatusMsg())
def msgInit(self):
msg = self.getStatusMsg()
self.send(msg)
def msgResume(self, hv_id):
central_command.hugveys[hv_id].eventQueue.put({'event': 'resume'})
def msgPause(self, hv_id):
central_command.hugveys[hv_id].eventQueue.put({'event': 'pause'})
def msgRestart(self, hv_id):
central_command.hugveys[hv_id].eventQueue.put({'event': 'restart'})
return WebSocketHandler
class Panopticon(object):
@ -40,22 +85,23 @@ class Panopticon(object):
self.command = central_command
self.config = config
self.application = tornado.web.Application([
(r"/ws", WebSocketHandler),
(r"/uploads/(.*)", tornado.web.StaticFileHandler, {"path": config['web']['files_dir']}),
(r"/(.*)", tornado.web.StaticFileHandler, {"path": web_dir, "default_filename": 'index.html'}),
(r"/ws", getWebSocketHandler(self.command)),
(r"/uploads/(.*)", tornado.web.StaticFileHandler,
{"path": config['web']['files_dir']}),
(r"/(.*)", tornado.web.StaticFileHandler,
{"path": web_dir, "default_filename": 'index.html'}),
], debug=True)
self.application.listen(config['web']['port'])
# self.loop.configure(evt_loop)
def start(self):
evt_loop = asyncio.new_event_loop()
asyncio.set_event_loop(evt_loop)
self.loop = tornado.ioloop.IOLoop.current()
logger.info(f"Start Panopticon on port {self.config['web']['port']}")
self.loop.start()
def stop(self):
self.loop.stop()

View file

@ -7,6 +7,7 @@ import asyncio
logger = logging.getLogger("narrative")
class Message(object):
def __init__(self, id, text):
self.id = id
@ -16,9 +17,9 @@ class Message(object):
@classmethod
def initFromJson(message, data, story):
msg = message(data['@id'], data['text'])
msg = message(data['@id'], data['text'])
msg.isStart = data['start'] if 'start' in data else False
return msg;
return msg
def setReply(self, text):
self.reply = text
@ -28,7 +29,8 @@ class Message(object):
def getReply(self):
if self.reply is None:
raise Exception("Getting reply while there is none! {0}".format(self.id))
raise Exception(
"Getting reply while there is none! {0}".format(self.id))
return self.reply
@ -38,6 +40,7 @@ class Condition(object):
A condition, basic conditions are built in, custom condition can be given by
providing a custom method.
"""
def __init__(self, id):
self.id = id
self.method = None
@ -45,7 +48,7 @@ class Condition(object):
@classmethod
def initFromJson(conditionClass, data, story):
condition = conditionClass(data['@id'])
condition = conditionClass(data['@id'])
# TODO: should Condition be subclassed?
if data['type'] == "replyContains":
condition.method = condition._hasMetReplyContains
@ -55,7 +58,7 @@ class Condition(object):
if 'vars' in data:
condition.vars = data['vars']
return condition;
return condition
def isMet(self, story):
"""
@ -98,6 +101,7 @@ class Direction(object):
"""
A condition based edge in the story graph
"""
def __init__(self, id, msgFrom: Message, msgTo: Message):
self.id = id
self.msgFrom = msgFrom
@ -111,18 +115,19 @@ class Direction(object):
def initFromJson(direction, data, story):
msgFrom = story.get(data['source'])
msgTo = story.get(data['target'])
direction = direction(data['@id'], msgFrom, msgTo)
direction = direction(data['@id'], msgFrom, msgTo)
if 'conditions' in data:
for conditionId in data['conditions']:
c = story.get(conditionId)
direction.addCondition(c)
return direction;
return direction
class Interruption(object):
"""
An Interruption. Used to catch events outside of story flow.
"""
def __init__(self, id):
self.id = id
self.conditions = []
@ -132,12 +137,13 @@ class Interruption(object):
@classmethod
def initFromJson(interruptionClass, data, story):
interrupt = interruptionClass(data['@id'])
interrupt = interruptionClass(data['@id'])
if 'conditions' in data:
for conditionId in data['conditions']:
c = story.get(conditionId)
interrupt.addCondition(c)
return interrupt;
return interrupt
storyClasses = {
'Msg': Message,
@ -146,34 +152,103 @@ storyClasses = {
'Interruption': Interruption,
}
class Stopwatch(object):
"""
Keep track of elapsed time. Use multiple markers, but a single pause/resume button
"""
def __init__(self):
self.isRunning = asyncio.Event()
self.reset()
def getElapsed(self, since_mark='start'):
t = time.time()
if self.paused_at != 0:
pause_duration = t - self.paused_at
else:
pause_duration = 0
return t - self.marks[since_mark] - pause_duration
def pause(self):
self.paused_at = time.time()
self.isRunning.clear()
def resume(self):
if self.paused_at == 0:
return
pause_duration = time.time() - self.paused_at
for m in self.marks:
self.marks[m] += pause_duration
self.paused_at = 0
self.isRunning.set()
def reset(self):
self.marks = {}
self.setMark('start')
self.paused_at = 0
self.isRunning.set()
def setMark(self, name):
self.marks[name] = time.time()
def clearMark(self, name):
if name in self.marks:
self.marks.pop(name)
class Story(object):
"""Story represents and manages a story/narrative flow"""
#TODO should we separate 'narrative' (the graph) from the story (the current user flow)
# TODO should we separate 'narrative' (the graph) from the story (the
# current user flow)
def __init__(self, hugvey_state):
super(Story, self).__init__()
self.hugvey = hugvey_state
self.events = [] # queue of received events
self.commands = [] # queue of commands to send
self.log = [] # all nodes/elements that are triggered
self.currentMessage = None
self.events = [] # queue of received events
self.commands = [] # queue of commands to send
self.log = [] # all nodes/elements that are triggered
self.currentMessage = None
self.timer = Stopwatch()
def pause(self):
logger.debug('pause hugvey')
self.timer.pause()
def resume(self):
logger.debug('resume hugvey')
self.timer.resume()
def getStoryCounts(self):
# counts = {}
# for item in self.log:
# n =item.__class__.__name__
# if n not in counts:
# counts[n] = 0
# counts[n] += 1
# return counts
return {
'messages': len([e for e in self.log if isinstance(e, Message)]),
'interruptions': len([e for e in self.log if isinstance(e, Interruption)])
}
def setStoryData(self, story_data):
"""
Parse self.data into a working story engine
"""
self.data = story_data
# keep to be able to reset it in the end
currentId = self.currentMessage.id if self.currentMessage else None
self.elements = {}
self.interruptions = []
self.directionsPerMsg = {}
self.startMessage = None # The entrypoint to the graph
self.startMessage = None # The entrypoint to the graph
self.reset()
for el in self.data:
className = storyClasses[el['@type']]
obj = className.initFromJson(el, self)
@ -181,22 +256,31 @@ class Story(object):
logger.debug(self.elements)
logger.debug(self.directionsPerMsg)
if currentId:
self.currentMessage = self.get(currentId)
if self.currentMessage:
logger.info(f"Reinstantiated current message: {self.currentMessage.id}")
logger.info(
f"Reinstantiated current message: {self.currentMessage.id}")
else:
logger.warn("Could not reinstatiate current message. Starting over")
logger.warn(
"Could not reinstatiate current message. Starting over")
def reset(self):
self.startTime = time.time()
self.currentMessage = None # currently active message, determines active listeners etc.
self.timer.reset()
# self.startTime = time.time()
# currently active message, determines active listeners etc.
self.currentMessage = None
self.lastMsgTime = None
self.lastSpeechStartTime = None
self.lastSpeechEndTime = None
self.variables = {} # captured variables from replies
self.variables = {} # captured variables from replies
self.finish_time = False
self.events = [] # queue of received events
self.commands = [] # queue of commands to send
self.log = [] # all nodes/elements that are triggered
def add(self, obj):
if obj.id in self.elements:
# print(obj)
@ -223,25 +307,24 @@ class Story(object):
return self.elements[id]
return None
def stop(self):
logger.info("Stop Story")
if self.isRunning:
self.isRunning = False
def _processPendingEvents(self):
# Gather events:
nr = len(self.events)
for i in range(nr):
e = self.events.pop(0)
logger.info("handle '{}'".format( e ))
logger.info("handle '{}'".format(e))
if e['event'] == "exit":
self.stop()
if e['event'] == 'connect':
# a client connected. Shold only happen in the beginning or in case of error
# that is, until we have a 'reset' or 'start' event.
self.setCurrentMessage(self.currentMessage) # reinitiate current message
# reinitiate current message
self.setCurrentMessage(self.currentMessage)
if e['event'] == "playbackFinish":
if e['msgId'] == self.currentMessage.id:
@ -254,6 +337,7 @@ class Story(object):
if e['event'] == 'speech':
# log if somebody starts speaking
# TODO: use pausing timer
if self.lastSpeechStartTime is None or self.lastSpeechStartTime < self.lastMsgTime:
self.lastSpeechStartTime = e['time']
@ -266,7 +350,8 @@ class Story(object):
for direction in directions:
for condition in direction.conditions:
if condition.isMet(self):
logger.info("Condition is met: {0}, going to {1}".format(condition.id, direction.msgTo.id))
logger.info("Condition is met: {0}, going to {1}".format(
condition.id, direction.msgTo.id))
self.log.append(condition)
self.log.append(direction)
self.setCurrentMessage(direction.msgTo)
@ -276,20 +361,21 @@ class Story(object):
"""
every 1/10 sec. determine what needs to be done based on the current story state
"""
loopDuration = 0.1 # Configure fps
loopDuration = 0.1 # Configure fps
lastTime = time.time()
logger.info("Start renderer")
while self.isRunning:
if self.isRunning is False:
break
# pause on timer paused
await self.timer.isRunning.wait() # wait for un-pause
for i in range(len(self.events)):
self._processPendingEvents()
if self.currentMessage.id not in self.directionsPerMsg:
# TODO: finish!
pass
self.finish()
directions = self.getCurrentDirections()
self._processDirections(directions)
@ -307,9 +393,11 @@ class Story(object):
def setCurrentMessage(self, message):
self.currentMessage = message
self.lastMsgTime = time.time()
self.lastMsgFinishTime = None # to be filled in by the event
self.lastMsgFinishTime = None # to be filled in by the event
logger.info("Current message: ({0}) \"{1}\"".format(message.id, message.text))
logger.info("Current message: ({0}) \"{1}\"".format(
message.id, message.text))
self.log.append(message)
# TODO: prep events & timer etc.
self.hugvey.sendCommand({
'action': 'play',
@ -321,8 +409,8 @@ class Story(object):
for direction in self.getCurrentDirections():
conditions = [c.id for c in direction.conditions]
logger.debug("- {0} -> {1} (when: {2}) ".format(direction.msgFrom.id, direction.msgTo.id, conditions))
logger.debug(
"- {0} -> {1} (when: {2}) ".format(direction.msgFrom.id, direction.msgTo.id, conditions))
def getCurrentDirections(self):
if self.currentMessage.id not in self.directionsPerMsg:
@ -332,10 +420,21 @@ class Story(object):
async def start(self):
logger.info("Starting story")
self.startTime = time.time()
self.timer.reset()
# self.startTime = time.time()
self.isRunning = True
self.setCurrentMessage(self.startMessage)
await self._renderer()
def isFinished(self):
if hasattr(self, 'finish_time'):
return self.finish_time
return False
def finish(self):
logger.info(f"Finished story for {self.hugvey.id}")
self.hugvey.pause()
self.finish_time = time.time()
self.timer.pause()

View file

@ -32,17 +32,26 @@ class GoogleVoiceClient(object):
# Create a thread-safe buffer of audio data
self.buffer = queue.Queue()
self.isRunning = False
self.isRunning = threading.Event()
self.toBeShutdown = False
self.target_rate = 16000
self.cv_laststate = None
self.restart = False
self.task = threading.Thread(target=self.run, name=f"hugvey#{self.hugvey.id}v")
self.task.setDaemon(True)
self.task.start()
def pause(self):
self.isRunning.clear()
self.restart = True
def resume(self):
self.isRunning.set()
def generator(self):
while self.isRunning:
while not self.toBeShutdown:
yield self.buffer.get()
def setLanguage(self, language_code):
@ -54,11 +63,13 @@ class GoogleVoiceClient(object):
self.restart = True
def run(self):
self.isRunning = True
self.isRunning.set()
while self.isRunning:
while not self.toBeShutdown:
try:
self.isRunning.wait()
self.speech_client = speech.SpeechClient()
config = types.RecognitionConfig(
encoding=enums.RecognitionConfig.AudioEncoding.LINEAR16,
@ -122,7 +133,7 @@ class GoogleVoiceClient(object):
self.restart = False
raise RequireRestart("Restart required")
if not self.isRunning:
if self.toBeShutdown:
logger.warn("Stopping voice loop")
break
except RequireRestart as e:
@ -142,7 +153,7 @@ class GoogleVoiceClient(object):
self.buffer.put_nowait(data)
def shutdown(self):
self.isRunning = False
self.toBeShutdown = True

3
local/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
*
!.gitignore

View file

@ -7,7 +7,7 @@ voice:
port: 4444
chunk: 2972
google_credentials: "/home/ruben/Documents/Projecten/2018/Hugvey/test_googlespeech/My First Project-0c7833e0d5fa.json"
hugveys: 3
hugveys: 25
languages:
- code: en-GB
file: story_en.json

100
www/hugvey_console.js Normal file
View file

@ -0,0 +1,100 @@
var panopticon;
class Panopticon {
constructor() {
console.log( "Init panopticon" );
this.hugveys = new Vue( {
el: "#status",
data: {
uptime: 0,
languages: [],
hugveys: []
},
methods: {
time_passed: function (hugvey, property) {
console.log("property!", Date(hugvey[property] * 1000));
return moment(Date(hugvey[property] * 1000)).fromNow();
}
}
} );
this.socket = new ReconnectingWebSocket( "ws://localhost:8888/ws", null, { debug: true, reconnectInterval: 3000 } );
this.socket.addEventListener( 'open', ( e ) => {
this.send( { action: 'init' } );
} );
this.socket.addEventListener( 'close', function( e ) {
console.log( 'Closed connection' );
} );
this.socket.addEventListener( 'message', ( e ) => {
let msg = JSON.parse( e.data );
if ( typeof msg['alert'] !== 'undefined' ) {
alert(msg['alert']);
}
if ( typeof msg['action'] === 'undefined' ) {
console.error( "not a valid message: " + e.data );
return;
}
switch ( msg['action'] ) {
case 'status':
this.hugveys.uptime = this.stringToHHMMSS(msg['uptime']);
this.hugveys.languages = msg['languages'];
this.hugveys.hugveys = msg['hugveys'];
break;
}
} );
}
send( msg ) {
this.socket.send( JSON.stringify( msg ) );
}
getStatus() {
// console.log('get status', this, panopticon);
panopticon.send( { action: 'get_status' } );
}
init() {
setInterval( this.getStatus, 3000 );
}
stringToHHMMSS (string) {
var sec_num = parseInt(string, 10); // don't forget the second param
var hours = Math.floor(sec_num / 3600);
var minutes = Math.floor((sec_num - (hours * 3600)) / 60);
var seconds = sec_num - (hours * 3600) - (minutes * 60);
if (hours < 10) {hours = "0"+hours;}
if (minutes < 10) {minutes = "0"+minutes;}
if (seconds < 10) {seconds = "0"+seconds;}
return hours+':'+minutes+':'+seconds;
}
loadNarrative(code, file) {
}
resume(hv_id) {
this.send({ action: 'resume', hugvey: hv_id })
}
pause(hv_id) {
this.send({ action: 'play', hugvey: hv_id })
}
restart(hv_id) {
this.send({ action: 'restart', hugvey: hv_id })
}
}
window.addEventListener( 'load', function() {
panopticon = new Panopticon();
panopticon.init();
})

View file

@ -1,17 +1,43 @@
<html>
<head>
<title>Pillow Talk Control Interface</title>
<title>Pillow Talk Control Interface</title>
<!-- development version, includes helpful console warnings -->
<script src="/vue.js"></script>
<script src="/reconnecting-websocket.js"></script>
<script src="/moment.min.js"></script>
<link rel="stylesheet" href="styles.css"></link>
</head>
<body>
<div id='status'>
</div>
<div id='story'>
</div>
<div id='hugvey'>
</div>
<div id='app'>{{message}}</div>
<script src="/hugvey_console.js"></script>
<div id='status'>
<div id='overview'>
<dl>
<dt>Uptime</dt>
<dd>{{uptime}}</dd>
<dt>Languages</dt>
<dd v-for="lang in languages" :title="lang.file"
@click="panopticon.loadNarrative(lang.code, lang.file)">{{lang.code}}</dd>
</dl>
</div>
<div class='hugvey' v-for="hv in hugveys"
:class="[{'hugvey--off': hv.status == 'off'},{'hugvey--on': hv.status != 'off'},{'hugvey--paused': hv.status == 'paused'},{'hugvey--running': hv.status == 'running'}]">
<h1>
{{ hv.id }}
<!-- / {{ hv.status }} -->
</h1>
<div v-if="hv.status != 'off'">
{{ hv.language }} / {{ hv.msg }}
<div v-if="hv.finished != false">
Finished: {{time_passed(hv, 'finished')}}
</div>
<div v-for="c, key in hv.counts"><dt>{{key}}</dt><dd>{{c}}</dd></div>
<div v-if="hv.status != 'running'" @click="panopticon.pause(hv.id)">Pause</div>
<div v-if="hv.status != 'paused'" @click="panopticon.resume(hv.id)">Resume</div>
</div>
</div>
</div>
<div id='story'></div>
<div id='hugvey'></div>
<script type='application/javascript' src="/hugvey_console.js"></script>
</body>
</html>

1
www/moment.min.js vendored Normal file

File diff suppressed because one or more lines are too long

832
www/narrative_builder.html Normal file
View file

@ -0,0 +1,832 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8">
<title>Pillow Talk - Narrative Builder</title>
<script src="https://d3js.org/d3.v5.min.js"></script>
<script type="text/javascript">
// https://raw.githubusercontent.com/KoryNunn/crel/master/crel.min.js
!function(n,e){"object"==typeof exports?module.exports=e():"function"==typeof define&&define.amd?define(e):n.crel=e()}(this,function(){function n(a){var d,s=arguments,p=s[1],y=2,m=s.length,x=n[o];if(a=n[u](a)?a:c.createElement(a),m>1){if((!e(p,r)||n[f](p)||Array.isArray(p))&&(--y,p=null),m-y==1&&e(s[y],"string"))a.textContent=s[y];else for(;y<m;++y)null!==(d=s[y])&&l(a,d);for(var v in p)if(x[v]){var A=x[v];e(A,t)?A(a,p[v]):a[i](A,p[v])}else e(p[v],t)?a[v]=p[v]:a[i](v,p[v])}return a}var e=function(n,e){return typeof n===e},t="function",r="object",i="setAttribute",o="attrMap",f="isNode",u="isElement",c=document,a=function(n){return n instanceof Node},d=function(n){return n instanceof Element},l=function(e,t){if(Array.isArray(t))return void t.map(function(n){l(e,n)});n[f](t)||(t=c.createTextNode(t)),e.appendChild(t)};return n[o]={},n[u]=d,n[f]=a,e(Proxy,"undefined")||(n.proxy=new Proxy(n,{get:function(e,t){return!(t in n)&&(n[t]=n.bind(null,t)),n[t]}})),n});
crel.attrMap['on'] = function(element, value) {
for (var eventName in value) {
element.addEventListener(eventName, value[eventName]);
}
};
</script>
<style media="screen">
body{
margin:0;
overflow: hidden;
font-family: "Noto Sans", sans-serif;
}
svg{
width:100vw;
height: 100vh;
cursor: grab;
}
svg:active{
cursor: grabbing;
}
circle{
cursor: pointer;
fill: rgb(119, 97, 142);
}
.startMsg circle{
fill: lightseagreen;
}
.endMsg circle{
fill: lightslategray;
}
.orphanedMsg{
fill: lightcoral;
}
text{
text-anchor: middle;
font-size: 11pt;
font-family: sans-serif;
fill: white;
}
line{
marker-end: url('#arrowHead');
stroke-width: 2px;
stroke: black;
}
line.link--noconditions{
stroke-dasharray: 5 4;
stroke: red;
}
label::after {
content: '';
clear: both;
display: block;
}
label{
width:100%;
font-weight:bold;
display: block;
margin: 0 -10px;
padding: 5px 10px;
}
label input,label select{
float: right;
}
label:nth-child(odd){
background-color: rgba(255,255,255,0.3);
}
#msg{
position: absolute;
top:0;
right:0;
width: 30%;
max-height:100%;
overflow-y: auto;
}
#msg .msg__info, #msg .directions > div{
padding: 10px;
margin-bottom: 10px;
background:lightgray;
}
#opener{
display: flex;
width: 100%;
height: 100vh;
justify-content: center;
align-items: center;
flex-direction: column;
}
#nodes g:hover circle,
.selectedMsg circle {
stroke: lightgreen;
stroke-width: 8;
}
.controlDown #nodes g:hover circle,
.secondaryMsg circle {
stroke: lightgreen;
stroke-width: 5;
stroke-dasharray: 10 3;
}
.condition h4{
text-align: center;
}
.condition + .condition::before {
content: "OR";
display: block;
border-bottom: solid 2px;
height: 10px;
margin-bottom: 15px;
text-align: center;
text-shadow: 2px 2px 2px lightgray,-2px 2px 2px lightgray,2px -2px 2px lightgray,-2px -2px 2px lightgray;
}
.condition--add{
/* text-align: center; */
}
.btn{
padding: 5px;
background:lightgray;
border-radius: 5px;
display: inline-block;
cursor: pointer;
}
.btn:hover{
background: lightblue;
}
</style>
</head>
<body>
<div id="opener">
<h1>Hugvey</h1>
<h3>Select a narrative json file</h3>
<input id='fileOpener' type="file" />
<div>
<input id='fileLoad' type="submit" value="Load file" />
<input id='stateLoad' type="button" value="Load last state" />
</div>
</div>
<div id="msg"></div>
<div id="controls">
<div id='btn-save' class='btn'>Save JSON</div>
<div id='btn-addMsg' class='btn'>New Message</div>
</div>
<svg id='graph' viewbox="0 0 1280 1024" preserveAspectRatio="xMidYMid">
<defs>
<marker markerHeight="8" markerWidth="8" refY="0" refX="12" viewBox="0 -6 16 12" preserveAspectRatio="none" orient="auto" id="arrowHead"><path d="M0,-6L16,0L0,6" fill="black"></path></marker>
</defs>
<g id='container'>
</g>
</svg>
</body>
<script type="text/javascript">
class Graph{
constructor() {
this.width = 1280;
this.height = 1024;
this.nodeSize = 80;
this.maxChars = 16;
this.svg = d3.select('#graph');
this.container = d3.select('#container');
this.selectedMsg = null;
this.messages = []; // initialise empty array. For the simulation, make sure we keep the same array object
this.directions = []; // initialise empty array. For the simulation, make sure we keep the same array object
this.conditions = []; // initialise empty array. For the simulation, make sure we keep the same array object
this.interruptions = []; // initialise empty array. For the simulation, make sure we keep the same array object
let graph = this;
this.controlDown = false;
document.addEventListener('keydown', function(e){
console.log(e);
if(e.which == "17") {
graph.controlDown = true;
document.body.classList.add('controlDown');
}
});
document.addEventListener('keyup', function(e){
console.log(e);
if(e.which == "17") {
graph.controlDown = false;
document.body.classList.remove('controlDown');
}
});
let c = this.container;
let zoomed = function(){
c.attr("transform", d3.event.transform);
}
this.svg.call(d3.zoom()
.scaleExtent([1 / 2, 8])
.on("zoom", zoomed));
this.nodesG = this.container.append("g")
.attr("id", "nodes")
this.linkG = this.container.append("g")
.attr("id", "links");
document.getElementById('btn-save').addEventListener('click', function(e){ graph.saveJson(); });
document.getElementById('btn-addMsg').addEventListener('click', function(e){ graph.createMsg(); });
}
clickMsg(msg) {
// event when a message is clicked.
console.log(msg);
if(this.controlDown) {
this.secondarySelectMsg(msg);
} else {
this.selectMsg(msg);
}
}
secondarySelectMsg(msg) {
if(this.selectedMsg !== null) {
this.addDirection(this.selectedMsg, msg);
} else {
console.error('No message selected as Source');
}
}
selectMsg(msg) {
let selectedEls = document.getElementsByClassName('selectedMsg');
while(selectedEls.length > 0){
selectedEls[0].classList.remove('selectedMsg');
}
document.getElementById(msg['@id']).classList.add('selectedMsg');
this.selectedMsg = msg;
this.showMsg(msg);
}
updateMsg() {
// used eg. after a condition creation.
this.showMsg(this.selectedMsg);
}
showMsg(msg) {
let msgEl = document.getElementById('msg');
msgEl.innerHTML = "";
let startAttributes = {
'name': msg['@id'] + '-start',
'disabled': true,
'type': 'checkbox',
'on': {
'change': this.getEditEventListener()
}
}
if(msg['start'] == true) {
startAttributes['checked'] = 'checked';
}
let msgInfoEl = crel('div', {'class': 'msg__info'},
crel('h1', {'class':'msg__id'}, msg['@id']),
crel('label',
crel('span', 'Text'),
crel('input', {
'name': msg['@id'] + '-text',
'value': msg['text'],
'on': {
'change': this.getEditEventListener()
}
} )
),
crel('label',
crel('span', 'Start'),
crel('input', startAttributes)
)
);
msgEl.appendChild(msgInfoEl);
// let directionHEl = document.createElement('h2');
// directionHEl.innerHTML = "Directions";
let fromDirections =[] , toDirections = [];
for(let direction of this.getDirectionsTo(msg)) {
toDirections.push(this.getDirectionEl(direction, msg));
}
for(let direction of this.getDirectionsFrom(msg)) {
fromDirections.push(this.getDirectionEl(direction, msg));
}
let directionsEl = crel('div', {'class': 'directions'},
crel('h2', 'Directions'),
...toDirections, ...fromDirections
);
msgEl.appendChild(directionsEl);
}
getDirectionEl(direction, msg) {
let directionEl = document.createElement('div');
if(direction['source'] == msg) {
directionEl.innerHTML = `<h3>To ${direction['target']['@id']}</h3>`;
} else {
directionEl.innerHTML = `<h3>From ${direction['source']['@id']}</h3>`;
}
let del = document.createElement('div');
del.innerHTML = "delete";
del.classList.add("deleteBtn");
let g = this;
del.addEventListener('click', (e) => g.rmDirection(direction));
directionEl.appendChild(del);
// TODO; conditions
for(let conditionId of direction['conditions']) {
let condition = this.getNodeById(conditionId);
directionEl.appendChild(this.getEditConditionFormEl(condition, direction));
}
directionEl.appendChild(this.getAddConditionFormEl(direction));
return directionEl;
}
getEditConditionFormEl(condition, direction) {
let conditionEl = crel('div', {'class': 'condition condition--edit'},
crel('h4', {'title': condition['@id']}, condition['type'])
)
let labelLabel = document.createElement('label');
labelLabel.innerHTML = "Description";
let labelInput = crel('input',{
'name': `${condition['@id']}-label`,
'value': typeof condition['label'] == 'undefined' ? "" : condition['label'],
'on': {
'change': this.getEditEventListener()
}
});
labelLabel.appendChild(labelInput);
conditionEl.appendChild(labelLabel);
for(let v in condition['vars']) {
let varLabel = document.createElement('label');
varLabel.innerHTML = v;
let varInput = document.createElement('input');
if(v == 'seconds') {
varInput.type = 'number';
}
varInput.name = `${condition['@id']}-vars.${v}`;
varInput.value = condition['vars'][v];
varInput.addEventListener('change', this.getEditEventListener());
varLabel.appendChild(varInput);
conditionEl.appendChild(varLabel);
}
return conditionEl;
}
getConditionTypes() {
if(typeof this.conditionTypes === 'undefined') {
// type: vars: attribtes for crel()
this.conditionTypes = {
'timeout': {
'seconds': {'type': 'number', 'value': 10, 'min':0, 'step': 0.1}
},
'replyContains': {
'regex': {'value': '.+'}
}
}
}
return this.conditionTypes;
}
fillConditionFormForType(conditionForm, type) {
conditionForm.innerHTML = "";
let vars = this.getConditionTypes()[type];
for(let v in vars){
let attr = vars[v];
attr['name'] = v;
conditionForm.appendChild(
crel('label',
crel('span', v),
crel('input', attr)
)
);
}
}
getAddConditionFormEl(direction) {
let optionEls = [];
let types = this.getConditionTypes();
for(let type in types) {
optionEls.push(crel('option', type));
}
let conditionForm = crel('div', {'class': 'condition--vars'});
let g = this;
let addConditionEl = crel('div', {'class': 'condition condition--add'},
crel('form', {
'on': {
'submit': function(e) {
e.preventDefault();
let form = new FormData(e.target);
console.log('submit', form);
let type = form.get('type');
form.delete('type');
let label = form.get('label');
form.delete('label');
let vars = {};
for(var pair of form.entries()) {
vars[pair[0]] = pair[1];
}
g.addConditionForDirection(type, label, vars, direction);
}
}
},
crel("h4", "Create New Condition"),
crel("label",
crel('span', "Type"),
crel('select', {
'name': 'type',
'on': {
'change': function(e){
g.fillConditionFormForType(conditionForm, e.target.value);
}
}}, optionEls),
),
crel("label",
crel('span', "Description"),
crel('input', {'name': 'label'})
),
conditionForm,
crel('input', {
'type':'submit',
'value':'create'
})
)
);
this.fillConditionFormForType(conditionForm, optionEls[0].value);
return addConditionEl;
}
rmConditionFromDirection(condition, direction) {
let id = condition['@id'];
// TODO
if(typeof direction != 'undefined') {
}
this._rmNode(id);
}
getConditionEl(condition) {
let conditionEl = document.createElement('div');
return conditionEl;
}
getDirectionsFrom(msg) {
return this.directions.filter(d => d['source'] == msg);
}
getDirectionsTo(msg) {
return this.directions.filter(d => d['target'] == msg);
}
addMsg() {
let msg = {
"@id": "n" + Date.now().toString(36),
"@type": "Msg",
"text": "New",
"start": false
}
this.data.push(msg);
this.updateFromData();
this.build();
return msg;
}
rmMsg(msg) {
let invalidatedDirections = this.directions.filter(d => d['source'] == msg || d['target'] == msg);
console.log('invalidated', invalidatedDirections);
for(let dir of invalidatedDirections) {
let i = this.data.indexOf(dir);
this.data.splice(i, 1);
}
this._rmNode(msg);
}
_rmNode(node) {
// remove msg/direction/condition/etc
let i = this.data.indexOf(node);
this.data.splice(i, 1);
this.updateFromData();
this.build();
return this.data;
}
addConditionForDirection(type, label, vars, direction) {
let con = this.addCondition(type, label, vars, true);
direction['conditions'].push(con['@id']);
this.updateFromData();
this.build();
this.updateMsg();
}
addCondition(type, label, vars, skip) {
let con = {
"@id": "c" + Date.now().toString(36),
"@type": "Condition",
"type": type,
"label": label,
"vars": vars
}
this.data.push(con);
if(skip !== true) {
this.updateFromData();
this.build();
}
return con;
}
addDirection(source, target) {
let dir = {
"@id": "d" + Date.now().toString(36),
"@type": "Direction",
"source": source,
"target": target,
"conditions": []
}
this.data.push(dir);
this.updateFromData();
this.build();
return dir;
}
rmDirection(dir) {
this._rmNode(dir);
}
createMsg() {
this.addMsg();
this.build();
}
getNodeById(id) {
return this.data.filter(node => node['@id'] == id)[0];
}
/**
* Use wrapper method, because for event handlers 'this' will refer to
* the input object
*/
getEditEventListener(){
let graph = this;
let el = function(e){
let parts = e.srcElement.name.split('-');
let id = parts[0], field = parts[1];
console.log(this, graph);
let node = graph.getNodeById(id);
let path = field.split('.'); // use vars.test to set ['vars']['test'] = value
var res=node;
for (var i=0;i<path.length;i++){
if(i == (path.length -1)) {
console.log('last', path[i]);
res[path[i]] = e.srcElement.value;
} else {
res=res[path[i]];
}
}
// node[field] = e.srcElement.value;
graph.build();
}
return el;
}
getJsonString() {
// recreate array to have the right order of items.
this.data = [...this.messages, ...this.conditions,
...this.directions, ...this.interruptions]
let d = [];
let toRemove = ['sourceX', 'sourceY', 'targetX', 'targetY', 'x','y', 'vx','vy']
for(let node of this.data) {
let n = {};
console.log(node['source']);
for (let e in node) {
if (node.hasOwnProperty(e) && toRemove.indexOf(e) == -1 ) {
if(this.data.indexOf(node[e]) != -1) {
n[e] = node[e]['@id'];
} else {
n[e] = node[e];
}
}
}
d.push(n);
}
return JSON.stringify(d);
}
saveJson() {
var blob = new Blob([this.getJsonString()], {type: 'application/json'});
if(window.navigator.msSaveOrOpenBlob) {
window.navigator.msSaveBlob(blob, "pillow_talk.json");
}
else{
var elem = window.document.createElement('a');
elem.href = window.URL.createObjectURL(blob);
elem.download = "pillow_talk.json";
document.body.appendChild(elem);
elem.click();
document.body.removeChild(elem);
}
}
loadData(data) {
this.data = data;
this.updateFromData();
this.build(true);
}
updateFromData() {
this.messages = this.data.filter((node) => node['@type'] == 'Msg');
this.directions = this.data.filter((node) => node['@type'] == 'Direction');
this.conditions = this.data.filter((node) => node['@type'] == 'Condition');
this.interruptions = this.data.filter((node) => node['@type'] == 'Interruption');
// save state;
this.saveState();
}
saveState() {
window.localStorage.setItem("lastState", this.getJsonString());
}
hasSavedState() {
return window.localStorage.getItem("lastState") !== null;
}
loadFromState() {
this.loadData(JSON.parse(window.localStorage.getItem("lastState")));
}
build(isInit) {
this.simulation = d3.forceSimulation(this.messages)
.force("link", d3.forceLink(this.directions).id(d => d['@id']))
.force("charge", d3.forceManyBody().strength(-1000))
.force("center", d3.forceCenter(this.width / 2, this.height / 2))
.force("collide", d3.forceCollide(this.nodeSize*2))
;
// Update existing nodes
let node = this.nodesG
.selectAll("g")
.data(this.messages, n => n['@id'])
;
// Update existing nodes
let newNode = node.enter();
let newNodeG = newNode.append("g")
.attr('id', d => d['@id'])
.call(d3.drag(this.simulation))
.on('click', function(d){
this.clickMsg(d);
}.bind(this))
;
console.log('a');
let circle = newNodeG.append("circle")
.attr('r', this.nodeSize)
// .text(d => d.id)
;
let text = newNodeG.append("text")
;
// remove
node.exit().remove();
node = node.merge(newNodeG);
// for all existing nodes:
node.attr('class', msg => {
let classes = [];
if( this.selectedMsg == msg) classes.push('selectedMsg');
if( msg['start'] == true ) classes.push('startMsg');
if(this.getDirectionsFrom(msg).length < 1) {
classes.push('endMsg');
if(this.getDirectionsTo(msg).length < 1) classes.push('orphanedMsg');
}
return classes.join(' ');
})
let link = this.linkG
.selectAll("line")
.data(this.directions)
;
let newLink = link.enter()
.append("line")
;
//remove
link.exit().remove();
link = link.merge(newLink);
link.attr('class', l => { return `link ` + (l['conditions'].length == 0 ? "link--noconditions" : "link--withconditions"); });
// console.log('c');
let formatText = (t) => {
if(t.length > this.maxChars) {
return t.substr(0, this.maxChars - 3) + '...';
} else {
return t;
}
};
node.selectAll("text").text(d => formatText(`(${d['@id']}) ${d['text']}`));
// console.log('q');
// // TODO: update text
// let text = newNodeG.append("text")
// // .attr('stroke', "black")
// .text(d => formatText(`(${d['@id']}) ${d['text']}`))
// // .attr('title', d => d.label)
// ;
let n = this.nodesG;
this.simulation.on("tick", () => {
link
.each(function(d){
let sourceX, targetX, midX, dx, dy, angle;
// This mess makes the arrows exactly perfect.
// thanks to http://bl.ocks.org/curran/9b73eb564c1c8a3d8f3ab207de364bf4
if( d.source.x < d.target.x ){
sourceX = d.source.x;
targetX = d.target.x;
} else if( d.target.x < d.source.x ){
targetX = d.target.x;
sourceX = d.source.x;
} else if (d.target.isCircle) {
targetX = sourceX = d.target.x;
} else if (d.source.isCircle) {
targetX = sourceX = d.source.x;
} else {
midX = (d.source.x + d.target.x) / 2;
if(midX > d.target.x){
midX = d.target.x;
} else if(midX > d.source.x){
midX = d.source.x;
} else if(midX < d.target.x){
midX = d.target.x;
} else if(midX < d.source.x){
midX = d.source.x;
}
targetX = sourceX = midX;
}
dx = targetX - sourceX;
dy = d.target.y - d.source.y;
angle = Math.atan2(dx, dy);
// Compute the line endpoint such that the arrow
// is touching the edge of the node rectangle perfectly.
d.sourceX = sourceX + Math.sin(angle) * this.nodeSize;
d.targetX = targetX - Math.sin(angle) * this.nodeSize;
d.sourceY = d.source.y + Math.cos(angle) * this.nodeSize;
d.targetY = d.target.y - Math.cos(angle) * this.nodeSize;
}.bind(this))
.attr("x1", function(d) { return d.sourceX; })
.attr("y1", function(d) { return d.sourceY; })
.attr("x2", function(d) { return d.targetX; })
.attr("y2", function(d) { return d.targetY; });
node.attr("transform", d => `translate(${d.x},${d.y})`);
// .attr("cy", d => d.y);
});
// this.simulation.alpha(1);
// this.simulation.restart();
if(typeof isInit != 'undefined' && isInit) {
for (let i = 0, n = Math.ceil(Math.log(this.simulation.alphaMin()) / Math.log(1 - this.simulation.alphaDecay())); i < n; ++i) {
this.simulation.tick();
}
}
return this.svg.node();
}
}
var graph = new Graph();
var openerDiv = document.getElementById("opener");
var fileOpenerBtn = document.getElementById('fileOpener');
var openerSubmit = document.getElementById('fileLoad');
var loadFileFromInput = function (inputEl) {
if(inputEl.files && inputEl.files[0]){
var reader = new FileReader();
reader.onload = function (e) {
var output=e.target.result;
let j = JSON.parse(output);
graph.loadData(j);
openerDiv.parentElement.removeChild(openerDiv);
};//end onload()
reader.readAsText(inputEl.files[0]);
}
}
fileOpenerBtn.addEventListener('change', function(){ loadFileFromInput(this); })
if(fileOpenerBtn.files.length) {
openerSubmit.addEventListener('click', function() { loadFileFromInput(fileOpener); });
} else {
openerSubmit.parentElement.removeChild(openerSubmit);
}
let loadStateBtn = document.getElementById('stateLoad');
if(graph.hasSavedState()) {
loadStateBtn.addEventListener('click', function() {
graph.loadFromState();
openerDiv.parentElement.removeChild(openerDiv);
});
} else {
loadStateBtn.parentElement.removeChild(loadStateBtn);
}
</script>
</html>

View file

@ -0,0 +1,365 @@
// MIT License:
//
// Copyright (c) 2010-2012, Joe Walnes
//
// 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.
/**
* This behaves like a WebSocket in every way, except if it fails to connect,
* or it gets disconnected, it will repeatedly poll until it successfully connects
* again.
*
* It is API compatible, so when you have:
* ws = new WebSocket('ws://....');
* you can replace with:
* ws = new ReconnectingWebSocket('ws://....');
*
* The event stream will typically look like:
* onconnecting
* onopen
* onmessage
* onmessage
* onclose // lost connection
* onconnecting
* onopen // sometime later...
* onmessage
* onmessage
* etc...
*
* It is API compatible with the standard WebSocket API, apart from the following members:
*
* - `bufferedAmount`
* - `extensions`
* - `binaryType`
*
* Latest version: https://github.com/joewalnes/reconnecting-websocket/
* - Joe Walnes
*
* Syntax
* ======
* var socket = new ReconnectingWebSocket(url, protocols, options);
*
* Parameters
* ==========
* url - The url you are connecting to.
* protocols - Optional string or array of protocols.
* options - See below
*
* Options
* =======
* Options can either be passed upon instantiation or set after instantiation:
*
* var socket = new ReconnectingWebSocket(url, null, { debug: true, reconnectInterval: 4000 });
*
* or
*
* var socket = new ReconnectingWebSocket(url);
* socket.debug = true;
* socket.reconnectInterval = 4000;
*
* debug
* - Whether this instance should log debug messages. Accepts true or false. Default: false.
*
* automaticOpen
* - Whether or not the websocket should attempt to connect immediately upon instantiation. The socket can be manually opened or closed at any time using ws.open() and ws.close().
*
* reconnectInterval
* - The number of milliseconds to delay before attempting to reconnect. Accepts integer. Default: 1000.
*
* maxReconnectInterval
* - The maximum number of milliseconds to delay a reconnection attempt. Accepts integer. Default: 30000.
*
* reconnectDecay
* - The rate of increase of the reconnect delay. Allows reconnect attempts to back off when problems persist. Accepts integer or float. Default: 1.5.
*
* timeoutInterval
* - The maximum time in milliseconds to wait for a connection to succeed before closing and retrying. Accepts integer. Default: 2000.
*
*/
(function (global, factory) {
if (typeof define === 'function' && define.amd) {
define([], factory);
} else if (typeof module !== 'undefined' && module.exports){
module.exports = factory();
} else {
global.ReconnectingWebSocket = factory();
}
})(this, function () {
if (!('WebSocket' in window)) {
return;
}
function ReconnectingWebSocket(url, protocols, options) {
// Default settings
var settings = {
/** Whether this instance should log debug messages. */
debug: false,
/** Whether or not the websocket should attempt to connect immediately upon instantiation. */
automaticOpen: true,
/** The number of milliseconds to delay before attempting to reconnect. */
reconnectInterval: 1000,
/** The maximum number of milliseconds to delay a reconnection attempt. */
maxReconnectInterval: 30000,
/** The rate of increase of the reconnect delay. Allows reconnect attempts to back off when problems persist. */
reconnectDecay: 1.5,
/** The maximum time in milliseconds to wait for a connection to succeed before closing and retrying. */
timeoutInterval: 2000,
/** The maximum number of reconnection attempts to make. Unlimited if null. */
maxReconnectAttempts: null,
/** The binary type, possible values 'blob' or 'arraybuffer', default 'blob'. */
binaryType: 'blob'
}
if (!options) { options = {}; }
// Overwrite and define settings with options if they exist.
for (var key in settings) {
if (typeof options[key] !== 'undefined') {
this[key] = options[key];
} else {
this[key] = settings[key];
}
}
// These should be treated as read-only properties
/** The URL as resolved by the constructor. This is always an absolute URL. Read only. */
this.url = url;
/** The number of attempted reconnects since starting, or the last successful connection. Read only. */
this.reconnectAttempts = 0;
/**
* The current state of the connection.
* Can be one of: WebSocket.CONNECTING, WebSocket.OPEN, WebSocket.CLOSING, WebSocket.CLOSED
* Read only.
*/
this.readyState = WebSocket.CONNECTING;
/**
* A string indicating the name of the sub-protocol the server selected; this will be one of
* the strings specified in the protocols parameter when creating the WebSocket object.
* Read only.
*/
this.protocol = null;
// Private state variables
var self = this;
var ws;
var forcedClose = false;
var timedOut = false;
var eventTarget = document.createElement('div');
// Wire up "on*" properties as event handlers
eventTarget.addEventListener('open', function(event) { self.onopen(event); });
eventTarget.addEventListener('close', function(event) { self.onclose(event); });
eventTarget.addEventListener('connecting', function(event) { self.onconnecting(event); });
eventTarget.addEventListener('message', function(event) { self.onmessage(event); });
eventTarget.addEventListener('error', function(event) { self.onerror(event); });
// Expose the API required by EventTarget
this.addEventListener = eventTarget.addEventListener.bind(eventTarget);
this.removeEventListener = eventTarget.removeEventListener.bind(eventTarget);
this.dispatchEvent = eventTarget.dispatchEvent.bind(eventTarget);
/**
* This function generates an event that is compatible with standard
* compliant browsers and IE9 - IE11
*
* This will prevent the error:
* Object doesn't support this action
*
* http://stackoverflow.com/questions/19345392/why-arent-my-parameters-getting-passed-through-to-a-dispatched-event/19345563#19345563
* @param s String The name that the event should use
* @param args Object an optional object that the event will use
*/
function generateEvent(s, args) {
var evt = document.createEvent("CustomEvent");
evt.initCustomEvent(s, false, false, args);
return evt;
};
this.open = function (reconnectAttempt) {
ws = new WebSocket(self.url, protocols || []);
ws.binaryType = this.binaryType;
if (reconnectAttempt) {
if (this.maxReconnectAttempts && this.reconnectAttempts > this.maxReconnectAttempts) {
return;
}
} else {
eventTarget.dispatchEvent(generateEvent('connecting'));
this.reconnectAttempts = 0;
}
if (self.debug || ReconnectingWebSocket.debugAll) {
console.debug('ReconnectingWebSocket', 'attempt-connect', self.url);
}
var localWs = ws;
var timeout = setTimeout(function() {
if (self.debug || ReconnectingWebSocket.debugAll) {
console.debug('ReconnectingWebSocket', 'connection-timeout', self.url);
}
timedOut = true;
localWs.close();
timedOut = false;
}, self.timeoutInterval);
ws.onopen = function(event) {
clearTimeout(timeout);
if (self.debug || ReconnectingWebSocket.debugAll) {
console.debug('ReconnectingWebSocket', 'onopen', self.url);
}
self.protocol = ws.protocol;
self.readyState = WebSocket.OPEN;
self.reconnectAttempts = 0;
var e = generateEvent('open');
e.isReconnect = reconnectAttempt;
reconnectAttempt = false;
eventTarget.dispatchEvent(e);
};
ws.onclose = function(event) {
clearTimeout(timeout);
ws = null;
if (forcedClose) {
self.readyState = WebSocket.CLOSED;
eventTarget.dispatchEvent(generateEvent('close'));
} else {
self.readyState = WebSocket.CONNECTING;
var e = generateEvent('connecting');
e.code = event.code;
e.reason = event.reason;
e.wasClean = event.wasClean;
eventTarget.dispatchEvent(e);
if (!reconnectAttempt && !timedOut) {
if (self.debug || ReconnectingWebSocket.debugAll) {
console.debug('ReconnectingWebSocket', 'onclose', self.url);
}
eventTarget.dispatchEvent(generateEvent('close'));
}
var timeout = self.reconnectInterval * Math.pow(self.reconnectDecay, self.reconnectAttempts);
setTimeout(function() {
self.reconnectAttempts++;
self.open(true);
}, timeout > self.maxReconnectInterval ? self.maxReconnectInterval : timeout);
}
};
ws.onmessage = function(event) {
if (self.debug || ReconnectingWebSocket.debugAll) {
console.debug('ReconnectingWebSocket', 'onmessage', self.url, event.data);
}
var e = generateEvent('message');
e.data = event.data;
eventTarget.dispatchEvent(e);
};
ws.onerror = function(event) {
if (self.debug || ReconnectingWebSocket.debugAll) {
console.debug('ReconnectingWebSocket', 'onerror', self.url, event);
}
eventTarget.dispatchEvent(generateEvent('error'));
};
}
// Whether or not to create a websocket upon instantiation
if (this.automaticOpen == true) {
this.open(false);
}
/**
* Transmits data to the server over the WebSocket connection.
*
* @param data a text string, ArrayBuffer or Blob to send to the server.
*/
this.send = function(data) {
if (ws) {
if (self.debug || ReconnectingWebSocket.debugAll) {
console.debug('ReconnectingWebSocket', 'send', self.url, data);
}
return ws.send(data);
} else {
throw 'INVALID_STATE_ERR : Pausing to reconnect websocket';
}
};
/**
* Closes the WebSocket connection or connection attempt, if any.
* If the connection is already CLOSED, this method does nothing.
*/
this.close = function(code, reason) {
// Default CLOSE_NORMAL code
if (typeof code == 'undefined') {
code = 1000;
}
forcedClose = true;
if (ws) {
ws.close(code, reason);
}
};
/**
* Additional public API method to refresh the connection if still open (close, re-open).
* For example, if the app suspects bad data / missed heart beats, it can try to refresh.
*/
this.refresh = function() {
if (ws) {
ws.close();
}
};
}
/**
* An event listener to be called when the WebSocket connection's readyState changes to OPEN;
* this indicates that the connection is ready to send and receive data.
*/
ReconnectingWebSocket.prototype.onopen = function(event) {};
/** An event listener to be called when the WebSocket connection's readyState changes to CLOSED. */
ReconnectingWebSocket.prototype.onclose = function(event) {};
/** An event listener to be called when a connection begins being attempted. */
ReconnectingWebSocket.prototype.onconnecting = function(event) {};
/** An event listener to be called when a message is received from the server. */
ReconnectingWebSocket.prototype.onmessage = function(event) {};
/** An event listener to be called when an error occurs. */
ReconnectingWebSocket.prototype.onerror = function(event) {};
/**
* Whether all instances of ReconnectingWebSocket should log debug messages.
* Setting this to true is the equivalent of setting all instances of ReconnectingWebSocket.debug to true.
*/
ReconnectingWebSocket.debugAll = false;
ReconnectingWebSocket.CONNECTING = WebSocket.CONNECTING;
ReconnectingWebSocket.OPEN = WebSocket.OPEN;
ReconnectingWebSocket.CLOSING = WebSocket.CLOSING;
ReconnectingWebSocket.CLOSED = WebSocket.CLOSED;
return ReconnectingWebSocket;
});

44
www/styles.css Normal file
View file

@ -0,0 +1,44 @@
body{
font-family: sans-serif;
}
#status{
display: flex;
flex-direction: row;
flex-wrap: wrap;
width: 430px;
height: 100%;
overflow-y: scroll;
}
#status > div{
width: 33.3333333%;
height: 150px;
border: solid 1px;
box-sizing: border-box;
position: relative;
}
.hugvey{
background-image: linear-gradient(to top, #587457, #35a589);
color:white;
display: flex;
flex-direction: column;
justify-content: center;
}
.hugvey.hugvey--off{
background-image: linear-gradient(to top, #575d74, #3572a5);
}
.hugvey h1{
text-align: center;
margin: 0;
}
.hugvey.hugvey--off h1::after{
content: '[off]'
}
.hugvey.hugvey--on h1 {
position: absolute;
left: 5px;
top: 5px;
}

11055
www/vue.js Normal file

File diff suppressed because it is too large Load diff