0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【ConoHa VPS】デジタルアートで癒やしを。Web Audio APIとCanvasで作る「波のインスタレーション」

0
Last updated at Posted at 2025-12-07

はじめに

ConoHa Advent Calendar 2025 8日目の記事です。

今回は、ConoHa VPS環境を使って、「音と光で癒やされるデジタル時計(波のインスタレーション)」 を構築してみました。

ただのWebページではなく、以下の技術を組み合わせて、環境大気のような「ずっと眺めていられるサイト」を目指しました。

  • HTML5 Canvas: 波紋とパーティクルの描画
  • Web Audio API: ペンタトニックスケール(五音音階)による自動演奏
  • Conway's Game of Life: 背景で密かに動くライフゲーム
  • NICT (情報通信研究機構): 日本標準時との同期

作ったもの

画面をクリック(またはタップ)すると、その場所から波紋が広がり、心地よい音が鳴ります。また、放置していてもランダムに波が生まれ、背景ではライフゲームがゆっくりと変化し続けます。

Animation.gif

技術的な解説

このアプリケーションは、1つのHTMLファイル(JavaScript含む)で完結していますが、内部ではいくつかのおもしろい処理を行っています。

1. キャンバスによる「波」と「光」の表現

requestAnimationFrame を使った描画ループの中で、波(Ripple)と粒子(Particle)のクラスを管理しています。

おもしろポイントは 「動的なカラーパレット遷移」 です。時間が経つにつれて、緑→赤→青…とテーマカラーが滑らかに変化するように実装しました。

// カラーパレットの遷移管理(抜粋)
function getCurrentColors() {
    const current = colorPalettes[currentPaletteIndex];
    const next = colorPalettes[nextPaletteIndex];
    // 現在のパレットと次のパレットの間を線形補間して色を決定
    // これにより、パチッと切り替わらず、夕暮れのように色が移ろいます
}

2. Web Audio APIによる「癒やしの音」

mp3などの音声ファイルは一切使用していません。ブラウザのオシレーター(発振器)を使ってリアルタイムに音を合成しています。

こだわったのは 「ペンタトニックスケール(五音音階)」 の採用です。どの音を適当に鳴らしても濁らず、和風で調和の取れた響きになります。

// 癒やしの音階(ペンタトニックスケール)
const healingNotes = [
    261.63, // C4
    293.66, // D4
    329.63, // E4
    392.00, // G4
    440.00, // A4
    523.25  // C5
];

function playRippleSound(x, y) {
    // クリック位置(x座標)によって音程を変える
    // 画面の上の方ほど音が大きく、フェードアウトを優しく設定
}

3. 背景に潜む「ライフゲーム」

