omitted sounds

日々の喧騒にかき消される音たち。そんな音の中から、いくつか拾ってみました。

JavaScript: html5 audioタグのオーディオ再生UIカスタマイズ

audioタグだけでもオーディオ再生はできます。ただ、利用者の環境によって変化する事のないシンプルなUIにしたかったので、少し調べる事にしました。 結果、概ねイメージ通りの仕上がりとなりましたが、割と手間が掛かったので残して置こうかと。

audioタグは、再生機能を提供してくれるだけではなく、JavaScriptを使えばオーディオデータの状態「state」を確認したり、再生する際に所々で「event」を投げてくれるので、「state」、「event」から多様な処理を実装できます。

★2022/6/12 更新★ iPhone(15.4) safariで2度目の再生は、loadなし再生ができるようになった事を確認。 どうやら再生時にオーディオ全体の長さ(duration)が取得できるようになった模様。全デバイスでload一回、複数再生できるようになりました。 Windows Edgeでpause後continueした際の初っ端が、クリップぽっくなるのが気になるが、html5なのでブラウザ毎に動作が異なるのは致し方なしか。

★2022/5/1 更新★ iPhone(15.4.1) safariで2度目再生ができない事象が発生。 再生時にオーディオ全体の長さ(duration)が取得できずInfinityとなった場合に発生する為、play()ではなくload()する仕様に変更しました。 全デバイスでのload一回、複数再生を目標としてましたが、これにより残念ながらiPhone/iPadが例外となってしまいました。

★2021/11/19 更新★ iPhone(15.1) safariで再生が永遠に終わらない事象が発生した為、一部機能を修正しました。 再生時にオーディオ全体の長さ(duration)が取得できずInfinityとなる為、timeupdateイベントのリスナーを追加しています。

★2021/09/26 更新★ iPhone safariでの再生ができなくなっていたので、一部機能を修正しました(iPadは特に問題は無く、iPhoneだけで発生)。 オーディオデータの読み込み後、初回再生はplaySoundFirst()、2回目以降は、playSound()を呼ぶことでiPhoneでのcurrentTimeによる問題(?)を回避しています。

audioタグ

audio: 埋め込み音声要素 - HTML: HyperText Markup Language | MDN

event

audio: 埋め込み音声要素#event - HTML: HyperText Markup Language | MDN

state

HTMLMediaElement.readyState - Web API | MDN
HTMLMediaElement.networkState - Web API | MDN

実現したかった事

  • 音量調整やシークバーは表示せず、play機能とpause機能をまとめたボタン1つのみを、UIデザインに合わせた形で配置
  • オーディオデータの読み込みは最初の一回で、再生都度の読み込みはしない(iPhone/iPadは使用上、都度読み込みとなってしまいました)

手間の掛かった所

何が面倒かというと、OSやブラウザ毎の差異です。

OSやブラウザ毎で、概ね「event」を投げるタイミングに差異はありませんでしたが、「state」に差異があった為、これを吸収する必要があります。

調べた結果(OSブラウザ差異)

audioタグのpreloadには、auto、metadata、noneの3つを設定できるのですが、この設定により各OS、ブラウザでのページ表示初期の「state」が異なります。調べた結果は下表の通りです。

audioタグ
preload
OS+ブラウザ readyState networkState
none iPhone/iPad+Safari 0:HAVE_NOTHING 1:NETWORK_IDLE
^ Windows 10+edge ^ ^
^ Windows 10+firefox ^ ^
^ Windows 10+chrome ^ ^
metadata iPhone/iPad+Safari 0:HAVE_NOTHING 2:NETWORK_LOADING
^ Windows 10+edge 1:HAVE_METADATA 1:NETWORK_IDLE
^ Windows 10+firefox ^ ^
^ Windows 10+chrome ^ ^
^ Windows 10+eclipse ^ ^
auto iPhone/iPad+Safari 4:HAVE_ENOUGH_DATA 1:NETWORK_IDLE
^ Windows 10+edge ^ ^
^ Windows 10+firefox ^ ^
^ Windows 10+chrome ^ ^
^ Windows 10+eclipse ^ ^

実装方針

