Changeset - 06da5db2fafe
[Not reviewed]
default
0 9 3
drewp@bigasterisk.com - 8 months ago 2024-05-30 08:08:07
drewp@bigasterisk.com
rewrite ascoltami to use the graph for more playback data
12 files changed with 419 insertions and 400 deletions:
0 comments (0 inline, 0 general)
src/light9/ascoltami/main.py
Show inline comments
 
#!bin/python
 
import logging
 
from typing import cast
 

	
 
import louie
 
from rdfdb.syncedgraph.syncedgraph import SyncedGraph
 
from rdflib import ConjunctiveGraph, Literal, URIRef
 
from rdflib.graph import _ContextType
 
from starlette.applications import Starlette
 
from starlette.routing import Route
 
from starlette_exporter import PrometheusMiddleware, handle_metrics
 

	
 
from light9 import networking, showconfig
 
from light9.ascoltami import webapp
 
from light9.ascoltami.import_gst import Gst
 
from light9.ascoltami.player import Player, PlayerState
 
from light9.ascoltami.playlist import NoSuchSong, Playlist
 
from light9.namespaces import L9
 
from light9.newtypes import decimalLiteral
 
from light9.run_local import log
 

	
 

	
 
class Ascoltami:
 

	
 
    def __init__(self, graph: SyncedGraph, show: URIRef):
 
        self.graph = graph
 
        self.player = Player(onStateChange=self.onStateChange, autoStopOffset=0)
 
        self.show = show
 
        self.ctx = cast(_ContextType, (URIRef(self.show + '/ascoltami')))
 

	
 
        self.playerState = PlayerState()
 
        self.playlist = Playlist(graph, show)
 

	
 
    def onStateChange(self, s: PlayerState):
 
        log.info('louie send')
 
        louie.send(webapp.OnStateChange, s=s)
 
        g = self.stateAsGraph(s)
 

	
 
        self.graph.patchSubgraph(newGraph=g, context=self.ctx)
 
        self.playerState = s
 

	
 
    def stateAsGraph(self, s):
 
        g = ConjunctiveGraph()
 
        asc = L9['ascoltami']
 
        if s.song:
 
            try:
 
                g.add((asc, L9['song'], self.playlist.songUri(s.song), self.ctx))
 
            except KeyError:
 
                pass
 
        g.add((asc, L9['duration'], decimalLiteral(s.duration), self.ctx))
 
        g.add((asc, L9['playing'], Literal(s.playing), self.ctx))
 
        if s.wallStartTime is not None:
 
            g.add((asc, L9['wallStartTime'], decimalLiteral(s.wallStartTime), self.ctx))
 
        if s.pausedSongTime is not None:
 
            g.add((asc, L9['pausedSongTime'], decimalLiteral(s.pausedSongTime), self.ctx))
 
        g.add((asc, L9['endOfSong'], Literal(s.endOfSong), self.ctx))
 
        return g
 

	
 
    def getPlayerState(self) -> PlayerState:
 
        return self.playerState
 

	
 
    def onEOS(self, song):
 
        self.player.pause()
 
        self.player.seek(0)
 

	
 
        thisSongUri = webapp.songUri(self.graph, URIRef(song))
 

	
 
        try:
 
            nextSong = self.playlist.nextSong(thisSongUri)
 
        except NoSuchSong:  # we're at the end of the playlist
 
            return
 

	
 
        self.player.setSong(webapp.songLocation(self.graph, nextSong), play=False)
 

	
 

	
 
def main():
 
    logging.getLogger('sse_starlette.sse').setLevel(logging.INFO)
 
    Gst.init(None)
 

	
 
    graph = SyncedGraph(networking.rdfdb.url, "ascoltami")
 
    asco = Ascoltami(graph, showconfig.showUri())
 

	
 
    h = webapp.WebHandlers(graph, asco.show, asco.player, asco.playlist, asco.getPlayerState)
 
    app = Starlette(
 
        debug=True,
 
        routes=[
 
            Route("/config", h.get_config),
 
            Route("/time", h.get_time, methods=["GET"]),
 
            Route("/time", h.post_time, methods=["POST"]),
 
            Route("/time/stream", h.timeStream),
 
            Route("/song", h.post_song, methods=["POST"]),
 
            Route("/songs", h.get_songs),
 
            Route("/seekPlayOrPause", h.post_seekPlayOrPause),
 
            Route("/output", h.post_output, methods=["POST"]),
 
            Route("/go", h.post_goButton, methods=["POST"]),
 
        ],
 
    )
 

	
 
    app.add_middleware(PrometheusMiddleware)
 
    app.add_route("/metrics", handle_metrics)
 

	
 
    return app
 

	
 

	
 
app = main()
src/light9/ascoltami/player.py
Show inline comments
 
@@ -9,91 +9,93 @@ import asyncio
 
import logging
 
import time
 
import traceback
 
from dataclasses import dataclass
 
from typing import NewType
 

	
 
from light9.ascoltami.import_gst import Gst  # type: ignore
 
from light9.metrics import metrics
 

	
 
log = logging.getLogger()
 

	
 
GstFileUri = NewType('GstFileUri', str)
 

	
 

	
 
@dataclass
 
class PlayerState:
 
    song: GstFileUri | None = None
 
    duration: float = 0
 
    playing: bool = False  # time is advancing, and song time is now()-wallStartTime
 
    # wall time of song-time 0 (this is adjusted when you seek)
 
    wallStartTime: float = 0
 
    pausedSongTime: float | None = None  # if we're paused, this has the song time
 
    endOfSong: bool = False  # True if we're in the stopped state due to EOS
 

	
 
def roundTime(secs: float) -> float:
 
    return round(secs, 2)
 

	
 
