Siv3D で Trombone Champ 的なものを作る

この記事では、音の高さを変える方法を説明し、[Trombone Champ](https://store.steampowered.com/app/1059990/Trombone_Champ/) のようなシステムの実装を紹介します。



# 音の高さとは

音とは空気や水などの振動であり、1次元の波で表されます。
コンピュータ上でも、音声は波として1次元配列で扱われます。

ここで、音の高さとは、その波の**周波数の高さ**です。
周波数の高い音ほど高い音に聞こえ、周波数の低い音ほど低い音に聞こえます。



# 周波数と音の高さ

音の高さが**1オクターブ高く**なると、その**周波数は2倍**になります。
つまり、音の**周波数が2倍**になると、その音は**1オクターブ高く**なります。

例えば、ドの音の周波数を2倍にすると、1つ高いドになります。
4倍にすると、2つ高いドに、8倍にすると、3つ高いドになります。
逆に、1/2倍すると、1つ低いドになります。

形式的に表すと、ある音より$n$オクターブ高い音の周波数は、もとの音の周波数の$2^n$倍です。

また、音の高さが**1半音高く**なると、その**周波数は$2^{1/12}$倍**になります。
つまり、音の**周波数が$2^{1/12}$倍**になると、その音は**1半音高く**なります。
ただし、これは[平均律](https://ja.wikipedia.org/wiki/%E5%B9%B3%E5%9D%87%E5%BE%8B)という音律における話です。

平均律は、1オクターブを12等分するもので、1半音は1/12オクターブ高い音になります。
$n$オクターブ高くなると、周波数は$2^n$倍になるから、1半音、すなわち1/12オクターブ高くなると、周波数は$2^{1/12}$倍になるというわけです。

ドの音の周波数を$2^{1/12}$倍すると、ド♯（レ♭）になります。
$2^{2/12}$倍するとレ、$2^{3/12}$倍するとレ♯（ミ♭）、$2^{4/12}$倍するとミになります。
逆に、$2^{-1/12}$倍するとシ、$2^{-2/12}$倍するとシ♭（ラ♯）、$2^{-3/12}$倍するとラになります。



# 音の高さを変えるには

Siv3D のプログラムで音の高さを変える方法について説明します。


## `Audio::setSpeed()`

音の高さを変えるためには、その周波数を変える必要があります。
しかし、`Audio` にはその周波数を変更するようなメンバ関数は用意されていません。

そこで、`Audio::setSpeed()` を使います。
再生速度と周波数は比例するため、再生速度を変化させることで音の高さを変えることができます。

ただし、再生速度を変化させると、もちろん再生時間も変化します。
音を高くするために再生速度を上げると、再生時間は短くなります。

![再生速度を上げると周波数が高くなり音が高くなるが、再生時間は短くなる](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643544/c87e4a5e-e169-cff1-b10b-d790a9640876.png)


## `GlobalAudio::BusSetPitchShiftFilter()`

音声の再生時間を変えずに音を高くしたいときは、[**ピッチシフト**](https://ja.wikipedia.org/wiki/%E3%82%BF%E3%82%A4%E3%83%A0%E3%82%B9%E3%83%88%E3%83%AC%E3%83%83%E3%83%81/%E3%83%94%E3%83%83%E3%83%81%E3%82%B7%E3%83%95%E3%83%88)が使用できます。

Siv3D では、`GlobalAudio::BusSetPitchShiftFilter()` を設定することによって、リアルタイムでピッチシフトすることができます。

ただし、音の高さを変化させたときに波形が繋がるとは限らず、ノイズが入ることがあるので、頻繁に音の高さを変化させることはできません。

![ピッチシフト](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643544/ec51d6d4-d83e-250a-7f25-2f9974bd2738.png)



# Siv3D で Trombone Champ 的なもの

`Audio::setSpeed()` を用いて、Trombone Champ 的なものを実装してみました。


## 実装

マウスを上下に動かすことで音の高さを変えることができ、スペースキーを押すとトロンボーンの音が再生されます。

```c++
# include <Siv3D.hpp> // Siv3D v0.6.13

double PosYToPitch(const double y)
{
	return ((0.5 - y / Scene::Height()) * 30);
}

double PitchToPosY(const double pitch)
{
	return ((0.5 - pitch / 30) * Scene::Height());
}

void Main()
{
	Scene::SetBackground(ColorF{ 0.6, 0.8, 0.7 });

	const Audio sound{ GMInstrument::Trombone, PianoKey::C4, 15s };

	while (System::Update())
	{
		if (KeySpace.down())
		{
			sound.play();
		}
		else if (KeySpace.up())
		{
			sound.stop();
		}

		const double pitch = Clamp(PosYToPitch(Cursor::PosF().y), -13.0, 13.0);

		const double vibrato = (Periodic::Sine1_1(0.2s) * 0.25);

		sound.setSpeed(Exp2((pitch + vibrato) / 12));

		for (const auto p : { -12, 0, 12 })
		{
			Line{ 0, PitchToPosY(p), Arg::direction(500, 0) }.draw(5, ColorF{ 1, 1.0 }, ColorF{ 1, 0 });
		}

		for (const auto p : { -10, -8, -7, -5, -3, -1, 2, 4, 5, 7, 9, 11 })
		{
			Line{ 0, PitchToPosY(p), Arg::direction(200, 0) }.draw(3, ColorF{ 1, 0.5 }, ColorF{ 1, 0 });
		}

		Line{ 100, 0, 100, Scene::Height() }
			.draw(12, ColorF{ 0 })
			.draw( 8, ColorF{ 1 });

		const Circle circle{ 100, PitchToPosY(pitch), 12 };

		if (sound.isPlaying())
		{
			Line{ 0, circle.y, Arg::direction(400, 0) }.draw(15, ColorF{ 1, 0.7 }, ColorF{ 1, 0 });

			circle.drawShadow(Vec2{ 0, 0 }, 15, 10, ColorF{ 1 });

			const double startAngle = (360_deg * sound.posSec() / sound.lengthSec());

			circle
				.draw(ColorF{ 0 })
				.stretched(-2)
				.drawPie(startAngle, (360_deg - startAngle), Palette::Hotpink);
		}
		else
		{
			circle
				.draw(ColorF{ 0 })
				.stretched(-2)
				.draw(ColorF{ 1 })
				.stretched(-3)
				.draw(Palette::Hotpink);
		}
	}
}
```


## スクリーンショット・動画

![スクリーンショット](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643544/05a66c4e-2030-480c-5a87-61756b275246.png)

https://twitter.com/raclamusi/status/1738989385918599515



# おわりに

実は、Trombone Champ やったことないです。