diff --git a/hugvey/story.py b/hugvey/story.py index 6160756..e72476d 100644 --- a/hugvey/story.py +++ b/hugvey/story.py @@ -25,8 +25,10 @@ from .communication import LOG_BS mainLogger = logging.getLogger("hugvey") logger = mainLogger.getChild("narrative") + class Utterance(object): """Part of a reply""" + def __init__(self, startTime): self.startTime = startTime self.endTime = None @@ -34,9 +36,9 @@ class Utterance(object): self.lastUpdateTime = startTime def setText(self, text, now): - self.text = text.lower() # always lowercase + self.text = text.lower() # always lowercase self.lastUpdateTime = now - + def hasText(self): return len(self.text) > 0 @@ -77,7 +79,7 @@ class Message(object): self.lightChange = None self.didRepeat = False self.fileError = False - + # Used by diversions, autogenerated directions should link to next chapter mark instead of the given msgTo self.generatedDirectionsJumpToChapter = False @@ -151,7 +153,7 @@ class Message(object): # if not None in self.variableValues.values(): # self.logger.warn(f"now fetch {name} for {self.id}") # asyncio.get_event_loop().create_task(self.getAudioFilePath()) - + def getVariableValue(self, var): return self.variableValues[var] if (self.variableValues[var] is not None) else self.story.configuration.nothing_text #TODO: translate nothing to each language @@ -173,7 +175,7 @@ class Message(object): if self.label and len(self.label): return self.label return self.getText() - + def getTextLabel(self): """ A combination of getText and getLabel for maximum verbosity @@ -236,8 +238,8 @@ class Message(object): await s.send_json(info) filename = await s.recv_string() s.close() - - # TODO: should this go trough the event Queue? risking a too long delay though + + # TODO: should this go trough the event Queue? risking a too long delay though if filename == 'local/crash.wav' or len(filename) < 1: self.logger.warning("Noting crash") self.fileError = True @@ -246,7 +248,7 @@ class Message(object): self.logger.debug(f"Fetched audio for {textlabel}: {filename}") return filename - + class Reply(object): def __init__(self, message: Message): @@ -373,7 +375,7 @@ class Condition(object): if 'vars' in data: condition.vars = data['vars'] - + if 'regex' in condition.vars: condition.vars['regex'] = condition.vars['regex'].rstrip() @@ -435,7 +437,7 @@ class Condition(object): self.vars['variable'] ) return r - + def _hasTimer(self, story) -> bool: if not story.lastMsgFinishTime: return False @@ -443,7 +445,7 @@ class Condition(object): loopTime = story.hugvey.command.timer.getElapsed() % 3600 ltTime = int(self.vars['less_than']) gtTime = int(self.vars['more_than']) - + if not ltTime and not gtTime: # ignore invalid times return @@ -455,21 +457,21 @@ class Condition(object): r = True else: r = False - + if 'inverseMatch' in self.vars and self.vars['inverseMatch']: r = not r - - - + + + self.logInfo = "Looptime is {} {} < {} < {}".format( - '' if r else 'not', + '' if r else 'not', f'{gtTime}' if gtTime else '-', loopTime, f'{ltTime}' if ltTime else '-', ) - + return r - + def _variableEquals(self, story) -> bool: v1 = story.variableValues[self.vars['variable1']] if story.hasVariableSet(self.vars['variable1']) else None v2 = story.variableValues[self.vars['variable2']] if story.hasVariableSet(self.vars['variable2']) else None @@ -483,10 +485,10 @@ class Condition(object): r = (v1 != v2) else: r = (v1 == v2) - + story.logger.info("'{}' {} '{}' ({})".format(v1, '==' if v1 == v2 else '!=', v2, r)) return r - + def _hasDiverged(self, story) -> bool: if not story.lastMsgFinishTime: return False @@ -509,15 +511,15 @@ class Condition(object): ) return r - + def _hasAudioError(self, story) -> bool: if not story.currentMessage or not story.currentMessage.fileError: return False - + self.logInfo = f"Has error loading audio file for {story.currentMessage.id}" - + return True - + def _hasPlayed(self, story) -> bool: if not story.lastMsgFinishTime: return False @@ -547,12 +549,12 @@ class Condition(object): ) return r - - + + def _hasVariableStorage(self, story) -> bool: if not story.lastMsgFinishTime: return False - + if self.hasRan: # Prevent multiple runs of the same query within eg. waiting for a timeout. return False @@ -561,11 +563,11 @@ class Condition(object): unique = bool(self.vars['unique']) if 'unique' in self.vars else False varValues = story.hugvey.command.variableStore.getLastOfName(self.vars['var_name'], story.language_code, number, unique) self.hasRan = True - + if len(varValues) < number: story.logger.warn(f"{self.id}: Too few instances of {self.vars['var_name']}, only {len(varValues)} in store") return False - + for i in range(number): story.setVariableValue( f"stored_{self.vars['var_name']}_{i+1}", @@ -574,7 +576,7 @@ class Condition(object): ) return True - + def _hasMetReplyContains(self, story) -> bool: """ Check the reply for specific characteristics: @@ -789,15 +791,15 @@ class Diversion(object): if self.type != 'repeat' and self.type !='interrupt': # repeat diversion should be usable infinte times self.hasHit = True - + story.addToLog(self) story.hugvey.eventLogger.info(f"diverge {self.id}") - + except Exception as e: story.logger.critical("Exception when attempting diversion") story.logger.exception(e) return False - + return r def createReturnDirectionsTo(self, story, startMsg, returnMsg, originalDirection = None, inheritTiming = True, timeoutDuration = .5, replyContainsDurations = None): @@ -840,11 +842,11 @@ class Diversion(object): msg = story.get(msgId) if not msg: continue - + usedReturnMessage = returnMsg if msg.generatedDirectionsJumpToChapter: usedReturnMessage = story.getNextChapterForMsg(returnMsg, canIncludeSelf=True) - + if not usedReturnMessage: # in case of a diversion in the last bit of the story, it can be there there is no return message. raise Exception(f"No return message found for {msg.id}") @@ -885,7 +887,7 @@ class Diversion(object): story.add(condition2) direction.isDiversionReturn = True # will clear the currentDiversion on story - story.diversionDirections.append(direction) + story.diversionDirections.append(direction) story.logger.info(f"Created direction: {direction.id} ({msg.id} -> {usedReturnMessage.id}) {condition.id} with timeout {finalTimeoutDuration}s") story.add(condition) story.add(direction) @@ -962,7 +964,7 @@ class Diversion(object): if not direction: # ignore the direction argument, and only check if the current message has a valid default return - + waitTime = story.applyTimeFactor(1.8 if 'waitTime' not in self.params else float(self.params['waitTime'])) timeSince = story.currentReply.getTimeSinceLastUtterance() if timeSince < waitTime: @@ -986,7 +988,7 @@ class Diversion(object): story.logger.critical(f"Not a valid message id for diversion: {self.params['msgId']}") return - + if 'nextChapterOnReturn' in self.params and self.params['nextChapterOnReturn']: msgTo = story.getNextChapterForMsg(story.currentMessage, False) or direction.msgTo returnInheritTiming = False @@ -1014,33 +1016,33 @@ class Diversion(object): #: :var story: Story if story.currentDiversion or not msgFrom or not msgTo: return False - + if not msgTo.chapterStart: # only when changing chapter return - + window_open_second = float(self.params['start_second']) window_close_second = window_open_second + float(self.params['window']) - + # Only keep a 1h loop now = story.hugvey.command.timer.getElapsed() % 3600 if now < window_open_second or now > window_close_second: return - + #open! msg = story.get(self.params['msgId']) if msg is None: story.logger.critical(f"Not a valid message id for diversion: {self.id} {self.params['msgId']}") return - + self.returnMessage = msgTo - + self.createReturnDirectionsTo(story, msg, msgTo, direction, inheritTiming=True) await story.setCurrentMessage(msg) story.currentDiversion = self return True - + async def _divergeIfRepeatRequest(self, story, msgFrom, msgTo, direction): """ Participant asks if message can be repeated. @@ -1052,7 +1054,7 @@ class Diversion(object): # Perhaps set isFinished when matching condition. if story.currentReply is None or story.currentReply.getTimeSinceLastUtterance() < story.applyTimeFactor(1.8): return - + if story.currentMessage.didRepeat: # repeat only once return @@ -1151,7 +1153,6 @@ class Diversion(object): async def _returnAfterTimeout(self, story): story.logger.info(f"Finalise diversion: {self.id}") - async def _divergeIfInterrupted(self, story, msgFrom, msgTo, direction): """ This is here as a placeholder for the interruption diversion. @@ -1177,14 +1178,16 @@ class Diversion(object): return True + class Configuration(object): id = 'configuration' - volume = 1 # Volume multiplier for 'play' command - nothing_text = "nothing" # When variable is not set, but used in sentence, replace it with this word. - time_factor = 1 - tempo_factor = 1 + volume = 1 # Volume multiplier for 'play' command + nothing_text = "nothing" # When variable is not set, but used in sentence, replace it with this word. + time_factor = 1 # time is multiplied to timeouts etc. (not playback) + tempo_factor = 1 # tempo is multiplied (playback) + pitch_modifier = 1 # pitch is added (playback) light0_intensity = 0 - light0_fade = 30. # fade duration in seconds + light0_fade = 30. # fade duration in seconds light0_isSophie = False light1_intensity = 150 light1_fade = 10. @@ -1210,7 +1213,7 @@ class Configuration(object): l = [] for i in range(5): l.append({ - 'intensity': int(c[f"light{i}_intensity"]), + 'intensity': int(c[f"light{i}_intensity"]), 'fade': float(c[f"light{i}_fade"]), 'isSophie': float(c[f"light{i}_isSophie"]) }) @@ -1396,13 +1399,13 @@ class Story(object): def setVariableValue(self, name, value, store=True): if name not in self.variables: self.logger.warn(f"Set variable that is not needed in the story: {name}") - + if name in self.variableValues and self.variableValues[name] == value: self.logger.debug(f"Skip double setting of variable {name} to {value}") return - + self.logger.debug(f"Set variable {name} to {value}") - + self.variableValues[name] = value if store: self.hugvey.command.variableStore.addVariable(self.runId, name, value, self.hugvey.id, self.language_code) @@ -1604,7 +1607,7 @@ class Story(object): if len(e['transcript'].strip()) < 1: self.logger.warning(f'ignore empty transcription {e}') continue - + # participants speaks, reset counter self.stats['consecutiveSilentTimeouts'] = 0 @@ -1641,7 +1644,7 @@ class Story(object): utterance.setText(e['transcript'], utterance.lastUpdateTime) else: utterance.setText(e['transcript'], now) - + self.hugvey.eventLogger.debug("speaking: content {} \"{}\"".format(id(utterance), e['transcript'])) if not self.timer.hasMark('first_speech'): self.timer.setMark('first_speech') @@ -1678,7 +1681,7 @@ class Story(object): # back to a previous point in time. # self.logger.warn("Skipping double direction for diversion") continue - + condition = self._processDirection(direction) if not condition: continue @@ -1688,7 +1691,7 @@ class Story(object): chosenDirection = direction metCondition = condition break - + isDiverging = await self._processDiversions(chosenDirection) @@ -1706,12 +1709,12 @@ class Story(object): if direction.isDiversionReturn and not direction.diversionHasReturned: self.logger.info(f"Mark diversion as returned for return direction {direction.id}") direction.diversionHasReturned = True - + # chosenDirection.diversionHasReturned = True await self.currentDiversion.finalise(self) await self.setCurrentMessage(chosenDirection.msgTo, allowReplyInterrupt=allowReplyInterrupt) - + return chosenDirection @@ -1782,22 +1785,22 @@ class Story(object): def logHasMsg(self, node): return node in self.msgLog - + def checkIfGone(self): ''' Make a guestimation if the audience has left... just really a simple timer check. - + If we do think so, give an error and stop the conversation ''' if not self.lastMsgFinishTime: # don't do it when hugvey is speaking return - + if self.timer.hasMark('last_speech') and self.timer.getElapsed('last_speech') > 30*60: self.hugvey.eventLogger.warning("Audience is quiet for too long...stopping") self.logger.warning("Audience is quiet, force END!") self._finish() - + def checkIfHanging(self): ''' Make a guestimation if the story is hanging at a message. Raise exception once. @@ -1807,9 +1810,9 @@ class Story(object): # or when it already gave the error for this message return diff = self.timer.getElapsed() - self.lastMsgFinishTime - + safeDiff = self.hugvey.command.config['story']['hugvey_critical_silence'] if 'hugvey_critical_silence' in self.hugvey.command.config['story'] else 90 - + if diff > safeDiff: self.hugvey.eventLogger.warning("Hugvey is quiet for very long!") self.logger.critical("Hugvey is quiet for very long!") # critical messages are forwarded to telegram @@ -1896,7 +1899,7 @@ class Story(object): message.id, message.getTextLabel())) if message.id != self.startMessage.id: self.addToLog(message) - + self.hugvey.eventLogger.info(f"message: {message.id} {message.uuid} start \"{message.getLabel()}\"") # TODO: prep events & timer etc. @@ -1916,9 +1919,12 @@ class Story(object): params['vol'] = "{:.4f}".format(params['vol']) params['tempo'] = (float(params['tempo']) if 'tempo' in params else 1) * (float(self.configuration.tempo_factor) if hasattr(self.configuration, 'tempo_factor') else 1) duration = float(duration) / params['tempo'] - params['tempo'] = "{:.4f}".format(params['tempo']) + params['pitch'] = (float(params['pitch']) if 'pitch' in params else 0)\ + + (float(self.configuration.pitch_modifier) if hasattr(self.configuration, 'pitch_modifier') else 0) + params['pitch'] = "{:.4f}".format(params['pitch']) + # self.hugvey.google.pause() # pause STT to avoid text events while decision is made self.hugvey.sendCommand({ 'action': 'play', @@ -1927,7 +1933,7 @@ class Story(object): 'params': params, 'duration': duration }) - + if message.lightChange is not None: self.fadeLightPreset(message.lightChange) # self.hugvey.setLightStatus(message.lightChange) @@ -1944,18 +1950,18 @@ class Story(object): logmsg += "\n- {0} -> {1} (when: {2}) ".format(direction.msgFrom.id, direction.msgTo.id, conditions) self.logger.log(LOG_BS,logmsg) - + # if message.id != self.startMessage.id: # self.storeState() - + def fadeLightPreset(self, presetNr: int): if presetNr < 0 or presetNr > 4: self.logger.critical(f"Error parsing light fade preset code '{presetNr}'") return - + preset = self.configuration.getLightPresets()[presetNr] self.currentLightPresetNr = presetNr - + self.hugvey.transitionLight(preset['intensity'], preset['fade'], preset['isSophie']) def getCurrentDirections(self): @@ -2039,7 +2045,7 @@ class Story(object): if msgId in checked: # self.logger.log(LOG_BS, f"Finish for {msgId} already checked") return [] - + checked.append(msgId) if not msgId in self.directionsPerMsg or len(self.directionsPerMsg[msgId]) < 1: @@ -2085,7 +2091,7 @@ class Story(object): return self.strands[msg.id] return self.calculateFinishesForMsg(msg.id, checked=[]) - + def applyTimeFactor(self, time) -> float: """ Apply the particularities of the configuration.time_factor diff --git a/www/js/hugvey_console.js b/www/js/hugvey_console.js index 2320767..cad580d 100644 --- a/www/js/hugvey_console.js +++ b/www/js/hugvey_console.js @@ -190,18 +190,18 @@ class Panopticon { if(this.hugveys.selectedId) { this.updateSelectedHugvey(); } - + let avail = 0; let blocked = 0; for(let hv of this.hugveys.hugveys) { if(hv.status =='available') avail ++; if(hv.status =='blocked') blocked ++; } - + this.hugveys.blockedHugveys = blocked; this.hugveys.availableHugveys = avail; - - + + break; case 'log': @@ -209,12 +209,12 @@ class Panopticon { } } ); } - + selectHugvey(hv_id) { this.hugveys.selectedId = hv_id; this.send({ action: 'selection', selected_id: hv_id }); } - + change_loop_time(newTime) { console.log('update', newTime); this.send({ action: 'loop_time', time: newTime }); @@ -288,7 +288,7 @@ class Panopticon { } } this.hugveys.selectedLang = code; - + let req = new XMLHttpRequest(); let graph = this.graph; req.addEventListener( "load", function( e ) { @@ -392,7 +392,7 @@ class Graph { graph.saveJson(); el.classList.remove('loading'); }, 100); - + } ); document.getElementById( 'btn-addMsg' ).addEventListener( 'click', function( e ) { graph.createMsg(); } ); document.getElementById( 'btn-diversions' ).addEventListener( 'click', function( e ) { graph.showDiversions(); } ); @@ -497,7 +497,7 @@ class Graph { } else if(type == 'repeat') { div['params']['regex'] = "can you repeat that\\?"; - } + } else if(type == 'collective_moment') { div['params']['start_second'] = 20 * 60; // second to start div['params']['window'] = 60; // how long to wait, in seconds @@ -1122,6 +1122,20 @@ class Graph { 'step': 0.01 }) ), + crel( + 'label', + "Playback pitch modifier: (< 0 is lower, >0 is higher)", + crel('input', { + 'type': 'number', + 'on': { + 'change': function(e){ + panopticon.graph.configuration['pitch_modifier'] = parseFloat(e.target.value) + } + }, + 'value': this.configuration.hasOwnProperty('pitch_modifier') ? this.configuration.pitch_modifier : 0, + 'step': 1 + }) + ), crel('hr'), crel('h2', 'Light fade setting #0'), crel( @@ -1444,7 +1458,7 @@ class Graph { "uploaded" ) : 'Auto-generated') ); - + let lightOptions = [ crel("option", {'value': null}, "Do nothing") ]; @@ -1463,18 +1477,18 @@ class Graph { } lightOptions.push(crel("option", l, `Fade preset #${i} (${intensity} in ${duration}s)`)); } - + // let lightOptionNone = {'value': null} -// +// // let lightOptionOn = {'value': 1} // let lightOptionOff = {'value': 0} -// +// // if(msg.hasOwnProperty('light')) { // if(msg['light'] === 1) lightOptionOn['selected'] = 'selected'; // if(msg['light'] === 0) lightOptionOff['selected'] = 'selected'; // if(msg['light'] === null) lightOptionNone['selected'] = 'selected'; // } - + let msgInfoEl = crel( 'div', { 'class': 'msg__info' }, crel('div', { 'class':'btn btn--delete btn--delete-msg', @@ -1893,7 +1907,7 @@ class Graph { if(attr.hasOwnProperty('description')) { inputs.push(crel('div', {'class':'description'}, attr['description'])); } - + } return inputs; }