JavaScript五度圏時計のクリックで和音が出るようにしてみた

先日作ったJavaScript五度圏時計ですが、これは単に<canvas>に描画しているだけで、仕掛けているイベントは1秒タイマーのみ。マウスイベントを拾うようには仕掛けていませんでした。

で、ふと思いました。

五度圏時計の文字盤をクリックしてその通りに和音が出たら楽しいだろうな…

せっかくCとかAmとかキー(調)が書いてあって、コード(和音)も同じように表記するので、その通りに和音が出るようにしたくなりますよね。(あ、これ、16年ほど前にMIDI Chord Helperを作ったときも全く同じことを考えた気が…ウクレレのコード表、アルファベット順じゃなくて五度圏順のほうが使いやすい!と考えた、あのときです…。)

Web Audio API で和音を鳴らす

JavaScriptで音を出す方法として Web Audio API を使う方法があります。

このWeb Audio APIで和音を出す方法は、ざっとこんな感じ。

let audioContextInstance;
try {
  window.AudioContext = window.AudioContext || window.webkitAudioContext;
  audioContextInstance = new AudioContext();
}
catch(e) {
  alert('Web Audio API is not supported in this browser');
}
let oscillators;
function startChord(rootnote, offsets) {
  const toOsc = freq => {
    const osc = audioContextInstance.createOscillator();
    const amp = audioContextInstance.createGain();
    osc.type = "sawtooth";
    osc.frequency.value = freq;
    osc.connect(amp);
    amp.gain.value = 0.05;
    amp.connect(audioContextInstance.destination);
    return osc;
  }
  const toFreq = note => 440 * Math.pow(2, (note % 12)/12);
  stopChord();
  oscillators = offsets.map(o => toOsc(toFreq(rootnote + o)));
  oscillators.forEach(o => o.start(audioContextInstance.currentTime));
}
function stopChord() {
  oscillators?.forEach(o => o.stop(audioContextInstance.currentTime));
  oscillators = null;
}

まず、AudioContext のインスタンス(コンテキスト)を作ります。このインスタンスから、oscillator(発振器)とgain(利得:音量を調整するアンプみたいなもの)を生成し、発振器 → アンプ → destination(出力先)、のように接続します。

ただし、発振器は単音で、一度stopしたらおしまい(使い捨て)らしいので、音を出すイベントを拾った時点で発振器を同時発音数だけ生成する形になります。発振器のstart()やstop()には、コンテキストから取得した現在時刻を与えることで「今すぐ実行」を指示します。

ここでは和音を出すときにrootnote(根音の番号)とoffsets(和音の構成音の根音からの半音単位の差)を与えるインターフェースにしています。音の番号はMIDIノート番号に準じた半音単位の音階(0がC音)です。周波数は、1オクターブで2倍→平均律の半音は 21/12 倍、A=440Hz、これだけ知っていれば半音単位の番号から計算式で求めることができます。 MIDI tuning standard の仕様として記述されている計算式もまさにそれです。

あとは発振器に波形を指定したり、アンプのゲイン値を調整したりするだけ。

マウスボタンで文字盤のクリック位置を取得

五度圏時計のクリック位置から、文字盤のどの文字が押されたかを判定し、それに従って先ほどの和音を鳴らす関数に与えられるようにします。せっかくなので、MIDI Chord Helper と全く同じように、右クリック、Shift、Alt、Ctrl などの操作で多種多様な和音を鳴らせるようにしてみましょう。