class Player:
 

	
 
    def __init__(self, autoStopOffset=4, onStateChange=lambda ps: None):
 
        """autoStopOffset is the number of seconds before the end of
 
        song before automatically stopping (which is really pausing).
 
        """
 
        self.autoStopOffset = autoStopOffset
 
        self.onStateChange = onStateChange
 
        self._playbin = self.pipeline = Gst.ElementFactory.make('playbin', None)
 

	
 
        self.playerState: PlayerState = PlayerState()
 

	
 
        self._lastWatchTime = 0
 
        self._autoStopTime = 0
 
        self._lastSetSongUri: GstFileUri | None = None
 

	
 
        # task.LoopingCall(self._watchTime).start(.050)
 
        asyncio.create_task(self._watchTime())
 

	
 
        #bus = self.pipeline.get_bus()
 
        # not working- see notes in pollForMessages
 
        #self._watchForMessages(bus)
 

	
 
    async def _watchTime(self):
 
        while True:
 
            now = time.time()
 
            try:
 
                self._pollForMessages()
 
                t = self.currentTime()
 
                # log.debug("watch %s < %s < %s", self._lastWatchTime, self._autoStopTime, t)
 
                if self._lastWatchTime < self._autoStopTime < t:
 
                    log.info("autostop")
 
                    self.pause()
 

	
 
                eos = t >= self.duration() - .1  #todo
 
                playing = self.isPlaying() and not eos
 
                ps = PlayerState(
 
                    song=self._getSongFileUri(),
 
                    duration=round(self.duration(), 2),
 
                    wallStartTime=round(now - t, 2) if playing else None,
 
                    duration=roundTime(self.duration()),
 
                    wallStartTime=roundTime(now - t) if playing else None,
 
                    playing=playing,
 
                    pausedSongTime=None if playing else t,
 
                    pausedSongTime=None if playing else roundTime(t),
 
                    endOfSong=eos,
 
                )
 

	
 
                if self.playerState != ps:
 
                    self.onStateChange(ps)
 
                    self.playerState = ps
 

	
 
                self._lastWatchTime = t
 
            except Exception:
 
                traceback.print_exc()
 
            await asyncio.sleep(0.5)
 

	
 
    def _watchForMessages(self, bus):
 
        """this would be nicer than pollForMessages but it's not working for
 
        me. It's like add_signal_watch isn't running."""
 
        bus.add_signal_watch()
 

	
 
        def onEos(*args):
 
            print("onEos", args)
 
            if self._onEOS is not None:
 
                self._onEOS(self._getSongFileUri())
 

	
 
        bus.connect('message::eos', onEos)
 

	
src/light9/ascoltami/webapp.py
Show inline comments
 
"""
 
this module shouldn't be necessary for playback to work
 
"""
 
import asyncio
 
import json
 
import logging
 
import socket
 
import subprocess
 
from dataclasses import dataclass, field
 
import time
 
from dataclasses import dataclass
 
from typing import Callable
 
from typing import Literal as Lit
 

	
 
import louie
 
from rdfdb.syncedgraph.syncedgraph import SyncedGraph
 
from rdflib import URIRef
 
from sse_starlette.sse import EventSourceResponse
 
from starlette.requests import Request
 
from starlette.responses import JSONResponse, PlainTextResponse
 

	
 
from light9.ascoltami.player import Player, PlayerState
 
from light9.ascoltami.playlist import Playlist
 
from light9.showconfig import showUri
 

	
 
log = logging.getLogger("web")
 

	
 

	
 
class OnStateChange:
 
    pass
 

	
 

	
 
@dataclass
 
class PlayerState2(PlayerState):
 
    song2: URIRef | None = None
 
    nextAction: Lit["finish"] | Lit['disabled'] | Lit['play'] = 'disabled'
 

	
 

	
 
@dataclass
 
class WebHandlers:
 
    graph: SyncedGraph
 
    show: URIRef
 
    player: Player
 
    playlist: Playlist
 
    getPlayerState: Callable[[], PlayerState]
 

	
 
    # _keep:list=field(default_factory=list)
 
    async def get_config(self, request: Request) -> JSONResponse:
 
        return JSONResponse(
 
            dict(
 
                host=socket.gethostname(),
 
                show=str(showUri()),
 
                times={
 
                    # these are just for the web display. True values are on Player.__init__
 
                    'intro': 4,
 
                    'post': 0
 
                }))
 

	
 
    def currentState(self, player: Player, playlist: Playlist) -> PlayerState2:
 
        if player.isAutostopped():
 
            nextAction = 'finish'
 
        elif player.isPlaying():
 
            nextAction = 'disabled'
 
        else:
 
            nextAction = 'play'
 

	
 
        ps = self.getPlayerState()
 
        return PlayerState2(
 
            song2=playlist.songUri((ps.song)) if ps.song else None,
 
            duration=ps.duration,
 
            playing=ps.playing,
 
            # state= player.states(),
 
            nextAction=nextAction,
 
        )
 

	
 
    async def get_time(self, request: Request) -> JSONResponse:
 
        return JSONResponse({'t': self.player.currentTime()})
 

	
 
    async def post_time(self, request: Request) -> PlainTextResponse:
 
        """
 
        post a json object with {pause: true} or {resume: true} if you
 
        want those actions. Use {t: <seconds>} to seek, optionally
 
        with a pause/resume command too.
 
        """
 
        params = await request.json()
 
        if params.get('pause', False):
 
            self.player.pause()
 
        if params.get('resume', False):
 
            self.player.resume()
 
        if 't' in params:
 
            self.player.seek(params['t'])
 
        return PlainTextResponse("ok")
 

	
 
    async def timeStream(self, request: Request):
 

	
 
        async def event_generator():
 
            last_sent = None
 
            last_sent_time = 0.0
 

	
 
            def onStateChange(s: PlayerState2):
 
                log.info('ws heanndlerr gets state')
 

	
 
            louie.connect(onStateChange, OnStateChange, weak=False)
 

	
 
            try:
 
                while True:
 
                    now = time.time()
 
                    msg = self.currentState(self.player, self.playlist)
 
                    if msg != last_sent or now > last_sent_time + 2:
 
                        event_data = json.dumps({
 
                            # obsolete- watch the graph for these
 
                            'duration': msg.duration,
 
                            'playing': msg.playing,
 
                            'song': self.playlist.songUri(msg.song) if msg.song else None,
 
                            'state': {},
 
                        })
 
                        yield event_data
 
                        last_sent = msg
 
                        last_sent_time = now
 

	
 
                    await asyncio.sleep(0.1)
 
            finally:
 
                log.info(f'bye listnner {event_generator}')
 
                louie.disconnect(onStateChange, OnStateChange, weak=False)
 

	
 
        return EventSourceResponse(event_generator())
 

	
 
    async def get_songs(self, request: Request) -> JSONResponse:
 

	
 
        songs_data = [
 
            {  #
 
                "uri": s,
 
                "path": self.playlist.fileUri(s),
 
                "label": self.playlist.label(s),
 
            } for s in self.playlist.allSongs()
 
        ]
 

	
 
        return JSONResponse({"songs": songs_data})
 

	
 
    async def post_song(self, request: Request) -> PlainTextResponse:
 
        """post a uri of song to switch to (and start playing)"""
 
        """post a uri of song to switch to (and seek to 0)"""
 

	
 
        song_uri = URIRef((await request.body()).decode('utf8'))
 
        self.player.setSong(self.playlist.fileUri(song_uri))
 

	
 
        return PlainTextResponse("ok")
 

	
 
    async def post_seekPlayOrPause(self, request: Request) -> PlainTextResponse:
 
        """curveCalc's ctrl-p or a vidref scrub"""
 

	
 
        data = await request.json()
 
        if 'scrub' in data:
 
            self.player.pause()
 
            self.player.seek(data['scrub'])
 
            return PlainTextResponse("ok")
 
        if 'action' in data:
 
            if data['action'] == 'play':
 
                self.player.resume()
 
            elif data['action'] == 'pause':
 
                self.player.pause()
 
            else:
 
                raise NotImplementedError
 
            return PlainTextResponse("ok")
 
        if self.player.isPlaying():
 
            self.player.pause()
