Simple Synthesizer with Vue and Web Audio API

by bc_rikko

HTML

<script src="https://cdn.jsdelivr.net/npm/vue"></script>
<div id="app">
  <div class="keyboard">
    <ul class="key-list">
      <li
        v-for="(note, i) in notes"
        :key="i"
        class="key"
        :class="{ '-sharp': isSharp(i) }"  
        @mousedown="stroke(note)"
        @mouseup="release"
        @mouseleave="release"
        ></li>
    </ul>
  </div>
</div>

SCSS

.keyboard {
  padding: 10px 0;
  background-color: rgba(6,6,6,.3);
}

.key-list {
  height: 250px;
  background-color: #666666;
  
  /* 改行させないため */
  overflow-x: scroll;
  overflow-y: hidden;
  white-space:nowrap;

  > .key {
    cursor: pointer;
    display: inline-block;
    
    width: 60px;
    height: calc(100% - 6px);  /* scrollbar分 */
    background-color: #ffffff;
    margin: 0 3px;

    border-radius: 0 0 5px 5px;

    &.-sharp {
      position: relative;
      width: 0;
      margin: 0;

      &::after {
        display: block;
        content: '';

        position: absolute;
        top: 0;
        left: calc(-50px / 2);
        width: 50px;
        height: 50%;

        border-radius: 0 0 5px 5px;
        background-color: #000000;
      }
    }
  }
}

JavaScript

// import vue@2.5.3
class VOscillator {
  constructor() {
    this.ctx = new AudioContext();
    this.osc;
  }
  
  /**
   * 音を鳴らす
   * @param note Hz
   */
  sound(note) {
    // Failed to execute 'start' on 'AudioScheduledSourceNode': cannot call start more than once.
    // というエラーがでるので、音を鳴らす度にオシレーターを再生成する
    this.osc = this.ctx.createOscillator();
    this.osc.frequency.value = note;
    this.osc.connect(this.ctx.destination);

    this.osc.start();
  }
  
  /**
   * 音を止める
   */
  stop() {
    this.osc.stop();
  }
}

new Vue({
  el: '#app',
  data () {
    return {
      KEY_A: 440,  // 基音: 440Hz
      osc: null    // オシレーター
    }
  },
  created () {
    this.osc = new VOscillator();
  },
  mounted () {
    // 基音付近にスクロール
    const keyList = document.querySelector('.key-list');
    keyList.scrollLeft = (keyList.scrollWidth / 2) - (keyList.offsetWidth / 2);
  },
  computed: {
    notes () {
      // 88鍵 -48は基音A4→A0までの距離
      return [...Array(88)].map((_, i) => this.KEY_A * Math.pow(2, (1 / 12) * (-48 + i)));
    }
  },
  methods: {
    isSharp (i) {
      // A#, C#, D#, F#, G#
      return [1, 4, 6, 9, 11].includes(i % 12)
    },
    stroke (note) {
      this.osc.sound(note);
    },
    release () {
      this.osc.stop();
    }
  }
});