Go言語でWeb Audio APIを使いたい

この記事の目標

Go言語でWeb Audio APIを用いてシンセサイザーを鳴らせるようにしたい.


環境

・macOS Big Sur ver. 11.5.1(20G80) -> 12.6 (21G115)
・MacBook Air M1, 2020 メモリ 8GB
・go1.18.3 darwin/arm64


Web Audio APIとは

Web Audio APIはW3Cが標準化を進める、ブラウザで音を扱うためのAPIです。Web Audio APIによって、音声ファイルをスピーカーから再生したり、マイクから入力された音声信号をWebサイトに反映させたり、Web Audio APIの提供する仕組みに則って (既存の音声ファイルに頼ることなく) 音を合成したりすることができます。Webサイトを音で演出したり、音に反応させたりといった用途で用いられるようです。

https://www.ubsecure.jp/blog/20201027

Web Audio APIでは,音の生成,入出力を音の処理単位オーディオ・ノードをつなげたグラフ構造として表現する.

例として,ギターの音の出し方をオーディオ・ノードで表現した図を以下に示す.


簡単な使い方

まずは,適当なWebページを開いて,開発者ツールのJavaScriptコンソールを出そう.

Macの場合はF12かCmd+Opt+Jで開ける.

コンソールの開き方が書いてあるこのサイトでやってみよう.


一度、Web Audio APIでピーッと音を鳴らしてみよう.Web Audio APIは以下の手順で利用する.

  1. AudioContextオブジェクトを用意する

  2. AudioNodeオブジェクトを必要なぶんだけ作成し接続する

  3. 入力側にあるAudioNodeのstart()メソッドを呼び信号生成を開始する

  4. (止めるとき) AudioContextオブジェクトのclose()メソッドを呼ぶ

ここでは1つのオシレータ (oscillator; 振動の発振器)で440Hzのサイン波を鳴らしてみる.

// デフォルト設定でAudioContextを取得
let ctx = new AudioContext()
// 基本的な音を発するオシレータのAudioNodeをコンテキストの中に作成
// 周波数は440Hz、波形はサイン波
let osc = new OscillatorNode(ctx)
osc.frequency = 440
osc.type = "sine"
// オシレータノードの出力をコンテキストのスピーカーに接続
osc.connect(ctx.destination)
// オシレータの処理を開始
osc.start()

// (音の高さを変えたいとき)oscのfrequencyパラメータの値を変更
// osc.frequency.value = 880
// (もし音止めたいとき) oscの処理を停止
// osc.stop()

そのままコンソールでEnterキーを押すとサイン並が鳴り続ける.

現状では,音を止めようとしていないので,止めたければブラウザバックなどでできる.
このままosc.stop()のコメントアウトを消しても,音の発生と停止の間が(理論上は)0秒なので音が鳴らなくなってしまう.

でもまぁここの目的は音を出すだけなのでこれで終わり.


音名と周波数の対応づけ

十二平均律を採用し,音階を決定する.

十二平均律は「1オクターブ離れた2音の周波数を均等に割る」ことによってピアノの黒鍵と白鍵の12個に対応づけしているので,プログラムでの周波数計算が楽になる.

全ての音に数字を割り振り,それをプログラム上で扱う.
MIDIノートナンバーを参照.
440Hzの音を69とする.

// 基準となるラの音 (concert A) の周波数
const concert_a_freq = 440;
// 基準となるラの音 (concert A) のMIDIノートナンバー
const concert_a_notenum = 69;

// MIDIノートナンバーを十二平均律で周波数に変換する関数
const convert_to_frequency = (notenum) => {

  // 基準音から何音高い/低いかを計算する
  const from_concert_a = notenum - concert_a_notenum;
  // 周波数を実際に計算する  
  // 十二平均律では2音の最小の周波数差は`2^(1/12)`となる  
  const freq = Math.pow(2, from_concert_a / 12) * concert_a_freq;
  return freq;
 };
// 音を慣らす準備準備
ctx = new AudioContext()
osc = new OscillatorNode(ctx)

// 基準音 (ラ: 440Hz, 69番) を設定
osc.frequency.value = convert_to_frequency(69)

// オシレータの処理を開始
osc.connect(ctx.destination)
osc.start()

// オシレータの周波数を変更
// 基準音より低くて一番近いド (60番) に設定
osc.frequency.value = convert_to_frequency(60)

これを先ほどと同様にして開発者ツールのコンソールで実行すると「ド」の音が鳴る.

osc.frequency.value = convert_to_frequency(60)

