🎶

SkyWay + Vue.jsで相対音感を体感できるアプリを作ってみた

2023/06/29に公開

はじめに

この記事は、Qiita Engineer Festa 2023 「新しくなったSkyWayを使ってみよう!」に参加するために作成した記事です

https://qiita.com/devgeeeen/items/2b33c9f3ead34ea42d1b

SkyWayというWebRTCサービスの応用方法について重点を置いて書いています

概要

相対音感とは?
音の高低や音程の関係を直感的に感じ取る能力のことを指し、
基準となる音を基に音の高さの違いからの音名を言い当てる事ができます

私は最近、趣味でハーモニカをやっているのですが、
練習方法としてYoutubeを見て学んだりしています
しかし、耳コピみたいな事はできないので楽譜がないと演奏はできず、
絶対音感や相対音感が身に付いたらなと思う事があります

そこで今回はSkyWay + Vue.jsで「相対音感を体感できるアプリ」を作ってみようと思います!

作成するもの

「相対音感を体感できるアプリ」としていますが、正確には以下の事ができる事を目指します

  • 楽器の音がどの音名か判別できる
  • 楽器の音が「ドレミファソラシド」に聞こえる

これを通話アプリに組み込むので、このようなイメージになります

絶対音感.jpg

完成したもの

※ 音声も聞いてほしいので動画をTwitterにあげて埋め込みました!

複数人同時通話

通話機能作成に使用した「SkyWay」に標準搭載されている機能です!
RoomIDを入力するとユーザーにIDが発行されて、入力されたIDの部屋に接続できます
(PCを3台並べて撮影しています)

12音符(音名)・周波数を表示

画面上に常に測定した「周波数」と周波数・基準周波数から判別した「12音符」を表示できます
基準となる「ラ」の音の周波数を入れてからそれを基に音名を特定しているので、
やっている事としては「相対音感」となります!

周波数と12音符については開発項目で説明を入れていますが、
周波数は音の振動が1秒間に何回繰り返されるかを表す数値で、音の高さを表す数値でもあります
12音符は音名で1オクターブを12個のドレミファソラシドに分けた音の名前です

標準音階モード

楽器の音が「固定された音階のドレミファソラシド」に聞こえるようになるモードです
聞こえる音が12平均律(標準的な音階)のドレミファソラシドに変換されて再生されます!

開発

それでは作り方です!
開発環境としてはMac + Dockerを使っています
SkyWayのトークン作成などは公式で詳しく記載されているので説明は省きます

https://skyway.ntt.com/ja/docs/user-guide/javascript-sdk/quickstart/

アプリ構成

これらを使って作成していきます

  • Vite
    • ビルドツール
    • 公式チュートリアルと同じようにParcelを使っても良かったのですが、
      Vue.jsと相性が良いのもありこちらを採用しました
  • Vue.js
    • フレームワーク
    • 画面を多少リッチに作りたかったのでVue.jsを採用しました
  • SkyWay
    • ライブラリ
    • 今回の主役でWebRTCが可能なサービスです
  • Pitchfinder
    • ライブラリ
    • 音声解析(ピッチ検出)などが可能なライブラリです
  • Web Audio API
    • ブラウザのAPI
    • ほとんどのブラウザに標準搭載されていてオーディオの再生や録音などに使用されます

Vue.jsでSkyWayを使える様にする

初めにViteにセットアップしたVue.jsにSkyWayを導入してみました
Vue3のComposition APIを使って書いています
(一部createElementでDOM操作しているのはモヤモヤしましたが仕方ないかな。。。)

コードコメントに処理の内容を記載していますが、
公式チュートリアルでやっている事をそのままVueのフォーマットに書き換えていますので、
詳しく知りたい方は公式の解説を見てもらえると良いと思います!

https://skyway.ntt.com/ja/docs/user-guide/javascript-sdk/quickstart/#170

<script setup lang='ts'>
import { ref, onMounted } from 'vue'
import {
  nowInSec,
  RemoteVideoStream,
  SkyWayAuthToken,
  SkyWayContext,
  SkyWayRoom,
  SkyWayStreamFactory,
  uuidV4,
} from '@skyway-sdk/room'
import { appId, secret } from './env'

