Tonejs / Tone.js

A Web Audio framework for making interactive music in the browser.
https://tonejs.github.io
MIT License
13.4k stars 976 forks source link

Velocity sample mapping for sampler instrument #1034

Open rhelsing opened 2 years ago

rhelsing commented 2 years ago

I'd like to help add velocity range mapping for the sampler instrument. Currently, I see the velocity only impacts the gain factor which is not ideal for many sample based instruments. Generally the timbre changes as well so most samplers accommodate velocity mapped samples.

const source = new ToneBufferSource({
                url: buffer,
                context: this.context,
                curve: this.curve,
                fadeIn: this.attack,
                fadeOut: this.release,
                playbackRate,
            }).connect(this.output);
            source.start(time, 0, buffer.duration / playbackRate, velocity);

I propose to add an api change that is non-destructive, current mapping should work exactly as it does, but allowing for optional velocity mapping as follows. Open to suggestions on how to make the api more consistent.

Assuming Midi velocity values, A1-0 would cover 0-66, A1-67 would cover 67-127. If you would prefer these values to be normalized to 0.0-1.0, I can do that instead.

If velocitySampleMode is set to true, pre processing would be done on the url mapping to accommodate for velocity ranges.

const sampler = new Tone.Sampler({
    urls: {
        "A1-0": "A1-0.mp3",
                "A2-67": "A1-67.mp3", 
        "A2-0": "A2-0.mp3",
        "A2-67": "A2-67.mp3",
    },
    baseUrl: "https://tonejs.github.io/audio/casio/",
    onload: () => {
        sampler.triggerAttackRelease(["C1", "E1", "G1", "B1"], 0.5);
    },
        velocitySampleMode: true
}).toDestination();
EagleSplash commented 1 year ago

I think your idea would be a great addition.

Does the MIDI standard have a "default" velocity that is used when a user does not specify a value? It might be helpful to allow for setting this as a parameter.

I'm wondering if an articulationSampleMode could be used in conjunction with velocitySampleMode? If you wanted to have both velocity and articulation mapped simultaneously, maybe you'd set different baseURLs for legato samples vs staccato samples... the preprocessor picks the correct folder based on articulation and then the correct sample based on velocity?

dirkk0 commented 1 year ago

I agree, good idea.

Does the MIDI standard have a "default" velocity that is used when a user does not specify a value? It might be helpful to allow for setting this as a parameter.

I think not. Usually (Master) Keyboards have velocity curves which makes things more complicated.

rhelsing commented 1 year ago

Here is what I built to wrap around the regular sampler. It supports round robin and velocity ranges.


