Help us understand the problem. What is going on with this article?

PPAPテストを Web Audio API で

More than 3 years have passed since last update.

今、世界を席巻している PPAP に絡めて、プログラマーとしては PPAP テストを実装しなければいけないと思って、書いてみました。結果だけみたい人は、以下のリンクをどうぞ。

  • PPAP テスト - PPAP テストに合格するまで、コンピュータに挑戦してもらうページ
    IMG_6670.PNG

  • PPAP マシーン - ドラムマシーンみたいに、自分で PPAP できるページ
    IMG_6673.PNG

背景

ズンドコキヨシとは?

今更、ズンドコキヨシって何?という人は、いるかな?
そんな人は、何はともあれ、 Qiita のまとめをどうぞ。

このズンドコキヨシネタで(ブームには少し乗り遅れた感じだったけど)ぼくも Web Audio API 版で Qiita デビューしました。

PPAP とは?

そもそも、PPAP って何?という人は、いるかな?

ぼくが知ったのは、 MIT メディアラボ所長の Joi Ito さんの Facebook への投稿からでした。
PPAP-joi-ito.jpg

YouTube にアップされてる動画

ズンドコキヨシ + PPAP = PPAP キヨシ?

この「ズンドコキヨシ」と「PPAP」を結びつけたのは、Qiita で見かけた以下の投稿です。

この投稿を見て、この週末はこれやろう、と思いました。(その結果がのこエントリーです。)

付記(2016/11/08)

その後、ググってたら、10月6日付けで、 PPAP とズンドコキヨシを結びつけた方がいらっしゃったようです。

最初の試み

当初は、ビデオを使って、 PPAP のバリエーションをいろいろ作りたいと思ってはじめました。

HTML5 の video タグでシーク

環境は、前回のズンドコキヨシと同じように、ブラウザーにしました。その方が、見れる人が多いと思って。(フロントエンドのプロではないので、前回同様 Javascript を生で触ります。)

ブラウザでビデオを扱うとなると、基本 video タグを使うのが簡単そうです。これでシークが自由にできれば、あとは素材を準備して、実装するだけなので、簡単、簡単、と思って始めました。

でも、普段、 Javascript をあんまり使わないので、実は Javascript で video を seek する方法も知りませんでした。

それで調べてみたら、 video のインスタンスのプロパティ currentTime に時間を指定すると、そこにジャンプすることが分かりました。ここまで順調。

sleep() がない

次の壁(というか学び)は、 sleep() が Javascript にはない、という事実。Javascript の常識みたいですが、時間に従って処理を行うのに sleep() で調整というアプローチはできないらしい。代わりに setTimeout(callback, milliseconds) を使って、 milliseconds 後に callback 関数を呼ぶ、という形で実装するようです。

ということで、シーケンサー的な処理を以下のように実装してみました。

var time_segments = [
 0,
 1.283, //  1 ピ、ピコ、ピコ、ピーコーターローオー
 5.886, //  2 ピコ
 14.963, //  3 ピピピピピピピ ピー、「ピーピーエーピー」
 18.502, //  4 ボパボパ、ボパボパ、ボパボパーボボパ
 22.025, //  5 ボパボパ、ボパボパ、ボパボパー
 25.554, //  6 I have
 26.190, //  7 a pen
 27.232, //  8 I have
 28.860, //  9 a apple
 // 以下省略
];
var sequence = [
 1,  // ピ、ピコ、ピコ、ピーコーターローオー
 3,  // ピピピピピピピ ピー、「ピーピーエーピー」
 4, // ボパボパ、ボパボパ、ボパボパーボボパ
 30, // ボパボパ、ボパジャーン
 25, // 「Pen」
 27, // 「Apple」
 26, // 「Pine-Apple」
 28, // 「Pen」
 // 以下省略
];

var vid = document.getElementById("myVideo");
var timer = 0;
var counter = 0;

