Mercurial > code > home > repos > light9
changeset 2094:ddbd0fca89ff
file moves for effectSequencer
author | drewp@bigasterisk.com |
---|---|
date | Mon, 30 May 2022 23:46:07 -0700 |
parents | 8516a39eedc9 |
children | ce8b66e68cd5 |
files | bin/effectSequencer bin/effectsequencer light9/effect/sequencer.html light9/effect/sequencer.py light9/effect/sequencer/__init__.py light9/effect/sequencer/sequencer.py light9/effect/sequencer/service.py light9/effect/sequencer/web/index.html light9/effect/sequencer/web/vite.config.ts |
diffstat | 8 files changed, 545 insertions(+), 521 deletions(-) [+] |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/bin/effectSequencer Mon May 30 23:46:07 2022 -0700 @@ -0,0 +1,5 @@ +#!/bin/sh +pnpx vite -c light9/effect/sequencer/web/vite.config.ts & +pdm run uvicorn light9.effect.sequencer.service:app --host 0.0.0.0 --port 8213 --no-access-log +wait +
--- a/bin/effectsequencer Mon May 30 23:45:28 2022 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,84 +0,0 @@ -#!bin/python -""" -plays back effect notes from the timeline -""" - -from run_local import log -from twisted.internet import reactor -from light9.metrics import metrics, metricsRoute -from rdfdb.syncedgraph import SyncedGraph -from light9 import networking, showconfig -import optparse, sys, logging -import cyclone.web -from rdflib import URIRef -from light9.effect.sequencer import Sequencer, Updates -from light9.collector.collector_client import sendToCollector - -from light9 import clientsession - - -class App(object): - - def __init__(self, show, session): - self.show = show - self.session = session - - self.graph = SyncedGraph(networking.rdfdb.url, "effectSequencer") - self.graph.initiallySynced.addCallback(self.launch) - - def launch(self, *args): - self.seq = Sequencer( - self.graph, - lambda settings: sendToCollector( - 'effectSequencer', - self.session, - settings, - # This seems to be safe here (and lets us get from - # 20fpx to 40fpx), even though it leads to big stalls - # if I use it on KC. - useZmq=True)) - - self.cycloneApp = cyclone.web.Application(handlers=[ - (r'/()', cyclone.web.StaticFileHandler, { - "path": "light9/effect/", - "default_filename": "sequencer.html" - }), - (r'/updates', Updates), - metricsRoute(), - ], - debug=True, - seq=self.seq, - graph=self.graph, - # stats=self.stats - ) - reactor.listenTCP(networking.effectSequencer.port, self.cycloneApp) - log.info("listening on %s" % networking.effectSequencer.port) - - -if __name__ == "__main__": - parser = optparse.OptionParser() - parser.add_option( - '--show', - help='show URI, like http://light9.bigasterisk.com/show/dance2008', - default=showconfig.showUri()) - parser.add_option("-v", - "--verbose", - action="store_true", - help="logging.DEBUG") - parser.add_option("--twistedlog", - action="store_true", - help="twisted logging") - clientsession.add_option(parser) - (options, args) = parser.parse_args() - log.setLevel(logging.DEBUG if options.verbose else logging.INFO) - - if not options.show: - raise ValueError("missing --show http://...") - - session = clientsession.getUri('effectSequencer', options) - - app = App(URIRef(options.show), session) - if options.twistedlog: - from twisted.python import log as twlog - twlog.startLogging(sys.stderr) - reactor.run()
--- a/light9/effect/sequencer.html Mon May 30 23:45:28 2022 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,149 +0,0 @@ -<!doctype html> -<html> - <head> - <title>effect sequencer</title> - <meta charset="utf-8" /> - <script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script> - <script src="/lib/debug/debug-build.js"></script> - <script> - debug.enable('*'); - </script> - <link rel="import" href="/lib/polymer/polymer.html"> - <link rel="import" href="/lib/iron-ajax/iron-ajax.html"> - <link rel="import" href="../rdfdb-synced-graph.html"> - <link rel="import" href="../resource-display.html"> - <script src="/node_modules/n3/n3-browser.js"></script> - <script src="/lib/async/dist/async.js"></script> - <script src="/lib/underscore/underscore-min.js"></script> - - <link rel="stylesheet" href="/style.css"> - </head> - <body> - - <dom-module id="light9-sequencer-ui"> - <template> - <style> - :host { - display: block; - } - td { - white-space: nowrap; - padding: 0 10px; - vertical-align: top; - vertical-align: top; - text-align: start; - } - tr.active { background: #151515; } - .inactive > * { opacity: .5; } - .effectSetting { - display: inline-block; - background: #1b1e21; - margin: 1px 3px; - } - .chart { - height: 40px; - background: #222; - display: inline-flex; - align-items: flex-end; - } - .chart > div { - background: #a4a54f; - width: 8px; - margin: 0 1px; - } - .number { - display: inline-block; - min-width: 4em; - } - </style> - <rdfdb-synced-graph graph="{{graph}}"></rdfdb-synced-graph> - - <h1>Sequencer <a href="stats/">[stats]</a></h1> - - <h2>Song</h2> - - <resource-display graph="{{graph}}" uri="{{report.song}}"></resource-display> - t={{report.roundT}} - - <h3>Notes</h3> - - <table> - <tr> - <th>Note</th> - <th>Effect class</th> - <th>Effect settings</th> - <th>Devices affected</th> - </tr> - <template is="dom-repeat" items="{{report.songNotes}}"> - - <tr class$="{{item.rowClass}}"> - <td> - <resource-display graph="{{graph}}" uri="{{item.note}}"></resource-display> - </td> - <td> - <resource-display graph="{{graph}}" uri="{{item.effectClass}}"></resource-display> - </td> - <td> - <template is="dom-repeat" items="{{item.effectSettingsPairs}}"> - <div> - <span class="effectSetting"> - <resource-display graph="{{graph}}" uri="{{item.effectAttr}}"></resource-display>: - <span class="number">{{item.value}}</span> - </span> - </div> - </template> - </td> - <td> - {{item.devicesAffected}} - </td> - </tr> - </template> - </table> - - </template> - <script> - HTMLImports.whenReady(function () { - Polymer({ - is: "light9-sequencer-ui", - properties: { - graph: {type: Object, notify: true}, - report: {type: Object, notify: true}, - }, - ready: function() { - var source = new EventSource('updates'); - source.addEventListener('message', (e) => { - const report = JSON.parse(e.data); - report.roundT = Math.floor((report.t || 0) * 1000) / 1000; - report.recentFps = Math.floor((report.recentFps || 0) * 10) / 10; - report.recentDeltasStyle = (report.recentDeltas || []).map((dt) => { - const height = Math.min(40, dt / 0.085 * 20); - return `height: ${height}px;` - }); - - const fakeUris = (report.songNotes || []).map((obj) => { return {value: obj.note, orig: obj} }); - const s = this.graph.sortedUris(fakeUris); - report.songNotes = s.map((u) => { return u.orig; }); - - (report.songNotes || []).forEach((note) => { - note.rowClass = note.nonZero ? 'active' : 'inactive'; - note.effectSettingsPairs = []; - - const attrs = Object.keys(note.effectSettings); - attrs.sort(); - attrs.forEach((attr) => { - note.effectSettingsPairs.push( - {effectAttr: attr, value: note.effectSettings[attr]}); - }); - }); - this.report = report; - }); - }, - }); - }); - </script> - </dom-module> - - <light9-sequencer-ui></light9-sequencer-ui> - - </body> -</html>
--- a/light9/effect/sequencer.py Mon May 30 23:45:28 2022 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,288 +0,0 @@ -''' -copies from effectloop.py, which this should replace -''' - -from louie import dispatcher -from rdflib import URIRef -from twisted.internet import reactor -from twisted.internet import defer -from twisted.internet.defer import Deferred, inlineCallbacks -from twisted.internet.inotify import INotify -from twisted.python.filepath import FilePath -import cyclone.sse -import logging, bisect, time -import traceback -from decimal import Decimal -from typing import Any, Callable, Dict, List, Tuple, cast, Union - -from light9.ascoltami.musictime_client import MusicTime -from light9.effect import effecteval -from light9.effect.settings import DeviceSettings -from light9.effect.simple_outputs import SimpleOutputs -from light9.namespaces import L9, RDF -from light9.newtypes import DeviceUri, DeviceAttr, NoteUri, Curve, Song -from rdfdb.syncedgraph import SyncedGraph -from light9.metrics import metrics - -import imp - -log = logging.getLogger('sequencer') - - -def pyType(n): - ret = n.toPython() - if isinstance(ret, Decimal): - return float(ret) - return ret - - -class Note(object): - - def __init__(self, graph: SyncedGraph, uri: NoteUri, effectevalModule, - simpleOutputs): - g = self.graph = graph - self.uri = uri - self.effectEval = effectevalModule.EffectEval( - graph, g.value(uri, L9['effectClass']), simpleOutputs) - self.baseEffectSettings: Dict[URIRef, Any] = {} # {effectAttr: value} - for s in g.objects(uri, L9['setting']): - settingValues = dict(g.predicate_objects(s)) - ea = settingValues[L9['effectAttr']] - self.baseEffectSettings[ea] = pyType(settingValues[L9['value']]) - - def floatVal(s, p): - return float(g.value(s, p).toPython()) - - originTime = floatVal(uri, L9['originTime']) - self.points: List[Tuple[float, float]] = [] - for curve in g.objects(uri, L9['curve']): - self.points.extend( - self.getCurvePoints(curve, L9['strength'], originTime)) - self.points.sort() - - def getCurvePoints(self, curve: Curve, attr, - originTime: float) -> List[Tuple[float, float]]: - points = [] - po = list(self.graph.predicate_objects(curve)) - if dict(po).get(L9['attr'], None) != attr: - return [] - for point in [row[1] for row in po if row[0] == L9['point']]: - po2 = dict(self.graph.predicate_objects(point)) - points.append( - (originTime + float(po2[L9['time']]), float(po2[L9['value']]))) - return points - - def activeAt(self, t: float) -> bool: - return self.points[0][0] <= t <= self.points[-1][0] - - def evalCurve(self, t: float) -> float: - i = bisect.bisect_left(self.points, (t, None)) - 1 - - if i == -1: - return self.points[0][1] - if self.points[i][0] > t: - return self.points[i][1] - if i >= len(self.points) - 1: - return self.points[i][1] - - p1, p2 = self.points[i], self.points[i + 1] - frac = (t - p1[0]) / (p2[0] - p1[0]) - y = p1[1] + (p2[1] - p1[1]) * frac - return y - - def outputSettings( - self, - t: float) -> Tuple[List[Tuple[DeviceUri, DeviceAttr, float]], Dict]: - """ - list of (device, attr, value), and a report for web - """ - report = { - 'note': str(self.uri), - 'effectClass': self.effectEval.effect, - } - effectSettings: Dict[DeviceAttr, Union[float, str]] = dict( - (DeviceAttr(da), v) for da, v in self.baseEffectSettings.items()) - effectSettings[L9['strength']] = self.evalCurve(t) - - def prettyFormat(x: Union[float, str]): - if isinstance(x, float): - return round(x, 4) - return x - - report['effectSettings'] = dict( - (str(k), prettyFormat(v)) - for k, v in sorted(effectSettings.items())) - report['nonZero'] = cast(float, effectSettings[L9['strength']]) > 0 - out, evalReport = self.effectEval.outputFromEffect( - list(effectSettings.items()), - songTime=t, - # note: not using origin here since it's going away - noteTime=t - self.points[0][0]) - report['devicesAffected'] = len(out.devices()) - return out, report - - -class CodeWatcher(object): - - def __init__(self, onChange): - self.onChange = onChange - - self.notifier = INotify() - self.notifier.startReading() - self.notifier.watch(FilePath(effecteval.__file__.replace('.pyc', - '.py')), - callbacks=[self.codeChange]) - - def codeChange(self, watch, path, mask): - - def go(): - log.info("reload effecteval") - imp.reload(effecteval) - self.onChange() - - # in case we got an event at the start of the write - reactor.callLater(.1, go) - - -class Sequencer(object): - - def __init__(self, - graph: SyncedGraph, - sendToCollector: Callable[[DeviceSettings], Deferred], - fps=40): - self.graph = graph - self.fps = fps - metrics('update_loop_goal_fps').set(self.fps) - metrics('update_loop_goal_latency').set(1 / self.fps) - self.sendToCollector = sendToCollector - self.music = MusicTime(period=.2, pollCurvecalc=False) - - self.recentUpdateTimes: List[float] = [] - self.lastStatLog = 0.0 - self._compileGraphCall = None - self.notes: Dict[Song, List[Note]] = {} # song: [notes] - self.simpleOutputs = SimpleOutputs(self.graph) - self.graph.addHandler(self.compileGraph) - self.lastLoopSucceeded = False - - self.codeWatcher = CodeWatcher(onChange=self.onCodeChange) - self.updateLoop() - - def onCodeChange(self): - log.debug('seq.onCodeChange') - self.graph.addHandler(self.compileGraph) - #self.updateLoop() - - @metrics('compile_graph').time() - def compileGraph(self) -> None: - """rebuild our data from the graph""" - for song in self.graph.subjects(RDF.type, L9['Song']): - - def compileSong(song: Song = cast(Song, song)) -> None: - self.compileSong(song) - - self.graph.addHandler(compileSong) - - @metrics('compile_song').time() - def compileSong(self, song: Song) -> None: - anyErrors = False - self.notes[song] = [] - for note in self.graph.objects(song, L9['note']): - try: - n = Note(self.graph, NoteUri(note), effecteval, - self.simpleOutputs) - except Exception: - log.warn(f"failed to build Note {note} - skipping") - anyErrors = True - continue - self.notes[song].append(n) - if not anyErrors: - log.info('built all notes') - - @inlineCallbacks - def updateLoop(self) -> None: - frameStart = time.time() - try: - sec = yield self.update() - except Exception as e: - self.lastLoopSucceeded = False - traceback.print_exc() - log.warn('updateLoop: %r', e) - reactor.callLater(1, self.updateLoop) - else: - took = time.time() - frameStart - metrics('update_loop_latency').observe(took) - - if not self.lastLoopSucceeded: - log.info('Sequencer.update is working') - self.lastLoopSucceeded = True - - delay = max(0, 1 / self.fps - took) - reactor.callLater(delay, self.updateLoop) - - @metrics('update_call').time() - @inlineCallbacks - def update(self) -> Deferred: - - with metrics('update_s0_getMusic').time(): - musicState = self.music.getLatest() - if not musicState.get('song') or not isinstance( - musicState.get('t'), float): - return defer.succeed(0.0) - song = Song(URIRef(musicState['song'])) - dispatcher.send('state', - update={ - 'song': str(song), - 't': musicState['t'] - }) - - with metrics('update_s1_eval').time(): - settings = [] - songNotes = sorted(self.notes.get(song, []), key=lambda n: n.uri) - noteReports = [] - for note in songNotes: - try: - s, report = note.outputSettings(musicState['t']) - except Exception: - traceback.print_exc() - raise - noteReports.append(report) - settings.append(s) - devSettings = DeviceSettings.fromList(self.graph, settings) - - dispatcher.send('state', update={'songNotes': noteReports}) - - with metrics('update_s3_send').time(): # our measurement - sendSecs = yield self.sendToCollector(devSettings) - - # sendToCollector's own measurement. - # (sometimes it's None, not sure why, and neither is mypy) - #if isinstance(sendSecs, float): - # metrics('update_s3_send_client').observe(sendSecs) - - -class Updates(cyclone.sse.SSEHandler): - - def __init__(self, application, request, **kwargs) -> None: - cyclone.sse.SSEHandler.__init__(self, application, request, **kwargs) - self.state: Dict = {} - dispatcher.connect(self.updateState, 'state') - self.numConnected = 0 - - def updateState(self, update: Dict): - self.state.update(update) - - def bind(self) -> None: - self.numConnected += 1 - - if self.numConnected == 1: - self.loop() - - def loop(self) -> None: - if self.numConnected == 0: - return - self.sendEvent(self.state) - reactor.callLater(.1, self.loop) - - def unbind(self) -> None: - self.numConnected -= 1
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/light9/effect/sequencer/sequencer.py Mon May 30 23:46:07 2022 -0700 @@ -0,0 +1,288 @@ +''' +copies from effectloop.py, which this should replace +''' + +from louie import dispatcher +from rdflib import URIRef +from twisted.internet import reactor +from twisted.internet import defer +from twisted.internet.defer import Deferred, inlineCallbacks +from twisted.internet.inotify import INotify +from twisted.python.filepath import FilePath +import cyclone.sse +import logging, bisect, time +import traceback +from decimal import Decimal +from typing import Any, Callable, Dict, List, Tuple, cast, Union + +from light9.ascoltami.musictime_client import MusicTime +from light9.effect import effecteval +from light9.effect.settings import DeviceSettings +from light9.effect.simple_outputs import SimpleOutputs +from light9.namespaces import L9, RDF +from light9.newtypes import DeviceUri, DeviceAttr, NoteUri, Curve, Song +from rdfdb.syncedgraph import SyncedGraph +from light9.metrics import metrics + +import imp + +log = logging.getLogger('sequencer') + + +def pyType(n): + ret = n.toPython() + if isinstance(ret, Decimal): + return float(ret) + return ret + + +class Note(object): + + def __init__(self, graph: SyncedGraph, uri: NoteUri, effectevalModule, + simpleOutputs): + g = self.graph = graph + self.uri = uri + self.effectEval = effectevalModule.EffectEval( + graph, g.value(uri, L9['effectClass']), simpleOutputs) + self.baseEffectSettings: Dict[URIRef, Any] = {} # {effectAttr: value} + for s in g.objects(uri, L9['setting']): + settingValues = dict(g.predicate_objects(s)) + ea = settingValues[L9['effectAttr']] + self.baseEffectSettings[ea] = pyType(settingValues[L9['value']]) + + def floatVal(s, p): + return float(g.value(s, p).toPython()) + + originTime = floatVal(uri, L9['originTime']) + self.points: List[Tuple[float, float]] = [] + for curve in g.objects(uri, L9['curve']): + self.points.extend( + self.getCurvePoints(curve, L9['strength'], originTime)) + self.points.sort() + + def getCurvePoints(self, curve: Curve, attr, + originTime: float) -> List[Tuple[float, float]]: + points = [] + po = list(self.graph.predicate_objects(curve)) + if dict(po).get(L9['attr'], None) != attr: + return [] + for point in [row[1] for row in po if row[0] == L9['point']]: + po2 = dict(self.graph.predicate_objects(point)) + points.append( + (originTime + float(po2[L9['time']]), float(po2[L9['value']]))) + return points + + def activeAt(self, t: float) -> bool: + return self.points[0][0] <= t <= self.points[-1][0] + + def evalCurve(self, t: float) -> float: + i = bisect.bisect_left(self.points, (t, None)) - 1 + + if i == -1: + return self.points[0][1] + if self.points[i][0] > t: + return self.points[i][1] + if i >= len(self.points) - 1: + return self.points[i][1] + + p1, p2 = self.points[i], self.points[i + 1] + frac = (t - p1[0]) / (p2[0] - p1[0]) + y = p1[1] + (p2[1] - p1[1]) * frac + return y + + def outputSettings( + self, + t: float) -> Tuple[List[Tuple[DeviceUri, DeviceAttr, float]], Dict]: + """ + list of (device, attr, value), and a report for web + """ + report = { + 'note': str(self.uri), + 'effectClass': self.effectEval.effect, + } + effectSettings: Dict[DeviceAttr, Union[float, str]] = dict( + (DeviceAttr(da), v) for da, v in self.baseEffectSettings.items()) + effectSettings[L9['strength']] = self.evalCurve(t) + + def prettyFormat(x: Union[float, str]): + if isinstance(x, float): + return round(x, 4) + return x + + report['effectSettings'] = dict( + (str(k), prettyFormat(v)) + for k, v in sorted(effectSettings.items())) + report['nonZero'] = cast(float, effectSettings[L9['strength']]) > 0 + out, evalReport = self.effectEval.outputFromEffect( + list(effectSettings.items()), + songTime=t, + # note: not using origin here since it's going away + noteTime=t - self.points[0][0]) + report['devicesAffected'] = len(out.devices()) + return out, report + + +class CodeWatcher(object): + + def __init__(self, onChange): + self.onChange = onChange + + self.notifier = INotify() + self.notifier.startReading() + self.notifier.watch(FilePath(effecteval.__file__.replace('.pyc', + '.py')), + callbacks=[self.codeChange]) + + def codeChange(self, watch, path, mask): + + def go(): + log.info("reload effecteval") + imp.reload(effecteval) + self.onChange() + + # in case we got an event at the start of the write + reactor.callLater(.1, go) + + +class Sequencer(object): + + def __init__(self, + graph: SyncedGraph, + sendToCollector: Callable[[DeviceSettings], Deferred], + fps=40): + self.graph = graph + self.fps = fps + metrics('update_loop_goal_fps').set(self.fps) + metrics('update_loop_goal_latency').set(1 / self.fps) + self.sendToCollector = sendToCollector + self.music = MusicTime(period=.2, pollCurvecalc=False) + + self.recentUpdateTimes: List[float] = [] + self.lastStatLog = 0.0 + self._compileGraphCall = None + self.notes: Dict[Song, List[Note]] = {} # song: [notes] + self.simpleOutputs = SimpleOutputs(self.graph) + self.graph.addHandler(self.compileGraph) + self.lastLoopSucceeded = False + + self.codeWatcher = CodeWatcher(onChange=self.onCodeChange) + self.updateLoop() + + def onCodeChange(self): + log.debug('seq.onCodeChange') + self.graph.addHandler(self.compileGraph) + #self.updateLoop() + + @metrics('compile_graph').time() + def compileGraph(self) -> None: + """rebuild our data from the graph""" + for song in self.graph.subjects(RDF.type, L9['Song']): + + def compileSong(song: Song = cast(Song, song)) -> None: + self.compileSong(song) + + self.graph.addHandler(compileSong) + + @metrics('compile_song').time() + def compileSong(self, song: Song) -> None: + anyErrors = False + self.notes[song] = [] + for note in self.graph.objects(song, L9['note']): + try: + n = Note(self.graph, NoteUri(note), effecteval, + self.simpleOutputs) + except Exception: + log.warn(f"failed to build Note {note} - skipping") + anyErrors = True + continue + self.notes[song].append(n) + if not anyErrors: + log.info('built all notes') + + @inlineCallbacks + def updateLoop(self) -> None: + frameStart = time.time() + try: + sec = yield self.update() + except Exception as e: + self.lastLoopSucceeded = False + traceback.print_exc() + log.warn('updateLoop: %r', e) + reactor.callLater(1, self.updateLoop) + else: + took = time.time() - frameStart + metrics('update_loop_latency').observe(took) + + if not self.lastLoopSucceeded: + log.info('Sequencer.update is working') + self.lastLoopSucceeded = True + + delay = max(0, 1 / self.fps - took) + reactor.callLater(delay, self.updateLoop) + + @metrics('update_call').time() + @inlineCallbacks + def update(self) -> Deferred: + + with metrics('update_s0_getMusic').time(): + musicState = self.music.getLatest() + if not musicState.get('song') or not isinstance( + musicState.get('t'), float): + return defer.succeed(0.0) + song = Song(URIRef(musicState['song'])) + dispatcher.send('state', + update={ + 'song': str(song), + 't': musicState['t'] + }) + + with metrics('update_s1_eval').time(): + settings = [] + songNotes = sorted(self.notes.get(song, []), key=lambda n: n.uri) + noteReports = [] + for note in songNotes: + try: + s, report = note.outputSettings(musicState['t']) + except Exception: + traceback.print_exc() + raise + noteReports.append(report) + settings.append(s) + devSettings = DeviceSettings.fromList(self.graph, settings) + + dispatcher.send('state', update={'songNotes': noteReports}) + + with metrics('update_s3_send').time(): # our measurement + sendSecs = yield self.sendToCollector(devSettings) + + # sendToCollector's own measurement. + # (sometimes it's None, not sure why, and neither is mypy) + #if isinstance(sendSecs, float): + # metrics('update_s3_send_client').observe(sendSecs) + + +class Updates(cyclone.sse.SSEHandler): + + def __init__(self, application, request, **kwargs) -> None: + cyclone.sse.SSEHandler.__init__(self, application, request, **kwargs) + self.state: Dict = {} + dispatcher.connect(self.updateState, 'state') + self.numConnected = 0 + + def updateState(self, update: Dict): + self.state.update(update) + + def bind(self) -> None: + self.numConnected += 1 + + if self.numConnected == 1: + self.loop() + + def loop(self) -> None: + if self.numConnected == 0: + return + self.sendEvent(self.state) + reactor.callLater(.1, self.loop) + + def unbind(self) -> None: + self.numConnected -= 1
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/light9/effect/sequencer/service.py Mon May 30 23:46:07 2022 -0700 @@ -0,0 +1,83 @@ +""" +plays back effect notes from the timeline +""" + +from run_local import log +from twisted.internet import reactor +from light9.metrics import metrics, metricsRoute +from rdfdb.syncedgraph import SyncedGraph +from light9 import networking, showconfig +import optparse, sys, logging +import cyclone.web +from rdflib import URIRef +from light9.effect.sequencer.sequencer import Sequencer, Updates +from light9.collector.collector_client import sendToCollector + +from light9 import clientsession + + +class App(object): + + def __init__(self, show, session): + self.show = show + self.session = session + + self.graph = SyncedGraph(networking.rdfdb.url, "effectSequencer") + self.graph.initiallySynced.addCallback(self.launch) + + def launch(self, *args): + self.seq = Sequencer( + self.graph, + lambda settings: sendToCollector( + 'effectSequencer', + self.session, + settings, + # This seems to be safe here (and lets us get from + # 20fpx to 40fpx), even though it leads to big stalls + # if I use it on KC. + useZmq=True)) + + self.cycloneApp = cyclone.web.Application(handlers=[ + (r'/()', cyclone.web.StaticFileHandler, { + "path": "light9/effect/", + "default_filename": "sequencer.html" + }), + (r'/updates', Updates), + metricsRoute(), + ], + debug=True, + seq=self.seq, + graph=self.graph, + # stats=self.stats + ) + reactor.listenTCP(networking.effectSequencer.port, self.cycloneApp) + log.info("listening on %s" % networking.effectSequencer.port) + + +if __name__ == "__main__": + parser = optparse.OptionParser() + parser.add_option( + '--show', + help='show URI, like http://light9.bigasterisk.com/show/dance2008', + default=showconfig.showUri()) + parser.add_option("-v", + "--verbose", + action="store_true", + help="logging.DEBUG") + parser.add_option("--twistedlog", + action="store_true", + help="twisted logging") + clientsession.add_option(parser) + (options, args) = parser.parse_args() + log.setLevel(logging.DEBUG if options.verbose else logging.INFO) + + if not options.show: + raise ValueError("missing --show http://...") + + session = clientsession.getUri('effectSequencer', options) + + app = App(URIRef(options.show), session) + if options.twistedlog: + from twisted.python import log as twlog + twlog.startLogging(sys.stderr) + reactor.run()
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/light9/effect/sequencer/web/index.html Mon May 30 23:46:07 2022 -0700 @@ -0,0 +1,149 @@ +<!doctype html> +<html> + <head> + <title>effect sequencer</title> + <meta charset="utf-8" /> + <script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script> + <script src="/lib/debug/debug-build.js"></script> + <script> + debug.enable('*'); + </script> + <link rel="import" href="/lib/polymer/polymer.html"> + <link rel="import" href="/lib/iron-ajax/iron-ajax.html"> + <link rel="import" href="../rdfdb-synced-graph.html"> + <link rel="import" href="../resource-display.html"> + <script src="/node_modules/n3/n3-browser.js"></script> + <script src="/lib/async/dist/async.js"></script> + <script src="/lib/underscore/underscore-min.js"></script> + + <link rel="stylesheet" href="/style.css"> + </head> + <body> + + <dom-module id="light9-sequencer-ui"> + <template> + <style> + :host { + display: block; + } + td { + white-space: nowrap; + padding: 0 10px; + vertical-align: top; + vertical-align: top; + text-align: start; + } + tr.active { background: #151515; } + .inactive > * { opacity: .5; } + .effectSetting { + display: inline-block; + background: #1b1e21; + margin: 1px 3px; + } + .chart { + height: 40px; + background: #222; + display: inline-flex; + align-items: flex-end; + } + .chart > div { + background: #a4a54f; + width: 8px; + margin: 0 1px; + } + .number { + display: inline-block; + min-width: 4em; + } + </style> + <rdfdb-synced-graph graph="{{graph}}"></rdfdb-synced-graph> + + <h1>Sequencer <a href="stats/">[stats]</a></h1> + + <h2>Song</h2> + + <resource-display graph="{{graph}}" uri="{{report.song}}"></resource-display> + t={{report.roundT}} + + <h3>Notes</h3> + + <table> + <tr> + <th>Note</th> + <th>Effect class</th> + <th>Effect settings</th> + <th>Devices affected</th> + </tr> + <template is="dom-repeat" items="{{report.songNotes}}"> + + <tr class$="{{item.rowClass}}"> + <td> + <resource-display graph="{{graph}}" uri="{{item.note}}"></resource-display> + </td> + <td> + <resource-display graph="{{graph}}" uri="{{item.effectClass}}"></resource-display> + </td> + <td> + <template is="dom-repeat" items="{{item.effectSettingsPairs}}"> + <div> + <span class="effectSetting"> + <resource-display graph="{{graph}}" uri="{{item.effectAttr}}"></resource-display>: + <span class="number">{{item.value}}</span> + </span> + </div> + </template> + </td> + <td> + {{item.devicesAffected}} + </td> + </tr> + </template> + </table> + + </template> + <script> + HTMLImports.whenReady(function () { + Polymer({ + is: "light9-sequencer-ui", + properties: { + graph: {type: Object, notify: true}, + report: {type: Object, notify: true}, + }, + ready: function() { + var source = new EventSource('updates'); + source.addEventListener('message', (e) => { + const report = JSON.parse(e.data); + report.roundT = Math.floor((report.t || 0) * 1000) / 1000; + report.recentFps = Math.floor((report.recentFps || 0) * 10) / 10; + report.recentDeltasStyle = (report.recentDeltas || []).map((dt) => { + const height = Math.min(40, dt / 0.085 * 20); + return `height: ${height}px;` + }); + + const fakeUris = (report.songNotes || []).map((obj) => { return {value: obj.note, orig: obj} }); + const s = this.graph.sortedUris(fakeUris); + report.songNotes = s.map((u) => { return u.orig; }); + + (report.songNotes || []).forEach((note) => { + note.rowClass = note.nonZero ? 'active' : 'inactive'; + note.effectSettingsPairs = []; + + const attrs = Object.keys(note.effectSettings); + attrs.sort(); + attrs.forEach((attr) => { + note.effectSettingsPairs.push( + {effectAttr: attr, value: note.effectSettings[attr]}); + }); + }); + this.report = report; + }); + }, + }); + }); + </script> + </dom-module> + + <light9-sequencer-ui></light9-sequencer-ui> + + </body> +</html>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/light9/effect/sequencer/web/vite.config.ts Mon May 30 23:46:07 2022 -0700 @@ -0,0 +1,20 @@ +import { defineConfig } from "vite"; + +const servicePort = 8213; +export default defineConfig({ + base: "/effectSeequencer/", + root: "./light9/effect/sequencer/web", + publicDir: "../web", + server: { + host: "0.0.0.0", + strictPort: true, + port: servicePort + 100, + hmr: { + port: servicePort + 200, + }, + }, + clearScreen: false, + define: { + global: {}, + }, +});