ただの塗りつぶし背景では面白くないので、背景には薄く ライフゲーム(Conway's Game of Life) を描画しています。

// 細胞が「過疎」でも「過密」でも死滅し、ちょうどいいと増えるアルゴリズム
if (state === 0 && neighbors === 3) {
    nextGrid[i][j] = 1; // 誕生
} else if (state === 1 && (neighbors < 2 || neighbors > 3)) {
    nextGrid[i][j] = 0; // 死滅
}

これにより、デジタルなノイズのような、有機的な模様のようなテクスチャが背景に生まれます。

4. NICT JSONによる時刻同期

時計としての精度を高めるため、PCのローカル時刻だけでなく、 NICT(情報通信研究機構) が配信している日本標準時のJSONを取得して補正をかけています。

async function syncTimeWithNICT() {
    // NICTのNTPサーバー(JSON)から正確な時刻を取得
    const response = await fetch('https://ntp-a1.nict.go.jp/cgi-bin/json');
    const data = await response.json();
    // サーバー時刻とローカル時刻の差分(Offset)を計算して保持
}

ConoHa VPSでの実行

このアプリケーションをConoHa VPS上で実行しました。
静的なファイルではありますが、VPSを使うことで以下のメリットがあります。

  1. 独自ドメインとSSL化: Let's EncryptなどでHTTPS化することで、マイクやオーディオ権限の扱いがスムーズになります
  2. 拡張性: 将来的にはNode.jsを入れて、WebSocket通信で「誰かがクリックした波紋が、世界中の別の人の画面にも表示される」といったリアルタイム同期機能を追加することも可能です
  3. どこでも楽しめる: イベントの休憩時間などでプロジェクターに出しておけば癒やしになると同時に時刻が分かります。スクリーンセーバーみたいな使い方ができます。

構築の簡易手順

今回はシンプルにNginxで配信しています(ぶっちゃけローカルでも動きます笑)。

  1. ConoHaのコントロールパネルからVPSを追加(OSはUbuntuなどを選択)
  2. SSHでログインし、Nginxをインストール
    sudo apt update
    sudo apt install nginx
    
  3. 作成した index.html/var/www/html などに配置
  4. ブラウザからVPSのIPアドレス(またはドメイン)にアクセス

まとめ

ConoHa VPSの安定した環境のおかげで、ブラウザを開けばいつでもどこでも癒やされる空間を作ることができました。

Web Audio APIとCanvasの組み合わせは、容量を食わずにリッチな表現ができるのでおすすめです。ぜひみなさんも、自分だけの「デジタル環境音」を作ってみてください。


[今回実装したソースコードはこちら]

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>波のインスタレーション</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            overflow: hidden;
        }
        
        body {
            background: linear-gradient(135deg, #0a1628 0%, #1a0b2e 50%, #0d1b2a 100%);
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
            font-family: 'Arial', sans-serif;
        }
        
        canvas {
            cursor: crosshair; /* インタラクティブな雰囲気を出すカーソル */
            display: block;
        }
        
        /* 時計のスタイル:神秘的な雰囲気を演出 */
        #clock {
            position: absolute;
            bottom: 40px;
            right: 40px;
            color: rgba(255, 255, 255, 0.8);
            font-size: 72px;
            font-weight: 300;
            letter-spacing: 4px;
            text-shadow: 0 0 20px rgba(255, 255, 255, 0.5),
                         0 0 40px rgba(100, 200, 255, 0.3);
            pointer-events: none;
            font-family: 'Courier New', monospace;
        }
        
        /* サウンドON/OFFボタン:フロストガラス風のデザイン */
        #soundBtn {
            position: absolute;
            bottom: 40px;
            left: 40px;
            background: rgba(255, 255, 255, 0.1);
            border: 1px solid rgba(255, 255, 255, 0.3);
            color: white;
            padding: 12px 24px;
            font-size: 14px;
            border-radius: 25px;
            cursor: pointer;
            transition: all 0.3s ease;
            backdrop-filter: blur(10px);
        }
        
        #soundBtn:hover {
            background: rgba(255, 255, 255, 0.2);
            transform: scale(1.05);
        }
    </style>
