Web Audio Library XSound 2.4.0 / 2.4.1 Released

1. Overview

久々の Web Audio API ネタです. XSound 2.4.0 からノイズの生成に対応しました. 2.4.0 ではホワイトノイズ (白色雑音), 2.4.1 ではピンクノイズの生成が可能になりました.

2. What is Noise ?

そもそもノイズ (雑音) とは何か ? から解説します. 世の中に存在する音のほとんどは周期的な波形をしており, 基本周波数をもつ sin 波とその整数倍の周波数をもつ sin 波 (倍音) の合成によって構成されます (それを周波数視点で分解するのがフーリエ変換です). しかしながら, 非周期的な波形をもつ音も存在しており, その典型例がノイズと呼ばれる音になります. ノイズにはいくつか種類がありますが, 有名でよく利用されるホワイトノイズとピンクノイズの特徴と, それを Web Audio API で生成するための実装を紹介します.

3. White Noise

ホワイトノイズ (白色雑音) の命名の由来は, そのスペクトルが白色のスペクトルと同じ (どの帯域でも同じ振幅をとる) であることから名づけられました. ホワイトノイズの実装はとても簡単です. なぜなら, 乱数を生成するだけだからです (ただし, 振幅が -1 〜 1 に収まるように値を調整する必要はあります). JavaScript の Math.random メソッドは 0 以上 1 未満の値を返すので, -0.5 したあと, 2 倍にすることで, -1 〜 1 の値に収まるようにしています.

// processor は ScriptProcessorNode のインスタンス
processor.onaudioprocess = event => {
    const outputLs = event.outputBuffer.getChannelData(0);
    const outputRs = event.outputBuffer.getChannelData(1);

    // bufferSize は ScriptProcessorNode のバッファサイズ
    for (let i = 0; i < bufferSize; i++) {
        outputLs[i] = 2 * (Math.random() - 0.5);
        outputRs[i] = 2 * (Math.random() - 0.5);
    }
}

4. Pink Noise

ピンクノイズの命名の由来は, そのスペクトルがピンク色のスペクトルと同じ (高周波数ほど振幅が減衰する) であることから名づけられました. ピンクノイズの実装は少し複雑 (すみませんが, 私も完全に理解できていません …) なので, 実装のみを紹介します.

// processor は ScriptProcessorNode のインスタンス
processor.onaudioprocess = event => {
    const outputLs = event.outputBuffer.getChannelData(0);
    const outputRs = event.outputBuffer.getChannelData(1);

    let b0 = 0;
    let b1 = 0;
    let b2 = 0;
    let b3 = 0;
    let b4 = 0;
    let b5 = 0;
    let b6 = 0;

    // bufferSize は ScriptProcessorNode のバッファサイズ
    for (let i = 0; i < bufferSize; i++) {
        const white = (Math.random() * 2) - 1;

        b0 = (0.99886 * b0) + (white * 0.0555179);
        b1 = (0.99332 * b1) + (white * 0.0750759);
        b2 = (0.96900 * b2) + (white * 0.1538520);
        b3 = (0.86650 * b3) + (white * 0.3104856);
        b4 = (0.55000 * b4) + (white * 0.5329522);
        b5 = (-0.7616 * b5) - (white * 0.0168980);

        outputLs[i] = b0 + b1 + b2 + b3 + b4 + b5 + b6 + (white * 0.5362);
        outputRs[i] = b0 + b1 + b2 + b3 + b4 + b5 + b6 + (white * 0.5362);

        outputLs[i] *= 0.11;
        outputRs[i] *= 0.11;

        b6 = white * 0.115926;
    }
}

う〜ん … 原理がよくわかりません w. 実は, ホワイトノイズに適切な Low-Pass Filter をかけることでも生成できるので, 原理がわからないと使いたくない場合は, その実装でもいいかもしれません.

5. References

Web Components <x-piano> v2.0.0 をリリースしました

1. Overview

Web Components である, <x-piano> v2.0.0 をリリースしました. v2.0.0 では, 以下の API で構成されています.

  • Shadow DOM v1
  • Custom Elements v1
  • ESModules

* Web Components を構成する API には HTML Templates も含まれますが, <x-piano> では利用していないので, 割愛します.

ちなみに, v1.x.x までは以下の API で構成されていました.

  • Shadow DOM v0
  • Custom Elements v0
  • HTML Imports

詳細な仕様は, Web Fundamentals などに記載されていますので, <x-piano> v2.0.0 のコードを例に, それぞれの API がどのようになっているかを簡単に解説します.

2. Shadow DOM v1

Shadow DOM とは, ツールや命名規則がなくても, Vanilla JavaScript で CSS とマークアップをバンドルし, 実装の詳細を非表示にして, 自己完結型 (例えば, document.querySelector は, コンポーネントの Shadow DOM 内のノードを返しません) のコンポーネントを作成するための API です. 詳細は, 最後のセクションのリファレンスを参考にしてください.

Shadow DOM v0 では, createShadowRoot メソッドを利用して, Shadow Root を生成していましたが, v1 では, 以下のようなコードになります (<x-piano> v2.0.0 より).

export default class Piano extends HTMLElement {
    /** @override */
    constructor() {
        super();

        this.attachShadow({ mode : 'open' });
    }

    render() {
        this.shadowRoot.innerHTML = `...`
    }
}

まず, HTMMLElement を継承したクラスを実装します. そして, attachShadow メソッドに, 引数 { mode : ‘open’ } を指定することで, Shadow Root が生成されます. あとは, Shadow DOM を生成するために innerHTML (や, appendChild) などの DOM 操作のメソッドを利用します.

3. Custom Elements v1

Custom Elements とは, その名前のとおり, 新しい HTML タグを作成したり, 既存の HTML タグを拡張したりするための API です. 詳細は, 最後のセクションのリファレンスを参考にしてください.

Custom Elements v0 では, document.registerElement というメソッドを利用して, Custome Elements を定義していましたが, Custom Elements v1 では, 以下のようなコードになります (<x-piano> v2.0.0 より).

export default class Piano extends HTMLElement {
    static get observedAttributes() {
        return [
            'ui-only',
            'type',
            'volume',
            'transpose',
            'glide',
            'attack',
            'decay',
            'sustain',
            'release'
        ];
    }

    constructor() {
        // 要素のインスタンスが作成またはアップグレードされたとき. 状態の初期化, イベントリスナーの設定, または, Shadow DOM の作成に利用します.
    }

    connectedCallback() {
        // 要素が DOM に挿入されるたびに呼び出されます. リソースの取得やレンダリングなどの, セットアップコードの実行に利用します.
    }

    disconnectedCallback() {
        // 要素が DOM から削除されるたびに呼び出されます. クリーンアップ コードの実行 (イベント リスナーの削除など) に利用します.
    }

    attributeChangedCallback() {
        // 属性が追加, 削除, 更新, または, 置換されたときに呼び出されます. また, そのためには, 対象の属性を指定する static get observedAttributes メソッドの実装が必要です.
    }
}

window.customElements.define('x-piano', Piano);

まずは, 拡張したい HTMLElement (例えば, HTMLButtonElement など) を指定して, クラスを実装します. あとは, window.customElements.define メソッドを利用して, タグ名を第 1 引数に, 対象のクラスを第 2 引数に指定して, Custom Elements を定義するだけです. これだけで, 最低限の実装は完了です. あとは, 必要に応じて, constructor や callback となるメソッドを実装するだけです.

4. ESModules

旧仕様の Web Components では, Custom Elements などを定義したファイルを読み込むには, HTML Imports という API を利用していましたが, 現在の仕様では, ESModules を利用するようになっています. API は非常に簡単で, script タグの type 属性に module を指定するだけです. また, ESModules に対応していないブラウザのために, nomodule 属性を利用した script タグも用意して, webpack などでバンドルしたフォールバックのスクリプトを読み込ませておきます. 具体的なコードは, 以下のようになります (<x-piano> v2.0.0 より).
定義した Custom Elements などを利用したい HTML のファイルで,

