ブログ

PROMPT → WEB
#007
SOUND LOOM
CLAUDE CODE × WEB AUDIO / CANVAS 2D

そのWeb演出、Claude Codeで作れます。クリエイティブWeb表現のプロンプト集 #07 Sound Loom

株式会社グルーヴサインの米沢です。

AIコーディングでクリエイティブなWeb表現を実際に作り、そのプロンプトごと公開していく「PROMPT → WEB」の第7回です。

PROMPT → WEB #007

Sound Loom

INTRODUCTION

ここまでの6回、入力はずっとカーソルの位置だけでした。粒子(#01)も、文字(#02)も、線(#03)も、物理(#04)も、計算(#05)も、製版(#06)も、人がマウスを動かすと何かが起きる、という同じ形をしていました。

今回は入力の軸を変えます。です。

ただし、音をWebで扱うときには面倒な事情があります。ブログの記事を開いたら突然音が鳴りだす、というのは最悪の体験です。ブラウザもそれを知っていて、ユーザーの操作なしに音を鳴らすことを禁止しています。「音を題材にしたい」と「勝手に鳴らしてはいけない」は、真正面からぶつかります。

この回の設計は、その折り合いの付け方から始まりました。結論はこうです。

音は最初から鳴っている。ただし出力の音量がゼロになっている。

Web Audio では、スピーカーへ送る手前の信号を取り出して解析できます。出口の音量を 0 にしても、その手前は動いています。つまり聞こえないが、見える。ページを開いた人は静かなままで、それでも作品は最初から生きている。「音を出す」を押した瞬間に、いま見えているものが聞こえはじめます。

見せ方は織物にしました。経糸(たていと)が周波数、緯糸(よこいと)が時間です。低い音は左の糸、高い音は右の糸。時間が経つほど布が織り上がって下へ流れていきます。棒グラフが上下する、あのよくある可視化にはしたくありませんでした。

RESULT

まず現物です。下のボタンを押してみてください。

クリックすると、この画面の上で Sound Loom が再生されます(何度でも再生可能)。ESC または CLOSE で終了します。

藍の地に、音が柄として織り込まれていきます。操作は3つです。

左下の織りネームに、いまの調と経糸の本数、そして音のON/OFFが出ています。ESC または CLOSE で終了します。

音を出してから染めを切り替えると、柄の変化と音階の変化が同時に起きるのが見えます。同じものを目と耳で二重に受け取る、という状態です。

WHAT WE MADE

技術的に分解すると、次のようになります。

HOW IT WORKS

1. 聞かせずに、見せる

いちばん大事なのはこの接続です。

// 声部 → bus → analyser(行き止まり。解析だけ)
//            → master → destination(master.gain = 0 なら無音)
const master = ctx.createGain();
master.gain.value = 0;              // 既定は無音
master.connect(ctx.destination);

const bus = ctx.createGain();
bus.connect(master);
bus.connect(analyser);              // ここが「見る」経路

master を 0 に絞っても、bus から先は動き続けます。オーディオグラフは出口(destination)から引っぱられて動くので、出口に繋がっている限り、途中の音量がいくつであっても計算は走ります。 そこから枝分かれした analyser は、その計算結果を覗いているだけです。

この挙動は実際に確かめました。ゲイン 0 のオシレータを繋いだ状態で getByteFrequencyData を読むと、440Hz に対応する20番目のビンが**255(最大値)**を返します。音は一切鳴っていません。

「音を出す」ボタンは、この master.gain を 0 から 0.5 へ動かしているだけです。音が始まるのではなく、塞いでいた出口が開く。だから押した瞬間、いま見えている柄と、聞こえてきた音がちゃんと一致しています。

2. 経糸に周波数を割り当てる

解析から返ってくるのは1024本のビンで、これは周波数について等間隔です。ところが音の高さの感じ方は等間隔ではありません。そのまま横に並べると、低音のすべてが左端の数本に押し込まれ、画面の大半を人間には区別できない高音域が占めることになります。

そこで経糸は対数で配置し、各経糸が受け持つビンの範囲を先に計算しておきます。

const f = F_MIN * Math.pow(F_MAX / F_MIN, i / (warpCount - 1));  // 経糸iの中心周波数
// 隣との中間(幾何平均)を境にして、その経糸が見るビンの範囲を決める
const fLo = Math.sqrt(f * warpFreq[i - 1]);
const fHi = Math.sqrt(f * warpFreq[i + 1]);

131本の経糸で 60Hz〜6000Hz、つまり約6.6オクターブをカバーします。左端の糸は数Hzぶん、右端の糸は200Hzぶんを受け持つ計算です。実際に走査するのは1024本のうち538本で、残りは6000Hz より上なので見ていません。

3. 格子を「布」に見せる一手間

同じ幅の四角を並べるだけでは、方眼紙にしかなりません。織物に見せているのは、交点の上下だけです。

const over = ((i + rowIndex) & 1) === 0;   // この交点は緯糸が上か
ctx.fillStyle = over ? dyeTable[mags[i]]   // 緯糸(音の色)
                     : warpColor(i);       // 経糸(藍の地)

たったこれだけで、市松に糸が重なり、布の表面に見えます。実際の平織そのままの規則です。計算の話ではなく、観察の話でした。

染めの色は、いちばん弱い段を地の藍と同じ色にしてあります。音が無い経糸はそのまま藍に沈み、音がある経糸だけが色を持つ。柄が浮かび上がるのは、そこに音があるからです。

4. 布は描き直さない

素直に作ると、毎フレーム全部の行を描き直したくなります。900px の画面で緯糸1本が3pxなら300行。経糸131本と掛けると、毎フレーム39,300回の塗りです。しかも履歴が長くなるほど重くなります。

そこで裏に1枚の布を持ち、そこへ1行だけ書き足します。表示は、書き込み位置で2枚に切り出して並べ直すだけです。

// 書き込み位置 cut を境に、2回の drawImage で切れ目なく見せる
if (cut > 0) vctx.drawImage(cloth, 0, 0, sw, cut, 0, sh - cut, sw, cut);
vctx.drawImage(cloth, 0, cut, sw, sh - cut, 0, 0, sw, sh - cut);

1フレームあたり fillRect 132回 + drawImage 2回。履歴が何行あっても変わりません。過去に描いたものをもう一度描かない、というだけの話ですが、効き方は桁で違います。

5. 音の時計で音を並べる

setInterval で音を鳴らすと、描画が重くなった瞬間にリズムが崩れます。画面の時計とオーディオの時計は別物だからです。Web Audio は音を未来の時刻に予約できるので、少し先まで先回りして並べておきます。

// いまから0.3秒先までを埋める。予約なので、描画が詰まってもリズムは崩れない
while (this.nextNoteTime < ctx.currentTime + 0.3) {
  this.pluck(semitone, this.nextNoteTime);
  this.nextNoteTime += interval;
}

6. 音が鳴らない環境でも、布は織れる

作っている途中で気づいたことがあります。オーディオが動かない環境が、思ったより多い。 省電力設定、ブラウザの制限、自動再生ポリシーの違い。そういう環境で真っ暗な画面だけが残るのは、作品として困ります。

そこで、解析値が意味のある値を返さない状態が続いたら、同じ音列から作った合成スペクトルに自動で切り替えるようにしました。音を鳴らす代わりに、「いま鳴っているはずの音」の周波数に山を置くだけです。柄は途切れません。音が戻ってくれば、また解析値に戻ります。

開幕の一反も同じ仕組みで織っています。空の織機から始めると画面が埋まるまで10秒かかって退屈なので、先に一反ぶんを織った状態で幕を開けます。

THE PROMPT

ここが本題です。上のデモを作るためにClaude Codeへ渡せる、汎用プロンプト全文を掲載します。右上の COPY ボタンでそのままコピーできます。色や音階を置き換えれば、自分用になります。

あなたはCreative Codingと Web Audio に精通したフロントエンドエンジニアです。
「Sound Loom」= ブラウザ自身が合成した音の周波数成分を「織物」として織り上げる
Web表現を、Web Audio API と Canvas 2D で実装してください。
音源ファイルは使わず、音はすべてブラウザ内で合成すること。

# 最重要の設計方針(ここを外さないこと)
- 音は最初から鳴らすが、【出力ゲインを 0 にして無音から始める】
    声部 → bus → analyser(行き止まり。解析だけ)
               → master(gain=0) → destination
  出口に繋がっている限りグラフは動くので、無音のまま解析値が取れる。
- 画面に「♪ 音を出す」ボタンを置き、押されたときだけ master.gain を上げる。
  押す前から布は織られていること(=聞こえないが、見える状態から始まる)。
- AudioContext が作れない/解析値が返らない環境では、
  【同じ音列から作った合成スペクトルで織り続ける】。無音でも作品として成立させる。

# 置き換える変数
- GROUND_COLOR = "#0e1830"    // 藍の地。経糸の色でもある
- MODES = 4つ。音階と染めの色を組にする:
    陰旋法 INSEN  [0,1,5,7,10] … 藍  "#356f9e" → "#f4f8fb"
    都節   MIYAKO [0,1,5,7,8]  … 紅  "#8a3548" → "#f7e2d8"
    律     RITSU  [0,2,5,7,9]  … 萌葱 "#356f47" → "#e9f3e2"
    琉球   RYUKYU [0,4,5,7,11] … 黄  "#957320" → "#f9f1d8"
  ※ どの染めも「いちばん弱い段」は地の色と同じにする。
     音が無い経糸は地に沈み、音のある経糸だけが色を持つ
- F_MIN = 60Hz / F_MAX = 6000Hz
- LABEL = "SOUND LOOM"

# 音の合成
- 持続音: のこぎり波2本を少しデチューンしてローパス。ずっと低く鳴らす
- 撥音: 三角波 + 指数エンベロープ(12ms で立ち上げ、1.9秒で減衰)
  + ローパスを時間で閉じる(撥いた直後だけ明るい)
- 余韻: フィードバックディレイ(0.32秒 / 帰還0.38 / 2.2kHz でダンプ)
- 音列: 選ばれた音階から確率的に選ぶ。16%は休符。4歩に1度、2オクターブ下を重ねる
- 【重要】setInterval で鳴らさない。ctx.currentTime を見て
  「いまから0.3秒先まで」を先回りして予約する(描画が詰まってもリズムを崩さないため)

# 織りの描画(Canvas 2D)
- 経糸 = 周波数。60Hz〜6000Hz を【対数で】画面の横幅に割り当てる。
  等間隔にすると低音側が潰れて、画面の大半が区別できない高音域になる
- 各経糸が受け持つ解析ビンの範囲を、隣の経糸との幾何平均を境にして先に計算しておく
- 緯糸 = 時間。1コマにつき1本(太さ3px程度、2フレームに1本くらいが落ち着く)
- 【重要】平織にする。交点ごとに ((経糸番号 + 行番号) % 2) で
  経糸と緯糸のどちらを上に描くかを入れ替える。これだけで方眼紙が布になる
- 解析値(0-255)はそのまま色に使わない。ノイズ床を切り、残りを持ち上げた対応表を作る
- 弱い音の日も布が痩せないよう、直近ピークに追従する自動ゲインを入れる
  (上げるのは速く、下げるのは遅く)

# 性能(ここを外すと履歴が伸びるほど重くなる)
- 【重要】布を毎フレーム描き直さない。裏に1枚 canvas を持ち、そこへ1行だけ書き足す。
  表示は書き込み位置を境に2回の drawImage で切り出して並べ直す(リングバッファ)
- 1フレームの描画命令は「fillRect ≒ 経糸の本数 + drawImage 2回」に収めること

# インタラクション
- ポインタ横位置 = 音域の中心、縦位置 = 音色の明るさ。目標値へ滑らかに寄せる
- ポインタが無い間(スマホ・操作前)は、音域がゆっくり行き来する
- クリック/タップで次の調へ。音階と染めが同時に変わる
- 隅に織りネーム(調名・経糸の本数・周波数範囲・音のON/OFF)を小さく置く
- ネオン・rainbow・黒背景+青紫・棒グラフ型のスペクトラム表示は使わない

# 品質要件(production)
- 全画面オーバーレイ。CLOSE ボタンと ESC で終了。多重起動を防ぐ
- 【重要】リサイズで裏の布を作り直したあと、その場で織り直すこと。
  作り直しただけだと布が消え、起動直後に飛んでくる resize でも真っさらになる
- 開幕は空の織機にしない。一反ぶんを先に織った状態で始める
  (同期ループでは音の時間が進まないので、ここは合成スペクトルを使う)
- devicePixelRatio は最大2でクランプし、以降の描画は CSS px で行う
- 狭い画面では、織りネーム・音のボタン・ヒントが重ならないよう配置を組み替える
- prefers-reduced-motion: 音を作らず、織り上がった布を1枚だけ描いて静止させる
- 終了時に requestAnimationFrame を停止し、全リスナを解除し、
  【AudioContext を close する】。何度開閉してもリークしないこと

CUSTOMIZE

PERFORMANCE

音の処理はブラウザ側(別スレッド)が持っていくので、こちらで重くなるのは描画と解析の受け取りです。

WHAT AI DID / WHAT HUMAN DIRECTION MATTERS

Web Audio の書き方そのものは、AIはよく知っています。オシレータとエンベロープ、フィルタ、ディレイの繋ぎ方。ctx.currentTime で先回りして予約するスケジューラも、言えばすぐ出てきます。実装の速度でいえば、この回がいちばん速かったかもしれません。

引っかかったのは3か所で、いずれも実装の誤りではなく前提の置き方でした。

ひとつめ。最初に出てきたのは「クリックしたら音が鳴りはじめ、それに合わせて可視化が動く」という、ごく普通の作りでした。正しい実装です。しかし押すまで画面には何も無い。 「無音のまま解析だけ動かす」という前提を置いたのは人間側です。置いた瞬間に、作品の性格が変わりました。

ふたつめ。経糸を等間隔に並べた最初の版は、低音が左端に詰まって画面の右半分がほとんど動きませんでした。これは音の話を知っているかどうかで、コードの良し悪しではありません。

みっつめ。これがいちばん時間を食いました。起動直後に一度だけ飛んでくる resize イベントで、裏の布を作り直して真っさらにしていた。 一反ぶん先に織っておく処理を入れたのに、画面はずっと空のままでした。「織れていないのか、描けていないのか、表示できていないのか」を切り分けるまでに数回の計測が必要で、結局「織れてはいるが、直後に消されていた」でした。AIも私も、動いているつもりのコードを最初は疑いませんでした。

人間の仕事として残ったのは、次のような判断でした。

最後のひとつは、今回いちばん効いた判断だと思っています。音を扱うと決めた時点で「音が出ない環境」は必ず出てきます。そこを例外処理として隅に追いやるのか、そこでも成立する作品として設計するのか。 後者を選ぶと、フォールバックが開幕の演出としても使える、というところまで繋がりました。

7回続けて、同じ感想になります。実装は速い。速くなったぶん、何を前提に置くかを決める時間が主役になりました。

NEXT

粒子、文字、線、物理、計算、製版、そして今回の音。入力の軸をひとつ増やした回でした。

次回はまた別の仕掛け ── ボロノイ分割、スクロール連動、影だけで形を見せる表現あたりを考えています。

今回のプロンプトも丸ごとコピーして使えます。音階の数列と染めの色を置き換えれば、まったく別の布が織り上がります。まずは音を出さずに、柄が生まれていくところだけ眺めてみてください。そのあとで音を出すと、さっきまで見ていたものが鳴りはじめます。

レガシーPHPシステムの保守・引き継ぎのご相談を受け付けています。
初回のご相談・ヒアリングは無料です。

無料相談・お問い合わせはこちら

株式会社グルーヴサイン 代表取締役 米沢忠司。PHP開発歴約20年。他社開発システムの保守・引き継ぎを主力に、2007年の設立から19期目。