</head>
<body>
    <canvas id="canvas"></canvas>
    <div id="clock"></div>
    <button id="soundBtn">🔈 サウンド ON</button>
    
    <script>
        const canvas = document.getElementById('canvas');
        const ctx = canvas.getContext('2d');
        
        // --- サウンドシステム ---
        let audioCtx = null;
        let soundEnabled = false;
        
        // 癒やしの音階(ペンタトニックスケール)
        // 不協和音になりにくい音階を選定しています
        const healingNotes = [
            261.63, // C4 (ド)
            293.66, // D4 (レ)
            329.63, // E4 (ミ)
            392.00, // G4 (ソ)
            440.00, // A4 (ラ)
            523.25  // C5 (ド)
        ];
        
        // AudioContextの初期化(ユーザー操作後に呼び出す必要がある)
        function initAudio() {
            if (!audioCtx) {
                audioCtx = new (window.AudioContext || window.webkitAudioContext)();
            }
            if (audioCtx.state === 'suspended') {
                audioCtx.resume();
            }
        }
        
        // 波紋生成時のサウンド再生
        function playRippleSound(x, y) {
            if (!soundEnabled || !audioCtx) return;
            
            // 画面のX座標に応じて音階を選択(左が低音、右が高音)
            const noteIndex = Math.floor((x / canvas.width) * healingNotes.length);
            const frequency = healingNotes[Math.min(noteIndex, healingNotes.length - 1)];
            
            const oscillator = audioCtx.createOscillator();
            const gainNode = audioCtx.createGain();
            
            // サイン波(Sine)は丸く柔らかい音色になります
            oscillator.type = 'sine';
            oscillator.frequency.setValueAtTime(frequency, audioCtx.currentTime);
            
            // 音量エンベロープ:Y座標が高いほど音が大きく、優しくフェードアウトさせる
            const volume = 0.1 + (y / canvas.height) * 0.1;
            gainNode.gain.setValueAtTime(0, audioCtx.currentTime);
            gainNode.gain.linearRampToValueAtTime(volume, audioCtx.currentTime + 0.1);
            gainNode.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 2);
            
            oscillator.connect(gainNode);
            gainNode.connect(audioCtx.destination);
            
            oscillator.start(audioCtx.currentTime);
            oscillator.stop(audioCtx.currentTime + 2);
        }
        
        // 環境音(ランダムに鳴るきらめき音)
        function playAmbientTone() {
            if (!soundEnabled || !audioCtx) return;
            
            const frequency = healingNotes[Math.floor(Math.random() * healingNotes.length)];
            const oscillator = audioCtx.createOscillator();
            const gainNode = audioCtx.createGain();
            
            // トライアングル波で少しキラッとした音色に
            oscillator.type = 'triangle';
            oscillator.frequency.setValueAtTime(frequency * 2, audioCtx.currentTime); // 1オクターブ上
            
            gainNode.gain.setValueAtTime(0, audioCtx.currentTime);
            gainNode.gain.linearRampToValueAtTime(0.02, audioCtx.currentTime + 0.3);
            gainNode.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 1.5);
            
            oscillator.connect(gainNode);
            gainNode.connect(audioCtx.destination);
            
            oscillator.start(audioCtx.currentTime);
            oscillator.stop(audioCtx.currentTime + 1.5);
        }
        
        const soundBtn = document.getElementById('soundBtn');
        soundBtn.addEventListener('click', () => {
            initAudio();
            soundEnabled = !soundEnabled;
            soundBtn.textContent = soundEnabled ? '🔊 サウンド OFF' : '🔈 サウンド ON';
        });
        
        // --- キャンバス設定 ---
        canvas.width = window.innerWidth;
        canvas.height = window.innerHeight;
        
        // --- カラーパレット管理 ---
        // 時間経過で移ろう色の定義
        let colorPalettes = [
            [ // 1. 青緑系
                {r: 0, g: 255, b: 150},
                {r: 0, g: 200, b: 255},
                {r: 150, g: 100, b: 255},
                {r: 255, g: 100, b: 200},
                {r: 100, g: 255, b: 200}
            ],
            [ // 2. 暖色系
                {r: 255, g: 100, b: 100},
                {r: 255, g: 200, b: 100},
                {r: 255, g: 255, b: 100},
                {r: 200, g: 100, b: 255},
                {r: 255, g: 150, b: 200}
            ],
            [ // 3. クール系
                {r: 100, g: 150, b: 255},
                {r: 150, g: 255, b: 255},
                {r: 200, g: 150, b: 255},
                {r: 255, g: 200, b: 255},
                {r: 150, g: 200, b: 255}
            ],
            [ // 4. ネイチャー系
                {r: 100, g: 255, b: 100},
                {r: 200, g: 255, b: 150},
                {r: 150, g: 255, b: 200},
                {r: 100, g: 200, b: 255},
                {r: 150, g: 255, b: 150}
            ]
        ];
        
        let currentPaletteIndex = 0;
        let nextPaletteIndex = 1;
        let paletteTransition = 0;
        const PALETTE_CHANGE_SPEED = 0.0005;
        
        // 現在の補間色を取得する関数
        function getCurrentColors() {
            const current = colorPalettes[currentPaletteIndex];
            const next = colorPalettes[nextPaletteIndex];
            const colors = [];
            
            for (let i = 0; i < 5; i++) {
                // 線形補間で滑らかな色変化を実現
                colors.push({
                    r: current[i].r + (next[i].r - current[i].r) * paletteTransition,
                    g: current[i].g + (next[i].g - current[i].g) * paletteTransition,
                    b: current[i].b + (next[i].b - current[i].b) * paletteTransition
                });
            }
            return colors;
        }
        
        function updateColorTransition() {
            paletteTransition += PALETTE_CHANGE_SPEED;
            if (paletteTransition >= 1) {
                paletteTransition = 0;
                currentPaletteIndex = nextPaletteIndex;
                nextPaletteIndex = (nextPaletteIndex + 1) % colorPalettes.length;
            }
        }
        
        // --- 波(Ripple)クラス ---
        class Ripple {
            constructor(x, y) {
                this.x = x;
                this.y = y;
                this.radius = 0;
                this.maxRadius = Math.random() * 250 + 150;
                this.speed = Math.random() * 2 + 1;
                this.opacity = 1;
                const colors = getCurrentColors();
                this.color = colors[Math.floor(Math.random() * colors.length)];
                this.lineWidth = Math.random() * 4 + 2;
            }
            
            update() {
                this.radius += this.speed;
                this.opacity = 1 - (this.radius / this.maxRadius);
                return this.radius < this.maxRadius;
            }
            
            draw() {
                ctx.shadowBlur = 20;
                ctx.shadowColor = `rgba(${this.color.r}, ${this.color.g}, ${this.color.b}, ${this.opacity})`;
                
                ctx.beginPath();
                ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
                ctx.strokeStyle = `rgba(${this.color.r}, ${this.color.g}, ${this.color.b}, ${this.opacity * 0.6})`;
                ctx.lineWidth = this.lineWidth;
                ctx.stroke();
                ctx.closePath();
                
                // 二重の波紋にしてリッチさを出す
                if (this.radius > 30) {
                    ctx.beginPath();
                    ctx.arc(this.x, this.y, this.radius - 30, 0, Math.PI * 2);
                    ctx.strokeStyle = `rgba(${this.color.r}, ${this.color.g}, ${this.color.b}, ${this.opacity * 0.3})`;
                    ctx.lineWidth = this.lineWidth * 0.6;
                    ctx.stroke();
                    ctx.closePath();
                }
                ctx.shadowBlur = 0;
            }
        }
        
        // --- パーティクルクラス ---
        class Particle {
            constructor(x, y) {
                this.x = x;
                this.y = y;
                this.size = Math.random() * 4 + 1;
                this.speedX = (Math.random() - 0.5) * 8;
                this.speedY = (Math.random() - 0.5) * 8;
                this.opacity = 1;
                const colors = getCurrentColors();
                this.color = colors[Math.floor(Math.random() * colors.length)];
            }
            
            update() {
                this.x += this.speedX;
                this.y += this.speedY;
                this.opacity -= 0.02;
                this.size *= 0.98;
                return this.opacity > 0 && this.size > 0.5;
            }
            
            draw() {
                ctx.shadowBlur = 10;
                ctx.shadowColor = `rgba(${this.color.r}, ${this.color.g}, ${this.color.b}, ${this.opacity})`;
                ctx.beginPath();
                ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
                ctx.fillStyle = `rgba(${this.color.r}, ${this.color.g}, ${this.color.b}, ${this.opacity})`;
                ctx.fill();
                ctx.closePath();
                ctx.shadowBlur = 0;
            }
        }
        
        let ripples = [];
        let particles = [];
        
        // --- ライフゲーム(背景演出) ---
        const cellSize = 20;
        let cols, rows;
        let grid = [];
        let nextGrid = [];
        
        function initGrid() {
            cols = Math.floor(canvas.width / cellSize);
            rows = Math.floor(canvas.height / cellSize);
            grid = [];
            nextGrid = [];
            
            for (let i = 0; i < cols; i++) {
                grid[i] = [];
                nextGrid[i] = [];
                for (let j = 0; j < rows; j++) {
                    // 初期状態はランダム(少なめに設定して静けさを出す)
                    grid[i][j] = Math.random() > 0.85 ? 1 : 0;
                    nextGrid[i][j] = 0;
                }
            }
        }
        
        function countNeighbors(x, y) {
            let sum = 0;
            for (let i = -1; i <= 1; i++) {
                for (let j = -1; j <= 1; j++) {
                    if (i === 0 && j === 0) continue;
                    const col = (x + i + cols) % cols;
                    const row = (y + j + rows) % rows;
                    sum += grid[col][row];
                }
            }
            return sum;
        }
        
        function updateGrid() {
            for (let i = 0; i < cols; i++) {
                for (let j = 0; j < rows; j++) {
                    const state = grid[i][j];
                    const neighbors = countNeighbors(i, j);
                    
                    // ライフゲームのルール
                    if (state === 0 && neighbors === 3) {
                        nextGrid[i][j] = 1; // 誕生
                    } else if (state === 1 && (neighbors < 2 || neighbors > 3)) {
                        nextGrid[i][j] = 0; // 死滅
                    } else {
                        nextGrid[i][j] = state; // 維持
                    }
                }
            }
            [grid, nextGrid] = [nextGrid, grid];
        }
        
        function drawGrid() {
            for (let i = 0; i < cols; i++) {
                for (let j = 0; j < rows; j++) {
                    if (grid[i][j] === 1) {
                        const x = i * cellSize;
                        const y = j * cellSize;
                        const colors = getCurrentColors();
                        const color = colors[(i + j) % colors.length];
                        
                        // 淡く描画して背景になじませる
                        ctx.shadowBlur = 8;
                        ctx.shadowColor = `rgba(${color.r}, ${color.g}, ${color.b}, 0.2)`;
                        ctx.fillStyle = `rgba(${color.r}, ${color.g}, ${color.b}, 0.1)`;
                        ctx.fillRect(x, y, cellSize - 1, cellSize - 1);
                        ctx.shadowBlur = 0;
                    }
                }
            }
        }
        
        initGrid();
        let frameCount = 0;
        let gridAge = 0;
        const GRID_RESET_INTERVAL = 1200; // 約20秒ごとにリセットして新鮮さを保つ
        
        // --- NICT時刻同期 ---
        let timeOffset = 0; 
        
        async function syncTimeWithNICT() {
            try {
                const response = await fetch('https://ntp-a1.nict.go.jp/cgi-bin/json');
                const data = await response.json();
                const nictTime = new Date(data.st * 1000); 
                const localTime = new Date();
                timeOffset = nictTime - localTime;
                console.log('NICT時刻と同期しました');
            } catch (error) {
                console.log('NICT時刻取得失敗。ローカル時刻を使用します。');
                timeOffset = 0;
            }
        }
        
        function updateClock() {
            const now = new Date(Date.now() + timeOffset);
            const hours = String(now.getHours()).padStart(2, '0');
            const minutes = String(now.getMinutes()).padStart(2, '0');
            const seconds = String(now.getSeconds()).padStart(2, '0');
            document.getElementById('clock').textContent = `${hours}:${minutes}:${seconds}`;
        }
        
        syncTimeWithNICT();
        setInterval(syncTimeWithNICT, 10 * 60 * 1000); // 10分ごとに再同期
        setInterval(updateClock, 1000);
        updateClock(); 
        
        // --- メインループとイベント ---
        function createRandomRipple() {
            const x = Math.random() * canvas.width;
            const y = Math.random() * canvas.height;
            ripples.push(new Ripple(x, y));
            
            if (Math.random() < 0.3) {
                playAmbientTone();
            }
            setTimeout(createRandomRipple, Math.random() * 800 + 400);
        }
        
        // 自動再生の開始
        for (let i = 0; i < 5; i++) {
            setTimeout(createRandomRipple, i * 200);
        }
        
        // クリック/タッチ時の処理
        const handleInteraction = (x, y) => {
            // 複数の波を作成して豪華に
            for (let i = 0; i < 5; i++) {
                setTimeout(() => {
                    ripples.push(new Ripple(x, y));
                }, i * 80);
            }
            // パーティクルを散らす
            for (let i = 0; i < 40; i++) {
                particles.push(new Particle(x, y));
            }
            playRippleSound(x, y);
        };

        canvas.addEventListener('click', (e) => {
            const rect = canvas.getBoundingClientRect();
            handleInteraction(e.clientX - rect.left, e.clientY - rect.top);
        });
        
        canvas.addEventListener('touchstart', (e) => {
            e.preventDefault();
            const rect = canvas.getBoundingClientRect();
            const touch = e.touches[0];
            handleInteraction(touch.clientX - rect.left, touch.clientY - rect.top);
        });
        
        // アニメーションループ
        function animate() {
            // 前のフレームを半透明で塗りつぶして残像効果(Trail)を作る
            ctx.fillStyle = 'rgba(10, 22, 40, 0.4)';
            ctx.fillRect(0, 0, canvas.width, canvas.height);
            
            updateColorTransition();
            
            // ライフゲームの更新頻度を落とす(3フレームに1回)
            frameCount++;
            gridAge++;
            
            if (gridAge >= GRID_RESET_INTERVAL) {
                initGrid();
                gridAge = 0;
            }
            
            if (frameCount % 3 === 0) {
                updateGrid();
            }
            drawGrid();
            
            // 波とパーティクルの更新
            ripples = ripples.filter(ripple => {
                ripple.draw();
                return ripple.update();
            });
            
            particles = particles.filter(particle => {
                particle.draw();
                return particle.update();
            });
            
            requestAnimationFrame(animate);
        }
        
        // 初期化
        ctx.fillStyle = '#0a1628';
        ctx.fillRect(0, 0, canvas.width, canvas.height);
        animate();
        
        // リサイズ対応
        window.addEventListener('resize', () => {
            canvas.width = window.innerWidth;
            canvas.height = window.innerHeight;
            ctx.fillStyle = '#0a1628';
            ctx.fillRect(0, 0, canvas.width, canvas.height);
            initGrid();
        });
    </script>
</body>
</html>

ここまで読んでくれたあなたへ

「このはちゃん、清楚かわいい!」

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?