<script type="module" src="src/components/index.js"></script>
 <script nomodule src="build/app.js"></script>

のように, script タグを記述します.

5. References

Media Source Extensions API

1. Overview

Media Source Extensions API (以下, MSE) は, W3C によって標準化されている HTTP ダウンロードを利用してストリーミング再生するために作られた JavaScript API です. この記事では, MSE を利用して, 簡単な MPEG-DASH コンテンツを再生するための実装を紹介します.

2. Create resources

再生するコンテンツのリソースを生成します. 生成には, ffmpeg と MP4Box が必要になるので, brew などでインストールしておきます.

$ brew install ffmpeg
$ brew install MP4Box

次に, 対象となる MP4 動画を ffmpeg でエンコードします.

ffmpeg -i ./input.mp4 \
  -vcodec libx264 \
  -vb 500k \
  -r 30 \
  -x264opts no-scenecut \
  -g 15 \
  -acodec aac \
  -ac 2 \
  -ab 128k \
  -frag_duration 5000000 \
  -movflags frag_keyframe+empty_moov \
  ./encoded.mp4

そして, MP4Boxを利用して, 動画を分割し, セグメントファイルとプレイリストを生成します.

MP4Box -frag 4000 \            
  -dash 4000 \
  -rap \
  -segment-name sample \
  -out ./output.mp4 \
  ./encoded.mp4

以上で, プレイリストである output.mpd と output.m4s と連番になったセグメントファイルが生成されます. MPD (Media Presentation Description) ファイルの実体は, セグメントファイルをどの順番で再生するかが記述された XML ファイルです.

3. Media Source Extensions API

リソースの準備ができたので, MSE を利用した JavaScript の実装を紹介します.

'use strict';

class MSE {
    constructor(video, file) {
        this.getDescription(video, file);
        this.segmentIndex = 0;
        this.mediaAppended = false;
    }

    // MPD ファイルを取得し, MIME タイプやコーデックの情報を取得する
    getDescription(video, file) {
        const xhr = new XMLHttpRequest();

        xhr.open('GET', file, true);
        xhr.responseType = 'document';

        xhr.onload = () => {
            this.mpd = xhr.response;

            const representation = this.mpd.querySelector('Representation');
            const mimeType       = representation.getAttribute('mimeType');
            const codecs         = representation.getAttribute('codecs');

            this.type = `${mimeType}; codecs="${codecs}"`;

            this.initVideo(video);
        };

        xhr.send(null);
    }

    // セグメントを追加できるようにする
    initVideo(video) {
        this.mediaSource = new MediaSource();
        this.mediaSource.addEventListener('sourceopen', this.initSourceBuffer.bind(this), false);

        video.src = window.URL.createObjectURL(this.mediaSource);
    }

    // 初期化情報が入ったセグメント (初期化セグメント) とメディア本体のセグメントを追加できるようにする
    initSourceBuffer() {
        this.sourceBuffer = this.mediaSource.addSourceBuffer(this.type);
        this.sourceBuffer.addEventListener('updateend', this.appendMediaSegment.bind(this), false);
        this.appendInitSegment();
    }

    // セグメントファイルのロードが完了したタイミングで, SourceBuffer に追加する
    appendSegment(event) {
        this.sourceBuffer.appendBuffer(event.currentTarget.response);

        if (this.mediaAppended) {
            this.sourceBuffer.removeEventListener('updateend', this.appendMediaSegment.bind(this), false);
        }
    }

    // 初期化セグメントが SourceBuffer に追加されて更新されたら, メディア本体のセグメントファイル (メディアセグメント) を取得して, SourceBuffer に追加する. この処理を, メディアセグメントの数だけ繰り返す.
    appendMediaSegment() {
        const xhr = new XMLHttpRequest();

        const segmentURL = this.mpd.querySelectorAll('SegmentURL')[this.segmentIndex++];

        if (!segmentURL) {
            this.mediaAppended = true;
            return;
        }

        xhr.open('GET', segmentURL.getAttribute('media'), true);
        xhr.responseType = 'arraybuffer';
        xhr.onload = this.appendSegment.bind(this);
        xhr.send(null);
    }

    // MDP から セグメントファイルの URL を取得する
    appendInitSegment() {
        const xhr = new XMLHttpRequest();

        xhr.open('GET', this.mpd.querySelector('Initialization').getAttribute('sourceURL'), true);
        xhr.responseType = 'arraybuffer';
        xhr.onload = this.appendSegment.bind(this);
        xhr.send(null);
    }
}

new MSE(document.querySelector('video'), 'output.mpd');

これで, MPEG-DASH コンテンツを再生するための最小限の実装ができました.

Media Source Extensions API リポジトリ (Safari のみ)

参考 : フロントエンドエンジニアのための動画ストリーミング技術基礎

Async Clipboard API

1. Overview

Async Clipboard API は, 非同期でクリップボードを扱うAPIです。Async Clipboard APIは, Promiseを返すので, 直感的に扱えるようになっています. 従来の API でも、Flash や document.execCommand を利用することでクリップボードを操作することができました。しかしながら, バグも多くブラウザ間の整合性が取れているとは言い難く, 実際に使おうとすると様々な問題に直面しました.

さらに, document.execCommand によるクリップボード操作は同期的であるため, 大きなデータを操作するとメインスレッドをブロックする可能性があり, 結果としてUXを損ないます。

そもそも, document.execCommand は元々は DOM 操作のための古いAPIであり, 仕様の策定もあまり進んでいません. したがって, クリップボードの操作のためには, 新しいAPIが必要だったのです.

2. Async Clipboard API

2 – 1. Write to Clopboard

クリップボードにプレインテキストを書き込むには, navigator.clipboard オブジェクトの writeText メソッドを利用します. また, このメソッドは Promise を返すので, then や async / await を利用して適切な処理を実装します.

navigator.clipboard.writeText('Click Copy Button');

プレインテキスト以外のデータ (HTML など) を書き込むには, DataTransfer オブジェクトと navigator.clipboard オブジェクトの write メソッドを利用します (しかしながら, Chrome 67 でもバグがあるので, 現状は利用できません).

const data = new DataTransfer();
data.items.add(`${source.value}`, 'text/html');

navigator.clipboard.write(data); // ここでエラーが発生する

2 – 2. Read from Clopboard

簡易的なコピーボタンの実装などであれば, これで十分に利用可能ですが, クリップボードからテキストを読み込むには, navigator.clipboard オブジェクトの readText メソッドを利用します. また, このメソッドも Promise を返すので, then や async / await を利用して適切な処理を実装します.

const text = await navigator.clipboard.readText();
console.log(text)

2 – 3. Others

Async Clipboard API では上記の API 以外にも, クリップボードの変化を受けとる navigator.clipboard オブジェクトの clipboardchange イベントであったり, Permissions API を利用して, クリップボードへのパーミッションを確認・要求することができます.

デモ

Server Sent Events (SSE) を AudioContext の state 変更に使えないか検証してみた

1. Overview

Chrome 66+ から Web Audio API にも Autoplay Policy Change が適用され, ユーザーのジェスチャー (click など) がないと AudioContext インスタンスの state が suspend のままとなり, 音が鳴らないようになってしまいました. この抜け道を探るべく, Server Sent Evets (SSE) の message イベントをフックにできないか検証してみました.

そして, 結論から述べますと, もちろんダメでした …

2. Code

サーバサイドは, とにかく名前つきイベントと message イベントを発生させることができればよいので, PHP でサクッと実装しました. 簡単に説明すると, 毎秒名前つきイベント (ping) を送信し, カウンターが 0 になったらデータのみのメッセージイベントを送信するというスクリプトです.

