初めて執筆をしました

この度, ご縁があって「WEB+DB PRESS Vol.105」の Firebase 特集の 第 2 章を執筆させていただいたので, その感想などを残しておきます.

きっかけ

きっかけは所属する会社からの経由で, 技術評論社様より Firebase 特集の執筆依頼を受けたので, 過去に Firebase に関連するディベロッパーズブログを公開したというだけで執筆候補に選ばれました. そして, あまり深く考えず, 滅多にない機会だからという理由で引き受けました. 私は, この依頼を受けるまで, 技術雑誌や書籍は, その技術のスペシャリストであったり, 有名であったり, フォロワー数の多いエンジニアが執筆するものだと思っていました (もちろん, そういう方のほうが, 依頼の確率は高いでしょう). 私は, 別に Firebase に関して詳しいわけではなく, ちょこっと業務で使ったというレベルでした. したがって, 執筆の依頼をいただけるかは, 正直, 運や縁なのかなぁーと今回の執筆を振り返って思いました.

執筆業

以下は, 実際に執筆してみて大変だったことです.

  • Firebase 初心者が対象だったので, その視点で執筆する
  • 雑誌なので, 分量調整が意外と大変
  • ちょっと長いコードになるとすぐに改行しないといけない
  • 2 人で執筆したので, 説明の粒度や文章のクセなどを統一する必要があった
  • 通常業務追い込みと執筆の追い込みが重なって死にそうな週があった

… などでしょうか. 私は, ニート時代に WEB SOUNDER という Web Audio API の解説サイトを制作して意外と好評を得た経験があるのですが, Web はその性質上, 分量調整など細かいことに気を配る必要はあまりないですが, やはり, 雑誌という媒体上, 分量調整は想定してた以上に大変でした.

結論

正直, 報酬だけを目当てにするならやらないほうがいいと思います (笑). そうではなく, 1 つのアウトプットの機会ととらえたり, そのアウトプットをより多くの人に読んだいただいたり, また, 執筆をとおして, 業務では会うことのない方と仕事をしたり … そういったことに醍醐味があるような気がします. また, 執筆をしたいかと問われたら, 答えは YES です (できれば, Web Audio API の書籍がいいですね) !

明日, 2018 年 6 月 23 日 (土) 発売です. 少しでも多くの人が, Firebase にふれる機会になればと思います.

webpack 3 から webpack 4 へアップデートする際にハマりそうなこと

1. Overview

なにかと破壊的な変更が多い webpack ですが, v3 から v4 にアップデートする際にも破壊的な変更があるので, 自分の備忘録として残しておきます.

2. webpack-cli

webpack 4 からは, コマンドライン機能が webpack-cli に分離されました. したがって, npm install する際に以下のようにする必要があります.

$ npm install --save-dev webpack webpack-cli

この対応は, あっという間に完了するかと思います.

3. mini-css-extract-plugin

以下の, webpack.config.js は v3 であれば正常に動作するものです.

const webpack            = require('webpack');
const ExtracktTextPlugin = require('extract-text-webpack-plugin');

module.exports = {
  entry: ['./src/main.js', './src/main.css'],
  output: {
    filename: 'app.js',
    path: `${__dirname}`
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: 'babel-loader'
      },
      {
        test: /\.css$/,
        use: [
          ExtracktTextPlugin.extract({
            use: [
              'css-loader',
              'postcss-loader'
            ]
          })
        ]
      },
      {
        test: /\.png$/,
        use: 'url-loader'
      }
    ]
  },
  plugins: [
    new webpack.LoaderOptionsPlugin({
      options: {
        config : {
          path: './postcss.config.js'
        }
      }
    }),
    new ExtracktTextPlugin({
      filename: './app.css'
    })
  ],
  devtool: 'source-map'
};

ところが, v4 からは, extract-text-webpack-plugin を使うとエラーが発生します. 代わりに, mini-css-extract-plugin を使う必要があります. それを使って書き直した webpack.config.js です.

const webpack            = require('webpack');
const ExtracktTextPlugin = require('mini-css-extract-plugin');