const token = new SkyWayAuthToken({
  jti: uuidV4(),
  iat: nowInSec(),
  exp: nowInSec() + 60 * 60 * 24,
  scope: {
    app: {
      id: appId,
      turn: true,
      actions: ['read'],
      channels: [
        {
          id: '*',
          name: '*',
          actions: ['write'],
          members: [
            {
              id: '*',
              name: '*',
              actions: ['write'],
              publication: {
                actions: ['write'],
              },
              subscription: {
                actions: ['write'],
              },
            },
          ],
          sfuBots: [
            {
              actions: ['write'],
              forwardings: [
                {
                  actions: ['write'],
                },
              ],
            },
          ],
        },
      ],
    },
  },
}).encode(secret)

const myVideo = ref(null)
const myAudio = ref(null)

const localVideo = ref(null)
const remoteVideoArea = ref(null)
const remoteAudioArea = ref(null)

const myId = ref('')
const roomName = ref('')

onMounted(async () => {
  // 自分の音声と映像を取得
  const { audio, video } = await SkyWayStreamFactory.createMicrophoneAudioAndCameraStream()
  myVideo.value = video
  myAudio.value = audio

  // 自分の映像を表示
  myVideo.value.attach(localVideo.value)
  await localVideo.value.play()
})

const join = async () => {
  if (roomName.value === '') return

  // 特定のRoomに入室する
  const context = await SkyWayContext.Create(token)
  const room = await SkyWayRoom.FindOrCreate(context, {
    type: 'p2p',
    name: roomName.value,
  })
  const me = await room.join()
  myId.value = me.id

  // 自分の映像と音声を公開する
  await me.publish(myVideo.value)
  await me.publish(myAudio.value)

  // 他のユーザーがいた・入室してきた時の処理
  const subscribeAndAttach = async (publication: any) => {
    if (publication.publisher.id === me.id) return

    const { stream } = await me.subscribe<RemoteVideoStream>(
      publication.id
    )

    // DOMに直接videoとaudioのelementを追加する
    let newMedia
    switch (stream.track.kind) {
      case 'video':
        newMedia = document.createElement('video')
        newMedia.width = 300
        newMedia.playsInline = true
        newMedia.autoplay = true

        stream.attach(newMedia)
        remoteVideoArea.value.appendChild(newMedia)
        break
      case 'audio':
        newMedia = document.createElement('audio')
        newMedia.controls = true
        newMedia.autoplay = true

        stream.attach(newMedia)
        remoteAudioArea.value.appendChild(newMedia)
        break
      default:
        return
    }
  }

  // 入室した部屋で公開されている映像・音声があった時に実行
  room.publications.forEach(subscribeAndAttach)
  // 別のユーザーが新しく映像・音声を公開した時にも実行
  room.onStreamPublished.add((e: any) => subscribeAndAttach(e.publication))
}
</script>

<template>
  <div>
    <div>ID: <span>{{ myId }}</span></div>
    <video ref="localVideo" muted playsinline class="local-video" />
    <div class="room-name">
      Room Name:
      <div v-if="!myId">
        <input v-model="roomName" type="text" />
        <button @click="join">Join</button>
      </div>
      <div v-else>{{roomName}}</div>
    </div>
    <div ref="remoteVideoArea" class="remote-videos" />
    <div ref="remoteAudioArea" class="remote-audios" />
  </div>
</template>

<style scoped>
.room-name {
  display: flex;
  justify-content: center;
  align-items: center;
  padding: 8px;
  gap: 8px;
}

.local-video {
  width: 300px;
}

.remote-videos {
  display: grid;
  grid-template-columns: 300px;
  grid-auto-flow: column;
  gap: 8px;
}

.remote-audios {
  display: grid;
  grid-template-columns: 300px;
  grid-auto-flow: column;
  gap: 8px;
}
</style>

通話相手の音声を解析する

次に音声解析の処理を作っていきます

12音符・周波数・音階

まず事前知識として12音符・周波数・音階について説明しておきます

  • 音名
    12音符は音を1オクターブごとに12個の異なる種類に分けたものです

  • 周波数
    周波数は音の振動が1秒間に何回繰り返されるかを表す数値で、
    音の周波数は音声解析によって測定する事ができます

  • 音階
    音の音名を特定するには周波数と音階が必要になります
    同じ周波数でもその音は「ド(C)」の場合もあるし「ファ(F)」の場合もあるので、
    音名を知るにはまず、その音がどの音階の音なのかを知る必要があるのです
    楽器などはある特定の音階で音が鳴るように調律されているので、
    その楽器の音階は音を一つ鳴らして周波数を測定する事で特定する事ができます