OSブラウザ差異がないのは「none」か「auto」ですが、「auto」はネットワーク環境による変動が想定される為、「none」で進める事にしました。 ただし、「none」の場合、eclipseのブラウザでは、JavaScriptでaudio操作が出来なくなる(仕様?)ので動作確認の際には、外部ブラウザの利用が必須です。

サイト全体はAdminLTE(https://adminlte.io)をベースに、アイコンはfontawsome(https://fontawesome.com)を利用してます。

コード

実は、preloadを「none」に決めてしまった後は「state」を意識する事なく実装できました。

html

<audio id="sound" src="sound.mp3" preload="none">
Your browser does not support the <code>audio</code> element.
</audio>
<div class="form-group player">
<a class="btn btn-app"><i class="fas fa-circle-notch fa-spin"></i>initialize</a>
</div>

JavaScript

<script>
var sound = document.getElementById("sound");
var max_duration = 10; // max sound length
sound.autoplay = false;

var elemPlayer = divSound.getElementsByClassName('form-group player')[sound_no];

var buttonLoad     = '<a class="btn btn-app"><i class="fas fa-circle-notch fa-spin" onclick="playSound()"></i>Loading...</a>';
var buttonPlay     = '<a class="btn btn-app"><i class="fas fa-play"  onclick="playSound()"></i>Play</a>';
var buttonStop     = '<a class="btn btn-app"><i class="fas fa-stop"  onclick="stopSound()"></i>Stop</a>';
var buttonPause    = '<a class="btn btn-app"><i class="fas fa-pause" onclick="pauseSound()"></i>Pause</a>';
var buttonCont     = '<a class="btn btn-app"><i class="fas fa-play"  onclick="playSound()"></i>Continue</a>';
var buttonLoadPlay = '<a class="btn btn-app"><i class="fas fa-play"  onclick="loadSound()"></i>Load & Play</a>';
var buttonAlert    = '<a class="btn btn-app"><i class="fas fa-exclamation-triangle"  onclick="loadSound()"></i>error</a>';

function initListener() {
    /*
    * readyState=0:HAVE_NOTHING
    *      networkState=0:NETWORK_EMPTY
    *                                      preload=none        init state for win eclipse
    *      networkState=1:NETWORK_IDLE
    *                                      preload=none        init state for win edge,firefox,chrome
    *                                      preload=none        init state for iphone/ipad safari
    *      networkState=2:NETWORK_LOADING
    *                                      preload=metadata    init state for iphone/ipad safari
    *      networkState=3:NETWORK_NO_SOURCE
    *                                      resource not found

    * readyState=1:HAVE_METADATA
    *      networkState=1:NETWORK_IDLE
    *                                      preload=metadata    init state for win edge(? ...could be)
    *      networkState=2:NETWORK_LOADING

    * readyState=2:HAVE_CURRENT_DATA
    * readyState=3:HAVE_FUTURE_DATA
    *      networkState=2:NETWORK_LOADING  event=loadeddata    ready to play

    * readyState=4:HAVE_ENOUGH_DATA
    *      networkState=2:NETWORK_LOADING  event=loadeddata    ready to play
    *      networkState=1:NETWORK_IDLE     event=loadeddata    ready to play
    *                                      preload=metadata    init state for win edge,firefox,chrome
    *                                      preload=auto        init state for win edge,firefox,chrome
    *                                      preload=auto        init state for iphone/ipad safari
    *
    */

    sound.addEventListener("error", function(){
        console.log("[event] error");
        message = sound.src;
        console.log(message);
        message = "readyState  :" + sound.readyState;
        console.log(message);
        message = "networkState:" + sound.networkState;
        console.log(message);

        message = "err=" + sound.readyState +":"+sound.networkState
        button = '<a class="btn btn-app"><i class="fas fa-exclamation-triangle"></i>'+message+'</a>';;
        elemPlayer.innerHTML = button;

    }, false);

    sound.addEventListener("emptied", function(){
        console.log("[event] emptied");
    }, false);

    sound.addEventListener("durationchange", function(){
        console.log("[event] durationchange");
        message = sound.src;
        console.log(message);
        message = "readyState  :" + sound.readyState;
        console.log(message);
        message = "networkState:" + sound.networkState;
        console.log(message);
    }, false);

    sound.addEventListener("loadstart", function(){
        console.log("[event] loadstart");
        message = sound.src;
        console.log(message);
        message = "readyState  :" + sound.readyState;
        console.log(message);
        message = "networkState:" + sound.networkState;
        console.log(message);

    }, false);

    sound.addEventListener("loadeddata", function(){
        console.log("[event] loadeddata");
        message = sound.src;
        console.log(message);
        message = "readyState  :" + sound.readyState;
        console.log(message);
        message = "networkState:" + sound.networkState;
        console.log(message);


        message = "load err=" + sound.readyState +":"+sound.networkState
        buttonError = '<a class="btn btn-app"><i class="fas fa-exclamation-triangle" onclick="location.reload()"></i>'+message+'</a>';

        if (sound.readyState === 4){
            if (sound.networkState === 1 || sound.networkState === 2){
                playSound();
            } else {
                elemPlayer.innerHTML = buttonError;
            }
        } else if (sound.readyState === 3){
            if (sound.networkState === 2){
                playSound();
            } else {
                elemPlayer.innerHTML = buttonError;
            }
        } else {
            elemPlayer.innerHTML = buttonError;
        }

    }, false);

    sound.addEventListener("play", function(){
        console.log("[event] play");
        message = sound.src;
        console.log(message);
        message = "readyState  :" + sound.readyState;
        console.log(message);
        message = "networkState:" + sound.networkState;
        console.log(message);

        elemPlayer.innerHTML = buttonPause;
    }, false);

    sound.addEventListener("pause", function(){
        console.log("[event] pause");
        message = sound.src;
        console.log(message);
        message = "readyState  :" + sound.readyState;
        console.log(message);
        message = "networkState:" + sound.networkState;
        console.log(message);

        if (sound.duration == Infinity){
            if (sound.currentTime == 0){
                elemPlayer.innerHTML = buttonLoadPlay;
            } else {
                elemPlayer.innerHTML = buttonCont;
            }
        } else {
            if (sound.currentTime == sound.duration){
                elemPlayer.innerHTML = buttonStop;
            } else {
                elemPlayer.innerHTML = buttonCont;
            }
        }
    }, false);

    sound.addEventListener("ended", function(){
        console.log("[event] ended");
        message = sound.src;
        console.log(message);
        message = "readyState  :" + sound.readyState;
        console.log(message);
        message = "networkState:" + sound.networkState;
        console.log(message);

        elemPlayer.innerHTML = buttonPlay;

    }, false);

    sound.addEventListener("timeupdate", function(){
        console.log("[event] timeupdate:"+sound.currentTime);
        if (sound.currentTime === 0){
            if (sound.duration === Infinity){
                stopSound();
            }
        }
        if (sound.currentTime > max_duration){
            stopSound();
        }
    }, false);
}


function playSound() {
    console.log("[function] playSound()");
    sound.play();
}

function stopSound() {
    console.log("[function] stopSound()");
    sound.pause();
    sound.currentTime=0;
}

function pauseSound() {
    console.log("[function] pauseSound()");
    sound.pause();
}

function loadSound() {
    console.log("[function] loadSound()");
    sound.load();

    message = sound.src;
    console.log(message);
    message = "readyState  :" + sound.readyState;
    console.log(message);
    message = "networkState:" + sound.networkState;
    console.log(message);

    elemPlayer.innerHTML = buttonLoad;
}

console.log("[onload] initListener");
initListener();

message = "init err=" + sound.readyState +":"+sound.networkState
buttonError = '<a class="btn btn-app"><i class="fas fa-exclamation-triangle" onclick="location.reload()"></i>'+message+'</a>';;
initButton = buttonError;

if (sound.readyState === 0){
    if (sound.networkState === 1){
        //elemPlayer.innerHTML = buttonLoadPlay;
        initButton = buttonLoadPlay;
    } else if (sound.networkState === 3){
        //elemPlayer.innerHTML = buttonLoadPlay;
        initButton = buttonLoadPlay;
    }
}
elemPlayer.innerHTML = initButton;

実装結果

先日公開したrsynth.netのsound libraryとして実装しています。 sound.rsynth.net

rsynth.netについては、こちらを omittedsounds.hatenablog.com