JavaScriptでスノーノイズ(砂嵐)

デジタル放送になってこれを目にする機会も少なくなりましたが、Canvas APIとWeb Audio APIを使って何か作りたかったのでとりあえず簡単そうなところからこれを題材に選びました。

[こちらから実行できます](https://akebi.jp/temp/snownoise.html)
ブラウザがWeb Audio APIに対応していれば、画面クリックでホワイトノイズも鳴ります。
音量にはご注意下さい。

~~~html:snownoise.html
<!DOCTYPE html>
<html lang='ja'>
<head>
<meta charset="utf-8" />
<title>Snow Noise</title>
<meta name='viewport' content='user-scalable=no,width=device-width,initial-scale=1' />
<style>
body {
    margin: 0px;
}
#canvas {
    position: fixed;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<script src='./snownoise.js'></script>
</body>
</html>
~~~

~~~js:snownoise.js
const canvas = document.getElementById('canvas');
const ps = window.parent.screen;

// キャンバスをずらすためのマージン分
const shiftMargin = 512;

// 回転や反転の使用
const useTransform = true;

// 真面目にノイズを描くサイズ
// 小さくするほど初期処理は早くなる反面、繰り返しパターンは目立ちやすくなる
const rWidth = 480;
const rHeight = 320;

// ディスプレイサイズ + ずらすマージン分をキャンバスサイズとして確保
if(useTransform) {
    // 回転利用の場合は長辺基準で確保
    canvas.width = canvas.height = Math.max(ps.width, ps.height) + shiftMargin;
} else {
    canvas.width = ps.width + shiftMargin;
    canvas.height = ps.height + shiftMargin;
}

onload = function(){
    drawCanvas();
//    setInterval(shiftCanvas, 1000 / 60);
    shiftCanvas();
}

function drawCanvas() {
    const cWidth = canvas.width;
    const cHeight = canvas.height;
    const cCtx = canvas.getContext('2d');

    // rWidth x rHeightのサイズ分だけ真面目にノイズを描く
    for (let x = 0; x < rWidth; x++) {
        for (let y = 0; y < rHeight; y++) {
            const n = Math.random() * 256;
            cCtx.fillStyle = '#'+ ('00000' + (n << 16 | n << 8 | n).toString(16)).slice(-6);
            cCtx.fillRect(x, y, 1, 1);
        }
    }

    // 真面目に描いた範囲からランダムな位置の1/4サイズをコピーして余白に敷き詰める
    for (let x = 0; x < cWidth; x+= rWidth / 2) {
        for (let y = 0; y < cHeight; y+= rHeight / 2) {
            if (!(x < rWidth && y < rHeight)) {
                cCtx.putImageData(cCtx.getImageData(Math.random() * rWidth / 2, Math.random() * rHeight / 2, Math.ceil(rWidth / 2), Math.ceil(rHeight / 2)), x, y);
            }
        }
    }
}

// 確保したマージン分の範囲でキャンバスをランダムな位置にずらす
let transformScaleX = 1;
let transformScaleY = 1;
let filterInvert = 0;
function shiftCanvas() {
    window.requestAnimationFrame(shiftCanvas);

    // 位置シフト
    canvas.style.left = (- Math.random() * shiftMargin) + 'px';
    canvas.style.top = (- Math.random() * shiftMargin) + 'px';

    if(useTransform) {
        // 90度単位の回転、左右上下ネガポジ反転も加え、見かけ上のバリエーションを増やす
        // 50%の確率で左右反転
        if(Math.random() > .5) transformScaleX *= -1;
        // 50%の確率で上下反転
        if(Math.random() > .5) transformScaleY *= -1;
        // 90度単位の角度をランダムに選択
        const transformRotate = Math.floor(Math.random() * 4) * 90;
        canvas.style.transform = 'scale(' + transformScaleX + ', ' + transformScaleY + ') rotate(' + transformRotate + 'deg)';
        // 50%の確率でネガポジ反転
        if(Math.random() > .5) {
            canvas.style.filter = 'invert(' + (filterInvert ^= 1) + ')';
        }
    }
}

// ホワイトノイズ生成
let source;
function generateNoise() {
    const AudioContext = window.AudioContext || window.webkitAudioContext;
    if (!AudioContext) return;
    const audioCtx = new AudioContext();

    // 10秒分バッファ確保
    const frameCount = audioCtx.sampleRate * 10;
    const buffer = audioCtx.createBuffer(1, frameCount, audioCtx.sampleRate);
    const nowBuffering = buffer.getChannelData(0);

    // 生成
    for (let i = 0; i < frameCount; i++) {
        nowBuffering[i] = Math.random() * 2 - 1;
    }

    source = audioCtx.createBufferSource();
    source.buffer = buffer;
    source.connect(audioCtx.destination);
    source.loop = true;
}

// 画面クリックで音をON/OFF
let sFlg = 0;
document.querySelector('body').onclick = function() {
    if (sFlg ^= 1) {
        generateNoise();
        if(source) source.start();
    } else if(source) source.stop();
}

// 2秒後マウスポインタを非表示、マウスを動かしたらポインタ復帰
let timeoutId;
let pointerHide = false;
window.onmousemove = function() {
    if(!pointerHide) {
        document.querySelector('body').style.cursor = '';
        pointerHide = true;
    }
    if(timeoutId) clearTimeout(timeoutId);
    timeoutId = setTimeout(function() {
        document.querySelector('body').style.cursor = 'none';
        pointerHide = false;
    }, 2000);
}
~~~