web/Light9CursorCanvas.ts
Show inline comments
 
import debug from "debug";
 
import { css, html, LitElement, PropertyValues } from "lit";
 
import { customElement, property } from "lit/decorators.js";
 
import Sylvester from "sylvester";
 
import { line } from "./drawing";
 
import { Vector } from "./lib/sylvester";
 

	
 
const $V = Sylvester.Vector.create;
 

	
 
const log = debug("cursor");
 

	
 
export interface PlainViewState {
 
  zoomSpec: { t1: () => number; t2: () => number };
 
  fullZoomX: (t: number) => number;
 
  zoomInX: (t: number) => number;
 
  cursor: { t: () => number };
 
  audioY: () => number;
 
  audioH: () => number;
 
  zoomedTimeY: () => number; // not what you think- it's the zone in between
 
  zoomedTimeH: () => number;
 
  mouse: { pos: () => Vector };
 
}
 

	
 
// For cases where you have a zoomed-out view on top of a zoomed-in view,
 
// overlay this element and it'll draw a time cursor on both views.
 
@customElement("light9-cursor-canvas")
 
export class Light9CursorCanvas extends LitElement {
 
  cursorPath: null | {
 
    top0: Vector;
 
    top1: Vector;
web/ascoltami/Light9AscoltamiTimeline.ts
Show inline comments
 
new file 100644
 
import { css, html, LitElement, PropertyValueMap } from "lit";
 
import { customElement, property, state } from "lit/decorators.js";
 
import Sylvester from "sylvester";
 
import { Zoom } from "../light9-timeline-audio";
 
import { PlainViewState } from "../Light9CursorCanvas";
 
import { getTopGraph } from "../RdfdbSyncedGraph";
 
import { show } from "../show_specific";
 
import { SyncedGraph } from "../SyncedGraph";
 
import { PlayerState } from "./PlayerState";
 
export { Light9TimelineAudio } from "../light9-timeline-audio";
 
export { Light9CursorCanvas } from "../Light9CursorCanvas";
 
export { RdfdbSyncedGraph } from "../RdfdbSyncedGraph";
 
export { ResourceDisplay } from "../ResourceDisplay";
 

	
 
const $V = Sylvester.Vector.create;
 

	
 
async function postJson(url: string, jsBody: Object) {
 
  return fetch(url, {
 
    method: "POST",
 
    headers: { "Content-Type": "applcation/json" },
 
    body: JSON.stringify(jsBody),
 
  });
 
}
 

	
 
@customElement("light9-ascoltami-timeline")
 
export class Light9AscoltamiTimeline extends LitElement {
 
  static styles = [
 
    css`
 
      .timeRow {
 
        margin: 14px;
 
        position: relative;
 
      }
 
      #overview {
 
        height: 60px;
 
      }
 
      #zoomed {
 
        margin-top: 40px;
 
        height: 80px;
 
      }
 
      #cursor {
 
        position: absolute;
 
        left: 0;
 
        top: 0;
 
        width: 100%;
 
        height: 100%;
 
      }
 
    `,
 
  ];
 
  graph!: SyncedGraph;
 
  @property() playerState: PlayerState = {
 
    duration: null,
 
    endOfSong: null,
 
    pausedSongTime: null,
 
    playing: null,
 
    song: null,
 
    wallStartTime: null,
 
  };
 
  @property() playerTime: number = 0;
 
  @state() zoom: Zoom;
 
  @state() overviewZoom: Zoom;
 
  @state() viewState: PlainViewState | null = null;
 
  constructor() {
 
    super();
 
    getTopGraph().then((g) => {
 
      this.graph = g;
 
    });
 
    this.zoom = this.overviewZoom = { duration: null, t1: 0, t2: 1 };
 
  }
 
  protected willUpdate(_changedProperties: PropertyValueMap<this>): void {
 
    super.willUpdate(_changedProperties);
 
    if ((_changedProperties.has("playerState") || _changedProperties.has("playerTime")) && this.playerState !== null) {
 
      const duration = this.playerState.duration;
 
      const t = this.playerTime;
 
      if (duration !== null) {
 
        const timeRow = this.shadowRoot!.querySelector(".timeRow") as HTMLDivElement;
 
        if (timeRow != null) {
 
          this.updateZooms(duration, t, timeRow);
 
        }
 
      }
 
    }
 
  }
 

	
 
  updateZooms(duration: number, t: number, timeRow: HTMLDivElement) {
 
    this.overviewZoom = { duration: duration, t1: 0, t2: duration };
 
    const t1 = t - 2;
 
    const t2 = t + 20;
 
    this.zoom = { duration: duration, t1, t2 };
 
    const w = timeRow.offsetWidth;
 
    this.viewState = {
 
      zoomSpec: { t1: () => t1, t2: () => t2 },
 
      cursor: { t: () => t },
 
      audioY: () => 0,
 
      audioH: () => 60,
 
      zoomedTimeY: () => 60,
 
      zoomedTimeH: () => 40,
 
      fullZoomX: (sec: number) => (sec / duration) * w,
 
      zoomInX: (sec: number) => ((sec - t1) / (t2 - t1)) * w,
 
      mouse: { pos: () => $V([0, 0]) },
 
    };
 
  }
 

	
 
  render() {
 
    const song = this.playerState?.song;
 
    if (!song) return html`(spectrogram)`;
 
    return html`
 
      <div class="timeRow">
 
        <div id="timeSlider"></div>
 
        <light9-timeline-audio id="overview" .show=${show} .song=${song} .zoom=${this.overviewZoom}> </light9-timeline-audio>
 
        <light9-timeline-audio id="zoomed" .show=${show} .song=${song} .zoom=${this.zoom}></light9-timeline-audio>
 
        <light9-cursor-canvas id="cursor" .viewState=${this.viewState}></light9-cursor-canvas>
 
      </div>
 
    `;
 
  }
 
}
web/ascoltami/Light9AscoltamiUi.ts
Show inline comments
 
import debug from "debug";
 
import { css, html, LitElement } from "lit";
 
import { css, html, LitElement, PropertyValues } from "lit";
 
import { customElement, property } from "lit/decorators.js";
 
import { classMap } from "lit/directives/class-map.js";
 
import { NamedNode } from "n3";
 
import Sylvester from "sylvester";
 
import { Zoom } from "../light9-timeline-audio";
 
import { PlainViewState } from "../Light9CursorCanvas";
 
import { getTopGraph } from "../RdfdbSyncedGraph";
 
import { SyncedGraph } from "../SyncedGraph";
 
import { TimingUpdate } from "./main";
 
import { showRoot } from "../show_specific";
 
export { Light9TimelineAudio } from "../light9-timeline-audio";
 
export { Light9CursorCanvas } from "../Light9CursorCanvas";
 
import { PlayerState } from "./PlayerState";
 
export { RdfdbSyncedGraph } from "../RdfdbSyncedGraph";
 
export { ResourceDisplay } from "../ResourceDisplay";
 
const $V = Sylvester.Vector.create;
 
export { Light9AscoltamiTimeline } from "./Light9AscoltamiTimeline";
 
export { Light9SongListing } from "./Light9SongListing";
 

	
 
debug.enable("*");
 
const log = debug("asco");
 

	
 
function byId(id: string): HTMLElement {
 
  return document.getElementById(id)!;
 
}
 
async function postJson(url: string, jsBody: Object) {
 
  return fetch(url, {
 
    method: "POST",
 
    headers: { "Content-Type": "applcation/json" },
 
    body: JSON.stringify(jsBody),
 
  });
 
}
 

	
 
@customElement("light9-ascoltami-ui")
 
export class Light9AscoltamiUi extends LitElement {
 
  graph!: SyncedGraph;
 
  times!: { intro: number; post: number };
 
  @property() nextText: string = "";
 
  @property() isPlaying: boolean = false;
 
  @property() show: NamedNode | null = null;
 
  @property() song: NamedNode | null = null;
 
  @property() selectedSong: NamedNode | null = null;
 
  @property() currentDuration: number = 0;
 
  @property() zoom: Zoom;
 
  @property() overviewZoom: Zoom;
 
  @property() viewState: PlainViewState | null = null;
 
  @property() host: any;
 
  @property() playerState: PlayerState = { duration: null, endOfSong: null, pausedSongTime: null, playing: null, song: null, wallStartTime: null };
 
  @property() playerTime: number = 0;
 
  static styles = [
 
    css`
 
      :host {
 
        display: flex;
 
        flex-direction: column;
 
      }
 
      .timeRow {
 
        margin: 14px;
 
        position: relative;
 
      }
 
      #overview {
 
        height: 60px;
 
      }
 
      #zoomed {
 
        margin-top: 40px;
 
        height: 80px;
 
      }
 
      #cursor {
 
        position: absolute;
 
        left: 0;
 
        top: 0;
 
        width: 100%;
 
        height: 100%;
 
      }
 
      #grow {
 
        flex: 1 1 auto;
 
        display: flex;
 
      }
 
      #grow > span {
 
        display: flex;
 
        position: relative;
 
        width: 50%;
 

	
 
      .keyCap {
 
        color: #ccc;
 
        background: #525252;
 
        display: inline-block;
 
        border: 1px outset #b3b3b3;
 
        padding: 2px 3px;
 
        margin: 3px 0;
 
        margin-left: 0.4em;
 
        font-size: 16px;
 
        box-shadow: 0.9px 0.9px 0px 2px #565656;
 
        border-radius: 2px;
 
      }
 
      #playSelected {
 
        height: 100px;
 
      }
 
      #songList {
 
        overflow-y: scroll;
 
        position: absolute;
 
        left: 0;
 
        top: 0;
 
        right: 0;
 
        bottom: 0;
 

	
 
      button {
 
        min-height: 48pt;
 
        min-width: 65pt;
 
      }
 
      #songList .row {
 
        width: 60%;
 
        min-height: 40px;
 
        text-align: left;
 
        position: relative;
 
      }
 
      #songList .row:nth-child(even) {
 
        background: #333;
 

	
 
      #mainRow {
 
        display: flex;
 
        flex-direction: row;
 
      }
 
      #songList .row:nth-child(odd) {
 
        background: #444;
 

	
 
      light9-song-listing {
 
        flex-grow: 1;
 
      }
 
      #songList button {
 
        min-height: 40px;
 
        margin-bottom: 10px;
 
      }
 
      #songList .row.playing {
 
        box-shadow: 0 0 30px red;
 
        background-color: #de5050;
 

	
 
      th {
 
        text-align: right;
 
      }
 
    `,
 
  ];
 
  render() {
 
    return html`<rdfdb-synced-graph></rdfdb-synced-graph>
 

	
 
  constructor() {
 
    super();
 
    getTopGraph().then((g) => {
 
      this.graph = g;
 
      this.graph.runHandler(this.updatePlayState.bind(this), "playstate-ui");
 
    });
 
    setInterval(this.updateT.bind(this), 100);
 
  }
 

	
 
      <link rel="stylesheet" href="../style.css" />
 
  protected async firstUpdated(_changedProperties: PropertyValues<this>) {
 
    this.bindKeys();
 
    const config = await (await fetch("/service/ascoltami/config")).json();
 
    document.title = document.title.replace("{{host}}", config.host);
 
    this.host = config.host;
 
  }
 

	
 
      <!-- <h1>ascoltami <a href="metrics">[metrics]</a></h1> -->
 
  updatePlayState() {
 
    const U = this.graph.U();
 
    const asco = U(":ascoltami");
 
    this.playerState = {
 
      duration: this.graph.optionalFloatValue(asco, U(":duration")),
 
      endOfSong: this.graph.optionalBooleanValue(asco, U(":endOfSong")),
 
      pausedSongTime: this.graph.optionalFloatValue(asco, U(":pausedSongTime")),
 
      wallStartTime: this.graph.optionalFloatValue(asco, U(":wallStartTime")),
 
      playing: this.graph.optionalBooleanValue(asco, U(":playing")),
 
      song: this.graph.optionalUriValue(asco, U(":song")),
 
    };
 
    this.updateT();
 
  }
 

	
 
      <div id="grow">
 
        <span>
 
          <div id="songList">
 
            <table>
 
              ${this.songList.map(
 
                (song) => html`
 
                  <tr
 
                    class="row ${classMap({
 
                      playing: !!(this.song && song.equals(this.song)),
 
                    })}"
 