絶対音感の人はこの「音階」がわからなくても音を聞いただけで、
正確に音名を聞き分けられるのですごいって言われているわけですね!

こちらの表が12音符を一覧にしたものです
12平均律というのは一般的に世界中で使用される標準的な音階です
順番が「ラ」スタートなのは、
音階の基準値にされるのは一般的に「ラ」だからです(後に出てくる関数にも関係します)

音名 音名(英語) 12平均律での周波数
A 440.00Hz
ラ(高) A# 466.16Hz
B 493.88Hz
C 261.63Hz
ド(高) C# 277.18Hz
D 293.66Hz
レ(高) D# 311.13Hz
E 329.63Hz
ファ F 349.23Hz
ファ(高) F# 369.99Hz
G 392.00Hz
ソ(高) G# 415.30Hz
// ドレミファソラシドの12音符(音名)
const notes = [
  'ラ',
  'ラ#',
  'シ',
  'ド',
  'ド#',
  'レ',
  'レ#',
  'ミ',
  'ファ',
  'ファ#',
  'ソ',
  'ソ#'
]

// 12平均律の周波数
const frequencies = [
  440.00, // ラ
  466.16, // ラ#(高)
  493.88, // シ
  261.63, // ド
  277.18, // ド#(高)
  293.66, // レ
  311.13, // レ#(高)
  329.63, // ミ
  349.23, // ファ
  369.99, // ファ#(高)
  392.00, // ソ
  415.30 // ソ#(高)
]

const analyzedAudio = ref('')
const audioPich = ref('')
const baseFrequency = ref(440)

音声解析

音の周波数を計測する処理についての説明です
音声解析を簡単に行う為に「Pitchfinder」という音声解析用のライブラリを使用します
しかし、SkyWayから配信されてくる音声をPitchfinderにぶっ込めば解析してくれるというわけでもなく、
まず音声データをPitchfinderで解析できる型に変換させなくてはいけません

SkyWayからの音声ファイルはMediaStreamTrack型なので、
これを最終的にPitchfinderで解析可能なFloat32Array型に変換します
SkyWayライブラリで扱われるオブジェクトの型は公式ドキュメントがあるので、
こちらを参考にするとわかりやすいと思います

https://javascript-sdk.api-reference.skyway.ntt.com/room/

MediaStreamTrackMediaStreamに変換して、
MediaStreamからオーディオソースノードとモノラルのスクリプトプロセッサーノードを作成して再生デバイスに接続しています
この音声を scriptNode.onaudioprocessを使い一定の入力バッファでコールバックさせて、
認識されたevent(AudioProcessingEvent)からaudioData(Float32Array)を抽出してPitchfinderで解析するという流れです

// DOMに直接videoとaudioのelementを追加する
let newMedia
switch (stream.track.kind) {
  case 'video':
    newMedia = document.createElement('video')
    newMedia.width = 300
    newMedia.playsInline = true
    newMedia.autoplay = true

    stream.attach(newMedia)
    remoteVideoArea.value.appendChild(newMedia)
    break
  case 'audio':
    newMedia = document.createElement('audio')
    newMedia.controls = true
    newMedia.autoplay = true

    // MediaStreamTrackをMediaStreamに変換
    const mediaStream = new MediaStream([stream.track])
    const audioContext = new AudioContext()
    // MediaStreamからオーディオソースノードを作成
    const sourceNode = audioContext.createMediaStreamSource(mediaStream)
    // モノラルのスクリプトプロセッサーノードを作成
    const scriptNode = audioContext.createScriptProcessor(2048, 1, 1)

    // 処理したオーディオデータが実際に再生させる
    sourceNode.connect(scriptNode)
    scriptNode.connect(audioContext.destination)

    // オーディオデータが一定のバッファサイズになったらコールバックする
    scriptNode.onaudioprocess = (event) => {
      // オーディオデータを取り出す(モノラル)
      const audioData = event.inputBuffer.getChannelData(0)
      // pitchfinderで音声解析
      const detectPitch = Pitchfinder.AMDF()
      const pitch = detectPitch(audioData)

      // 解析できた音があった場合画面に表示する
      if (pitch) {
        audioPich.value = Math.round(pitch)
        const pitchIndex = pitchToIndex(audioPich.value)
        analyzedAudio.value = notes[pitchIndex]
      } else {
        audioPich.value = ''
        analyzedAudio.value = ''
      }
    }

    stream.attach(newMedia)
    remoteAudioArea.value.appendChild(newMedia)
    break
  default:
    return
}

