Synthbot

SoundSelection widget

Feb 9th, 2020 (edited)
77
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
  1. // Base class for widgets
  2. const { DOMWidgetView } = require('@jupyter-widgets/base');
  3.  
  4. /**
  5.  * Utility class for handling an svg element. This is meant to hold a
  6.  * chart. The margins on this class are used to bound the rendered data.
  7.  * They are NOT the margins of the whole chart.
  8.  */
  9. class SvgDisplay {
  10.   svg;
  11.   width;
  12.   height;
  13.  
  14.   /**
  15.    * @param div Container for this svg element.
  16.    * @param size {width, height} of this svg element.
  17.    * @param margin {top, right, bottom, left} margins for data, excluding
  18.    * axis labels.
  19.    */
  20.   constructor(div, size, margin) {
  21.     const { width: svgWidth, height: svgHeight } = size;
  22.     const width = svgWidth - margin.left - margin.right;
  23.     const height = svgHeight - margin.top - margin.bottom;
  24.  
  25.     this.svg = d3.select(div)
  26.       .append('svg')
  27.       .attr('width', svgWidth)
  28.       .attr('height', svgHeight)
  29.       .append('g')
  30.       .attr('transform', `translate(${margin.left},${margin.top})`);
  31.  
  32.     this.width = width;
  33.     this.height = height;
  34.   }
  35. }
  36.  
  37. /**
  38.  * Utility class to hold sound data.
  39.  */
  40. class SoundSamples {
  41.   samples;
  42.   rate;
  43.   duration;
  44.  
  45.   constructor({ samples, rate }) {
  46.     this.samples = samples;
  47.     this.rate = rate;
  48.     this.duration = samples.length / rate;
  49.   }
  50. }
  51.  
  52. /**
  53.  * Class to render time axis and pcm axis. This keeps track of
  54.  * how to scale values to match data to rendering locations.
  55.  */
  56. class ScaledAxes {
  57.   #display;
  58.   #axisBottom;
  59.   #axisLeft;
  60.   #axisTransformX;
  61.   #axisTransformY;
  62.   scaleX;
  63.   scaleY;
  64.  
  65.   /**
  66.    * @param display SvgDisplay object
  67.    */
  68.   constructor(display) {
  69.     this.#display = display;
  70.     // display element to represent time axis
  71.     this.#axisBottom = display.svg.append('g');
  72.     // display element to represent pcm axis
  73.     this.#axisLeft = display.svg.append('g');
  74.   }
  75.  
  76.   /**
  77.    * Adjust scale and axes to match the given sound data.
  78.    * @param sound SoundSamples object.
  79.    */
  80.   setScales(sound) {
  81.     // function to transform time value to x position
  82.     this.scaleX = d3.scaleLinear()
  83.       .domain([0, sound.duration])
  84.       .range([0, this.#display.width]);
  85.  
  86.     // function to transform pcm value to y position
  87.     this.scaleY = d3.scaleLinear()
  88.       .domain([d3.min(sound.samples), d3.max(sound.samples)])
  89.       .range([this.#display.height, 0]);
  90.  
  91.     // function to transform the time axis
  92.     this.#axisTransformX = (g) => (
  93.       g.attr('transform', `translate(0,${this.#display.height})`)
  94.         .call(d3.axisBottom(this.scaleX))
  95.     );
  96.  
  97.     // function to transform the pcm axis
  98.     this.#axisTransformY = (g) => g.call(d3.axisLeft(this.scaleY));
  99.  
  100.     return this;
  101.   }
  102.  
  103.   /**
  104.    * @param t d3js transition object
  105.    */
  106.   render(t) {
  107.     this.#axisBottom.transition(t)
  108.       .call(this.#axisTransformX);
  109.  
  110.     this.#axisLeft.transition(t)
  111.       .call(this.#axisTransformY);
  112.   }
  113. }
  114.  
  115. /**
  116.  * Class to render a sequence of pcm samples.
  117.  */
  118. class PcmLine {
  119.   #path;
  120.   #samples;
  121.   #line;
  122.  
  123.   /**
  124.    * @param display SvgDisplay object. Data bounds in the svg are
  125.    * determined by the margins passed to the SvgDisplay object.
  126.    */
  127.   constructor(display) {
  128.     /**
  129.      * Any data that should be rendered needs to be added to this array.
  130.      * Note: datum() retains the input reference. It doesn't make a copy
  131.      * of its input.
  132.      */
  133.     this.#samples = [];
  134.     this.#path = display.svg.append('path');
  135.     this.#path.datum(this.#samples);
  136.   }
  137.  
  138.   /**
  139.    * Set the data to render.
  140.    *
  141.    * @param sound SoundSamples object
  142.    * @param scaledAxes ScaledAxes object. This is used to determine the
  143.    * position of each sound sample.
  144.    */
  145.   setSound(sound, scaledAxes) {
  146.     // function to transform each sample to a location
  147.     this.#line = d3.line()
  148.       .x((d, i) => scaledAxes.scaleX(i / sound.rate))
  149.       .y((d) => scaledAxes.scaleY(d));
  150.  
  151.     // remove old samples, push new samples
  152.     const removeLength = this.#samples.length;
  153.     this.#samples.push(...sound.samples);
  154.     this.#samples.splice(0, removeLength);
  155.  
  156.     return this;
  157.   }
  158.  
  159.   /**
  160.    * @param t d3js transition object
  161.    */
  162.   render(t) {
  163.     this.#path.transition(t)
  164.       .attr('fill', 'none')
  165.       .attr('stroke', 'steelblue')
  166.       .attr('stroke-width', 1.5)
  167.       .attr('d', this.#line);
  168.   }
  169. }
  170.  
  171. /**
  172.  * Transparent surface to select a time point and display the current
  173.  * selection.
  174.  */
  175. class TimeSelection {
  176.   #duration;
  177.   #width;
  178.   #marker;
  179.   #cursor;
  180.   #overlay;
  181.   #markerOpacity = 0;
  182.   #cursorOpacity = 0;
  183.   #cursorX;
  184.   #markerX;
  185.   #callbacks = [];
  186.  
  187.   /**
  188.    * @param display SvgDisplay indicating surface location and size
  189.    * @param sound SoundSamples object currently being displayed
  190.    */
  191.   constructor({ svg, width, height }, { duration }) {
  192.     this.#duration = duration;
  193.     this.#width = width;
  194.  
  195.     // object to keep track of the last selected location
  196.     this.#marker = svg.append('line')
  197.       .attr('y1', 0)
  198.       .attr('y2', height)
  199.       .style('stroke', 'red')
  200.       .style('stroke-width', 0.5);
  201.  
  202.     // object to keep track of the current cursor location
  203.     this.#cursor = svg.append('line')
  204.       .attr('y1', 0)
  205.       .attr('y2', height)
  206.       .style('stroke', 'red')
  207.       .style('stroke-width', 0.5);
  208.  
  209.     // transparent overlay to track mouse events
  210.     this.#overlay = svg.append('rect')
  211.       .style('fill', 'none')
  212.       .style('pointer-events', 'all')
  213.       .attr('width', width)
  214.       .attr('height', height);
  215.  
  216.     this.#setupInteractivity(this.#overlay);
  217.   }
  218.  
  219.   /**
  220.    * Internal function to attach mouse event listeners to the overlay.
  221.    *
  222.    * @param overlay target surface for mouse events
  223.    */
  224.   #setupInteractivity = (overlay) => {
  225.     overlay.on('mouseover', (d, i, nodes) => this.#showCursor(nodes[i]))
  226.       .on('mouseout', () => this.#hideCursor())
  227.       .on('mousemove', (d, i, nodes) => this.#moveCursor(nodes[i]))
  228.       .on('click', (d, i, nodes) => this.#select(nodes[i]));
  229.   }
  230.  
  231.   #moveCursor = (node) => {
  232.     this.#cursorX = d3.mouse(node)[0];
  233.     this.render();
  234.   };
  235.  
  236.   #showCursor = (node) => {
  237.     this.#cursorX = d3.mouse(node)[0];
  238.     this.#cursorOpacity = 1;
  239.     this.render();
  240.   };
  241.  
  242.   #hideCursor = () => {
  243.     this.#cursorOpacity = 0.0;
  244.     this.render();
  245.   };
  246.  
  247.   /**
  248.    * Handle a selection. This renders the selection point as a line and
  249.    * invokes any registered callbacked.
  250.    */
  251.   #select = (node) => {
  252.     const offset = d3.mouse(node)[0];
  253.     const unboundedTime = (offset / this.#width) * this.#duration;
  254.     const selectedTime = Math.min(Math.max(0, unboundedTime), this.#duration);
  255.  
  256.     this.#markerX = offset;
  257.     // make the last-selection marker visible, if it wasn't already
  258.     this.#markerOpacity = 0.5;
  259.     this.render();
  260.  
  261.     // invoke any registered callbacks
  262.     this.#callbacks.forEach((f) => f(selectedTime));
  263.   };
  264.  
  265.   /**
  266.    * Register a callback that's invoked whenever the user makes a
  267.    * selection.
  268.    *
  269.    * @param callback (timeOffset) => {...}
  270.    */
  271.   addCallback(callback) {
  272.     this.#callbacks.push(callback);
  273.   }
  274.  
  275.   /**
  276.    * Clear the last selection. It's expected that this is called whenever
  277.    * the data being displayed changes such that the last selection becomes
  278.    * invalid.
  279.    *
  280.    * @param sound the new SoundSamples object being displayed
  281.    */
  282.   reset({ duration }) {
  283.     this.#duration = duration;
  284.     this.#cursorOpacity = 0;
  285.     this.#markerOpacity = 0;
  286.  
  287.     this.render();
  288.   }
  289.  
  290.   render() {
  291.     this.#cursor.style('opacity', this.#cursorOpacity)
  292.       .attr('x1', this.#cursorX)
  293.       .attr('x2', this.#cursorX);
  294.  
  295.     this.#marker.style('opacity', this.#markerOpacity)
  296.       .attr('x1', this.#markerX)
  297.       .attr('x2', this.#markerX);
  298.   }
  299. }
  300.  
  301. /**
  302.  * Transparent surface to show any highlighted time points over the data.
  303.  */
  304. class TimeHighlights {
  305.   #container;
  306.   #marks = [];
  307.   #width;
  308.   #height;
  309.  
  310.   /**
  311.    * @param display SvgDisplay object
  312.    */
  313.   constructor({ svg, width, height }) {
  314.     this.#container = svg.append('g');
  315.     this.#width = width;
  316.     this.#height = height;
  317.   }
  318.  
  319.   /**
  320.    * Highlight the specified mark points.
  321.    *
  322.    * @param marks array of time points
  323.    * @param scaledAxes ScaledAxes object to map time to a display offset
  324.    */
  325.   setMarks(marks, scaledAxes) {
  326.     this.#marks = marks.map(scaledAxes.scaleX);
  327.     return this;
  328.   }
  329.  
  330.   render(t) {
  331.     // enter fn for a d3 join: create any new marks and slide into place
  332.     const createMarks = (enter) => (
  333.       enter.append('line')
  334.         // new marks enter from the right
  335.         .attr('x1', () => this.#width)
  336.         .attr('x2', () => this.#width)
  337.         .attr('y1', 0)
  338.         .attr('y2', this.#height)
  339.         // ... are a transparent gray
  340.         .style('stroke', 'gray')
  341.         .style('stroke-width', 0.5)
  342.         .style('opacity', 0.5)
  343.         .call((slideIntoPlace) => (
  344.           // ... and slide into place
  345.           slideIntoPlace.transition(t)
  346.             .attr('x1', (d) => d)
  347.             .attr('x2', (d) => d)
  348.         ))
  349.     );
  350.  
  351.     // update fn for a d3 join: slide marks into their new position
  352.     const updateMarks = (update) => (
  353.       update.transition(t)
  354.         .attr('x1', (d) => d)
  355.         .attr('x2', (d) => d)
  356.     );
  357.  
  358.     // remove fn for a d3 join: remove these immediately
  359.     const removeMarks = (exit) => exit.remove();
  360.  
  361.     this.#container.selectAll('line')
  362.       .data(this.#marks)
  363.       .join(createMarks, updateMarks, removeMarks);
  364.   }
  365. }
  366.  
  367. /**
  368.  * Composite rendering class to draw sound samples and select a time
  369.  * point.
  370.  */
  371. class SoundSelection {
  372.   #display;
  373.   #transitTime;
  374.   #axes;
  375.   #path;
  376.   #timeSelection;
  377.   #highlights;
  378.  
  379.   /**
  380.    * @param display SvgDisplay object
  381.    * @param transitTime time for transition animations to complete
  382.    * @param sound SoundSamples object
  383.    * @param marks array of time points to highlight in the sound samples
  384.    */
  385.   constructor(display, transitTime, sound, marks) {
  386.     this.#display = display;
  387.     this.#transitTime = transitTime;
  388.  
  389.     // Create the visual objects that make up this composite
  390.     this.#axes = new ScaledAxes(this.#display).setScales(sound);
  391.     this.#path = new PcmLine(this.#display).setSound(sound, this.#axes);
  392.     this.#timeSelection = new TimeSelection(this.#display, sound);
  393.     this.#highlights = new TimeHighlights(this.#display).setMarks(marks, this.#axes);
  394.  
  395.     // Render all visual objects
  396.     this.#render();
  397.   }
  398.  
  399.   /**
  400.    * Register a callback for when the user makes a selection.
  401.    *
  402.    * @param callback (timeOffset) => {...}
  403.    */
  404.   addCallback(callback) {
  405.     this.#timeSelection.addCallback(callback);
  406.     return this;
  407.   }
  408.  
  409.   /**
  410.    * Set time points to highlight in the displayed sound samples.
  411.    *
  412.    * @param marks array of time points
  413.    */
  414.   setMarks(marks) {
  415.     const t = d3.transition().duration(this.#transitTime);
  416.     this.#highlights.setMarks(marks, this.#axes);
  417.     this.#highlights.render(t);
  418.   }
  419.  
  420.   /**
  421.    * Display a new set of sound samples.
  422.    *
  423.    * @param sound SoundSamples object
  424.    */
  425.   setSound(sound) {
  426.     const t = d3.transition().duration(this.#transitTime);
  427.  
  428.     // update the axes
  429.     this.#axes.setScales(sound);
  430.     this.#axes.render(t);
  431.  
  432.     // clear any existing selection
  433.     this.#timeSelection.reset(sound);
  434.  
  435.     // update the rendered pcm data
  436.     this.#path.setSound(sound, this.#axes);
  437.     this.#path.render(t);
  438.   }
  439.  
  440.   /**
  441.    * Set how long it takes for transitions animations to complete.
  442.    *
  443.    * @param transitTime time in milliseconds
  444.    */
  445.   setTransitTime(transitTime) {
  446.     this.#transitTime = transitTime;
  447.   }
  448.  
  449.   /**
  450.    * Internal function to render this view immediately. This is used the
  451.    * the first time this object needs to be rendered.
  452.    */
  453.   #render = () => {
  454.     const t = d3.transition().duration(0);
  455.     this.#axes.render(t);
  456.     this.#path.render(t);
  457.     this.#timeSelection.render();
  458.     this.#highlights.render(t);
  459.   }
  460. }
  461.  
  462. /**
  463.  * A widget class to render pcm data and select a time point.
  464.  */
  465. class SoundSelectionWidget extends DOMWidgetView {
  466.   render() {
  467.     // container for the widget
  468.     const div = document.createElement('div');
  469.  
  470.     // grab parameters set from Python
  471.     const size = this.model.get('size');
  472.     const margin = this.model.get('margin');
  473.     const transitTime = this.model.get('transition_time');
  474.     const sound = new SoundSamples(this.model.get('data'));
  475.     const marks = this.model.get('marks');
  476.  
  477.     // set up the widget element and on-selection callback
  478.     const display = new SvgDisplay(div, size, margin);
  479.     const soundSelection = new SoundSelection(display, transitTime, sound, marks)
  480.       .addCallback((time) => {
  481.         this.model.set('selection', time);
  482.         this.model.save_changes();
  483.       });
  484.  
  485.     // update the widget whenever the Python side sends new data
  486.     this.model
  487.       .on('change:data', () => {
  488.         const newSound = new SoundSamples(this.model.get('data'));
  489.         soundSelection.setSound(newSound);
  490.       })
  491.       .on('change:marks', () => {
  492.         const newMarks = this.model.get('marks');
  493.         soundSelection.setMarks(newMarks);
  494.       })
  495.       .on('change:transition_time', () => {
  496.         const newTransitTime = this.model.get('transition_time');
  497.         soundSelection.setTransitTime(newTransitTime);
  498.       });
  499.  
  500.     this.el.appendChild(div);
  501.   }
  502. }
  503.  
  504. // expose the widget to jupyter
  505. require.undef('SoundSelectionWidget');
  506. define('SoundSelectionWidget', ['@jupyter-widgets/base'], () => {
  507.   return { SoundSelectionWidget };
  508. });
Advertisement
Add Comment
Please, Sign In to add comment