function circleOfFifthsClockPushed(event, isToPreventContextMenu = false) {
  const rect = event.target.getBoundingClientRect();
  const position = {
    x: event.clientX - (rect.left + rect.right)/2,
    y: event.clientY - (rect.top + rect.bottom)/2,
  };
  const distance = Math.sqrt(position.x ** 2 + position.y ** 2) / event.target.width;
  if( distance < 0.15 || distance > 0.55 ) {
    return;
  }
  if( isToPreventContextMenu ) {
    event.preventDefault();
    return;
  }
  let offset3rd = distance < 0.3 ? -1 : distance > 0.45 ? 1 : 0;
  const hour = Math.round(-Math.atan2(-position.x, -position.y) / RADIAN_PER_HOUR);
  const rootnote = hour + 6 * (hour & 1) + (offset3rd < 0 ? 12 : 15);
  let offset5th = 0;
  if( event.altKey ) {
    offset5th = (offset3rd == 1 ? 1 : -1);
    if( offset3rd == 1 ) offset3rd = 0;
  }
  let offset7th = 0;
  if( event.button == 2 ) offset7th += 2;
  if( event.shiftKey ) ++offset7th;
  const add9th = event.ctrlKey;
  const offsets = [0, 4+offset3rd, 7+offset5th];
  if( offset7th ) offsets.push(8 + offset7th);
  if( add9th ) offsets.push(14);
  startChord(rootnote, offsets);
}
const [tapStart, tapEnd] =
  (typeof window.ontouchstart !== 'undefined') ? ['touchstart', 'touchend'] : ['mousedown', 'mouseup'];
canvas.addEventListener(tapStart, circleOfFifthsClockPushed, false);
canvas.addEventListener(tapEnd, stopChord, false);
canvas.addEventListener('contextmenu', e => circleOfFifthsClockPushed(e, true) );
canvas.addEventListener('touchmove', e => e.preventDefault());
canvas.addEventListener('selectstart', e => circleOfFifthsClockPushed(e, true));

まず、mousedownイベントからクリック位置を取得し、中心からの(x,y)を求めます。次に、ピタゴラスの定理によって中心からの距離を計算します。この距離の範囲によって、反応させない場所、マイナー、メジャー、sus4を鳴らす範囲を決めます。

ブラウザの場合、右クリックのデフォルトの動作は、コンテキストメニューの表示ですが、和音を鳴らすようにしてもコンテキストメニューが出てしまいます。こうなると邪魔なだけでなく、マウスボタンを離しても和音が止まらない現象が発生してしまいます。そこで、和音を出す有効領域でコンテキストメニューが出ないよう、contextmenuイベントも仕掛けておきます。イベント関数では、isToPreventContextMenu という引数を使って、mousedownイベントの場合は和音を鳴らし、contextmenuイベントの場合はpreventDefault()でコンテキストメニューを抑止、という使い分けをします。有効領域を外れている場合、イベント関数では処理が中止されるので、右クリックで通常通りコンテキストメニューが表示されます。

さらに、ダブルクリックとみなされたときに下側のテキストが選択されてしまうのを防ぐため、selectstartイベントも同様に仕掛けておきます。

有効領域で和音を鳴らすことが決まったら、次に、方向から何時かを判定します。atan2関数で(x,y)から tan-1 の値すなわち角度を求め、時計の短針の1時間あたりの角速度で割って四捨五入すれば、五度圏時計の時間位置、すなわちその調の♯の数(マイナスは♭の数)が求まります。ここから半音階に変換するには、奇数のときだけ6(半オクターブは6半音)を足すだけです。

あとは、ShiftキーなどのON/OFFを判定し、MIDI Chord Helperと全く同じ動作をするようにします。

mousedownとmouseupは、スマホの場合にイベント名がtouchstart/touchendに変わるようにしています。

使用感

音楽理論のツボのページに貼り付けてある五度圏時計をクリックして実際にコードを鳴らしてみると…

なかなかいい感じ!! sus4、7th、m7-5、dim7、aug、9th、など色々なコードがすぐに出せるので、TVやラジオで流れている音楽のコード進行をこの五度圏時計をクリックし、そのまま楽器セッションして遊べちゃいます。

時計の文字盤を五度圏にして、和音が出るようにしたら、いつの間にか楽器になってた。これって面白いですね。MIDI Chord HelperやCAmiDionを作ったきっかけと似ています。

AndroidのChromeでも試してみましたが、うまく音が出ないようです…。時計はちゃんと表示できて動いていますが、これも別のブラウザだと表示されないこともあったりします。スマホで動けば、外出先でも楽しめるのに…(今後の課題かな…)

コメントを残す