server-sent-events.php

<?php
// Server Sent Events の MIME タイプ
header("Content-Type: text/event-stream\n\n");

$counter = mt_rand(1, 10);

while (true) {
  echo "event: ping\n";

  $dateTime = new DateTime();
  $now = $dateTime->format('Y-m-d H:i:s');

  echo 'data: {"time" : "' . $now . '"}';
  echo "\n\n";

  $counter--;

  if ($counter === 0) {
    echo 'data: This is a message at time ' . $now . "\n\n";
    $counter = mt_rand(1, 10);
  }

  ob_end_flush();
  flush();
  sleep(1);
}

server-sent-events.js

let audiocontext = null;

const eventSource = new EventSource('server-sent-events.php');

const onReceive = event => {
    console.log(event.type);
    console.dir(event.data);

    if (audiocontext === null) {
        audiocontext = new AudioContext();
        audiocontext.resume();
    }

    const oscillator = audiocontext.createOscillator();

    oscillator.connect(audiocontext.destination);

    oscillator.start(0);

    if (audiocontext instanceof AudioContext) {
        const p = document.createElement('p');
        p.textContent = audiocontext.state;

        document.body.appendChild(p);
    }
};

eventSource.addEventListener('ping', onReceive, false);

eventSource.onmessage = onReceive;

eventSource.onerror = event => {
    eventSource.close();
};

document.body.addEventListener('click', () => {
    eventSource.close();
}, false);

検証結果は残念でしたが, SSE を実装したのは初めてなので学びになりました. 次は, Go で実装してみたいです.

Web Audio API – AudioWorklet で遊ぶ –

この記事は WebAudio/WebMIDI API Advent Calendar 2017 Advent Calendar 2017 の 24 日目です.
ということで, 久々の Web Audio API ネタです.

1. Overview

Chrome 64 から実験的フラグつきでついに AudioWorklet が利用可能になります. この記事では, AudioWorklet の概要を解説し, 少し実践的なサンプルをとおして AudioWorklet に慣れることを目的とします.

What is AudioWorklet ?

Web Audio API が定義する標準のノード (GainNode, DelayNode, BiquadFilterNode … など) の組み合わせのみでは困難な音響処理 (ピッチシフターやノイズサプレッサなど) を直接サウンドデータにアクセスして演算することで実装するためのオブジェクト群 (AudioWorklet, AudioWorkletNode, AudioWorkletGlobalScope, AudioWorkletProcessor) です.

The Problems of ScriptProcessorNode

Web Audio API が実装された当初から, Web Audio API を利用している方はご存知だと思いますが, 直接サウンドデータにアクセスして演算するという処理は ScriptProcessorNode の役割でした. では, なぜ AudioWorklet に役割が置き換わるのでしょうか ? ScriptProcessorNode には実装当初から常に 2 つの問題を抱えていました.

  • イベントハンドラで非同期に実行されるので, レイテンシ (遅延) に問題を引き起こす
  • メインスレッドで実行されるので, UI や再生されるサウンドに問題を引き起こす

AudioWorklet では, これらの問題を解決するために, メインスレッドとは別に, オーディオスレッドで動作するように仕様策定され, そして, (Chrome 64 で) 実装されました.

2. AudioWorklet samples

とりあえず, 動作とコードをひととおり確認したい方は, GitHub Pages にアップしているのでご利用ください. 以下のセクションでは, 5 つのサンプルのうち 3 つにフォーカスして解説したいと思います.

Bypass

まずは, ウォーミングアップです. 特に意味のない処理ですが, 入力されたオシレーターをそのまま出力するだけです.

main-scripts/bypass.js

'use strict';

document.addEventListener('DOMContentLoaded', () => {
    const context = new AudioContext();

    let oscillator = null;

    context.audioWorklet.addModule('./worklet-scripts/bypass.js').then(() => {
        const bypass = new AudioWorkletNode(context, 'bypass');

        document.querySelector('button').addEventListener('click', event => {
            const button = event.currentTarget;

            if (button.textContent === 'START') {
                oscillator = context.createOscillator();

                oscillator.connect(bypass);
                bypass.connect(context.destination);

                oscillator.start(0);

                button.textContent = 'STOP';
            } else {
                oscillator.stop(0);

                button.textContent = 'START';
            }
        }, false);
    });
}, false);

まず, AudioContext#audioWorklet#addModule メソッドで, オーディオスレッドで実行させるスクリプトを読み込みます. そして, このメソッドは Promise を返すので, then に後続の処理を記述します. このあたりの処理は, Service Workers に近い処理となっています.

次に, AudioWorkletNode インスタンスを生成します. 第 1 引数には, AudioContext インスタンスを, 第 2 引数には任意の文字列を指定しますが, この文字列は, オーディオスレッドで実行させるスクリプトで実行する registerProcessor と合わせる必要があります.

そして, AudioWorkletNode は AudioNode を継承しているので, 通常のノードと同じように接続するだけです.

メインスレッドでの基本処理はこの 3 つです. このあとのサンプルでも同じです. 結構簡単なのではないでしょうか ?

worklet-scripts/bypass.js

'use strict';

class Bypass extends AudioWorkletProcessor {
    constructor() {
        super();
    }

    process(inputs, outputs) {
        const input  = inputs[0];
        const output = outputs[0];

        for (let channel = 0, numberOfChannels = output.length; channel < numberOfChannels; channel++) {
            output[channel].set(input[channel]);
        }

        return true;
    }
}

registerProcessor('bypass', Bypass);

まず, AudioWorkletProcessor を継承させたクラスを実装する必要があります.

次に, process メソッドで, 実際にサウンドデータにアクセスする処理を記述します. これは, ScriptProcessorNode における onaudioprocess イベントハンドラに相当する部分です. 引数には, 入力データにアクセスするための inputs と, 出力するための outputs 引数が自動で渡されます. const input = inputs[0];, const output = outputs[0]; は, イディオムのようなものと覚えてしまって大丈夫でしょう (ただし, 入力データがない場合は注意してください). 今回は, 入力データをそのまま出力させるだけなので, チャンネルごとに Float32Array を取得して, Float32Array#set するだけです.

return true; を指定することで, process メソッドが繰り返し呼び出されます.

最後に, AudioWorkletGlobalScope#registerProcessor メソッドを呼び出します. 第 1 引数には, メインスレッドで AudioWorkletNode インスタンスを生成する場合に指定した文字列を, 第 2 引数には, 実装したクラスを指定します.

White Noise

このままでは, おもしろくありませんから, Web Audio API が定義する標準のノードでは実装できないけど, 簡単な音響処理として, ホワイトノイズを生成してみましょう.

main-scripts/white-noise.js

'use strict';

document.addEventListener('DOMContentLoaded', () => {
    const context = new AudioContext();

    context.audioWorklet.addModule('./worklet-scripts/white-noise.js').then(() => {
        const noiseGenerator = new AudioWorkletNode(context, 'white-noise');

        document.querySelector('button').addEventListener('click', event => {
            const button = event.currentTarget;

            if (button.textContent === 'START') {
                noiseGenerator.connect(context.destination);

                button.textContent = 'STOP';
            } else {
                noiseGenerator.disconnect(0);

                button.textContent = 'START';
            }
        }, false);
    });
}, false);

メインスレッドは, 先ほどのセクションで述べたように, 重要なのは 3 つの処理, すなわち,

  1. オーディオスレッドで実行するスクリプトの登録
  2. AudioWorkletNode インスタンスの生成
  3. AudioWorkletNode インスタンスの接続

worklet-scripts/white-noise.js

'use strict';

class WhiteNoise extends AudioWorkletProcessor {
    constructor() {
        super();
    }