                  >
 
                    <td><resource-display .uri=${song} noclick></resource-display></td>
 
                    <td>
 
                      <button @click=${this.onSelectSong.bind(this, song)}>
 
                        <span>Select</span>
 
                      </button>
 
                    </td>
 
                  </tr>
 
                `
 
              )}
 
            </table>
 
          </div> </span
 
        ><span>
 
          <div id="right">
 
  updateT() {
 
    if (this.playerState.wallStartTime !== null) {
 
      this.playerTime = Date.now() / 1000 - this.playerState.wallStartTime;
 
    } else if (this.playerState.pausedSongTime !== null) {
 
      this.playerTime = this.playerState.pausedSongTime;
 
    } else {
 
      this.playerTime = 0;
 
    }
 
  }
 

	
 
  render() {
 
    return html`
 
      <h1>ascoltami on ${this.host}</h1>
 

	
 
      <span><rdfdb-synced-graph></rdfdb-synced-graph></span>
 
      <div id="mainRow">
 
        <light9-song-listing
 
          @selectsong=${(ev: any) => {
 
            this.selectedSong = ev.detail.song;
 
          }}
 
          .selectedSong=${this.selectedSong}
 
        ></light9-song-listing>
 
            <div>
 
              Selected:
 
              <resource-display .uri=${this.selectedSong}></resource-display>
 