function play_sequence() {
    if (timer) {
        clearTimeout(timer);
        timer = 0;
    }
    if (counter >= sequence.length) {
        vid.pause();
        counter = 0;
    } else {
        var i_seq = sequence[counter]
        vid.currentTime = time_segments[i_seq];

        if (vid.paused) {
            vid.play();
        }

        dt = (time_segments[i_seq+1] - time_segments[i_seq]) * 1000;
        timer = setTimeout(play_sequence, dt);

        counter += 1;
    }
}

ここで、 time_segments 配列には、コントロールに必要な cue のリストが入っています。(単位は秒)

sequence 配列は、再生する cue を順番に羅列したものです。ここではテストのため静的な配列を作ってますが、実際に PPAP テストを実装する時は、動的に一部ランダムな並びになるようにする予定でした。

Safari の挙動が悪い

当初、Mac の Chrome で実験してて、まあまあ動くなと思って、 iPhone の Safari でみたら、seek 後の再生に 0.5 秒くらい音的な空白が生じることが発覚(動画もその間、止まってる)。いろいろ調整してみたが、Mac の Safari でも挙動が同じなので、多分ダメそうです。

ということで、この線は今の時点では断念することにしました。
現状のコードは以下に置いてあるので、興味ある人はご覧下さい:

副産物

この件で調べ物していて、新しい知見を得ました。それは、長らく iPhone の Safari で video を再生しようとすると、勝手にフルスクリーンモードの独自プレーヤーが立ち上がる問題、これが最近、やっと緩和されたらしいのです!

但し、 video タグに playsinline をつける必要があります。ちなみに、上の実験版も、 iPhone で inline の映像を見ることができます。

ちなみに、フルスクリーンで起動されてしまったプレーヤーも、ピンチインすると inline モードになるようにもなったらしいですね。

次善策、 Web Audio API 版

ここまで粘って(土曜日が終わって)あきらめるのも残念なので、せめて動作するものを形にしておこうと思い、芸は無いですが Web Audio API 版の PPAP テストを作ることに方針変更しました。

と言っても、さすがにサンプルだけ置き換えても面白く無いので、いくつかバージョンアップを試みました。

PPAP のランダムシーケンスの生成

この部分は、いろいろ考え方があるでしょうが、音楽的なこと(ビートを保つため)から、以下のような、ある種の簡略化を行いました:

  • 状態は4つもつ:ペン/パイナポー/アポー/ペーン
  • 生成されるシーケンスには、上の4つの状態が重複なく1回現れる

つまり、ペンとペーンは別物として扱うということと、生成される4つの状態から、例えば「ペン/ペン/ペン/ペン」とか「パイナポー/パイナポー/アポー/ペン」とかは除外するということです。

実際のシーケンスの生成は、まず初期配列を作っておいて、その要素を置換することで行いました。

var randomize = function(array) {
  var n = array.length;  

  for (var i = 1; i < n; i++) {
    var r = Math.random();
    r *= (n - i);
    var j1 = Math.floor(r) + i;
    var x = array[i-1];
    array[i-1] = array[j1];
    array[j1] = x;
  }
}

適当に作った割に、比較的それなりの時間で正解が出てくるので、ちょうどいいくらいだったのかなと思います。深掘りしたい人は、このアルゴリズムで本当にランダムな配列ができているのかどうか、調べたら面白いかもしれませんね。

ラベルの書き換え

ズンドコキヨシでは、画面は静的で、ただ音だけが流れるものでした。今回、ビデオは断念しましたが、せめて文字だけでも動的に書き換えるようにしようと、改良してみました。

Web Audio API も、上述の setTimeout() のように、実際に処理を行う時間を指定しておいて、プログラム自体はどんどん先に進んでしまうようです。なので Web Audio API でサンプルの再生を行っているところで、テキストの書き換え処理を setTimeout() でセットすればいいようです。

上の video の seek の時は同じ callback 関数を呼ぶ形でしたが、今度は、呼び出すテキスト書き換えの関数に文字列を渡したいと思いました。ここで、さてこの場合には setTimeout() をどのように呼べばいいのだろう?と普段 Javascript を書かない人間には分からないことだらけです。調べたら、以下のように書けることが分かりました。

