はじめに
音(WAV)と画像(BMP)を まったく同じメモリ の上で扱い、画像を編集すると音が変わり、音を流すと画像になる、というエンジンを作りました。FFT・CNN・ViT(Vision Transformer)もすべて自前で書いていて、外部ライブラリは使っていません(C++20 の標準ライブラリのみ)。
リポジトリ: https://github.com/purplecatman2005-dev/synesthesia-engine
この記事について
コードと記事は、AI(Claude)と対話しながら作りました。結果はすべて手元で実行したものです。
できること
| コマンド | 内容 |
|---|---|
see |
音を「見る」: 音を周波数ごとに分解して画像にする |
hear |
画像を「聴く」: 画像の明るさを音の強さとして鳴らす |
fuse |
混ぜる: 画像の「目立つ部分」に合わせて音を強めたり弱めたりする |
composer.exe demo を実行すると、アルペジオ(和音を順に鳴らす音)と幾何学模様の画像を自動で作り、3 つのモードを全部試します。
| 入力画像 |
see: アルペジオを画像に |
fuse: 画像で音を変調 |
|---|---|---|
![]() |
![]() |
![]() |
真ん中と右の画像は、横軸が時間、縦軸が音の高さ(下が低い音)です。右の fuse では、左の画像の同心円模様が音の中に溶け込み、背景に模様として現れています。
核となるアイデア: 同じメモリを 2 通りに読む
音を短い区間ごとに FFT すると、「時間 × 周波数」の表(スペクトログラム)になります。表の 1 マスは複素数で、実部と虚部の 2 つの float です。
ここで、この表を 「縦 = 時間、横 = 周波数、色 = 2 チャンネル(実部・虚部)」の画像だと思って読んでも、メモリの並びはまったく同じです。そこで、1 本の float 配列に対して「画像として読む窓」と「音のスペクトルとして読む窓」の 2 つを用意しました。
1本の float バッファ (frames × bins × 2)
├── ImageView : 画像として解釈 pixel(y=時間, x=周波数, ch={実部,虚部})
└── SpectralView : 音として解釈 複素スペクトル(時間, 周波数)
// 画像として見る: pixel(y=frame, x=bin, ch={re,im}) がスペクトルの1セルと一致
ImageView as_image(int h, int w, int c) {
assert(static_cast<size_t>(h) * w * c == buf_->size());
return ImageView(buf_, h, w, c);
}
// スペクトルとして見る: frames x bins の複素数配列
SpectralView as_spectrum(int frames, int bins) {
assert(static_cast<size_t>(frames) * bins * 2 == buf_->size());
return SpectralView(buf_, frames, bins);
}
2 つの窓は同じ shared_ptr<vector<float>> を持っているだけなので、片方で書き換えると、もう片方からも即座に見えます。コピーも変換も発生しません。
std::complex<float> が「float 2 つを並べたもの」と同じ並びになることは C++ の規格で保証されているので、float 配列を複素数配列として読み直すことができます。
fuse: 画像の注目領域で音を変える
fuse モードでは次の順に処理します。
- 音を STFT(短時間フーリエ変換)して、共有バッファに入れる
- 画像を自作の ViT に通し、Attention から「画像のどこが目立つか」の地図(顕著性マップ)を作る
- 共有バッファを画像として開き、顕著性マップに合わせてピクセルを明るく・暗くする
- 同じバッファを音として読み直し、逆 STFT で波形に戻す
3 の部分のコードはこれだけです。ピクセルを書き換えているつもりが、そのまま音のスペクトルを書き換えています。
// ここが融合の核: 同じメモリを「画像」として開き、ピクセル操作で音のスペクトルを変調する。
// iv.at(f,b,{0,1}) は spec.at(f,b) の実部・虚部そのもの。
ImageView iv = unified.as_image(frames, bins, 2);
for (int f = 0; f < frames; ++f)
for (int b = 0; b < bins; ++b) {
const float s = saliency.at(bins - 1 - b, f, 0);
const float gain = 0.25f + 1.75f * s; // 画像の注目領域ほど音が強く鳴る
iv.at(f, b, 0) *= gain;
iv.at(f, b, 1) *= gain;
}
// 同じバッファをスペクトルとして読み直して音へ
SpectralView spec = unified.as_spectrum(frames, bins);
std::vector<float> samples = istft(spec, cfg);
実部と虚部に同じ倍率を掛けているので、音の位相は変えずに大きさだけが変わります。
全部自前で書いたもの
| ファイル | 中身 |
|---|---|
fft.hpp |
FFT(Cooley-Tukey)、STFT / 逆 STFT(元の波形に完全に戻る形) |
nn.hpp |
小さな CNN(畳み込み → ReLU → プーリング × 2)と ViT(8×8 パッチ + Attention 1 ブロック) |
audio_io.hpp / image_io.hpp
|
WAV(16bit PCM)と BMP(24bit)の読み書き |
tensor.hpp |
上で説明した共有バッファ |
テスト
test_fusion.exe で 12 個のテストが走ります。一部を抜粋します。
STFT→ISTFT再構成:
[PASS] 内部サンプルの最大誤差 < 1e-3
UnifiedTensorエイリアシング (等価データの核):
[PASS] スペクトルへの書き込みが画像ビューから見える
[PASS] 画像ピクセルの書き込みがスペクトルから見える
[PASS] 画像側のゲイン操作=スペクトルのマグニチュード変調
ViT Attention:
[PASS] Attention各行の和 = 1 (softmax)
「音 → スペクトル → 音」で元に戻ること、片方の窓への書き込みがもう片方から見えることを確認しています。
動かし方
MinGW-w64 の g++(13 以上)が必要です。
git clone https://github.com/purplecatman2005-dev/synesthesia-engine
cd synesthesia-engine
build.bat
test_fusion.exe :: テスト
composer.exe demo :: 素材を作って全モードを実行
自分のファイルでも試せます。
composer.exe see in.wav out.bmp
composer.exe hear in.bmp out.wav
composer.exe fuse in.wav in.bmp out.wav out.bmp
ハマったところ: -static を付けないと落ちる
Windows で Git をインストールしていると、PATH 上に Git 付属の libstdc++-6.dll があり、MinGW のものと食い違ってプログラムが起動直後に落ちました。-static でリンクして、DLL に頼らない exe にすることで解決しています。
限界
- CNN と ViT の重みは学習していません(乱数で初期化したまま)。Attention は画像の模様には反応しますが、「顔」や「猫」のような意味を理解して注目しているわけではありません
- WAV は 16bit PCM、BMP は 24bit 無圧縮のみ対応です
- STFT の設定(窓 1024 サンプル、ずらし幅 512)は固定です
おわりに
「音も画像も、結局はメモリ上の数の並び」ということを、1 本の配列を 2 通りに読むことで形にしてみました。fuse の出力 WAV を聴くと、画像の模様に合わせて音が強まったり弱まったりするのが分かります。
感想や改善案があれば、コメントや GitHub の Issue で教えてください。
リポジトリ: https://github.com/purplecatman2005-dev/synesthesia-engine