型変換ができたらPitchfinderに渡すだけで周波数を測定してくれます

ちなみに一般的に音声解析といえばフーリエ変換がありますが、
Pitchfinderでは「AMDF」というアルゴリズムを使ってピッチ検出を行なっているようです
(AMDFについて知りたい方はググってください)

Pitchfinderでは他にもYINなどのアルゴリズムが使えますが、
全て試した結果AMDFが一番精度が良かったのでAMDFを採用しています

// pitchfinderで音声解析
const detectPitch = Pitchfinder.AMDF()
const pitch = detectPitch(audioData)

周波数から音名を特定

周波数から音名を特定する方法です
この式はよくわからないサイトに載ってた数式を関数にしたものなので、
これが正しいのかはちょっと自信ないです…(正確に動いたので良しとしました)

  1. 測定したい楽器の音の「ラ」の周波数を測定して、音階の基準値として保存します
  2. 測定したい楽器で解析したい音を鳴らして、周波数を測定します
  3. 2で測定した周波数を1の基準値を使って、音の半音を算出します
  4. その値から12で剰余演算してあまりをインデックスとして12音符の配列に入れる
// 基準の周波数と測定したピッチから12音符を判定する
const pitchToIndex = (pitch: number) => {
  // ラから測定した音までの半音の数を計算
  const semitone = Math.round(12 * Math.log2(pitch / baseFrequency.value))
  // ラからの半音の数を12で割った余りを計算
  // 結果となる余りはマイナスになる事があるので、それに更に12を足して12で割った余りを出す事で「0~11」の値を出す
  return (semitone % 12 + 12) % 12
}

// notes[pitchToIndex(pitch)]

解析された音名を代わりに再生する(標準音階モード)

最後に「標準音階モード」の説明です
内容としては解析された音名の「12平均律」での音階で再生するというものです

まず画面上にトグルスイッチを用意して、機能のオンオフができるようにしておきます

<div class="toggles">
  <label for="absolute-pitch" class="toggle-label">標準音階モード</label>
  <input v-model="isAbsolutePitch" type="checkbox" class="toggle" id="absolute-pitch" />
</div>
const isAbsolutePitch = ref(false)

次に音データを再生するコードを書きます
ここでもWeb Audio APIを使用します

audioContextからオシレーターノードを作成して
それに流したい周波数を渡せば簡単にブラウザで音声を再生する事ができます

Pitchfinderのピッチ検出がかなりの高頻度で行われるので、
再生時間は0.3秒としました(長いとカオスになる)

あとはいい感じにスタイルを整えて完成です!

audioPich.value = Math.round(pitch)
const pitchIndex = pitchToIndex(audioPich.value)
analyzedAudio.value = notes[pitchIndex]

if (isAbsolutePitch.value) {
  // オシレーターノードを作成
  const oscillator = audioContext.createOscillator()
  oscillator.frequency.value = frequencies[pitchIndex]

  // 出力先に接続してオシレーターを再生する
  oscillator.connect(audioContext.destination)
  oscillator.start()

  // オシレーターを0.3秒だけ再生して停止する
  oscillator.stop(audioContext.currentTime + 0.3)
}

最後に

感想!久々に時間をかけて記事を書いたので疲れました!
最初は音階の意味も知らずに「絶対音感を体感できるアプリ」として作っていたんですが、
作りながら調べてる内に自分が作っているものは相対音感だと気づいて、
内容を書き直すなんて事がありました笑

WebRTCを触るのは初めてでしたが、
SkyWayは公式ドキュメント(特にライブラリドキュメント)が充実していたので進めやすかったです!

Web Audio APIは少ししか触っていませんが奥が深いなという印象、
音声解析に関してもWebでやっている人がほとんどいない事もあり調べるのに苦労しました…笑

Github

こちらが今回作成したコードのgithubリポジトリになります
起動方法諸々READMEに記載してあるので遊んでみてね!

https://github.com/git-gen/talk-in-absolute-pitch

Discussion