var change_button_text = function(label) {
  button.textContent = label;
}

// 中略

  // intro
  setTimeout(function(){ change_button_text("(PPAP)")}, (time + bar * 1.5 - context.currentTime) * 1000);
  playSound(sample1, time);
  time = time + bar * 2;

  // theme
  setTimeout(function(){ change_button_text("(レッツビギーン)")}, (time - context.currentTime) * 1000);
  playSound(sample2, time);
  time = time + bar;

実装

上に書いたようなことを使って、イントロとテーマ部分は定型で、PPAP 部分を毎回ランダムに作って、 PPAP テストを行い、成功ならループを抜けて、おめでとうの「ピコ」を鳴らして、スタート待機に戻る、という構成にしたコード (Javascript) が以下になります。

js:ppap-test.js
window.AudioContext = window.AudioContext || window.webkitAudioContext;  
var context = new AudioContext();

var button;


var getAudioBuffer = function(url, fn) {  
  var request = new XMLHttpRequest();
  request.responseType = 'arraybuffer';

  request.onreadystatechange = function() {
    if (request.readyState === 4) {
      if (request.status === 0 || request.status === 200) {
        context.decodeAudioData(request.response, function(buffer) {
          fn(buffer);
        });
      }
    }
  };

  request.open('GET', url, true);
  request.send('');
};


var playSound = function(buffer, time) {
  var source = context.createBufferSource();
  source.buffer = buffer;
  source.connect(context.destination);
  source.start(time);
};


var sample1 = null;
var sample2 = null;
var sample3 = null;
var sample4 = null;
var sample5 = null;
var sample6 = null;
var sample7 = null;


var randomize = function(array) {
  var n = array.length;  

  for (var i = 1; i < n; i++) {
    var r = Math.random();
    r *= (n - i);
    var j1 = Math.floor(r) + i;
    var x = array[i-1];
    array[i-1] = array[j1];
    array[j1] = x;
  }
}

var change_button_text = function(label) {
  button.textContent = label;
}
var append_button_text = function(label) {
  button.textContent += label;
}


// main
window.onload = function() {

  button = document.getElementById("btn_start");


  getAudioBuffer('PPAP-1.wav', function(buffer1) {
    sample1 = buffer1;
  });
  getAudioBuffer('PPAP-4-2.wav', function(buffer2) {
    sample2 = buffer2;
  });
  getAudioBuffer('PPAP-3-P1_.wav', function(buffer3) {
    sample3 = buffer3;
  });
  getAudioBuffer('PPAP-3-PA_.wav', function(buffer4) {
    sample4 = buffer4;
  });
  getAudioBuffer('PPAP-3-AP_.wav', function(buffer5) {
    sample5 = buffer5;
  });
  getAudioBuffer('PPAP-3-P2_.wav', function(buffer6) {
    sample6 = buffer6;
  });
  getAudioBuffer('PPAP-5.wav', function(buffer78) {
    sample7 = buffer78;
  });
};