    process(inputs, outputs) {
        const output = outputs[0];

        for (let channel = 0, numberOfChannels = output.length; channel < numberOfChannels; channel++) {
            const outputChannel = output[channel];

            for (let i = 0, len = outputChannel.length; i < len; i++) {
                outputChannel[i] = 2 * (Math.random() - 0.5);
            }
        }

        return true;
    }
}

registerProcessor('white-noise', WhiteNoise);

process メソッドを除けば, ほとんど Bypass のサンプルと変わりません. つまり, AudioWorklet の処理の本質はそれほど難しくはないのです. process メソッドも, 入力がないのと, Float32Array の要素を 1 つずつ生成していく点を除けば Bypass のサンプルと同じです. あとは, 結局のところ, ホワイトノイズの生成処理を実装できるかどうかです. 少しずつ慣れてきましたか ? 最後は, ちょっと本格的なエフェクトを実装してみましょう.

Vocal Canceler

ボーカルキャンセラを実装するには, もう 1 つだけ AudioWorklet に関する知識を追加する必要があります. それは, メインスレッドとオーディオスレッド間でのメッセージングです. もっとも, WebWorkers や Service Workers でも利用されている, postMessage メソッドと onmessage イベントハンドラを利用するだけなので, それほど難しくはありません. 実際に, ボーカルキャンセラのメインスレッドとオーディオスレッドのスクリプトを見てみましょう.

main-scripts/vocal-canceler.js

'use strict';

document.addEventListener('DOMContentLoaded', () => {
    const context = new AudioContext();

    let source = null;

    context.audioWorklet.addModule('./worklet-scripts/vocal-canceler.js').then(() => {
        const vocalCanceler = new AudioWorkletNode(context, 'vocal-canceler');

        document.querySelector('[type="file"]').addEventListener('change', event => {
            const file = event.target.files[0];

            if (file && file.type.includes('audio')) {
                const objectURL = window.URL.createObjectURL(file);

                const audioElement = document.querySelector('audio');

                audioElement.src = objectURL;

                audioElement.addEventListener('loadstart', () => {
                    if (source === null) {
                        source = context.createMediaElementSource(audioElement);
                    }

                    source.connect(vocalCanceler);
                    vocalCanceler.connect(context.destination);

                    audioElement.play(0);
                }, false);
            }
        }, false);

        document.querySelector('[type="range"]').addEventListener('input', event => {
            vocalCanceler.port.postMessage(event.currentTarget.valueAsNumber);
        }, false);
    });
}, false);

メインスレッドの基本の 3 つの処理は変わりません. 注目していただきたいのは, AudioWorkletNode#MessagePort#postMessage メソッドです. これによって, ボーカルキャンセラに必要なパラメータをオーディオスレッドに送信しています.

worklet-scripts/vocal-canceler.js

'use strict';

class VocalCanceler extends AudioWorkletProcessor {
    constructor() {
        super();

        this.depth = 0;

        this.port.onmessage = event => {
            this.depth = event.data;
        };
    }

    process(inputs, outputs) {
        const input  = inputs[0];
        const output = outputs[0];

        const numberOfChannels = output.length;

        if (numberOfChannels === 2) {
            const inputLs  = input[0];
            const inputRs  = input[1];
            const outputLs = output[0];
            const outputRs = output[1];

            for (let i = 0, len = outputLs.length; i < len; i++) {
                outputLs[i] = inputLs[i] - (this.depth * inputRs[i]);
                outputRs[i] = inputRs[i] - (this.depth * inputLs[i]);
            }
        } else {
            output[0].set(input[0]);
        }

        return true;
    }
}

registerProcessor('vocal-canceler', VocalCanceler);

オーディオスレッド側では, AudioWorkletProcessor#MessagePort#onmessage イベントハンドラでメインスレッドから送信されたデータを受診します. データは, イベントオブジェクトの data プロパティに格納されています.

今回の例では, メインスレッドからオーディオスレッドへのメッセージングでしたが, 逆も可能で, つまり双方向にメッセージをやりとりすることが可能です.

3. Conclusion

いかがでしたでしょうか ? 少し駆け足になってしまった感もありますが, おそらく仕様などから想像していたよりも簡単だったのではないのでしょうか ? 結局のところ, 本質は ScriptProcessorNode と同じく, 音響処理に関する知識やそれを実装に落とし込めるかの力量にかかっているように思います.

Chrome 64 でもフラグが必要なので, 実際のプロダクトでは, クロスブラウザ対応などを考慮すると ScriptProcessorNode がしばらく君臨するでしょうが, 来たるべきときに備えて, AudioWorklet にも慣れておくといいことがあるかもしれません !

AudioWorklet W3C

Web フォントの表示の最適化 〜 Font Loading API 〜

1. Overview

Web フォントはその性質上, ブラウザがテキストの表示に必要なフォントを知ることができるのは他のリソースと比較するとずっと後になります.

  1. @font-face が含まれた CSS をロード
  2. スタイルとドキュメントを結合した視覚部分を表現するツリーの構築
  3. テキストとフォントの関係が示された時に, 必要なフォントを確定する

といった, フローとなっているからです. これが引き起こす問題として, FOUT (Flash of Unstyled Text) / FOIT (Flash of Invisible Text) があります.

FOUT

リクエストしたフォントがロードされるまでの間, いくつかのブラウザではタイムアウトするまでの時間が設定されています. タイムアウトまでの間にロードが完了しなかった場合は, 代替フォントが表示され, 代替フォントから指定フォントへの切り替わりの瞬間にチラつきが発生する問題です.

FOIT

ブラウザによってはフォントのロードを待ち続けるように実装されているものもあり, その場合はロードが完了するまでテキストが表示されなくなる問題です.

これらの問題を解決する具体的な手段として, Font Loading API という JavaScript の API が仕様策定されています.

2. Font Loading API

フォントのリソースをロードするためのメソッドが定義されており, あわせてフォントの状態を検知する手段もいくつか定義されています. これによって, @font-face では困難な細かな制御を可能にします.

具体的なコードを見てみましょう.


// 第 1 引数は, `font-family` となる文字列
// 第 2 引数は, フォントファイルの URL か `ArrayBuffer`
// 第 3 引数は, オプションで `{ unicodeRange : 'U+0100-024F' }` などを指定します
const font = new FontFace('Source Code Pro', 'url(source-code-pro.woff2)');

// `FontFace` のインスタンスは, `Promise`
font.load()
  .then(loadedFace => {
    document.fonts.add(loadedFace);
    document.body.style.fontFamily = `'Source Code Pro', monospace`;
  });

`FontFace#load` は, 評価された時点でフォントのリソースを要求するので, リクエストの遅延を少し早めることができます.

以下のコードは, タイムアウトによるフォールバックが必要な場合の実装例です.

const font = new FontFace('Source Code Pro', 'url(source-code-pro.woff2)');
const timeout = 3000;

Promise.race([new Promise((resolve, reject) => setTimeout(reject, timeout)), font.load()])
  .then(loadedFace => {
    // 指定したフォントのロードが完了した場合
  })
  .catch(() => {
    // 指定したフォントのロードに失敗した場合
  });

3. Reference

Progressive Web Apps Roadshow Tokyo 2017 に参加してきた

1. What is Progressive Web Apps (PWA)

そもそも, Progressive Web Apps (以下, PWA) とは, ネイティブアプリに近いユーザー体験を与える Web アプリケーションのことで以下のような特徴があるものとされています (はじめてのプログレッシブ ウェブアプリ).

段階的
プログレッシブ・エンハンスメントを基本理念としたアプリであるため, ブラウザに関係なく, すべてのユーザーに利用してもらえます
レスポンシブ
パソコンでもモバイルでもタブレットでも, 次世代の端末でも, あらゆるフォームファクタに適合します
ネットワーク接続に依存しない
Service Worker の活用により, オフラインでも, ネットワーク環境が良くない場所でも動作します
アプリ感覚
App Shell モデルに基づいて作られているため, アプリ感覚で操作できます
常に最新
Service Worker の更新プロセスにより, 常に最新の状態に保たれます
安全
覗き見やコンテンツの改ざんを防ぐため, HTTPS 経由で配信されます
発見しやすい
W3C のマニフェストと Service Worker の登録スコープにより, 「アプリケーション」として認識されつつ, 検索エンジンからも発見することができます
再エンゲージメント可能
プッシュ通知のような機能を通じで容易に再エンゲージメントを促すことができます
インストール可能
ユーザーが気に入ればアプリのリンクをホーム画面に残しておくことができ, アプリストアで探し回る必要はありません
リンク可能
URL を使って簡単に共有でき, 複雑なインストールの必要はありません

要件が多いので, もう少し的を絞ると,

Fast
3 sec 未満のページロード時間. Service Worker によるキャッシュ
Integrated
Web App Manifest による Add to Homescreen や Prompt to Install, Payment Request API など
Reliable
Service Worker によるキャッシュなど
Engaging
Web Push Notifications など

なかでも印象に残ったことは, Web Push Notifications の使いどころでした. Web Push Notifications を使うには以下の 3 つの条件が揃っていることが望ましいと言えます.

  • timely
  • precise
  • relevant

2. HTTPS

先ほどのセクションで紹介したように, PWA の特徴を実装するには, Service Worker の導入が不可欠です. そして, Service Worker は (localhost を除き) HTTPS のページでしか動作しません. さらに, ページを HTTPS にすることは以下の 3 つの要件を満たすためにも必要になります (また, HTTP/2 を利用するためにも, 事実上 HTTPS は不可欠になります).

  • Identity
  • Confidentiality
  • Integrity

HTTPS のページにアクセスさせるためには,

301 Moved Permanently ステータスコードを使う方法では, リダイレクトされるまでユーザーは暗号化されない接続をすることになるので, 悪意のあるサイトへ誘導するなど中間者攻撃の可能性があります.

一方で, Strict-Transport-Security: max-age=expireTime HTTP レスポンスヘッダを利用する方法では, ブラウザがその Web ページに初めて HTTPS 接続をしたときに, サーバーが Strict-Transport-Security ヘッダで応答すると, ブラウザはその情報を記録します. これによって Strict Transport Security の機能が有効になり, ブラウザがそのサイトへ HTTP 接続することはなくなり, 自動的に HTTPS 接続を試みるようになります.

Strict-Transport-Security ヘッダで指定された有効期限が経過すると, 自動的な HTTPS への変換は行われなくなり, HTTP で接続を試みるようになります.

つまり, Strict-Transport-Security ヘッダで指定された有効期限内で 2 回目以降, 自動的に HTTPS 接続をさせるためのヘッダで, 毎回リダイレクトする必要なく HTTPS の Web ページに誘導できます.

3. Accelerated Mobile Pages (AMP)

Accelerated Mobile Pages (AMP) とは, モバイルでの Web サイト閲覧を高速化することを目的とするオープンソースプロジェクトや, そのための仕様やライブラリのことです. PWA との関係で言えば, PWA は主に Service Worker を利用して, 2 回目以降の閲覧においてユーザー体験を向上させることですが, 逆に AMP は初回の閲覧においてユーザー体験を向上させることにあります (Start fast, stay fast!).

AMP を構成する技術の特徴で言えば, HTML に AMP のためのタグを記述することにあります (AMP HTML)

<!DOCTYPE html>
<html ⚡>
  <head>
    <link rel="canonical" href="hello-world.html">
    ...
    <style amp-boilerplate>...</style>
    <script async src="https://cdn.ampproject.org/v0.js"</script>
  </head>
  <body>
    <div>Hello World!</div>
    <amp-img src="hello.jpg" height="400" width="800"></amp-img>
  </body>
</html>

そして, AMP と PWA (Service Worker) を組み合わせる重要なタグが,

<amp-install-serviceworker>

また, 組み合わせ方にも以下の 3 つの方法があります.

  • AMP as PWA
  • AMP to PWA
  • AMP in PWA

AMP  by Example

4. Code Labs

Firebase Cloud Messaging (FCM) で Web Push 通知を実装してみた

Overview

Web Push 通知を実装してみた」で Web Push 通知の概要と, web-push モジュールを利用した Web Push 通知の実装を記載しました. しかしながら, web-push モジュールを利用した Web Push 通知は鍵のやりとりが少々煩雑で実装も複雑になってしまいます.

Firebase Cloud Messaging (FCM) を利用すると, 鍵のやりとりの代わりにトークンを利用することで Web Push 通知を簡単に実装することができます.

What is Firebase Cloud Messaging ?

クロスプラットフォームのプッシュ通知のためのソリューションです. 詳細は, こちらのドキュメントを参考にしてください.

How to use Firebase Cloud Messaging ?

Firebase console から, アプリに Firebase を追加します.

Install Firebase

$ npm init -y
$ npm install --save firebase

Install Packages

今回は, クライアントサイドは, ES2015 (Babel) を利用し, サーバーサイドは express を利用して実装します. また, ビルドには webpack を利用します.

$ npm install --save body-parser express
$ npm install --save-dev babel-core babel-loader babel-plugin-transform-class-properties babel-preset-es2015 webpack

webpack.config.js

module.exports = {
  entry: { js: './src/main.js' },
  output: { path: `${__dirname}/public`, filename: 'app.js' },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: 'babel-loader',
      }
    ]
  },
  devtool: 'source-map'
};

.babelrc

{
  "presets": ["es2015"],
  "plugins": ["transform-class-properties"]
}

コマンドを簡単に実行できるように, npm scripts も定義しておきましょう.

package.json

// ...
"scripts": {
  "build": "webpack",
  "start": "npm run build && node server.js"
},
// ...

Implement Server Side Script

ローカルサーバーを起動できるように, サーバーサイドのスクリプトを実装します (Web Push に関連する処理はのちほど実装します).

server.js

'use strict';

const express    = require('express');
const bodyParser = require('body-parser');
const app        = express();

app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(express.static('public'));

const port = process.env.PORT || 5000;

app.listen(port, () => {
    console.log(`Listening on port ${port} ...`);
});

public/index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8" />
    <title>Web Push Example by Firebase Cloud Messaging (FCM)</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" />
    <link rel="stylesheet" href="./app.css" type="text/css" media="all" />
</head>
<body>
    <div class="WebPush">
        <form method="post" action="/api/webpush/subscribe">
            <dl>
                <dt><label for="text-title">Title</label></dt>
                <dd><input type="text" id="text-title" name="text-title" /></dd>
                <dt><label for="text-body">Body</label></dt>
                <dd><input type="text" id="text-body" name="text-body" /></dd>
                <dt><label for="url-icon">Icon</label></dt>
                <dd><input type="url" id="url-icon" name="url-icon" /></dd>
                <dt><label for="url-link">Link</label></dt>
                <dd><input type="url" id="url-link" name="url-link" /></dd>
            </dl>
            <button type="submit">Web Push</button>
        </form>
    </div>
    <script type="text/javascript" src="./app.js"></script>
</body>
</html>

public/app.css

@charset "UTF-8";

* {
    margin: 0;
    padding: 0;
}

body {
    font-family:Helvetica, Arial, sans-serif;
    font-size: 16px;
    color: #999;
    line-height: 1.5;
    min-width: 320px;
}

.WebPush {
    margin: 24px auto 0;
    width: 90%;
}

dl > dt {
    margin-bottom: 0.5rem;
    font-size: 1.25rem;
}

dl > dd {
    margin-bottom: 0.5rem;
}

input {
    outline: none;
    border: 2px solid #CCC;
    padding: 0.5rem;
    width: 18rem;
    font-size: 1rem;
    color: #999;
    border-radius: 12px;
    transition: box-shadow 0.6s ease;
}

input:focus {
    box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.3) inset;
}

button {
    cursor: pointer;
    outline: none;
    margin-top: 1rem;
    border: none;
    padding: 1rem 1.5rem;
    font-size: 1rem;
    color: #FFF;
    background-color: #999;
    border-radius: 12px;
    transition: background-color 0.6s ease;
}

button:hover {
    background-color: #666;
}

button:active {
    box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.6) inset;
}

以上の実装をして,

$ npm start

を実行すれば, http://localhost:5000 でページが表示されるようになります.

Implement Web Push

ここからが本題で, Firebase Cloud Messaging を利用した Web Push 通知を実装します.
まずは, クライアントサイドで読み込むスクリプトから実装します.

src/main.js

'use strict';

import * as firebase from 'firebase';

// Initialize Firebase
const config = {
    // ...
};

firebase.initializeApp(config);

const messaging = firebase.messaging();

config は, Firebase console で Firebase に追加したアプリの初期化コードスニペットからコピペしてきます.
内容としては, 以下のようなキーとバリューのプレインオブジェクトです.

const config = {
    apiKey            : '',
    authDomain        : '.firebaseapp.com',
    databaseURL       : 'https://.firebaseio.com',
    storageBucket     : '.appspot.com',
    messagingSenderId : '',
};

たったこれだけのコードで, Firebase Cloud Messaging を使う準備ができます. web-push モジュールを利用する場合と比較すると, 非常に簡潔になることがわかるかと思います.

続いて, Web Push 通知を有効にするために,

  1.  通知の許可をユーザーから得る
  2.  Firebase トークンを取得する

処理を実装します.

src/main.js

// ...

if (navigator.serviceWorker) {
    navigator.serviceWorker.register('./firebase-messaging-sw.js').then(() => {
        return navigator.serviceWorker.ready;
    }).catch((error) => {
        console.error(error);
    }).then((registration) => {
        messaging.useServiceWorker(registration);

        // 通知の許可 -> トークンの取得の順でないと, トークンの取得に失敗する
        messaging.requestPermission().then(() => {
            console.log('Notification permission granted.');

            messaging.getToken().then((token) => {
                console.log(token);
            }).catch((error) => {
                console.error(error);
            });

        }).catch((error) => {
            console.log('Unable to get permission to notify.', error);
        });
    });
}

Firebase Messaging Object (messaging) が Notification オブジェクトをラップしているので, そのメソッド (messaging.requestPermission) を利用して, ユーザーから通知の許可を得るダイアログを表示し, 許可を得ることができれば, Firebase トークンの取得 (messaging.getToken) を実行します. コメントにあるように, この順を踏まないと, Firebase トークンの取得に失敗してしまいます.

また, Service Worker のファイルは, firebase-messagin-sw.js でないと警告が表示されるので, 命名は合わせておきます.

最後に, 取得した Firebase トークンをサーバーに送信して, 保存します.

src/main.js

// ...

if (navigator.serviceWorker) {
    navigator.serviceWorker.register('./firebase-messaging-sw.js').then(() => {
        return navigator.serviceWorker.ready;
    }).catch((error) => {
        console.error(error);
    }).then((registration) => {
        messaging.useServiceWorker(registration);

        // 通知の許可 -> トークンの取得の順でないと, トークンの取得に失敗する
        messaging.requestPermission().then(() => {
            console.log('Notification permission granted.');

            messaging.getToken().then((token) => {
                console.log(token);

                const options = {
                    method  : 'POST',
                    headers : new Headers({ 'Content-Type' : 'application/json' }),
                    body    : JSON.stringify({ token })
                };

                fetch('/api/webpush/register', options).then((res) => {
                    console.dir(res);
                }).catch((error) => {
                    console.error(error);
                });
            }).catch((error) => {
                console.error(error);
            });

        }).catch((error) => {
            console.log('Unable to get permission to notify.', error);
        });
    });
}

ビルドして, public/app.js を生成しておきます.

$ npm run build

続いて, Firebase トークンを保存するための API を実装します.

server.js

// ...

const bodies = [];

app.post('/api/webpush/register', (req, res) => {
    const body = req.body;

    bodies.push(body);

    res.status(200).set('Content-Type', 'application/json').send(JSON.stringify(body));
});

特に難しい処理はなく, 受信した Firebase トークンを配列に保存して, Web Push 通知を送信するときに利用できるようにしておきます (実際には, DB に保存することになるでしょうが ).

そして, Web Push 通知を送信するための API を実装します.

server.js

// ...

const https = require('https');

// ...

const bodies = [];

// ...

app.post('/api/webpush/subscribe', (req, res) => {
    const notification = {
        title : req.body['text-title'],
        body  : req.body['text-body'],
        icon  : req.body['url-icon']
    };

    const data =  {
        url : req.body['url-link']
    };

    Promise.all(bodies.map((body) => {
        return new Promise((resolve, reject) => {
            const options = {
                method  : 'POST',
                host    : 'fcm.googleapis.com',
                path    : '/fcm/send',
                headers : {
                    'Content-Type'  : 'application/json',
                    'Authorization' : 'key=`Your Server Key`'
                }
            };

            const to = body.token;

            const content_available = true;

            https.request(options, (response) => {
                const data = [];

                response.on('data',  (chunk) => data.push(chunk));
                response.on('end',   ()      => resolve(JSON.parse(Buffer.concat(data).toString())));
                response.on('error', (error) => reject(error));
            }).end(JSON.stringify({ notification, data, to, content_available }));
        });
    })).then((result) => {
        res.status(200).set('Content-Type', 'application/json').send(JSON.stringify(result));
    }).catch((error) => {
        res.status(500).set('Content-Type', 'application/json').send(JSON.stringify(error));
    });
});

処理の概要としては, プッシュ 通知を送信する, https://fcm.googleapis.com/fcm/send に POST するための HTTP クライアントを実装して, そのレスポンスを Service Worker に渡すだけです.

'Authorization' : 'key=`Your Server Key`'

ここのキーの取得方法は,

  1.  Google Developers Console にアクセス
  2.  「認証情報」の Server key の キーをコピペ

1 つ注意点としては, プッシュ通知に送る任意の情報として, data キーのプレインオブジェクトがありがますが, これを notification のなかに含めてしまうと, Service Worker 側でアクセスできないので, notification とは別に Service Worker に渡す必要があります.

あとは, Service Worker のスクリプトを実装をすれば完成です

public/firebase-messagin-sw.js

'use strict';

self.addEventListener('install', (event) => {
    event.waitUntil(skipWaiting());
}, false);

self.addEventListener('activate', (event) => {
    event.waitUntil(self.clients.claim());
}, false);

self.addEventListener('push', (event) => {
    if (!event.data) {
        return;
    }

    const parsedData   = event.data.json();
    const notification = parsedData.notification;
    const title        = notification.title;
    const body         = notification.body;
    const icon         = notification.icon;
    const data         = parsedData.data;

    event.waitUntil(
        self.registration.showNotification(title, { body, icon, data })
    );
}, false);

self.addEventListener('notificationclick', (event) => {
    event.waitUntil(self.clients.openWindow(event.notification.data.url));
}, false);

Service Worker の処理は, web-push モジュールを利用した場合の実装と大差ありません. push イベントで取得するデータの構造が少々変更されているぐらいです.

Web Push 通知を実装してみた

Overview

これまで, ネイティブアプリでしか使えなかったプッシュ通知が, Web アプリケーションからも使えるように仕様策定が進められています. これによって, メールアドレスや外部サービスのアカウントを登録してもらわなくても, ユーザーに最新情報を伝えることが可能になります.

Web Push 通知 (Firefox)
Web Push 通知 (Firefox)

 

Web Push 通知は,

  • 画面上に通知を表示する機能
  • サーバーから通知を受信する機能

の 2 つに分解して考えることができます. そしてそれぞれの機能は,

  • Web Notifications API (画面上に通知を表示する機能)
  • Web Push API (サーバーから通知を受信する機能)

という 2 つの JavaScript の API によって実装することが可能です.

Web Notifications API

Web Notifications API でデスクトップ通知を表示するには, まずユーザーの許可を得る必要があります.

Notification.requestPermission().then((permission) => {
    switch (permission) {
      case 'granted':
        // 許可された場合
        break;
      case 'denied':
        // ブロックされた場合
        break;
      case 'default':
        // 無視された場合
        break;
      default:
        break;
    }
});

Notification.requestPermission メソッドを実行すると, ブラウザは以下のようなダイアログを表示してユーザーに許可を要求します.

Permit notifications
Permit notifications (Firefox)

許可状態はオリジンごとにブラウザに記憶されるので, 同一オリジンに存在するサイトであれば, 2 回目以降は許可は必要ありません.

1 度ブロックされるとダイアログが表示されないので, あらためて許可を得ることはできません. また, プログラム側からブロックを取り消すことも不可能です. ブロックの取り消しは, ユーザー操作によって, ブラウザの記憶を削除, または, 変更した場合のみ可能です.

Cancel block
Cancel block (Firefox)

いったん, 許可を得ることができれば通知の表示は簡単で, Notification インスタンスを生成するだけで表示できます.

const title    = '見出し';
const options  = {
    body : '本文',
    icon : 'アイコン画像のパス',
    data : {
      foo : '任意のデータ'
     }
};

const notification = new Notification(title, options);

第 1 引数のタイトルは必須です. 第 2 引数はオプションですが, よく指定するオプションを以下に示します.

Notification のオプション (使用頻度の高いオプション)
Property Type Description
body string 本文の文字列
icon string アイコン画像の URL, または, パス
tag string 通知を識別する文字列
data any 通知にもたせたい任意のデータ

tag は画面上に表示されるものではないので, 使い方がわかりにくいかもしれませんが, これは主に, すでに表示されている通知を置き換えるために使います. 通常, 1件以上の通知が表示されている状態でさらに通知を生成すると, 既存の通知とは別に新たな通知が表示されます. しかし, tag の値が既知の通知と一致する場合は, 新しい通知が別に表示されるのではなく, 当該通知の中身が新しいもので置き換えられます. tag オプションをうまく活用することで, 通知まみれになるのを防止することができます.

ユーザーがデスクトップ通知をクリックしたときに何らかの処理を実行するには, Notification インスタンスに対して, イベントリスナーを設定します.

notification.addEventListener('click', (event) => {
    console.dir(event);
}, false);

以上で, 「画面上に通知を表示する機能」は実装できました.

Web Push API

Service Worker を利用し, ブラウザでプッシュ通知を受けとるために用意された JavaScript の API です.

Web Push API を利用するには, プッシュ通知の送信元が正当なアプリケーションサーバーだと認証するために利用する公開鍵と李密鍵のペアを生成する必要があります.

Node.js の web-push モジュールを利用すると, Web Push API の利用に適したフォーマットで出力されるので今回はこれを使います.この方法で生成した鍵は + が – に, / が _ にそれぞれ置き換えられ, 末尾の = が削除された URL セーフな Base64 としてエンコードされています. 公開鍵は, クライアントサイド側で利用するので, 公開鍵を取得するための Web API をサーバーサイドに実装します. また, Base64 エンコードのままでは利用できないので, クライアントサイドでバイナリ形式に変換する必要があります.

$ npm init -y
$ npm install --save body-parser express web-push

server.js

'use strict';

const webpush    = require('web-push');
const express    = require('express');
const bodyParser = require('body-parser');
const app        = express();

const contact   = 'mailto:rilakkuma.san.xjapan@gmail.com';
const vapidKeys = webpush.generateVAPIDKeys();

// アプリケーションの連絡先と, サーバーサイドの鍵ペアの情報を登録
webpush.setVapidDetails(contact, vapidKeys.publicKey, vapidKeys.privateKey);

// POST パラメータをパースする (のちほど実装)
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended : false }));

app.use(express.static('public'));

const port = process.env.PORT || 3000;

app.listen(port, () => {
    console.log(`Listening on port ${port} ...`);
});

// 公開鍵をクライアントサイドに渡す
app.get('/api/webpush/get', (req, res) => {
    return res.json({
        publicKey : vapidKeys.publicKey
    });
});

public/app.js

'use strict';

// Base64 エンコードからバイナリ形式に変換する
function urlsafeBase64ToBinary(urlsafeBase64) {
    const base64 = urlsafeBase64.replace(/-/g, '+')
                                .replace(/_/g, '/');

    const raw    = window.atob(base64);
    const binary = new Uint8Array(raw.length);

    for (let i = 0, len = binary.length; i < len; i++) {
         binary[i] = raw.charCodeAt(i);
    }

    return binary;
}

// ...

const options = {
    method  : 'GET',
    headers : new Headers({ 'Content-Type' : 'application/json' })
};

fetch('/api/webpush/get', options)
    .then((res) => res.json())
    .then((res) => {
        console.log(res.publicKey);  // Base64 エンコード
        console.log(urlsafeBase64ToBinary(res.publicKey));  // バイナリ形式
    })
    .catch((error) => {
        console.dir(error);
        console.log('Fetching public key failed.');
    });

Web Push API を利用して, プッシュ通知を受信するには, プッシュサービスに対してプッシュ通知を購読 (subscribe) する必要があります.

Service Worker の登録に成功したら, PushManager.subscribe メソッドを呼び出して, プッシュサービスに対してプッシュ通知の購読を要求します. このとき, プッシュサービスに公開鍵の情報を渡すために, 引数のオブジェクトの applicationServerKey プロパティにサーバーから取得したバイナリ形式の公開鍵を指定します.

購読要求の処理は非同期で実行されるので, メソッドの戻り値は Promise です. 購読要求が成功すると, コールバック関数の引数に PushSubscription オブジェクトが渡されます. この PushSubscription オブジェクトから, プッシュ通知の送信に必要な情報を取得できます.

そして, その情報をアプリケーションサーバーに送信 (POST) すれば購読は完了です.

server.js

'use strict';

const webpush    = require('web-push');
const express    = require('express');
const bodyParser = require('body-parser');
const app        = express();

const contact   = 'mailto:rilakkuma.san.xjapan@gmail.com';
const vapidKeys = webpush.generateVAPIDKeys();

// アプリケーションの連絡先と, サーバーサイドの鍵ペアの情報を登録
webpush.setVapidDetails(contact, vapidKeys.publicKey, vapidKeys.privateKey);

// POST パラメータをパースする (のちほど実装)
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended : false }));

app.use(express.static('public'));

const port = process.env.PORT || 3000;

app.listen(port, () => {
    console.log(`Listening on port ${port} ...`);
});

// 公開鍵をクライアントサイドに渡す
app.get('/api/webpush/get', (req, res) => {
    return res.json({
        publicKey : vapidKeys.publicKey
    });
});

// 購読のための POST 先
app.post('/api/webpush/subscribe', (req, res) => {
    // do something ...
});

public/app.js

'use strict';

// Base64 エンコードからバイナリ形式に変換する
function urlsafeBase64ToBinary(urlsafeBase64) {
    const base64 = urlsafeBase64.replace(/-/g, '+')
                                .replace(/_/g, '/');

    const raw    = window.atob(base64);
    const binary = new Uint8Array(raw.length);

    for (let i = 0, len = binary.length; i < len; i++) {
         binary[i] = raw.charCodeAt(i);
    }

    return binary;
}

// ArrayBuffer から Base64 エンコードに変換する
function arrayBufferToBase64(arrayBuffer) {
    return window.btoa(String.fromCharCode.apply(null, new Uint8Array(arrayBuffer))).replace(/\+/g, '-').replace(/\//g, '_');
}

if (navigator.serviceWorker) {
    // Service Worker 登録
    navigator.serviceWorker.register('./service-worker-web-push.js').then(() => {
        console.log('Registering Service Worker is successful.');
        return navigator.serviceWorker.ready;
    }).catch(() => {
        console.error('Registering Service Worker failed.');
    }).then((registration) => {
        const options = {
            method  : 'GET',
            headers : new Headers({ 'Content-Type' : 'application/json' })
        };

        return fetch('/api/webpush/get', options)
                   .then((res) => res.json())
                   .then((res) => {
                       // プッシュサービスに対してプッシュ通知の購読を要求
                       return registration.pushManager.subscribe({
                           userVisibleOnly      : true,
                           applicationServerKey : urlsafeBase64ToBinary(res.publicKey)  // バイナリ形式の公開鍵を渡す
                       });
                   }).catch((error) => {
                       console.dir(error);
                       console.log('Fetching public key failed.');
                   });
    }).then((subscription) => {
        // POST の準備
        document.getElementById('hidden-endpoint').value = subscription.endpoint;
        document.getElementById('hidden-auth').value     = arrayBufferToBase64(subscription.getKey('auth'));    // PushSubscription#getKey の戻り値の型は ArrayBuffer なので, Base64 エンコード文字列に変換する
        document.getElementById('hidden-p256dh').value   = arrayBufferToBase64(subscription.getKey('p256dh'));  // PushSubscription#getKey の戻り値の型は ArrayBuffer なので, Base64 エンコード文字列に変換する
    }).catch((error) => {
        console.dir(error);
        console.error('Subscribing web push failed.');
    });
}

public/index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8" />
    <title>Web Push Example</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" />
    <link rel="stylesheet" href="./app.css" type="text/css" media="all" />
</head>
<body>
    <div class="WebPush">
        <form method="post" action="/api/webpush/subscribe">
            <dl>
                <dt><label for="text-title">Title</label></dt>
                <dd><input type="text" id="text-title" name="text-title" /></dd>
                <dt><label for="text-body">Body</label></dt>
                <dd><input type="text" id="text-body" name="text-body" /></dd>
                <dt><label for="url-icon">Icon</label></dt>
                <dd><input type="url" id="url-icon" name="url-icon" /></dd>
                <dt><label for="url-link">Link</label></dt>
                <dd><input type="url" id="url-link" name="url-link" /></dd>
            </dl>
            <ul>
                <li><input type="hidden" id="hidden-endpoint" name="hidden-endpoint" /></li>
                <li><input type="hidden" id="hidden-auth" name="hidden-auth" /></li>
                <li><input type="hidden" id="hidden-p256dh" name="hidden-p256dh" /></li>
            </ul>
            <button type="submit">Web Push</button>
        </form>
    </div>
    <script type="text/javascript" src="./app.js"></script>
</body>
</html>

以上で, プッシュ通知を受信するための下準備はできたので, あとはプッシュ通知を送受信する実装だけです.

購読時に, クライアントサイドから取得したエンドポイント URI に対して POST リクエストを送信します. プッシュサービスがリクエストを受信し, 署名の検証に成功すると該当するブラウザに対して通知が送信されます.

server.js

'use strict';

const webpush    = require('web-push');
const express    = require('express');
const bodyParser = require('body-parser');
const app        = express();

const contact   = 'mailto:rilakkuma.san.xjapan@gmail.com';
const vapidKeys = webpush.generateVAPIDKeys();

// アプリケーションの連絡先と, サーバーサイドの鍵ペアの情報を登録
webpush.setVapidDetails(contact, vapidKeys.publicKey, vapidKeys.privateKey);

// POST パラメータをパースする
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));

app.use(express.static('public'));

const port = process.env.PORT || 3000;

app.listen(port, () => {
    console.log(`Listening on port ${port} ...`);
});

// 公開鍵をクライアントサイドに渡す
app.get('/api/webpush/get', (req, res) => {
    return res.json({
        publicKey : vapidKeys.publicKey
    });
});

// 購読のための POST 先
app.post('/api/webpush/subscribe', (req, res) => {
    // プッシュ通知の送信先情報 (実際には, DB などから取得)
    const subscription = {
        endpoint : req.body['hidden-endpoint'],
        keys     : {
            auth   : req.body['hidden-auth'],
            p256dh : req.body['hidden-p256dh']
        }
    };

    // プッシュ通知で送信したい任意のデータ
    const payload = JSON.stringify({
        title : req.body['text-title'],
        body  : req.body['text-body'],
        icon  : req.body['url-icon'],
        url   : req.body['url-link']
    });

    // 購読時に, クライアントサイドから取得したエンドポイント URI に対して POST リクエストを送信
    webpush.sendNotification(subscription, payload).then((response) => {
        return res.json({
            statusCode : response.statusCode || -1,
            message    : response.message    || ''
        });
    }).catch((error) => {
        console.dir(error);
        return res.json({
            statusCode : error.statusCode || -1,
            message    : error.message    || '',
        });
    });
});

クライアントサイドでそれを受信したときに, 該当する Service Worker が起動していなければこのタイミングで起動し, push イベントが発生します. このイベントリスナーでデスクトップ通知の表示などの処理を実行します.

public/service-worker-web-push.js

'use strict';

self.addEventListener('install', (event) => {
    event.waitUntil(skipWaiting());
}, false);

self.addEventListener('activate', (event) => {
    event.waitUntil(self.clients.claim());
}, false);

self.addEventListener('push', (event) => {
    // デスクトップ通知の表示処理
}, false);

ネイティブアプリケーションのプッシュ通知とは異なり, Web Push によるプッシュ通知は, ブラウザが起動しているときでないと受信できません. ブラウザを起動していない間にアプリケーションサーバーからプッシュ通知が送信された場合は, その次にブラウザを起動したタイミングで受信されます.

最後に, 通知を受信したときの Service Worker の処理を実装します.  アプリケーションサーバーから送信された通知は, プッシュサービスを経由してブラウザに伝わり, Service Worker のイベントリスナーが呼び出されます.

アプリケーションサーバーで送信時に付与したペイロードは, pushイベントオブジェクトの data プロパティに格納されています. json メソッドで JSON 文字列をパースしたオブジェクトを取得できます (それ以外にも, text メソッドや arrayBuffer メソッド, blob メソッドなどもあります).

そして, デスクトップ通知の表示ですが, Service Worker 内では Notification クラスにアクセスできないので, self.registration (ServiceWorkerRegistration) の showNotification メソッドを利用します. このメソッドの引数は, Notification コンストラクタの引数とほぼ同じです.

また, デスクトップ通知をクリックしたときに実行するイベントリスナーの設定も異なっており, self (ServiceWorkerGlobalScope) に対して, notificationclick イベントのリスナーを設定します.

public/service-worker-web-push.js

'use strict';

self.addEventListener('install', (event) => {
    event.waitUntil(skipWaiting());
}, false);

self.addEventListener('activate', (event) => {
    event.waitUntil(self.clients.claim());
}, false);

self.addEventListener('push', (event) => {
    // デスクトップ通知の表示処理
    if (!event.data) {
        return;
    }

    const data  = event.data.json();  // ペイロードを JSON 形式でパース
    const title = data.title;
    const body  = data.body;
    const icon  = data.icon;
    const url   = data.url;

    event.waitUntil(
        self.registration.showNotification(title, { body, icon, data : { url } })
    );
}, false);

self.addEventListener('notificationclick', (event) => {
    const notification = event.notification;  // Notification インスタンスを取得
    const url          = notification.data.url;

    // 通知をクリックしたら, URL で指定されたページを新しいタブで開く
    event.waitUntil(self.clients.openWindow(url));
}, false);