module.exports = {
  entry: ['./src/main.js', './src/main.css'],
  output: {
    filename: 'app.js',
    path: `${__dirname}`
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: 'babel-loader'
      },
      {
        test: /\.css$/,
        use: [
          ExtracktTextPlugin.loader,
          'css-loader',
          'postcss-loader'
        ]
      },
      {
        test: /\.png$/,
        use: 'url-loader'
      }
    ]
  },
  plugins: [
    new webpack.LoaderOptionsPlugin({
      options: {
        config : {
          path: './postcss.config.js'
        }
      }
    }),
    new ExtracktTextPlugin({
      filename: './app.css'
    })
  ],
  devtool: 'source-map'
};

CSS の module.rules.use が変わっていることに着目してください. extract-text-webpack-plugin では extract というクラスメソッドがありましたが, mini-css-extract-plugin では, それがありません. 代わりに, 上記のように loader を指定する必要があります.

コンピュータサイエンスの世界に入って 10 年が経ちました

Prologue

今日, 2018 年 5 月 6 日は, 私がコンピュータサイエンスの世界に入って 10 年が経った日です. つまり, 2008 年 5 月 6 日にコンピュータサイエンスの世界に入りました. きっかけは … 大学時代, とりあえず建築学科に進学しましたが, やる気なしおちゃん … 3 年を棒に振りました. しかし, 大学 4 回生のときに受講したプログラミングの講義をきっかけにこれなら熱中できるかも … と強引な予測のもと, とりあえず基本情報技術者の資格を取得しよう … それが始まりでした. つまり, 10 年前の今日, 基本情報技術者の 1 回目の講義日だったわけです. 普通の人は, いまどきだと, iOS アプリを作ってみたいとか Web アプリを作ってみたいとかプログラミングがコンピュータサイエンスの入り口という方が多いと思いますが, 私の場合は, きっかけがちょっと変わってますよね.

2008

約 3 ヶ月の学習の末, 基本情報技術者を取得することができました. 就職活動もやめていたし, 大学生活が不完全燃焼ということもあって, 専攻を変えて大学院に進学することを決めました. 院試のために, C 言語の基礎中の基礎みたいなのを独学で学びました. 朝は, 大学の図書館で院試の勉強, 昼から夕方まで卒論, 夕方から22 : 00 まで再度, 大学の図書館で院試の勉強という生活が半年ほど続きました.

2009

何はともあれ, 北陸先端科学技術大学院大学に進学したわけですが, 初めて実際にプログラムを書いたり, UNIX OS (Solaris) に触れたり, LaTex に苦戦したりの前半半年を過ごしました. 進学当初は, 音楽が好きだったこともあり, 楽器メーカーで電子楽器の製作 (組み込みプログラミング) をしたいと考えていましたので, 音情報処理研究室を選択しました. しかし, 結構な誤算で音の物理特性や音信号処理の数学には強くなったものの, あまりプログラミングをしたり, コンピュータサイエンスを学んだりするような専攻ではなかったので徐々にモチベーションは下がっていきました.

2010

研究のモチベーションがあがらない, 就職活動でなんども北陸 <-> 東京の往復, 強制収容所での生活などのストレスが重なり, うつになりました.

2011 – 2014

My Library を参照してください.

2015 – 2016

D 社に新卒入社. しかし, まともにプログラミングさせてもらえず, テスター・社内ニートになり, ここでいたら自分の目的, すなわち, 東京のレベルの高い Web 企業でスキルを向上させることが達成できないと考え, 2016 年 10 月 31 日で退社しました. そして, 2016 年 11 月 1 日に C 社に転職しました.

2017 – 2018

たまたま受検した, 応用情報技術者試験をきっかけにコンピュータサイエンスを学ぶ楽しさやスキル向上のために東京にきたことを思い出し, できるだけ仕事を早く終わって, 東京都立図書館にいって学ぶ毎日 … とても充実しています !

Epilogue

この 10 年間を振り返ってみると, いくつかのターニングポイントがあったように思います.

  • 基本情報技術者試験の学習を始めたこと
  • 大学院に進学したこと
  • うつになったこと
  • Y 社から内定をいただき, 入社辞退したこと
  • JavaScript, Web Audio API に出会ったこと
  • 学生 1 年 + ニート 1.5 年 + アルバイト 1.5 年 の 4年間
  • 東京にきたこと
  • 応用情報技術者試験をきっかけに, 忘れかけていたことを思い出したこと

次は, どんなターニングポイントが待っているのかわかりませんが, いつどこでどんなターニングポイントが待っていようと, 自分のやりたいことをやるだけです. 通勤中に自分のプロダクトを実装したり, 技術書を読んだり, 仕事で学んで, 早く帰って図書館にいって学ぶ … もしかすると, そういったルーティンが自分にとってよきターニングポイントを呼び寄せるのかもしれません.

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 のみ)

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

