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 @@
Save
Create message
+
View Diversions
div['params']['returnAfterStrand'] = e.target.checked + } + } + if(div['params']['returnAfterStrand']) { + returnAttrs['checked'] = 'checked'; + } + let msgOptions = [crel('option',"")]; + let starts = this.messages.filter( m => m.hasOwnProperty('start') && m['start'] == true); + for(let startMsg of starts) { + let optionParams = {}; + if(div['params']['msgId'] == startMsg['@id']) { + optionParams['selected'] = 'selected'; + } + msgOptions.push(crel('option', optionParams , startMsg['@id'])); + } + + divsNoResponse.push(crel( + 'div', { + 'class': 'diversion', + 'on': { + 'mouseover': function(e) { + if(div['params']['msgId']) + document.getElementById(div['params']['msgId']).classList.add('selectedMsg'); + }, + 'mouseout': function(e) { + if(div['params']['msgId']) + document.getElementById(div['params']['msgId']).classList.remove('selectedMsg'); + } + } + }, + crel('h3', div['@id']), + crel( + 'div', { + 'class':'btn btn--delete', + 'on': { + 'click': (e) => this.deleteDiversion(div) + } + }, 'Delete diversion'), + crel('label', 'Consecutive Silences', + crel('input', { + 'type': 'number', + 'value': div['params']['consecutiveSilences'], + 'on': { + 'change': (e) => div['params']['consecutiveSilences'] = parseInt(e.target.value) + } + }) + ), + crel('label', 'On n-th instance', + crel('input', { + 'type': 'number', + 'value': div['params']['timesOccured'], + 'on': { + 'change': (e) => div['params']['timesOccured'] = parseInt(e.target.value) + } + }) + ), + crel('label', 'Return to point of departure afterwards', + crel('input', returnAttrs) + ), + crel('label', 'Go to (start message)', + crel('select', {'on': { + 'change': (e) => div['params']['msgId'] = e.target.value + }}, ...msgOptions) + ) + )); + } + if(div['type'] == 'repeat'){ + divsRepeat.push(crel( + 'div', {'class': 'diversion'}, + crel('h3', div['@id']), + crel( + 'div', { + 'class':'btn btn--delete', + 'on': { + 'click': (e) => this.deleteDiversion(div) + } + }, 'Delete diversion'), + crel('label', 'Regex', + crel('input', { + 'type': 'text', + 'value': div['params']['regex'], + 'on': { + 'change': (e) => div['params']['regex'] = e.target.value + } + }) + ) + )); + } + } + + console.log(divsNoResponse, divsRepeat); + + let divEl = crel( + 'div', + { + 'id': 'diversions' + }, + crel('h1', 'Configure Diversions'), + crel('div', + crel('h2', 'In case of No Response'), + ...divsNoResponse, + crel('div', + { + 'class': 'btn', + 'on': { + 'click': (e) => this.createDiversion('no_response') + } + }, + 'New case for no_response' + ) + ), + crel('div', + crel('h2', 'Request repeat'), + ...divsRepeat, + crel('div', + { + 'class': 'btn', + 'on': { + 'click': (e) => this.createDiversion('repeat') + } + }, + 'New case for repeat' + ) + ) + ); + + msgEl.appendChild(divEl); + } showMsg( msg ) { let msgEl = document.getElementById( 'msg' ); @@ -279,7 +471,7 @@ class Graph { } - + let startAttributes = { 'name': msg['@id'] + '-start', // 'readonly': 'readonly', @@ -291,6 +483,17 @@ class Graph { if ( msg['start'] == true ) { startAttributes['checked'] = 'checked'; } + let beginningAttributes = { + 'name': msg['@id'] + '-beginning', +// 'readonly': 'readonly', + 'type': 'checkbox', + 'on': { + 'change': this.getEditEventListener() + } + } + if ( msg['beginning'] == true ) { + beginningAttributes['checked'] = 'checked'; + } let params = {}; if(msg.hasOwnProperty('params')) { @@ -357,6 +560,10 @@ class Graph { crel( 'span', 'Start' ), crel( 'input', startAttributes ) ), + crel( 'label', + crel( 'span', 'Beginning' ), + crel( 'input', beginningAttributes ) + ), crel( 'label', crel( 'span', 'Audio' ), diff --git a/www/scss/styles.scss b/www/scss/styles.scss index 5b384e5..f87e9d3 100644 --- a/www/scss/styles.scss +++ b/www/scss/styles.scss @@ -314,6 +314,12 @@ img.icon{ } } + + #diversions{ + .diversion{ + background: pink; + } + } } #nodes g:hover circle, @@ -321,6 +327,12 @@ img.icon{ stroke: lightgreen; stroke-width: 27; } + + .diversion{ + circle{ + fill: pink !important; + } + } .controlDown #nodes g:hover circle, .secondaryMsg circle {