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: {},
+  },
+});