var start = function() {
  button.disabled = true;

  // We'll start playing the rhythm 100 milliseconds from "now"
  var startTime = context.currentTime + 0.100;

  var bar = 1.762; // duration for 1 bar
  var eighthNoteTime = bar / 8;


  // initialization
  var ppap = new Array(4);
  for(var i = 0; i < 4; i++)
  {
    ppap[i] = i;
  }

  var time = startTime;

  // intro
  setTimeout(function(){ change_button_text("(PPAP)")}, (time + bar * 1.5 - context.currentTime) * 1000);
  playSound(sample1, time);
  time = time + bar * 2;

  // theme
  setTimeout(function(){ change_button_text("(レッツビギーン)")}, (time - context.currentTime) * 1000);
  playSound(sample2, time);
  time = time + bar;

  while (1) {
    var status = 0;

    randomize(ppap);
    setTimeout(function(){ change_button_text("")}, (time - context.currentTime) * 1000 - 10);
    for (var i = 0; i < 4; i++) {
      if (ppap[i] == 0) {
        // pen
    setTimeout(function(){ append_button_text("ペン")}, (time - context.currentTime) * 1000);
        playSound(sample3, time);
        time = time + eighthNoteTime;
      } else if (ppap[i] == 1) {
        // pineapple
    setTimeout(function(){ append_button_text("パイナポー")}, (time - context.currentTime) * 1000);
        playSound(sample4, time);
        time = time + eighthNoteTime * 3;
      } else if (ppap[i] == 2) {
        // apple
    setTimeout(function(){ append_button_text("アポー")}, (time - context.currentTime) * 1000);
        playSound(sample5, time);
        time = time + eighthNoteTime * 2;
      } else if (ppap[i] == 3) {
        // pen
    setTimeout(function(){ append_button_text("ペーン")}, (time - context.currentTime) * 1000);
        playSound(sample6, time);
        time = time + eighthNoteTime * 2;
      } else {
        // piko
    setTimeout(function(){ append_button_text("ピコ")}, (time - context.currentTime) * 1000);
        playSound(sample7, time);
        time = time + eighthNoteTime * 2;
      }
    }

    // PPAP test
    if (ppap[0] == 0 &&
        ppap[1] == 1 &&
        ppap[2] == 2 &&
        ppap[3] == 3) {
        break;
    }

    // theme
    setTimeout(function(){ change_button_text("(トライアゲーン)")}, (time - context.currentTime) * 1000);
    playSound(sample2, time);
    time = time + bar;

  }

  // piko
  setTimeout(function(){ change_button_text("\\(^O^)/")}, (time - context.currentTime) * 1000);
  playSound(sample7, time);
  time = time + eighthNoteTime * 2;


  setTimeout(function(){ change_button_text("スタート")}, (time + bar - context.currentTime) * 1000);
  setTimeout(function(){ button.disabled = false;}, (time + bar - context.currentTime) * 1000);
};

対応する HTML と CSS はそれぞれ以下になります。

html:ppap-test.html
<!doctype html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>PPAP test</title>
    <link rel="stylesheet" type="text/css" href="ppap-test.css" />
    <script src="ppap-test.js"></script>
  </head>

<body>

<button id="btn_start" onclick="start()">スタート</button>

<div id="credit">PPAP テスト / Web Audio API 版</div>

</body>
</html>
css:ppap-test.css
body {
    width: 100vw;
    height: 100vh;
    color: #ccc;
    background-color: #000;
}

#btn_start {
    position: absolute;
    width: 98vw;
    height: 92vh;
    top: 6vh;
    left: 1vw;
    right: 99vw;
    bottom: 93vh;
    font-size: 10vh;
    color: #000;
    background-color: #ff0;
}

#credit {
    position: absolute;
    width: 99vw;
    height: 5vh;
    top: 1vh;
    left: 1vw;
    right: 99vw;
    bottom: 99vh;
    font-size: 3vh;
    color: #fff;
    background-color: #000;
}

出来上がったページが以下になります。
IMG_6670.PNG

PPAP マシーンの方は、前の「キ・ヨ・シ!マシーン」とほとんど同じなので、解説は省きます。
IMG_6673.PNG

(気になっている点が1つ、このPPAP マシーンを iPhone で使うと、タップの感度が悪いのか、タップ後の応答が悪いのか、使い心地がよくないですね。無いか改善策はあるのだろうか?)

まとめ

  • PPAP テスト(および PPAP マシーン)は、とりあえず動くものができました。
  • 本当にやりたかった video 版 PPAPが、思ったように動きませんでした。
  • iPhone の Safari の video タグのポリシー変更を知ったのは収穫でした。

コード一式を GitHub に上げておいた。 https://github.com/kichiki/ppap-test

補足

Javascript の時間処理について、今回、実践を通じて知見が深まったのですが、多分これが、 Node.js が Javascript を採用した理由の1つであるノンブロッキング I/O ということなんでしょうね。(よく分からずに書いています。)

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした