音声波形の画像を表示させる


この記事は[Siv3D Advent Calender](/advent-calendar/2023/siv3d)の22日目の記事です。
そして投稿日の本日12/22はそう……冬至ですね。
この記事を見た皆さん、かぼちゃの煮つけを！ かぼちゃの煮つけを食べましょう！

# 音声波形を表示させて切り出しできるようにしたい
思いついたゲームをSiv3Dで作成するにあたり、長い音声ファイルをバラバラに分割する必要が出てきたのですが、
そのツールもSiv3Dで作るか、となり、切り出す位置を微調整したりするためには音声波形を可視化したほうがいいな、と思い至りました。

Siv3Dのリファレンスだと周波数スペクトルを表示する方法は記載があったはずですが、波形を表示するサンプルはなさそうです。
そこで画像化するクラスを作ったのでその内容を記事にしようと思います。

# そもそも音声波形はどうやって表示されているのか

音声編集をしてるとよく見るこの表示。これどうやって実現してるんだ……？

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/146445/885b2a62-2e40-a9b4-ec14-f510e85c4ab9.png)


ひとまず独力で考えました。

結果、「折れ線グラフみたいに1サンプル1ピクセルで点を打ちまくって、縮小表示すれば行けるじゃろ！」と考えました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/146445/62f9371f-34b5-13e9-fc64-01116c878e91.png)
これを縮小表示するイメージですね。

# 折れ線グラフ作戦→玉砕

音声のAudioにファイルを読み込ませてから.Sample()を呼び出すと、生データであるfloatのデータの塊にアクセスできます。
1つ1つのfloatが1サンプルを意味し、それぞれの値の範囲は-1から+1までです（多分）。

画像へ変換するにあたっては、Imageを利用してやります。
ImageはTextureと違い、1ピクセルずつの細やかな画像データ操作ができるので、データの可視化なんかに便利です。



が、実際に動かしてみるとImageの生成がうまくいきません。
「なぜ……？」と思ってデバッグで値を辿った結果。

幅200万ピクセルのImageを生成しようとしていました。
そりゃあ無謀ってもんでさあ。

# 処理を改良
そんなわけで、早くも観念して他の実装方法を調べてみたところ、
「1ピクセル1サンプルではなく、一定量のサンプルを1ピクセルに担当させ、その中で最大値と最小値を取り出して線で表現し、連ねて表示させる」
という方法があるらしいとわかりました。なるほど賢い。
この方法は最大値と最小値で波形を決めているので、ほんの僅かでも「ビッッ！」とかノイズが入ったりすると表示が如実に影響されるわけですが、あまり気にする場面はないと思います。

# コード
そんなわけでクラスにしてみます。
LoadSoundメソッドで予め音声ファイルを読ませます。画像の生成はRectの横幅に収まるサイズか、1ピクセルあたりのサンプル数かのどちらかの指定で作れるようにしています。

ステレオ音声だと左か右かどちらのチャンネルのデータを読むかを決めねばなりません。これはSiv3D上だとAudio.getSamplesメソッドを呼ぶときに指定します。このクラスでは常に左チャネルを取ることにしています。



```cpp:nuo_waveform.h
#include <siv3d.hpp>

class Nuo_Waveform {
public:
	Nuo_Waveform();
	bool LoadSound(String FileName);
	bool GenerateWaveformImageByImageWidth(Rect Size);
	bool GenerateWaveformImageBySamplePerPixel(uint32 SamplePerPixel, uint32 height);
	void Draw();
	void SetPosAt(Point at);

private:
	Audio		snd;	
	Image		img;				
    Texture 	tex;			
	Point		pos;				//左上の座標
	String		filename;			
	Color		waveform_color;
};
```
```cpp:nuo_waveform.cpp
Nuo_Waveform::Nuo_Waveform()
{
	filename = U"";
	waveform_color = Color(255, 255, 0);
	pos = { 0,0 };
}

bool Nuo_Waveform::LoadSound(String FileName)
{
	snd = Audio(FileName);
	bool file_exists = FileSystem::IsFile(FileName);
	filename = (file_exists) ? FileName : U"";
	return file_exists;
}

bool Nuo_Waveform::GenerateWaveformImageByImageWidth(Rect Size)
{
	if (filename == U"" || Size.w <= 0)return false;
	uint32 sample_per_pixel = (uint32)snd.samples() / Size.w;
	GenerateWaveformImageBySamplePerPixel(sample_per_pixel, Size.h);
	return true;
}

bool Nuo_Waveform::GenerateWaveformImageBySamplePerPixel(uint32 SamplePerPixel, uint32 height)
{
	if (filename == U"" || SamplePerPixel <= 0)return false;
	size_t new_width = snd.samples() / SamplePerPixel;
	img = Image(new_width, height);

	uint32 smpl_index = 0;
	const float* smpl_data = snd.getSamples(0);
	for (size_t i = 0; i < new_width; i++) 
    {
		float fmin = 0;
		float fmax = 0;
		for (uint32 offset = 0; offset < SamplePerPixel; offset++) 
		{
			const float* smpl_testing = smpl_data + i * SamplePerPixel + offset;
			if (*smpl_testing > fmax) 
   			{
				fmax = *smpl_testing;
			}
			else if (*smpl_testing < fmin) 
   			{
				fmin = *smpl_testing;
			}
		}
		double y1 = (height * fmax + height) / 2.0;
		double y2 = (height * fmin + height) / 2.0;
		Line(i, y1, i, y2).overwrite(img, waveform_color);
		smpl_index += SamplePerPixel;
	}
	tex = Texture(img);
	return true;
}

void Nuo_Waveform::Draw()
{
	tex.draw(pos);
}

void Nuo_Waveform::SetPosAt(Point at)
{
	pos.x = at.x - img.width() / 2;
	pos.y = at.y - img.height() / 2;
}

```
後は、以下のような感じで音声波形を表示させるコードを打てば
```cpp:Main.cpp
# include <Siv3D.hpp> // Siv3D v0.6.13
#include "nuo_waveform.h"

void Main()
{
	Nuo_Waveform waveform;
	waveform.LoadSound(U"sound.wav");
	waveform.GenerateWaveformImageByImageWidth(Rect(800, 100));
	waveform.SetPosAt(Scene::Center());

	while (System::Update())
	{
			waveform.Draw();
	}
}

```

はいこの通り！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/146445/a2cb4b7b-a382-8655-631f-1c52376913f2.png)


長い音声を連番で切り出したいというのがそもそもの目的なので、クリックした位置から再生させたり表示を拡大縮小させたりしたいので、もう少し作り込んでいきます。

本来の目的を忘れない程度にちゃちゃっとやっていきたいですね（と言いながらあまり進捗は良くないのですが……）