Пример #1
0
module.exports = function(context){

  var loopGrid = LoopGrid(context)
  var looper = Looper(loopGrid)
  var recording = computedRecording(loopGrid)

  var scheduler = context.scheduler
  var gridMapping = getPushGridMapping()
  loopGrid.shape.set(gridMapping.shape)

  var shiftHeld = false

  // controller midi port
  var midiPort = MidiPort(context, function (stream, lastStream) {
    if (lastStream) turnOffAllLights(lastStream)
    if (stream) {
      turnOffAllLights(stream)
      initDisplay()
    }
  })

  // Push display
  var display = new PushDisplay(midiPort.stream)

  // extend loop-grid instance
  var obs = ObservStruct({
    port: midiPort,
    loopLength: loopGrid.loopLength,
    chunkPositions: Dict({})
  })

  obs.gridState = ObservStruct({
    active: loopGrid.active,
    playing: loopGrid.playing,
    recording: recording,
    triggers: loopGrid.grid
  })

  var releaseLooper = watch(looper, loopGrid.loops.set)

  obs.context = context
  obs.playback = loopGrid
  obs.looper = looper
  obs.repeatLength = Observ(2)

  var flags = computeFlags(context.chunkLookup, obs.chunkPositions, loopGrid.shape)

  watch( // compute targets from chunks
    computeTargets(context.chunkLookup, obs.chunkPositions, loopGrid.shape),
    loopGrid.targets.set
  )

  // grab the midi for the current port
  obs.grabInput = function(){
    midiPort.grab()
  }

  // loop transforms
  var transforms = {
    selector: Selector(gridMapping.shape, gridMapping.stride),
    holder: Holder(looper.transform),
    mover: Mover(looper.transform),
    repeater: Repeater(looper.transformTop),
    suppressor: Suppressor(looper.transform, gridMapping.shape, gridMapping.stride)
  }

  var outputLayers = ObservGridStack([

    // recording
    mapGridValue(recording, pushColors.pads.redLow),

    // active
    mapGridValue(loopGrid.active, pushColors.pads.greenLow),

    // selected
    mapGridValue(transforms.selector, pushColors.pads.blue),

    // suppressing
    mapGridValue(transforms.suppressor, pushColors.pads.red),

    // playing
    mapGridValue(loopGrid.playing, pushColors.pads.limeHi)

  ])

  var controllerGrid = ObservMidi(midiPort.stream, gridMapping, outputLayers)
  var inputGrabber = GrabGrid(controllerGrid)

  var noRepeat = computeIndexesWhereContains(flags, 'noRepeat')
  var freezeSuppress = computeIndexesWhereContains(flags, 'freezeSuppress')

  var grabInputExcludeNoRepeat = function (listener) {
    return inputGrabber(listener, { exclude: noRepeat })
  }

  var inputGrid = Observ()
  watch(inputGrabber, inputGrid.set)
  var activeIndexes = computeActiveIndexes(inputGrid)

  // trigger notes at bottom of input stack
  var output = DittyGridStream(inputGrid, loopGrid.grid, context.scheduler)
  output.on('data', loopGrid.triggerEvent)

  // midi command button mapping
  // On Push this is the row right above the pads
  var buttons = MidiButtons(midiPort.stream, {
    store: '176/102',
    flatten: '176/103',
    undo: '176/104',
    redo: '176/105',
    hold: '176/106',
    suppress: '176/107',
    snap2: '176/108',
    select: '176/109'
  })

  var releaseLoopLengthLights = []

  watchButtons(buttons, {

    store: function(value){
      if (value){
        this.flash(pushColors.pads.green)
        looper.store()
      }
    },

    flatten: function(value){
      if (value){
        var active = activeIndexes()
        if (looper.isTransforming() || active.length){
          looper.transform(holdActive, active)
          looper.flatten()
          transforms.selector.stop()
          this.flash(pushColors.pads.green, 100)
        } else {
          this.flash(pushColors.pads.red, 100)
          transforms.suppressor.start(scheduler.getCurrentPosition(), transforms.selector.selectedIndexes())
          looper.flatten()
          transforms.suppressor.stop()
          transforms.selector.stop()
        }
      }
    },

    undo: function(value){
      if (value){
        if (shiftHeld){ // halve loopLength
          var current = obs.loopLength() || 1
          obs.loopLength.set(current/2)
          this.flash(pushColors.pads.green, 100)
        } else {
          looper.undo()
          this.flash(pushColors.pads.red, 100)
          buttons.store.flash(pushColors.pads.red)
        }
      }
    },

    redo: function(value){
      if (value){
        if (shiftHeld){ // double loopLength
          var current = obs.loopLength() || 1
          obs.loopLength.set(current*2)
          this.flash(pushColors.pads.green, 100)
        } else {
          looper.redo()
          this.flash(pushColors.pads.red, 100)
          buttons.store.flash(pushColors.pads.red)
        }
      }
    },

    hold: function(value){
      if (value){
        var turnOffLight = this.light(pushColors.pads.yellow)
        transforms.holder.start(
          scheduler.getCurrentPosition(),
          transforms.selector.selectedIndexes(),
          turnOffLight
        )
      } else {
        transforms.holder.stop()
      }
    },

    suppress: function (value) {
      if (value) {
        var turnOffLight = this.light(pushColors.pads.red)
        transforms.suppressor.start(scheduler.getCurrentPosition(), transforms.selector.selectedIndexes(), freezeSuppress(), turnOffLight)
      } else {
        transforms.suppressor.stop()
      }
    },

    select: function(value){
      if (value){
        var turnOffLight = this.light(pushColors.pads.green)
        transforms.selector.start(inputGrabber, function done(){
          transforms.mover.stop()
          transforms.selector.clear()
          turnOffLight()
        })
      } else {
        if (transforms.selector.selectedIndexes().length){
          transforms.mover.start(inputGrabber, transforms.selector.selectedIndexes())
        } else {
          transforms.selector.stop()
        }
      }
    }
  })

  // shift button (share select button)
  watch(buttons.select, function(value){
    if (value){
      shiftHeld = true

      // turn on loop length lights
      releaseLoopLengthLights.push(
        buttons.undo.light(pushColors.pads.pink),
        buttons.redo.light(pushColors.pads.pink)
      )

      // Update display
      display
      .setCell(3, 2, "Halve")
      .setCell(3, 3, "Double")
      .update();

    } else {
      shiftHeld = false

      // turn off loop length lights
      while (releaseLoopLengthLights.length){
        releaseLoopLengthLights.pop()()
      }

      // Update display
      display
      .setCell(3, 2, "Undo")
      .setCell(3, 3, "Redo")
      .update();
    }
  })

  // light up undo buttons by default
  buttons.undo.light(pushColors.pads.cyan)
  buttons.redo.light(pushColors.pads.cyan)

  buttons.store.light(pushColors.pads.amberLow)


  var willFlatten = computed([activeIndexes, looper.transforms], function (indexes, transforms) {
    return !!indexes.length || !!transforms.length
  })

  // light up store button when transforming (flatten mode)
  var releaseFlattenLight = null
  watch(willFlatten, function(value){
    if (value && !releaseFlattenLight){
      releaseFlattenLight = buttons.flatten.light(pushColors.pads.greenLow)
    } else if (!value && releaseFlattenLight){
      releaseFlattenLight()
      releaseFlattenLight = null
    }
  })

  // Push side buttons - labels don't match, left here for reference
  // var repeatButtons = MidiButtons(duplexPort, {
  //   0: '176/43',
  //   1: '176/42',
  //   2: '176/41',
  //   3: '176/40',
  //   4: '176/39',
  //   5: '176/38',
  //   6: '176/37',
  //   7: '176/36'
  // })

  // Push top row buttons
  var repeatButtons = MidiButtons(midiPort.stream, {
    0: '176/20',
    1: '176/21',
    2: '176/22',
    3: '176/23',
    4: '176/24',
    5: '176/25',
    6: '176/26',
    7: '176/27'
  })

  // repeater
  var releaseRepeatLight = null
  mapWatchDiff(repeatStates, repeatButtons, obs.repeatLength.set)
  watch(obs.repeatLength, function(value){
    var button = repeatButtons[repeatStates.indexOf(value)]
    if (button){
      if (releaseRepeatLight) releaseRepeatLight()
      releaseRepeatLight = button.light(pushColors.buttons.amberLow)
    }
    transforms.holder.setLength(value)
    if (value < 2){
      transforms.repeater.start(grabInputExcludeNoRepeat, value)
    } else {
      transforms.repeater.stop()
    }
  })


  // visual metronome / loop position
  var releaseBeatLight = null
  var currentBeatLight = null
  var currentBeat = null

  watch(loopGrid.loopPosition, function(value){
    var beat = Math.floor(value[0])
    var index = Math.floor(value[0] / value[1] * 8)
    var button = repeatButtons[index]

    if (index != currentBeatLight){
      if (button){
        releaseBeatLight&&releaseBeatLight()
        releaseBeatLight = button.light(pushColors.buttons.greenLow, 0)
      }
      currentBeatLight = index
    }

    if (beat != currentBeat){
      button.flash(pushColors.buttons.greenHi)
      currentBeat = beat
    }
  })

  // cleanup / disconnect from keyboard on destroy

  obs.destroy = function(){
    recording.destroy()
    midiPort.destroy()
    display.init()
    output.destroy()
    loopGrid.destroy()
    looper.destroy()
    releaseLooper()
  }

  return obs

  // scoped

  function initDisplay() {
    // Clear screen
    display.init();

    // Top line
    display
    .setCell(0, 3, "    Loop")
    .setCell(0, 4, "Drop");

    // Repeats
    display
    .setCell(2, 0, "Trigger")
    .setCell(2, 1, "   1")
    .setCell(2, 2, "  2/3")
    .setCell(2, 3, "  1/2")
    .setCell(2, 4, "  1/3")
    .setCell(2, 5, "  1/4")
    .setCell(2, 6, "  1/6")
    .setCell(2, 7, "  1/8");

    // Buttons
    display
    .setCell(3, 0, "RecLoop")
    .setCell(3, 1, "Clr/Flat")
    .setCell(3, 2, "Undo")
    .setCell(3, 3, "Redo")
    .setCell(3, 4, "BeatHold")
    .setCell(3, 5, "Suppress")
    .setCell(3, 6, "SwapTrgt")
    .setCell(3, 7, "Select");

    display.update();
  }

  function turnOffAllLights (port) {
    var LOW_PAD = 36, // Bottom left
        HI_PAD = 99,  // Top Right
        LOW_REPEAT = 10,
        HI_REPEAT = 27,
        LOW_BUTTON = 102,
        HI_BUTTON = 109;

    // Clear notes
    for (var pad = LOW_PAD; pad <= HI_PAD; pad++) {
      port.write([128, pad, 0]);
    }

    // Clear repeat buttons
    for (var button = LOW_REPEAT; button <= HI_REPEAT; button++) {
      port.write([176, button, 0]);
    }

    // Clear buttons
    for (var button = LOW_BUTTON; button <= HI_BUTTON; button++) {
      port.write([176, button, 0]);
    }
  }
}
Пример #2
0
module.exports = function(context){

  var loopGrid = LoopGrid(context)
  var looper = Looper(loopGrid)

  var scheduler = context.scheduler
  var gridMapping = getLaunchpadGridMapping()
  loopGrid.shape.set(gridMapping.shape)

  var shiftHeld = false
  var activatedAt = 0

  var midiPort = MidiPort(context, function (port, lastPort) {
    // turn off on switch
    lastPort && lastPort.write([176, 0, 0])
    if (port) {
      port.write([176, 0, 0])
      activatedAt = Date.now()
    }
  })

  // extend loop-grid instance
  var obs = ObservStruct({
    port: midiPort,
    loopLength: loopGrid.loopLength,
    chunkPositions: ObservVarhash({})
  })

  obs.gridState = ObservStruct({
    active: loopGrid.active,
    playing: loopGrid.playing,
    recording: looper.recording,
    triggers: loopGrid.grid
  })

  obs.activeInput = computed([midiPort.stream], function (value) {
    return !!value
  })

  watch(looper, loopGrid.loops.set)

  obs.context = context
  obs.playback = loopGrid
  obs.looper = looper
  obs.repeatLength = Observ(2)

  var flags = computeFlags(context.chunkLookup, obs.chunkPositions, loopGrid.shape)

  watch( // compute targets from chunks
    computeTargets(context.chunkLookup, obs.chunkPositions, loopGrid.shape),
    loopGrid.targets.set
  )

  // grab the midi for the current port
  obs.grabInput = function () {
    midiPort.grab()
  }

  // loop transforms
  var transforms = {
    selector: Selector(gridMapping.shape, gridMapping.stride),
    holder: Holder(looper.transform),
    mover: Mover(looper.transform),
    repeater: Repeater(looper.transformTop),
    suppressor: Suppressor(looper.transform, gridMapping.shape, gridMapping.stride)
  }

  var outputLayers = ObservGridStack([

    // recording
    mapGridValue(looper.recording, stateLights.redLow),

    // active
    mapGridValue(loopGrid.active, stateLights.greenLow),

    // flash selected chunk
    mapGridValue(Highlighting(loopGrid.grid, context.setup.selectedChunkId), stateLights.amberLow),

    // selected
    mapGridValue(transforms.selector, stateLights.green),

    // suppressing
    mapGridValue(transforms.suppressor, stateLights.red),

    // playing
    mapGridValue(loopGrid.playing, stateLights.amber)

  ])

  var controllerGrid = ObservMidi(midiPort.stream, gridMapping, outputLayers)
  var inputGrabber = GrabGrid(controllerGrid)

  var noRepeat = computeIndexesWhereContains(flags, 'noRepeat')
  var grabInputExcludeNoRepeat = function (listener) {
    return inputGrabber(listener, { exclude: noRepeat })
  }

  var inputGrid = Observ()
  watch(inputGrabber, inputGrid.set)
  var activeIndexes = computeActiveIndexes(inputGrid)

  // trigger notes at bottom of input stack
  var output = DittyGridStream(inputGrid, loopGrid.grid, context.scheduler)
  output.on('data', loopGrid.triggerEvent)

  obs.currentlyPressed = computed([controllerGrid, loopGrid.grid], function (value, grid) {
    return grid.data.filter(function (name, index) {
      if (value.data[index]) {
        return true
      }
    })
  })

  // midi button mapping
  var buttons = MidiButtons(midiPort.stream, {
    store: '176/104',
    flatten: '176/105',
    undo: '176/106',
    redo: '176/107',
    hold: '176/108',
    suppress: '176/109',
    snap2: '176/110',
    select: '176/111'
  })

  var releaseLoopLengthLights = []

  watchButtons(buttons, {

    store: function(value){
      if (value){
        this.flash(stateLights.green)
        looper.store()
      }
    },

    flatten: function(value){
      if (value){
        var active = activeIndexes()
        if (looper.isTransforming() || active.length){
          looper.transform(holdActive, active)
          looper.flatten()
          transforms.selector.stop()
          this.flash(stateLights.green, 100)
        } else {
          this.flash(stateLights.red, 100)
          transforms.suppressor.start(transforms.selector.selectedIndexes())
          looper.flatten()
          transforms.suppressor.stop()
          transforms.selector.stop()
        }
      }
    },

    undo: function(value){
      if (value){
        if (shiftHeld){ // halve loopLength
          var current = obs.loopLength() || 1
          obs.loopLength.set(current/2)
          this.flash(stateLights.green, 100)
        } else {
          looper.undo()
          this.flash(stateLights.red, 100)
          buttons.store.flash(stateLights.red)
        }
      }
    },

    redo: function(value){
      if (value){
        if (shiftHeld){ // double loopLength
          var current = obs.loopLength() || 1
          obs.loopLength.set(current*2)
          this.flash(stateLights.green, 100)
        } else {
          looper.redo()
          this.flash(stateLights.red, 100)
          buttons.store.flash(stateLights.red)
        }
      }
    },

    hold: function(value){
      if (value){
        var turnOffLight = this.light(stateLights.yellow)
        transforms.holder.start(
          scheduler.getCurrentPosition(),
          transforms.selector.selectedIndexes(),
          turnOffLight
        )
      } else {
        transforms.holder.stop()
      }
    },

    suppress: function(value){
      if (value){
        var turnOffLight = this.light(stateLights.red)
        transforms.suppressor.start(transforms.selector.selectedIndexes(), turnOffLight)
      } else {
        transforms.suppressor.stop()
      }
    },

    swapTarget: function (value) {
      if (value) {
        getPortSiblings(obs, context.setup.controllers)[1].grabInput()
      } else if (Date.now() - activatedAt > 500) {
        getPortSiblings(obs, context.setup.controllers)[0].grabInput()
      }
    },

    select: function(value){
      if (value){
        var turnOffLight = this.light(stateLights.green)
        transforms.selector.start(inputGrabber, function done(){
          transforms.mover.stop()
          transforms.selector.clear()
          turnOffLight()
        })
      } else {
        if (transforms.selector.selectedIndexes().length){
          transforms.mover.start(inputGrabber, transforms.selector.selectedIndexes())
        } else {
          transforms.selector.stop()
        }
      }
    }
  })

  // shift button (share select button)
  watch(buttons.select, function(value){
    if (value){
      shiftHeld = true

      // turn on loop length lights
      releaseLoopLengthLights.push(
        buttons.undo.light(stateLights.greenLow),
        buttons.redo.light(stateLights.greenLow)
      )

    } else {
      shiftHeld = false

      // turn off loop length lights
      while (releaseLoopLengthLights.length){
        releaseLoopLengthLights.pop()()
      }
    }
  })

  // light up undo buttons by default
  buttons.undo.light(stateLights.redLow)
  buttons.redo.light(stateLights.redLow)

  buttons.store.light(stateLights.amberLow)


  var willFlatten = computed([activeIndexes, looper.transforms], function (indexes, transforms) {
    return !!indexes.length || !!transforms.length
  })

  // light up store button when transforming (flatten mode)
  var releaseFlattenLight = null
  watch(willFlatten, function(value){
    if (value && !releaseFlattenLight){
      releaseFlattenLight = buttons.flatten.light(stateLights.greenLow)
    } else if (!value && releaseFlattenLight){
      releaseFlattenLight()
      releaseFlattenLight = null
    }
  })


  var repeatButtons = MidiButtons(midiPort.stream, {
    0: '144/8',
    1: '144/24',
    2: '144/40',
    3: '144/56',
    4: '144/72',
    5: '144/88',
    6: '144/104',
    7: '144/120'
  })

  // repeater
  var releaseRepeatLight = null
  mapWatchDiff(repeatStates, repeatButtons, obs.repeatLength.set)
  watch(obs.repeatLength, function(value){
    var button = repeatButtons[repeatStates.indexOf(value)]
    if (button){
      if (releaseRepeatLight) releaseRepeatLight()
      releaseRepeatLight = button.light(stateLights.amberLow)
    }
    transforms.holder.setLength(value)
    if (value < 2){
      transforms.repeater.start(grabInputExcludeNoRepeat, value)
    } else {
      transforms.repeater.stop()
    }
  })


  // visual metronome / loop position
  var releaseBeatLight = null
  var currentBeatLight = null
  var currentBeat = null

  watch(loopGrid.loopPosition, function(value){
    var beat = Math.floor(value[0])
    var index = Math.floor(value[0] / value[1] * 8)
    var button = repeatButtons[index]

    if (index != currentBeatLight){
      if (button){
        releaseBeatLight&&releaseBeatLight()
        releaseBeatLight = button.light(stateLights.greenLow, 0)
      }
      currentBeatLight = index
    }

    if (beat != currentBeat){
      button.flash(stateLights.green)
      currentBeat = beat
    }
  })

  // cleanup / disconnect from keyboard on destroy

  obs.destroy = function () {
    midiPort.destroy()
    output.destroy()
    loopGrid.destroy()
  }

  return obs
}
Пример #3
0
module.exports = function (context) {
  var loopGrid = LoopGrid(context)
  var looper = Looper(loopGrid)
  var recordingLoop = Observ()
  var recording = computedRecording(loopGrid, recordingLoop)

  var scheduler = context.scheduler
  var gridMapping = getLaunchpadGridMapping()
  loopGrid.shape.set(gridMapping.shape)

  var activatedAt = 0
  var shiftHeld = false
  var releases = []

  var midiPort = MidiPort(context, function (port, lastPort) {
    // turn off on switch
    lastPort && lastPort.write(turnOffAll)
    if (port) {
      port.write(turnOffAll)
      activatedAt = Date.now()
    }
  })

  // extend loop-grid instance
  var obs = ObservStruct({
    port: midiPort,
    loopLength: loopGrid.loopLength,
    chunkPositions: Dict({})
  })

  obs.gridState = ObservStruct({
    active: loopGrid.active,
    playing: loopGrid.playing,
    recording: recording,
    triggers: loopGrid.grid
  })

  obs.activeInput = computed([midiPort.stream], function (value) {
    return !!value
  })

  releases.push(
    watch(looper, loopGrid.loops.set)
  )

  obs.context = context
  obs.playback = loopGrid
  obs.looper = looper
  obs.repeatLength = Observ(2)
  obs.recordingLoop = recordingLoop

  var repeatOffbeat = Observ(false)
  var flags = computeFlags(context.chunkLookup, obs.chunkPositions, loopGrid.shape)

  watch( // compute targets from chunks
    computeTargets(context.chunkLookup, obs.chunkPositions, loopGrid.shape),
    loopGrid.targets.set
  )

  // grab the midi for the current port
  obs.grabInput = function () {
    midiPort.grab()
  }

  // loop transforms
  var transforms = {
    selector: Selector(gridMapping.shape, gridMapping.stride),
    holder: Holder(looper.transform),
    mover: Mover(looper.transform),
    repeater: Repeater(looper.transformTop),
    suppressor: Suppressor(looper.transform, gridMapping.shape, gridMapping.stride)
  }

  var chunkColors = ChunkColors(context.chunkLookup, context.setup.selectedChunkId, loopGrid.targets, loopGrid.shape)

  var outputLayers = ObservGridStack([

    chunkColors,

    // recording
    mapGridValue(recording, 7),

    // active
    applyColorFilter(chunkColors, {
      multiply: 8,
      saturate: 1.5,
      active: loopGrid.active
    }),

    // selected
    mapGridValue(transforms.selector, 12),

    // suppressing
    mapGridValue(transforms.suppressor, 7),

    // playing
    applyColorFilter(chunkColors, {
      multiply: 8,
      add: 10,
      active: loopGrid.playing
    })

  ])

  setLights(outputLayers, midiPort.stream)

  var controllerGrid = ObservMidi(midiPort.stream, gridMapping)
  var inputGrabber = GrabGrid(controllerGrid)

  var noRepeat = computeIndexesWhereContains(flags, 'noRepeat')
  var freezeSuppress = computeIndexesWhereContains(flags, 'freezeSuppress')

  var grabInputExcludeNoRepeat = function (listener) {
    return inputGrabber(listener, { exclude: noRepeat })
  }

  var inputGrid = Observ()
  watch(inputGrabber, inputGrid.set)
  var activeIndexes = computeActiveIndexes(inputGrid)

  // trigger notes at bottom of input stack
  var output = DittyGridStream(inputGrid, loopGrid.grid, context.scheduler)
  output.on('data', loopGrid.triggerEvent)

  obs.currentlyPressed = computed([controllerGrid, loopGrid.grid], function (value, grid) {
    return grid.data.filter(function (name, index) {
      if (value.data[index]) {
        return true
      }
    })
  })

  // midi button mapping
  var buttons = MidiButtons(midiPort.stream, {
    store: '176/104',
    flatten: '176/105',
    undo: '176/106',
    redo: '176/107',
    hold: '176/108',
    suppress: '176/109',
    swapTarget: '176/110',
    select: '176/111'
  })

  var releaseLoopLengthLights = []
  var ignoreStoreUp = false

  // store loop when end time is reached
  releases.push(
    context.scheduler.onSchedule(schedule => {
      if (Array.isArray(obs.recordingLoop()) && obs.recordingLoop()[1]) {
        var scheduleLength = schedule.to - schedule.from
        var from = obs.recordingLoop()[0]
        var duration = obs.recordingLoop()[1]
        var to = from + duration
        var remaining = to - schedule.to

        if (remaining <= scheduleLength) {
          looper.store(from - remaining + duration, duration)
          buttons.store.flash(stateLights.green)
          obs.recordingLoop.set(null)
        }
      }
    })
  )

  watchButtons(buttons, {
    store: function (value) {
      // ignore an up press if we are holding the store
      if (!value && ignoreStoreUp) {
        ignoreStoreUp = false
        return
      }

      if (!shiftHeld && Array.isArray(obs.recordingLoop()) && scheduler.getCurrentPosition() - obs.recordingLoop()[0] > 0.9) {
        // button up or down, when active recording
        // loop the duration of recording

        var duration = scheduler.getCurrentPosition() - obs.recordingLoop()[0]
        obs.loopLength.set(quantizeDuration(duration))
        looper.store()
        this.flash(stateLights.red)

        // stop recording
        obs.recordingLoop.set(null)
      } else if (value) { // BUTTON DOWN -----
        if (shiftHeld) {
          // start recording from next % 4 beats quantized (ignore the up)
          ignoreStoreUp = true

          // calculate quantized loop recorder start
          var currentPosition = scheduler.getCurrentPosition()
          var startPosition = Math.floor(currentPosition + 1.7)

          obs.recordingLoop.set([startPosition, obs.loopLength()])
        } else {
          // lets assume we're recording a loop, but if the duration is too short, we'll loop the last x bars
          obs.recordingLoop.set([scheduler.getCurrentPosition()])
        }
      } else { // BUTTON UP ----
        // record key was pressed quickly, just loop the last `loopLength`

        if (obs.recordingLoop()) {
          looper.store(obs.recordingLoop()[0])
          obs.recordingLoop.set(null)
        }

        this.flash(stateLights.green)
      }
    },

    flatten: function (value) {
      if (value) {
        if (buttons.store()) {
          // store is held, lock in the recording
          ignoreStoreUp = true
        } else if (obs.recordingLoop()) {
          // cancel recording if pressed
          obs.recordingLoop.set(null)
        } else {
          var active = activeIndexes()
          if (looper.isTransforming() || active.length) {
            looper.transform(holdActive, active)
            looper.flatten()
            transforms.selector.stop()
            this.flash(stateLights.green, 100)
          } else {
            this.flash(stateLights.red, 100)
            transforms.suppressor.start(scheduler.getCurrentPosition(), transforms.selector.selectedIndexes())
            looper.flatten()
            transforms.suppressor.stop()
            transforms.selector.stop()
          }
        }
      }
    },

    undo: function (value) {
      if (value) {
        if (shiftHeld) { // halve loopLength
          var current = obs.loopLength() || 1
          if (current > 1 / 8) {
            obs.loopLength.set(quantizeToSquare(current / 2))
            this.flash(stateLights.green, 100)
          }
        } else {
          looper.undo()
          this.flash(stateLights.red, 100)
          buttons.store.flash(stateLights.red)
        }
      }
    },

    redo: function (value) {
      if (value) {
        if (shiftHeld) { // double loopLength
          var current = obs.loopLength() || 1
          if (current < 64) {
            obs.loopLength.set(quantizeToSquare(current) * 2)
            this.flash(stateLights.green, 100)
          }
        } else {
          looper.redo()
          this.flash(stateLights.red, 100)
          buttons.store.flash(stateLights.red)
        }
      }
    },

    hold: function (value) {
      if (value) {
        var turnOffLight = this.light(stateLights.purpleLow)
        transforms.holder.start(
          scheduler.getCurrentPosition(),
          transforms.selector.selectedIndexes(),
          turnOffLight
        )
      } else {
        transforms.holder.stop()
      }
    },

    suppress: function (value) {
      if (value) {
        var turnOffLight = this.light(stateLights.red)
        transforms.suppressor.start(scheduler.getCurrentPosition(), transforms.selector.selectedIndexes(), freezeSuppress(), turnOffLight)
      } else {
        transforms.suppressor.stop()
      }
    },

    swapTarget: function (value) {
      if (value) {
        getPortSiblings(obs, context.setup.controllers)[1].grabInput()
      } else if (Date.now() - activatedAt > 500) {
        getPortSiblings(obs, context.setup.controllers)[0].grabInput()
      }
    },

    select: function (value) {
      if (value) {
        var turnOffLight = this.light(stateLights.green)
        transforms.selector.start(inputGrabber, function done () {
          transforms.mover.stop()
          transforms.selector.clear()
          turnOffLight()
        })
      } else {
        if (transforms.selector.selectedIndexes().length) {
          transforms.mover.start(inputGrabber, transforms.selector.selectedIndexes())
        } else {
          transforms.selector.stop()
        }
      }
    }
  })

  // shift button (share select button)
  watch(buttons.select, function (value) {
    if (value) {
      shiftHeld = true

      // turn on loop length lights
      releaseLoopLengthLights.push(
        buttons.undo.light(stateLights.greenLow),
        buttons.redo.light(stateLights.greenLow)
      )
    } else {
      shiftHeld = false

      // turn off loop length lights
      while (releaseLoopLengthLights.length) {
        releaseLoopLengthLights.pop()()
      }
    }
  })

  // light up undo buttons by default
  buttons.undo.light(stateLights.redLow)
  buttons.redo.light(stateLights.redLow)

  buttons.store.light(stateLights.grey)

  var releaseRecordingLight = null
  watch(obs.recordingLoop, (value) => {
    releaseRecordingLight && releaseRecordingLight()
    releaseRecordingLight = null
    if (value != null) {
      releaseRecordingLight = buttons.store.light(stateLights.red)
    }
  })

  var willFlatten = computed([activeIndexes, looper.transforms, buttons.store], function (indexes, transforms, storeHeld) {
    return !!indexes.length || !!transforms.length || !!storeHeld
  })

  // light up store button when transforming (flatten mode)
  var releaseFlattenLight = null
  watch(willFlatten, function (value) {
    if (value && !releaseFlattenLight) {
      releaseFlattenLight = buttons.flatten.light(stateLights.greenLow)
    } else if (!value && releaseFlattenLight) {
      releaseFlattenLight()
      releaseFlattenLight = null
    }
  })

  var repeatButtonOutput = computed([loopGrid.loopPosition, obs.repeatLength, repeatOffbeat, obs.recordingLoop], (loopPosition, repeatLength, repeatOffbeat, recordingLoop) => {
    var result = {}
    var repeatIndex = repeatStates.indexOf(repeatLength)
    var currentBeat = Math.floor(loopPosition[0])

    if (recordingLoop) {
      var pos = scheduler.getCurrentPosition() - recordingLoop[0]
      var duration = recordingLoop[1] ? recordingLoop[1] : 32
      for (let i = 0; i < 8; i++) {
        if (pos < 0) {
          // counting down to record
          if (i >= Math.floor(8 + pos * 4)) {
            result[i] = stateLights.yellow
          }
        } else {
          var currentIndex = Math.floor(pos / duration * 8)
          if (currentIndex === i && loopPosition[0] >= currentBeat && loopPosition[0] < currentBeat + 0.1) {
            // flash light on beat
            result[i] = stateLights.red
          } else if ((pos > 0 && currentIndex >= i) || (pos < 0 && currentIndex <= i)) {
            result[i] = stateLights.redLow
          } else if (repeatIndex === i) {
            result[i] = stateLights.grey
          }
        }
      }
    } else {
      var beatIndex = Math.floor(loopPosition[0] / loopPosition[1] * 8)
      for (let i = 0; i < 8; i++) {
        if (beatIndex === i && loopPosition[0] >= currentBeat && loopPosition[0] < currentBeat + 0.1) {
          // flash light on beat
          result[i] = stateLights.green
        } else if (repeatIndex === i) {
          result[i] = repeatOffbeat ? stateLights.red : stateLights.grey
        } else if (beatIndex === i) {
          result[i] = stateLights.greenLow
        }
      }
    }
    return result
  })

  var repeatButtons = ObservMidi(midiPort.stream, {
    0: '144/89',
    1: '144/79',
    2: '144/69',
    3: '144/59',
    4: '144/49',
    5: '144/39',
    6: '144/29',
    7: '144/19'
  }, repeatButtonOutput)

  // repeater
  mapWatchDiff(repeatStates, repeatButtons, obs.repeatLength.set)
  watch(obs.repeatLength, function (value) {
    transforms.holder.setLength(value)
    if (value < 2 || shiftHeld) {
      repeatOffbeat.set(shiftHeld)
      transforms.repeater.start(grabInputExcludeNoRepeat, value, shiftHeld)
    } else {
      transforms.repeater.stop()
    }
  })

  // cleanup / disconnect from keyboard on destroy

  obs.destroy = function () {
    recording.destroy()
    midiPort.destroy()
    output.destroy()
    loopGrid.destroy()
    looper.destroy()
    while (releases.length) {
      releases.pop()()
    }
  }

  return obs
}