の部分を消して実行してみると「ラ」の音が鳴るので,周波数が変更されていることがわかる.


音色を増やす

今鳴らしているのはsin波だが,波形を変えることで音色を変えることができる.

波形を変える手っ取り早い手段は,Web Audio APIのOscillatorNodeにtypeというプロパティを変更することだ.

例えば,

osc.type = "square"

とすると矩形波にすることができる.先ほど「ド」の音を鳴らしたコードのoscの宣言の直後にでもこれを入れて,コンソールで実行してみると,音色が変わることがわかる.

osc.type = "sawtooth"

とするとノコギリ波ができる.
これも同じ方法で試してみると良い.

enum OscillatorType {
"sine",
"square",
"sawtooth",
"triangle",
"custom"
};

https://g200kg.github.io/web-audio-api-ja/131010/index.html#methodsandparams-OscillatorNode-section

こうなってるから,選択できる波形は5種類か?
customがどのような扱いなのだろう.

名前の通り,自分で定義できるらしい.

リズムをとる

ここまでは,音を鳴らしたら鳴らしたままだったが,ここでは時間経過に応じて音高を変化させたり,音を切ったりする.

音符は種類によって音の長さが決まっている.1小節分の長さを全音符で表現し、長さが半分になると2分音符、さらに半分で4分音符…のようになる.このように相対的に定義することで音楽の速さが異なっても同じ記法が使えるようにする.

それではここで4分音符1個分の時間が何秒かを考える.これは,テンポ(bpm)を考えると求めることができる.テンポとは,「1minに4分音符をいくつ打つか」で表現されている,つまり,テンポが120なら4分音符1個分の長さは60[sec]/120[回]=0.5[sec]となる.

Ex)メトロノームの実装

本来,指定の時間にイベントを処理するにはOSのイベントタイマ等を用いて定期的にイベントの有無を調べ,あったら対応する処理を行うというプログラミングが必要となるが,AudioParamのsetValueAtTime()メソッドは,値を変える時間 (AudioContextを開始してからの秒数) を指定することができる.

AudioParam.setValueAtTime()を用いてメトロノームを作ってみることでイベント処理の基本を学ぶ.

let ctx = new AudioContext()
// オシレータ
let osc = new OscillatorNode(ctx)
osc.frequency.value = 440
// 音量制御用のノード
let gain = new GainNode(ctx)
gain.gain.value = 0

// 接続してオシレータを開始
osc.connect(gain)
gain.connect(ctx.destination)
osc.start()

// テンポに従って音の鳴り始める(音量が0.3になる)時間と鳴り終わる(音量が0になる)時間を設定する
let bpm = 120
let note_length = 60 / bpm
// 120回分のメトロノームの音を設定する
for (let n = 0; n < 120; n++) {
    // 音の開始・終了時間を計算する
    let start_time = n * note_length;
    let end_time = start_time + 0.05
    // gain (音量)を時間指定で設定することで鳴らしたり止めたりする
    gain.gain.setValueAtTime(0.3, ctx.currentTime + start_time)
    gain.gain.setValueAtTime(0.0, ctx.currentTime + end_time)
    // 小節の最初の音だけ高くする
    if (n % 4 == 0) {
        osc.frequency.setValueAtTime(880, ctx.currentTime + start_time)
    } else {
        osc.frequency.setValueAtTime(440, ctx.currentTime + start_time)
    }
}

これを今までと同じように開発者ツールのコンソール上で実行するとbpm=120のメトロノームが実行される.


Goをブラウザ上で実行する

GoのコードをWebAssembly(WASM)にコンパイルすることで実現する.

WebAssemblyとは

WebAssembly(wasm)はブラウザが解釈できる専用のバイナリ形式で、低レベルなアセンブリ風言語です。
Web標準として開発されており、現在では様々な言語がコンパイル後のバイナリ形式としてWebAssemblyを指定できるようになっています。

WebAssemblyにコンパイルされたプログラムはブラウザやNode.jsなどの環境で、Javascriptと一緒に実行することができます。
代表的な言語ではC、C++、Rustがあります。
Goもv1.11から標準機能として、コンパイル時にWebAssemblyが指定できるようになりました。

https://www.asobou.co.jp/blog/web/go-webassembly

GoをWASN形式にコンパイルする

次のコードをmain.goとして作業ディレクトリに作成する.

package main
 
import "fmt"
 
func main() {
	fmt.Println("Hello, Go Assembly!!!")
}

次のコマンドでコンパイルを行う.

GOOS=js GOARCH=wasm go build -o main.wasm