          <light9-ascoltami-timeline .playerState=${this.playerState} .playerTime=${this.playerTime}></light9-ascoltami-timeline>
 
          <div>
 
            <button ?disabled=${this.selectedSong == null} @click=${this.onLoadSelected}>Change to and play selected <span class="keyCap">l</span></button>
 
            <button ?disabled=${false} @click=${this.onCmdZero}>Seek to zero <span class="keyCap">z</span></button>
 
            <button ?disabled=${this.playerState.playing || this.playerState.song == null} @click=${this.onCmdPlay}>Play <span class="keyCap">p</span></button>
 
            <button ?disabled=${!this.playerState.playing} @click=${this.onCmdStop}>Stop <span class="keyCap">s</span></button>
 
            <button ?disabled=${true} @click=${this.onCmdGo}>Go <span class="keyCap">g</span></button>
 
            </div>
 
            <div>
 
              <button id="playSelected" ?disabled=${this.selectedSong === null} @click=${this.onPlaySelected}>Play selected from start</button>
 
          ${this.renderPlayerStateTable()}
 
            </div>
 
          </div>
 
        </span>
 
      </div>
 

	
 
      <div class="timeRow">
 
        <div id="timeSlider"></div>
 
        <light9-timeline-audio id="overview" .show=${this.show} .song=${this.song} .zoom=${this.overviewZoom}> </light9-timeline-audio>
 
        <light9-timeline-audio id="zoomed" .show=${this.show} .song=${this.song} .zoom=${this.zoom}></light9-timeline-audio>
 
        <light9-cursor-canvas id="cursor" .viewState=${this.viewState}></light9-cursor-canvas>
 
      </div>
 

	
 
      <div class="commands">
 
        <button id="cmd-stop" @click=${this.onCmdStop} class="playMode ${classMap({ active: !this.isPlaying })}">
 
          <strong>Stop</strong>
 
          <div class="key">s</div>
 
        </button>
 
        <button id="cmd-play" @click=${this.onCmdPlay} class="playMode ${classMap({ active: this.isPlaying })}">
 
          <strong>Play</strong>
 
          <div class="key">p</div>
 
        </button>
 
        <button id="cmd-intro" @click=${this.onCmdIntro}>
 
          <strong>Skip intro</strong>
 
          <div class="key">i</div>
 
        </button>
 
        <button id="cmd-post" @click=${this.onCmdPost}>
 
          <strong>Skip to Post</strong>
 
          <div class="key">t</div>
 
        </button>
 
        <button id="cmd-go" @click=${this.onCmdGo}>
 
          <strong>Go</strong>
 
          <div class="key">g</div>
 
          <div id="next">${this.nextText}</div>
 
        </button>
 
