diff --git a/README.md b/README.md index 2e1bfae..ed90208 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,14 @@ # Hugvey / Pillow Talk -Panpoticon -: Fancy nickname for the web interface that allows altering the story and running the individual Hugveys -Voice -: Lyrebird voice syntehsis API wrapper. Set the oAuth token using a token generated [here](http://hugvey.rubenvandeven.com/oauth.php) -Client -: Individual Hugveys that stream their mic output and play audiofiles trough the Panopticon. Communication with the server is done through zmq -: Connect with them trough hugvey1.local etc (1-25). -Central Command/server -: One server to rule them all. Start individual threads/subprocesses for the individual Hugveys. The Panopticon is started when starting the server. - +- Panpoticon + + Fancy nickname for the web interface that allows altering the story and running the individual Hugveys +- Voice + + Lyrebird voice syntehsis API wrapper. Set the oAuth token using a token generated [here](http://hugvey.rubenvandeven.com/oauth.php) +- Client + + Individual Hugveys that stream their mic output and play audiofiles trough the Panopticon. Communication with the server is done through zmq + + Connect with them trough hugvey1.local etc (1-25). +- Central Command/server + + One server to rule them all. Start individual threads/subprocesses for the individual Hugveys. The Panopticon is started when starting the server. ## Server @@ -28,6 +27,14 @@ To run it: `python hugvey_client.py -c client_config.yml` The Panopticon uses gulp to compile SASS into CSS, and to set up browser-sync for css & js. For now, no js user facing dependencies are managed trough node/npm. +After starting the server: + +```bash +cd www +gulp +``` + + ## Installation create and load Python virtualenv diff --git a/hugvey/story.py b/hugvey/story.py index 464d7ee..80a8bc7 100644 --- a/hugvey/story.py +++ b/hugvey/story.py @@ -48,7 +48,7 @@ class Message(object): @classmethod def initFromJson(message, data, story): msg = message(data['@id'], data['text']) - msg.isStart = data['start'] if 'start' in data else False + msg.isStart = data['beginning'] if 'beginning' in data else False msg.afterrunTime = data['afterrun'] if 'afterrun' in data else 0. if 'audio' in data: msg.audioFile = data['audio']['file'] @@ -241,14 +241,24 @@ class Condition(object): return False if 'onlyIfNoReply' in self.vars and self.vars['onlyIfNoReply']: - if story.currentReply and story.currentReply.hasUtterances(): + if story.currentReply and story.currentReply is not None and story.currentReply.hasUtterances(): logger.log(LOG_BS, f'Only if no reply has text! {story.currentReply.getText()}') # 'onlyIfNoReply': only use this timeout if participants doesn't speak. return False # else: # logger.debug('Only if no reply has no text yet!') - - return now - story.lastMsgFinishTime >= float(self.vars['seconds']) + + hasMetTimeout = now - story.lastMsgFinishTime >= float(self.vars['seconds']) + if not hasMetTimeout: + return False + + # update stats: + story.stats['timeouts'] +=1 + if story.currentReply is None or not story.currentReply.hasUtterances(): + story.stats['silentTimeouts'] +=1 + story.stats['consecutiveSilentTimeouts'] += 1 + + return True def _hasMetReplyContains(self, story) -> bool: """ @@ -327,6 +337,7 @@ class Direction(object): self.id = id self.msgFrom = msgFrom self.msgTo = msgTo + #: :type self.conditions: list(Condition) self.conditions = [] self.conditionMet = None @@ -359,28 +370,93 @@ class Diversion(object): An Diversion. Used to catch events outside of story flow. """ - def __init__(self, id): + def __init__(self, id, type: str, params: dict): self.id = id - self.conditions = [] - - def addCondition(self, condition: Condition): - self.conditions.append(condition) + self.params = params + self.finaliseMethod = None + if type == 'no_response': + self.method = self._divergeIfNoResponse + self.finaliseMethod = self._returnAfterNoResponse + self.counter = 0 + if type == 'repeat': + self.method = self._divergeIfRepeatRequest + self.regex = re.compile(self.params['regex']) + + if not self.method: + raise Exception("No valid type given for diversion") @classmethod def initFromJson(diversionClass, data, story): - diversion = diversionClass(data['@id']) - if 'conditions' in data: - for conditionId in data['conditions']: - c = story.get(conditionId) - diversion.addCondition(c) + diversion = diversionClass(data['@id'], data['type'], data['params']) + return diversion def getLogSummary(self): return { 'id': self.id, -# 'time': self.replyTime } + + async def divergeIfNeeded(self, story, msgFrom, msgTo): + """ + Validate if condition is met for the current story state + Returns True when diverging + """ + return await self.method(story, msgFrom, msgTo) + + async def finalise(self, story): + """" + Only used if the Diversion sets the story.currentDiversion + """ + if not self.finaliseMethod: + return False + await self.finaliseMethod(story) + return True + + async def _divergeIfNoResponse(self, story, msgFrom, msgTo): + """ + Participant doesn't speak for x consecutive replies (has had timeout) + """ + ':type story: Story' + if story.currentDiversion: + return False + + if story.stats['diversions']['no_response'] + 1 == self.params['timesOccured'] and story.stats['consecutiveSilentTimeouts'] >= int(self.params['consecutiveSilences']): + story.stats['diversions']['no_response'] += 1 + msg = story.get(self.params['msgId']) + if msg is None: + logger.critical(f"Not a valid message id for diversion: {self.params['msgId']}") + return + + logger.info(f"Diverge: No response {self.id} {story.stats}") + self.returnMessage = msgTo + await story.setCurrentMessage(msg) + story.currentDiversion = self + return True + + return + + async def _returnAfterNoResponse(self, story): + logger.info(f"Finalise diversion: {self.id}") + story.stats['consecutiveSilentTimeouts'] = 0 # reset counter after diverging + if self.params['returnAfterStrand']: + await story.setCurrentMessage(self.returnMessage) + + async def _divergeIfRepeatRequest(self, story, msgFrom, msgTo): + """ + Participant asks if message can be repeated. + """ + if story.currentReply is None or story.currentReply.isSpeaking(): + return + + r = self.regex.search(story.currentReply.getText()) + if r is None: + return + + logger.info(f"Diverge: request repeat {self.id}") + story.stats['diversions']['repeat'] += 1 + await story.setCurrentMessage(msgFrom) + return True storyClasses = { @@ -450,9 +526,11 @@ class Story(object): self.commands = [] # queue of commands to send self.log = [] # all nodes/elements that are triggered self.currentMessage = None + self.currentDiversion = None self.currentReply = None self.timer = Stopwatch() self.isRunning = False + self.diversions = [] self.variables = {} def pause(self): @@ -510,6 +588,8 @@ class Story(object): logger.debug(self.elements) logger.debug(self.directionsPerMsg) + + self.diversions = [el for el in self.elements.values() if type(el) == Diversion] if currentId: self.currentMessage = self.get(currentId) @@ -528,6 +608,7 @@ class Story(object): for var in msg.variables: self.registerVariable(var, msg) + logger.info(f'has variables: {self.variables}') @@ -536,6 +617,7 @@ class Story(object): # self.startTime = time.time() # currently active message, determines active listeners etc. self.currentMessage = None + self.currentDiversion = None self.lastMsgTime = None self.lastSpeechStartTime = None self.lastSpeechEndTime = None @@ -549,6 +631,12 @@ class Story(object): self.stats = { 'timeouts': 0, + 'silentTimeouts': 0, + 'consecutiveSilentTimeouts': 0, + 'diversions': { + 'no_response': 0, + 'repeat': 0, + } } for msg in self.getMessages(): @@ -613,11 +701,19 @@ class Story(object): # self.hugvey.google.resume() if self.currentMessage.id not in self.directionsPerMsg: - logger.info("THE END!") - self.stop() - return + if self.currentDiversion is not None: + logger.info("end of diversion") + await self.currentDiversion.finalise(self) + self.currentDiversion = None + else: + logger.info("THE END!") + self.stop() + return if e['event'] == 'speech': + # participants speaks, reset counter + self.stats['consecutiveSilentTimeouts'] = 0 + # message is still playing: if self.currentMessage and not self.lastMsgFinishTime and self.previousReply and self.previousReply.forMessage.interruptCount < 4: timeDiff = self.timer.getElapsed() - self.previousReply.forMessage.getFinishedTime() @@ -626,8 +722,8 @@ class Story(object): logger.warn("Interrupt message, replay {}".format(self.previousReply.forMessage.id)) self.currentReply = self.previousReply self.previousReply.forMessage.interruptCount += 1 - self.currentMessage = await self.setCurrentMessage(self.previousReply.forMessage) - + self.currentMessage = await self.setCurrentMessage(self.previousReply.forMessage, self.previousReply) + # log if somebody starts speaking # TODO: implement interrupt if self.currentReply is None: @@ -641,6 +737,7 @@ class Story(object): async def _processDirections(self, directions): + ':type directions: list(Direction)' for direction in directions: for condition in direction.conditions: if condition.isMet(self): @@ -650,9 +747,22 @@ class Story(object): self.addToLog(condition) self.addToLog(direction) self.currentMessage.setFinished(self.timer.getElapsed()) - await self.setCurrentMessage(direction.msgTo) + isDiverging = await self._processDiversions(direction.msgFrom, direction.msgTo) + if not isDiverging: + await self.setCurrentMessage(direction.msgTo) return direction + async def _processDiversions(self, msgFrom, msgTo) -> bool: + """ + Process the diversions on stack. If diverging, return True, else False + """ + diverge = False + for diversion in self.diversions: + d = await diversion.divergeIfNeeded(self, msgFrom, msgTo) + if d: + diverge = True + return diverge + def addToLog(self, node): self.log.append((node, self.timer.getElapsed())) @@ -689,7 +799,10 @@ class Story(object): logger.info("Stop renderer") - async def setCurrentMessage(self, message): + async def setCurrentMessage(self, message, useReply = None): + """ + Use Reply allows to pre-initiate a reply to use with the message. This is used eg. when doing an interruption. + """ if self.currentMessage and not self.lastMsgFinishTime: logger.info("Interrupt playback {}".format(self.currentMessage.id)) # message is playing @@ -704,7 +817,7 @@ class Story(object): # if not reset: self.previousReply = self.currentReply # we can use this for interrptions - self.currentReply = self.currentMessage.reply + self.currentReply = useReply #self.currentMessage.reply # else: # # if we press 'save & play', it should not remember it's last reply to that msg # self.previousReply = self.currentReply # we can use this for interrptions diff --git a/www/css/styles.css b/www/css/styles.css index 3a07540..9a23859 100644 --- a/www/css/styles.css +++ b/www/css/styles.css @@ -187,10 +187,14 @@ img.icon { position: absolute; top: 5px; right: 0px; } + #story #msg #diversions .diversion { + background: pink; } #story #nodes g:hover circle, #story .selectedMsg circle { stroke: lightgreen; stroke-width: 27; } + #story .diversion circle { + fill: pink !important; } #story .controlDown #nodes g:hover circle, #story .secondaryMsg circle { stroke: lightgreen; diff --git a/www/index.html b/www/index.html index 26ff637..2bbd0fe 100644 --- a/www/index.html +++ b/www/index.html @@ -68,6 +68,7 @@