window.CustomSampler = function CustomSampler(options) {
  var parent = this
  var options = options
  var synths = {}
  this.velocityRange = false
  this.rangeMap = {}
  this.roundRobbins = false
  this.release = 1
  this.midiKeys = []
  this.roundRobbinState = {}
  this.roundRobbinOptions = []

  this.init = function(){
    // if multiple velocity sample mode.. multiple

    if(options.files){

      // parse and map rr and v
      if(options.files.map(x => x.split('/').at(-1) ).filter(x => x.includes('_v')).length > 0){
        this.velocityRanges = true
      }
      if(options.files.filter(x => x.includes('_rr')).length > 0){
        this.roundRobbins = true
      }
      if(options.release){
        this.release = options.release
      }

      var urlMap = this.buildUrlMap(options.files, this.roundRobbins)
      if(!this.velocityRanges && !this.roundRobbins){
        synths['base'] = new Tone.Sampler({
          urls: urlMap,
          release: this.release,
        })

      }else if(!this.velocityRanges && this.roundRobbins){
        // building synth for each unique note id..
        // some wont have them...
        // will need to ensure all do for now??
        Object.keys(urlMap).forEach(rr => {
          synths[rr] = new Tone.Sampler({
            urls: urlMap[rr],
            release: this.release,
          })
        })

        this.roundRobbinOptions = Object.keys(urlMap).filter(x => !x.includes('midi') )

      }else if(this.velocityRanges && !this.roundRobbins){
        Object.keys(urlMap).forEach(range => {
          synths[range] = new Tone.Sampler({
            urls: urlMap[range],
            release: this.release,
          })

          for(var z = parseInt(range.split('-')[0]); z<=parseInt(range.split('-')[1]); z++){
            this.rangeMap[z] = range
          }
        })
      }else if(this.velocityRanges && this.roundRobbins){

        Object.keys(urlMap).forEach(range => {

          Object.keys(urlMap[range]).forEach(rr => {

            synths[range+'_'+rr] = new Tone.Sampler({
              urls: urlMap[range][rr],
              release: this.release,
            })

          })
          this.roundRobbinOptions.push([range, Object.keys(urlMap[range]).filter(x => !x.includes('midi') )])

          for(var z = parseInt(range.split('-')[0]); z<=parseInt(range.split('-')[1]); z++){
            this.rangeMap[z] = range
          }
        })

      }

    }else{
      // old method

      // preprocessing.. based on velocity mode
      if(options['velocityRanges']){

        Object.keys(options['velocityMapConfig']).forEach(x => {

          var urlsForRange = {}

          for(var z = parseInt(x.split('-')[0]); z<=parseInt(x.split('-')[1]); z++){
            rangeMap[z] = x
          }

          Object.keys(options["urls"]).forEach(y => {
            if(y.includes(x)){
              urlsForRange[y.replace('_'+x, '')] = options["urls"][y]
            }
          })

          synths[x] = new Tone.Sampler({
            urls: urlsForRange,
            release: options['release'],
          })

        })

      }else{

        synths['base'] = new Tone.Sampler({
          urls: options['urls'],
          release: options['release'],
        })

      }
    }

  }

  this.chain = function (...cOptions) {
    Object.keys(synths).forEach(s => {
      synths[s].chain(...cOptions)
    })
    return parent
  }

  this.buildUrlMap = function(files, applyRoundRobbin = false){

    var urlMap = {}
    files.forEach(x => {
      var file = x.split('/').at(-1)
      var note = file.split('.')[0].split('_')[0].toUpperCase()
      var velocityRange = file.split('_v')[1]
      if(velocityRange){
        velocityRange = velocityRange.split('.')[0].split('_')[0]
      }
      var roundRobbin = file.split('_rr')[1]
      if(roundRobbin){
        roundRobbin = roundRobbin.split('.')[0].split('_')[0]
      }

      // build up progressive urlMap that will be looped through
      if(velocityRange && !roundRobbin && !applyRoundRobbin){
        if(!urlMap[velocityRange]){
          urlMap[velocityRange] = {}
        }
        urlMap[velocityRange][note] = x
      }else if(!velocityRange && (roundRobbin || applyRoundRobbin)){
        if(!roundRobbin){
          roundRobbin = 'midi'+Tone.Frequency(note, "note").toMidi()
          this.midiKeys.push(Tone.Frequency(note, "note").toMidi())
        }
        if(!urlMap[roundRobbin]){
          urlMap[roundRobbin] = {}
        }
        urlMap[roundRobbin][note] = x
      }else if(velocityRange && (roundRobbin|| applyRoundRobbin)){
        if(!urlMap[velocityRange]){
          urlMap[velocityRange] = {}
        }
        if(!urlMap[velocityRange][roundRobbin]){
          urlMap[velocityRange][roundRobbin] = {}
        }
        urlMap[velocityRange][roundRobbin][note] = x

      }else if(!velocityRange && !roundRobbin && !applyRoundRobbin){
        urlMap[note] = x
      }
    })
    return urlMap

  }

  this.triggerAttack = function(notesArr, time, velocity){
    // console.log(notesArr, time, velocity)
    if(this.velocityRanges && this.roundRobbins){

      //combined... assuming for now that every layer has same amount of variations and velocity..
      var thisValue = parseInt(velocity*127.0)
      var topOfRange = parseFloat(this.rangeMap[thisValue].split('-')[1])
      var normalizedVelocity = parseFloat(thisValue)/topOfRange
      //set velocity curve for more natural sound
      if(normalizedVelocity < 0.3){
        normalizedVelocity += 0.4 //velocity curve
      }
      if(this.rangeMap[thisValue].includes('127')){
        normalizedVelocity += 0.2 //velocity curve
      }

      notesArr.forEach(n => {
        var midiNote = Tone.Frequency(n, "note").toMidi()
        var distances = this.midiKeys.map(x => [Math.abs(midiNote-x), x]).filter(x => x[0] < 12 ).sort(x => x[0])
        if(distances.length > 0){
          var key = "midi"+distances[0][1]
          synths[key].triggerAttack([n], time, velocity)
        }else{
          //cycle cycle!
          var robbinOptions = this.roundRobbinOptions.find(x => x[0] == this.rangeMap[thisValue])[1]
          if(this.roundRobbinState[n]){
            // move forward so not repeating for note
            this.roundRobbinState[n] = (this.roundRobbinState[n]+1)%robbinOptions.length
          }else{
            //initialize random index
            this.roundRobbinState[n] = Math.floor((Math.random()*robbinOptions.length))
          }
          // if in roundRobbinState, move forward and play, if not, pick one and play
          synths[this.rangeMap[thisValue]+'_'+robbinOptions[this.roundRobbinState[n]]].triggerAttack([n], time, velocity)
        }
      })

    }else if(this.velocityRanges){
      // choose specific synth based on velocity...
      // normalized velocity number in range.. 0-1
      // var normalizedVelocity = 0.5*
      var thisValue = parseInt(velocity*127.0)
      var topOfRange = parseFloat(this.rangeMap[thisValue].split('-')[1])
      var normalizedVelocity = parseFloat(thisValue)/topOfRange
      //set velocity curve for more natural sound
      if(normalizedVelocity < 0.3){
        normalizedVelocity += 0.4 //velocity curve
      }
      if(this.rangeMap[thisValue].includes('127')){
        normalizedVelocity += 0.2 //velocity curve
      }
      // console.log('volume',normalizedVelocity, this.rangeMap[thisValue])
      // just pass velocity
      synths[this.rangeMap[thisValue]].triggerAttack(notesArr, time, normalizedVelocity)

    }else if(this.roundRobbins){
      // cycle each time if available
      // check midi note.. if close to midi.. use it instead of others
      notesArr.forEach(n => {
        var midiNote = Tone.Frequency(n, "note").toMidi()
        var distances = this.midiKeys.map(x => [Math.abs(midiNote-x), x]).filter(x => x[0] < 12 ).sort(x => x[0])
        if(distances.length > 0){
          var key = "midi"+distances[0][1]
          synths[key].triggerAttack([n], time, velocity)
        }else{
          //cycle cycle!
          if(this.roundRobbinState[n]){
            // move forward so not repeating for note
            this.roundRobbinState[n] = (this.roundRobbinState[n]+1)%this.roundRobbinOptions.length
          }else{
            //initialize random index
            this.roundRobbinState[n] = Math.floor((Math.random()*this.roundRobbinOptions.length))
          }
          // if in roundRobbinState, move forward and play, if not, pick one and play
          synths[this.roundRobbinOptions[this.roundRobbinState[n]]].triggerAttack([n], time, velocity)
        }
      })

    }else{
      // just playing all... round robbin should cycle through.. have index for each note played, start with random
      Object.keys(synths).forEach(s => {
        synths[s].triggerAttack(notesArr, time, velocity)
      })
    }

    notesArr.forEach(n => {
      notesPressed.push({event: 'on', note: n, velocity: velocity, time: time})
      highlightKey(n)
      // console.log({event: 'on', note: n, velocity: velocity, time: time})
    })

  }

  this.triggerRelease = function(notesArr, time){
    Object.keys(synths).forEach(s => {
      // try release on all?
      synths[s].triggerRelease(notesArr, time)
    })

    notesArr.forEach(n => {
      notesPressed.push({event: 'off', note: n, time: time})
      unHighlightKey(n)
    })
  }

  this.triggerAttackRelease = function(notesArr, dur, time, velocity){
    // if(this.velocityRanges){
      // choose specific synth based on velocity...
      // normalized velocity number in range.. 0-1
      // var normalizedVelocity = 0.5*
      this.triggerAttack(notesArr, time, velocity)
      this.triggerRelease(notesArr, time+Tone.Time(dur).toSeconds())

    // }else{
    //   Object.keys(synths).forEach(s => {
    //     // based on velocity.. use different synth and apply slightly diff volume in range..
    //     synths[s].triggerAttack(notesArr, time, velocity)
    //     synths[s].triggerRelease(notesArr, time+Tone.Time(dur).toSeconds())
    //   })
    // }
  }

  parent.init()
}
IARI commented 11 months ago

This would absolutely be an awesome addition! if the maintainers would want to add this, I can offer to try to add typescript to the code above from @rhelsing