      </div>`;
 
    `;
 
  }
 
  renderPlayerStateTable() {
 
    return html` <table>
 
      <tr>
 
        <th>duration</th>
 
        <td>${this.playerState.duration}</td>
 
      </tr>
 
      <tr>
 
        <th>endOfSong</th>
 
        <td>${this.playerState.endOfSong}</td>
 
      </tr>
 
      <tr>
 
        <th>pausedSongTime</th>
 
        <td>${this.playerState.pausedSongTime?.toFixed(3)}</td>
 
      </tr>
 
      <tr>
 
        <th>playing</th>
 
        <td>${this.playerState.playing}</td>
 
      </tr>
 
      <tr>
 
        <th>song</th>
 
        <td>${this.playerState.song?.value}</td>
 
      </tr>
 
      <tr>
 
        <th>wallStartTime</th>
 
        <td>${this.playerState.wallStartTime}</td>
 
      </tr>
 
      <tr>
 
        <th>t</th>
 
        <td>${this.playerTime.toFixed(3)}</td>
 
      </tr>
 
    </table>`;
 
  }
 

	
 
  onSelectSong(song: NamedNode, ev: MouseEvent) {
 
    if (this.selectedSong && song.equals(this.selectedSong)) {
 
      this.selectedSong = null;
 
    } else {
 
      this.selectedSong = song;
 
    }
 
  }
 
  async onPlaySelected(ev: Event) {
 

	
 
  async onLoadSelected() {
 
    if (!this.selectedSong) {
 
      return;
 
    }
 
    await fetch("../service/ascoltami/song", { method: "POST", body: this.selectedSong.value });
 
    await fetch("/service/ascoltami/song", { method: "POST", body: this.selectedSong.value });
 
    this.selectedSong = null;
 
  }
 

	
 
  onCmdStop(ev?: MouseEvent): void {
 
    postJson("../service/ascoltami/time", { pause: true });
 
    postJson("/service/ascoltami/time", { pause: true });
 
  }
 

	
 
  onCmdPlay(ev?: MouseEvent): void {
 
    postJson("../service/ascoltami/time", { resume: true });
 
  }
 
  onCmdIntro(ev?: MouseEvent): void {
 
    postJson("../service/ascoltami/time", { t: this.times.intro, resume: true });
 
    postJson("/service/ascoltami/time", { resume: true });
 
  }
 
  onCmdPost(ev?: MouseEvent): void {
 
    postJson("../service/ascoltami/time", {
 
      t: this.currentDuration - this.times.post,
 
      resume: true,
 
    });
 

	
 
  onCmdGo(ev?: MouseEvent): void {
 
    postJson("/service/ascoltami/go", {});
 
  }
 
  onCmdGo(ev?: MouseEvent): void {
 
    postJson("../service/ascoltami/go", {});
 

	
 
  onCmdZero(ev?: MouseEvent): void {
 
    postJson("/service/ascoltami/time", { t: 0 });
 
  }
 

	
 
  bindKeys() {
 
    document.addEventListener("keypress", (ev) => {
 
      if (ev.which == 115) {
 
      if (ev.key == "l") {
 
        this.onLoadSelected();
 
      } else if (ev.key == "z") {
 
        this.onCmdZero();
 
      } else if (ev.key == "p") {
 
        this.onCmdPlay();
 
      } else if (ev.key == "s") {
 
        this.onCmdStop();
 
        return false;
 
      }
 
      if (ev.which == 112) {
 
        this.onCmdPlay();
 
        return false;
 
      } else if (ev.key == "g") {
 
        this.onCmdGo();
 
      } else {
 
        return true;
 
      }
 
      if (ev.which == 105) {
 
        this.onCmdIntro();
 
        return false;
 
      }
 
      if (ev.which == 116) {
 
        this.onCmdPost();
 
        return false;
 
      }
 

	
 
      if (ev.key == "g") {
 
        this.onCmdGo();
 
        return false;
 
      }
 
      return true;
 
    });
 
  }
 

	
 
  async musicSetup() {
 
    // shoveled over from the vanillajs version
 
    const config = await (await fetch("../service/ascoltami/config")).json();
 
    this.show = new NamedNode(config.show);
 
    this.times = config.times;
 
    document.title = document.title.replace("{{host}}", config.host);
 
    try {
 
      const h1 = document.querySelector("h1")!;
 
      h1.innerText = h1.innerText.replace("{{host}}", config.host);
 
    } catch (e) {}
 

	
 
    (window as any).finishOldStyleSetup(this.times, this.onOldStyleUpdate.bind(this));
 
  }
 

	
 
  onOldStyleUpdate(data: TimingUpdate) {
 
    this.nextText = data.next;
 
    this.isPlaying = data.playing;
 
    this.currentDuration = data.duration;
 
    this.song = new NamedNode(data.song);
 
    this.overviewZoom = { duration: data.duration, t1: 0, t2: data.duration };
 
    const t1 = data.t - 2,
 
      t2 = data.t + 20;
 
    this.zoom = { duration: data.duration, t1, t2 };
 
    const timeRow = this.shadowRoot!.querySelector(".timeRow") as HTMLDivElement;
 
    const w = timeRow.offsetWidth;
 
    this.viewState = {
 
      zoomSpec: { t1: () => t1, t2: () => t2 },
 
      cursor: { t: () => data.t },
 
      audioY: () => 0,
 
      audioH: () => 60,
 
      zoomedTimeY: () => 60,
 
      zoomedTimeH: () => 40,
 
      fullZoomX: (sec: number) => (sec / data.duration) * w,
 
      zoomInX: (sec: number) => ((sec - t1) / (t2 - t1)) * w,
 
      mouse: { pos: () => $V([0, 0]) },
 
    };
 
  }
 

	
 
  @property() songList: NamedNode[] = [];
 
  constructor() {
 
    super();
 
    this.bindKeys();
 
    this.zoom = this.overviewZoom = { duration: null, t1: 0, t2: 1 };
 

	
 
    getTopGraph().then((g) => {
 
      this.graph = g;
 
      this.musicSetup(); // async
 
      this.graph.runHandler(this.graphChanged.bind(this), "loadsongs");
 
    });
 
  }
 
  graphChanged() {
 
    this.songList = [];
 
    try {
 
      const playList = this.graph.uriValue(
 
        //
 
        this.graph.Uri(showRoot),
 
        this.graph.Uri(":playList")
 
      );
 
      log(playList);
 
      this.songList = this.graph.items(playList) as NamedNode[];
 
    } catch (e) {
 
      log("no playlist yet");
 
    }
 
    log(this.songList.length);
 
  }
 
}
web/ascoltami/Light9SongListing.ts
Show inline comments
 
new file 100644
 
import debug from "debug";
 
import { css, html, LitElement } from "lit";
 
import { customElement, property, state } from "lit/decorators.js";
 
import { NamedNode } from "n3";
 
import { getTopGraph } from "../RdfdbSyncedGraph";
 
import { show } from "../show_specific";
 
import { SyncedGraph } from "../SyncedGraph";
 
export { Light9TimelineAudio } from "../light9-timeline-audio";
 
export { Light9CursorCanvas } from "../Light9CursorCanvas";
 
export { RdfdbSyncedGraph } from "../RdfdbSyncedGraph";
 
export { ResourceDisplay } from "../ResourceDisplay";
 
const log = debug("songs");
 

	
 
async function postJson(url: string, jsBody: Object) {
 
  return fetch(url, {
 
    method: "POST",
 
    headers: { "Content-Type": "applcation/json" },
 
    body: JSON.stringify(jsBody),
 
  });
 
}
 

	
 
class SongRow {
 
  constructor(public uri: NamedNode, public label: string) {}
 
}
 

	
 
@customElement("light9-song-listing")
 
export class Light9SongListing extends LitElement {
 
  static styles = [
 
    css`
 
      td {
 
        font-size: 18pt;
 
        white-space: nowrap;
 
        padding: 0 10px;
 
        background: #9b9b9b;
 
      }
 
      tr.current td {
 
        background: #dbf5e4;
 
      }
 
      tr td:first-child {
 
        box-shadow: initial;
 
      }
 
      tr.current td:first-child {
 
        box-shadow: inset 0px 2px 10px 0px black;
 
      }
 
      button {
 
        min-height: 30pt;
 
        min-width: 65pt;
 
      }
 
    `,
 
  ];
 
  graph!: SyncedGraph;
 
  @state() playingSong: NamedNode | null = null;
 
  @property() selectedSong: NamedNode | null = null;
 
  @state() songs: SongRow[] = [];
 
  @state() isPlaying = false;
 
  constructor() {
 
    super();
 
    getTopGraph().then((g) => {
 
      this.graph = g;
 
      this.graph.runHandler(this.getSongs.bind(this), "getsongs");
 
      this.graph.runHandler(this.playState.bind(this), "playstate");
 
    });
 
  }
 
  getSongs() {
 
    this.songs = [];
 
    const U = this.graph.U();
 
    try {
 
      const playlist: NamedNode = this.graph.uriValue(show, U(":playList"));
 

	
 
      (this.graph.items(playlist) as NamedNode[]).forEach((song: NamedNode) => {
 
        this.songs.push(new SongRow(song, this.graph.labelOrTail(song)));
 
      });
 
    } catch (e) {
 
      // likely it's startup- no graph yet
 
      log(e);
 
    }
 
  }
 
  playState() {
 
    const U = this.graph.U();
 
    try {
 
      this.isPlaying = this.graph.booleanValue(U(":ascoltami"), U(":playing"));
 
      this.playingSong = this.graph.uriValue(U(":ascoltami"), U(":song"));
 
    } catch (e) {
 
      log(e);
 
    }
 
  }
 
  render() {
 
    return html`
 
      <table>
 
        ${this.songs.map((song) => {
 
          const playing = this.playingSong && song.uri.equals(this.playingSong);
 
          const selected = this.selectedSong && song.uri.equals(this.selectedSong);
 
          // could use <resource-display .uri=${song} noclick></resource-display>
 
          return html`
 
            <tr class="song ${playing ? "current" : ""}" data-uri=${song.uri.value} @click=${this.clickSongRow}>
 
              <td>
 
                <a href=${song.uri.value}>
 
                  <span class="num">${song.label.slice(0, 2)}</span>
 
                  <span class="song-name">${song.label.slice(2).trim()}</span>
 
                </a>
 
              </td>
 
              <td>
 
                <button ?disabled=${this.isPlaying || (this.selectedSong != null && !selected)}>${selected ? "Deselect" : "Select"}</button>
 
              </td>
 
            </tr>
 
          `;
 
        })}
 
      </table>
 
    `;
 
  }
 
  clickSongRow(ev: MouseEvent) {
 
    ev.preventDefault();
 
    const row = (ev.target as HTMLElement).closest(".song") as HTMLElement;
 
    const song = new NamedNode(row.dataset.uri!);
 
    if (this.selectedSong && song.equals(this.selectedSong)) {
 
      this.selectedSong = null;
 
    } else {
 
      this.selectedSong = song;
 
    }
 
    this.dispatchEvent(new CustomEvent("selectsong", { detail: { song: this.selectedSong } }));
 
  }
 
}
web/ascoltami/PlayerState.ts
Show inline comments
 
new file 100644
 
import { NamedNode } from "n3";
 

	
 
export interface PlayerState {
 
  duration: number | null;
 
  endOfSong: boolean | null;
 
  pausedSongTime: number | null;
 
  playing: boolean | null;
 
  song: NamedNode<string> | null;
 
  wallStartTime: number | null;
 
}
web/ascoltami/index.html
Show inline comments
 
<!DOCTYPE html>
 
<html>
 
  <head>
 
    <title>ascoltami on {{host}}</title>
 
    <link rel="stylesheet" href="../style.css" />
 
    <style>
 
      #cmd-go {
 
        min-width: 5em;
 
      }
 
      .song-name {
 
        padding-left: 0.4em;
 
      }
 
      .dimStalled #currentTime {
 
        font-size: 20px;
 
        background: green;
 
        color: black;
 
        padding: 3px;
 
      }
 
      .dimStalled {
 
        font-size: 90%;
 
      }
 
      body {
 
        margin: 0;
 
        padding: 0;
 
        overflow: hidden;
 
        min-height: 100vh;
 
      }
 
      #page {
 
        width: 100%;
 
        height: 100vh; /* my phone was losing the bottom :( */
 
        display: flex;
 
        flex-direction: column;
 
      }
 
      #page > div,
 
      #page > p {
 
        flex: 0 1 auto;
 
        margin: 0;
 
      }
 
      light9-ascoltami-ui {
 
        flex: 1 1 auto;
 
      }
 
    </style>
 
    <meta
 
      name="viewport"
 
      content="user-scalable=no, width=device-width, initial-scale=.7"
 
    />
 
    <meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=.7" />
 
    <script type="module" src="./Light9AscoltamiUi"></script>
 
  </head>
 
  <body>
 
    <div id="page">
 
      <h1>ascoltami on {{host}}</h1>
 
      <div class="songs" style="display: none"></div>
 

	
 
      <div class="dimStalled">
 
        <table>
 
          <tr>
 
            <td colspan="3">
 
              <strong>Song:</strong> <span id="currentSong"></span>
 
            </td>
 
          </tr>
 
          <tr>
 
            <td><strong>Time:</strong> <span id="currentTime"></span></td>
 
            <td><strong>Left:</strong> <span id="leftTime"></span></td>
 
            <td>
 
              <strong>Until autostop:</strong>
 
              <span id="leftAutoStopTime"></span>
 
            </td>
 
          </tr>
 
          <tr>
 
            <td colspan="3">
 
               <span id="states"></span>
 
            </td>
 
          </tr>
 
        </table>
 
      </div>
 

	
 
      <hr />
 
      <light9-ascoltami-ui></light9-ascoltami-ui>
 
      <p><a href="">reload</a></p>
 
    </div>
 
    <script type="module" src="./main.ts"></script>
 
  </body>
 
</html>
web/light9-timeline-audio.ts
Show inline comments
 
@@ -6,92 +6,95 @@ import { getTopGraph } from "./RdfdbSync
 
import { SyncedGraph } from "./SyncedGraph";
 

	
 
const log = debug("audio");
 

	
 
export interface Zoom {
 
  duration: number | null;
 
  t1: number;
 
  t2: number;
 
}
 

	
 
function nodeHasChanged(newVal?: NamedNode, oldVal?: NamedNode): boolean {
 
  if (newVal === undefined && oldVal === undefined) {
 
    return false;
 
  }
 
  if (newVal === undefined || oldVal === undefined) {
 
    return true;
 
  }
 
  return !newVal.equals(oldVal);
 
}
 

	
 
// (potentially-zoomed) spectrogram view
 
@customElement("light9-timeline-audio")
 
export class Light9TimelineAudio extends LitElement {
 
  graph!: SyncedGraph;
 
  graphReady: Promise<void>;
 
  render() {
 
    return html`
 
      <style>
 
        :host {
 
          display: block;
 
          /* shouldn't be seen, but black is correct for 'no
 
         audio'. Maybe loading stripes would be better */
 
          background: #202322;
 
        }
 
        div {
 
          width: 100%;
 
          height: 100%;
 
          overflow: hidden;
 
        }
 
        img {
 
          height: 100%;
 
          position: relative;
 
          transition: left 0.1s linear;
 
        }
 
      </style>
 
      <div>
 
        <img src=${this.imgSrc} style="width: ${this.imgWidth}; left: ${this.imgLeft}" />
 
      </div>
 
    `;
 
  }
 
  @property({ hasChanged: nodeHasChanged }) show!: NamedNode;
 
  @property({ hasChanged: nodeHasChanged }) song!: NamedNode;
 
  @property() zoom: Zoom = { duration: null, t1: 0, t2: 1 };
 
  @state() imgSrc: string = "#";
 
  @state() imgWidth: string = "0"; // css
 
  @state() imgLeft: string = "0"; // css
 

	
 
  constructor() {
 
    super();
 

	
 
    getTopGraph().then((g) => {
 
    this.graphReady = getTopGraph().then((g) => {
 
      this.graph = g;
 
    });
 
  }
 

	
 
  updated(changedProperties: PropertyValues) {
 
  async updated(changedProperties: PropertyValues) {
 
    super.updated(changedProperties);
 
    if (changedProperties.has("song") || changedProperties.has("show")) {
 
      await this.graphReady;
 
      if (this.song && this.show) {
 
        this.graph.runHandler(this.setImgSrc.bind(this), "timeline-audio " + this.song);
 
        this.graph.runHandler(this.setImgSrc.bind(this), "timeline-audio " + this.song.value);
 
      }
 
    }
 
    if (changedProperties.has("zoom")) {
 
      this.imgWidth = this._imgWidth(this.zoom);
 
      this.imgLeft = this._imgLeft(this.zoom);
 
    }
 
  }
 

	
 
  setImgSrc() {
 
    try {
 
      var root = this.graph.stringValue(this.show, this.graph.Uri(":spectrogramUrlRoot"));
 
    } catch (e) {
 
      return;
 
    }
 

	
 
    try {
 
      var filename = this.graph.stringValue(this.song, this.graph.Uri(":songFilename"));
 
    } catch (e) {
 
      return;
 
    }
 

	
 
    this.imgSrc = root + "/" + filename.replace(".wav", ".png").replace(".ogg", ".png");
 
    log(`imgSrc ${this.imgSrc}`);
 
  }
web/show_specific.ts
Show inline comments
 
import { NamedNode } from "n3";
 

	
 
export const shortShow = "dance2024";
 
export const showRoot = `http://light9.bigasterisk.com/show/${shortShow}`;
 
\ No newline at end of file
 
export const showRoot = `http://light9.bigasterisk.com/show/${shortShow}`;
 
export const show = new NamedNode(showRoot)
 
\ No newline at end of file
web/timeline/viewstate.ts
Show inline comments
 
import * as ko from "knockout";
 
import * as d3 from "d3";
 
import debug from "debug";
 
import * as ko from "knockout";
 

	
 
import Sylvester from "sylvester";
 
const $V = Sylvester.Vector.create;
 

	
 
const log = debug("viewstate");
 
export class ViewState {
 
  zoomSpec: {
 
    duration: ko.Observable<number>; // current song duration
 
    t1: ko.Observable<number>;
 
    t2: ko.Observable<number>;
 
  };
 
  cursor: { t: ko.Observable<number> };
 
  mouse: { pos: ko.Observable<Vector> };
 
  width: ko.Observable<number>;
 
  coveredByDiagramTop: ko.Observable<number>;
 
  audioY: ko.Observable<number>;
 
  audioH: ko.Observable<number>;
 
  zoomedTimeY: ko.Observable<number>;
 
  zoomedTimeH: ko.Observable<number>;
 
  rowsY: ko.Observable<number>;
 
  fullZoomX: d3.ScaleLinear<number, number>;
 
  zoomInX: d3.ScaleLinear<number, number>;
 
  zoomAnimSec: number;
 
  constructor() {
 
    // caller updates all these observables
 
    this.zoomSpec = {
 
      duration: ko.observable(100), // current song duration
0 comments (0 inline, 0 general)