すると,2MB程度のmain.wasmが作成される.
GOOS 、 GOARCH はクロスコンパイル用の設定で最新情報は公式ドキュメントOptional environment variablesに投げる.

ブラウザで実行する

出来上がったmain.wasmをブラウザで実行するには,Go公式が配布しているwasm_exec.jsを使う.

このファイルを作業ディレクトリ上に格納する.

HTMLファイルの作成

waem_exec.jsを読み込み,コンパイルしたmain.wasmを指定するためのhtmlを作成する.
次のコードをindex.htmlとする.

<html>
 
<head>
    <meta charset="utf-8">
    <title>Go WebAssembly</title>
</head>
 
<body>
    <h1>Go WebAssembly</h1>
    <script src="./wasm_exec.js"></script>
    <script>
        const go = new Go();
        let mod, instance;
        WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject).then((result) => {
            mod = result.module;
            inst = result.instance;
            document.getElementById("run").disabled = false;
        });
 
        async function run() {
            console.clear();
            await go.run(instance);
            instance = await WebAssembly.instantiate(mod, go.importObject);
        }
    </script>
    <button onClick="run();" id="run" disabled>Run</button>
</body>
 
</html>

HTTTPサーバの起動

index.htmlをHTTPで配信するためにGoでサーバを作成する.
以下をserver.goとする.

package main
 
import (
	"log"
	"net/http"
)
 
func main() {
	http.Handle("/", http.FileServer(http.Dir("")))
	log.Fatal(http.ListenAndServe(":8080", nil))
}

ターミナル上で次のコマンドを実行する.

go run server.go

http://localhost:8080/
にアクセスして「Run」をクリックするとConsole にGoのファイルでfmtした「Hello, Go Assembly!!!」が表示されてるのがわかる.


GoでJavaScriptを操作する

syscall/jisライブラリを利用してGoからJavaScriptにアクセスすることを考える.
syscall/jsにはブラウザ側のグローバルオブジェクトを取得する機能があり、このグローバルオブジェクトを介してGoとJavascriptでやり取りが可能になります。

GoからJavaScriptのDOMを作成して表示

ファイル構成は以下のようにする.

├── docs
│   ├── build.wasm
│   ├── index.html
│   └── wasm_exec.js
├── main.go
└── src
    ├── go.mod
    ├── go.sum
    └── webassembly.go

DOMとは

DOM とは Document Object Model の略で HTML や XML 文書を取り扱うための API です。 DOM は標準化団体である W3C で以前は標準化が行われていましたが、現在では WHATWG によって標準化が行われています。

https://www.javadrive.jp/javascript/dom/index1.html

main.go

index.htmlを表示するためのwebサーバとしてmain.goを以下のように作る.

package main
 
import (
	"log"
	"net/http"
)
 
func main() {
	port := "8080"
	http.Handle("/", http.FileServer(http.Dir("./docs/")))
	log.Printf("Listen on port: %s", port)
	http.ListenAndServe(":"+port, nil)
}


docs/index.html

wasm_exec.jsを読み込み、src/webassembly.goをビルドしたbuild.wasmを実行させる.

<html>
    <head>
        <meta charset="utf-8"/>
        <script src="wasm_exec.js"></script>
        <script>
            const go = new Go();
            WebAssembly.instantiateStreaming(fetch("build.wasm"), go.importObject).then((result) => {
                go.run(result.instance);
            });
        </script>
    </head>
    <body>
    </body>
</html>

src/webassembly.go

syscall/js をimportしてDOM要素を作成し、html側のbodyに追加させるファイル.

package main
 
import (
	"syscall/js"
)
 
func main() {
	// グローバルオブジェクト(window)を取得します
	window := js.Global()
 
	// document オブジェクトを取得します
	document := window.Get("document")
 
	// bodyを取得します
	body := document.Get("body")
 
	// h1 のDOMを作成します
	h1 := document.Call("createElement", "h1")
	h1.Set("innerHTML", "Hello Webassembly!")
 
	// h1をbodyに追加します
	body.Call("appendChild", h1)
 
	// プログラムが終了しないように待機します
	select {}
 
}

実行

ビルドコマンドは次のようになる.
srcディレクトリで行う.

MacBook-Air src % GOOS=js GOARCH=wasm go build -o docs/build.wasm

この状態でmain.goを実行させる.

MacBook-Air src % cd ..
MacBook-Air sound_wav % go run main.go
2022/10/15 20:59:38 Listen on port: 8080


http://localhost:8080/