React Context API

1. Overview

React v16.3 から Context API というのが導入されました. この API を利用することによって Redux と同じようなデータフローを簡単に実装可能になるようです.

2. Implement

重要になるコンポーネントが, React#createContext メソッドによって生成される, Provider と Consumer です. その名前のとおり, Provider の value props に渡した値が, Consumer に渡ってきます.

2 – 1. Provider

Redux での, Provider, createStore に相当する.

2 – 2. Consumer

react-redux の connect に相当する.

'use strict';

import React from 'react';

const { Provider, Consumer } = React.createContext();

const Counter = () => (
    <Consumer>
        {({ state, actions }) => (
            <React.Fragment>
                <span>{state.count}</span>
                <button type="button" onClick={actions.increment}>+1</button>
                <button type="button" onClick={actions.decrement}>-1</button>
            </React.Fragment>
        )}
    </Consumer>
);

export default class App extends React.Component {
    state = {
        count : 0
    };

    constructor(props) {
        super(props);

        this.onClickIncrement = this.onClickIncrement.bind(this);
        this.onClickDecrement = this.onClickDecrement.bind(this);
    }

    onClickIncrement() {
        this.setState(prevState => {
            return { count : (prevState.count + 1) };
        });
    }

    onClickDecrement() {
        this.setState(prevState => {
            return { count : (prevState.count - 1) };
        });
    }

    render() {
        return (
            <Provider
                value={{
                    state   : this.state,
                    actions : {
                        increment : this.onClickIncrement,
                        decrement : this.onClickDecrement
                    }
                }}
            >
                <Counter />
            </Provider>
        );
    }
}

3. Conclusion

小規模なアプリであれば, わざわざ Redux (や MobX) を導入しなくても, Context API でいけるのでは ? と思いました.

Context API のサンプルリポジトリ

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 で実装してみたいです.

react-router v4 で onEnter ライクなことを実装する

1. Overview

react-routerにはあった onEnter というなにかしらの <Route /> に入ったときのフックポイントが用意されていました. しかしながら, v4 からはルーターの機能に特化したものとなり, onEnter などのフックポイントが廃止されました. しかしながら, onEnter ライクな処理をすることは可能なのでその実装方法をメモしておきます.

2. Implement routing by react-router v4

react-router v4 によるルーティングはおおよそ以下のような実装となります.

import React from 'react';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';

import Home from './components/Home';
import PageA from './components/PageA';
import PageB from './components/PageB';
import PageC from './components/PageC';

export default () => {
  return (
    <Router>
      <Switch>
        <Route exact path="/" component={Home} />
        <Route exact path="/a" component={PageA} />
        <Route exact path="/b" component={PageB} />
        <Route exact path="/c" component={PageC} />
      </Switch>
    </Router>
  );
};

3. onEnter

onEnter ライクな処理となる関数を実装します.

const render = (Component) => ({ history, match }) => {
  // ページローディングなどを表示する: 例 YouTube のページ上部に表示されるプログレスバーなど

  return <Component history={history} match={match} />;
};

ちょっと見づらいかもしれませんが, 関数が関数を返す, いわゆるクロージャ (高階関数) となっています.
history は, ネイティブの history オブジェクトではなく react-router が定義する history オブジェクトで, match は パスからパラメータを取得するためのオブジェクトです.

そして, ルーティングを以下のように実装します.

import React from 'react';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';

import Home from './components/app';
import PageA from './components/PageA';
import PageB from './components/PageB';
import PageC from './components/PageC';

const render = (Component) => ({ history, match }) => {
  // ページローディングなどを表示する: 例 YouTube のページ上部に表示されるプログレスバーなど

  return <Component history={history} match={match} />;
};

export default () => {
  return (
    <Router>
      <Switch>
        <Route exact path="/" render={render(Home)} />
        <Route exact path="/a" render={render(PageA)} />
        <Route exact path="/b" render={render(PageB)} />
        <Route exact path="/c" render={render(PageC)} />
      </Switch>
    </Router>
  );
};

注目すべきは, component props ではなく, render props になっていることです (ちなみに, component props と render props は共存できません).

これで onEnter ライクな処理が可能になります.

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