このとき,なんかgo.modとかgo.sumとかが乱立してるとなにも表示されなくなるから必要なもの以外は消しておこう.

内容を変える

さっきのwebseembly.goのでは,ただ文字列を表示しただけでつまらないので,中身を変更してみる.

次のようにコードを変更する.

package main
 
import (
	"fmt"
	"strconv"
	"syscall/js"
)
 
func main() {
	// グローバルオブジェクト(window)を取得します
	window := js.Global()
 
	// document オブジェクトを取得します
	document := window.Get("document")
 
	// bodyを取得します
	body := document.Get("body")
 
	// p のDOMを作成します
	counter := 0
	p := document.Call("createElement", "p")
	p.Set("id", "counter")
	p.Set("innerHTML", strconv.Itoa(counter))
 
	// ボタンのDOMを作成し、Clickイベントを設定します
	btn := document.Call("createElement", "button")
	btn.Set("textContent", "count up!")
	btn.Call("addEventListener", "click", js.FuncOf(func(js.Value, []js.Value) interface{} {
		counter++
		fmt.Println(counter) // console.logに出力します
		document.Call("getElementById", "counter").Set("innerHTML", strconv.Itoa(counter)) // カウンターの表示を更新します
		return nil
	}))
 
	// pをbodyに追加します
	body.Call("appendChild", p)
	// ボタンをbodyに追加します
	body.Call("appendChild", btn)
 
	// プログラムが終了しないように待機します
	select {}
 
}

内容変更したあとはさっきと同じようにビルド,main.goの実行をする.このとき,さっきと同じ8080ポートをそのまま使うと,さっきのが残っていて今回のが適用されないので,main.goのportの値を8008にして,
http://localhost:8008/
に行く,すると


このような感じになる.
count up!ボタンを押すときちんと数字も増えていく.



GoでWebAudioAPIを使う

WebAudioAPIの簡単な使い方を見る時に使った,次のJavaScriptのコードをGo言語に書き換えることで試験的に行う.

// デフォルト設定でAudioContextを取得
let ctx = new AudioContext()
// 基本的な音を発するオシレータのAudioNodeをコンテキストの中に作成
// 周波数は440Hz、波形はサイン波
let osc = new OscillatorNode(ctx)
osc.frequency = 440
osc.type = "sine"
// オシレータノードの出力をコンテキストのスピーカーに接続
osc.connect(ctx.destination)
// オシレータの処理を開始
osc.start()

JavaScriptをGoに変換する際に参考にするサイトは


一つ目のサイトにないところは公式ドキュメントなどを参照して補う必要がありそうだ.
例えば,new演算子のインスタンス生成などは載っていないが,ドキュメントを見ると


js.ValueにはNewメソッドがあるようだ.なので

JS: new AudioContext()
Go: js.Global().Get("AudioContext").New()

のように変換する.

以上を踏まえると,目的のJavaScriptのコードをGoに書き換えると

webassembly.go

package main
 
import (
	"syscall/js"
)
 
func main() {
	// グローバルオブジェクト(window)を取得します
	window := js.Global()
 
	// document オブジェクトを取得します
	document := window.Get("document")
 
	// bodyを取得します
	body := document.Get("body")
	 
	// ボタンのDOMを作成し、Clickイベントを設定します
	btn := document.Call("createElement", "button")
	btn.Set("textContent", "music start!")
	btn.Call("addEventListener", "click", js.FuncOf(func(js.Value, []js.Value) interface{} {
		ctx := js.Global().Get("AudioContext").New()
		osc := js.Global().Get("OscillatorNode").New(ctx)
		osc.Set("frequency", 440)
		osc.Set("type", "sine")
		osc.Call("connect", ctx.Get("destination"))
		osc.Call("start")
		body.Call("appendChild", osc)
		return nil
	}))
	// ボタンをbodyに追加します
	body.Call("appendChild", btn)
 
	// プログラムが終了しないように待機します
	select {}
 
}

ここで,カウントアップの時に使ったようにボタンを配置して,そのボタンを押した時に音が流れるようになっていることに気がつくと思う.

これはChromeの仕様上,サイトを開いてすぐ音が鳴るのが禁止されていて,何かユーザ操作イベントを挟む必要があったからで,Chromeじゃなかったらなくてもできるところはある.


このようになるので,ここのボタンを押すとシンセサイザーが鳴る

書き換えるところで沼にハマりまくったので,質問したものを載せておく.




これにてこの記事の目標は達成した.
あとはボタンを押したあとの中身を変えることで色々なことができるだろう.

この記事が気に入ったらサポートをしてみませんか?