JavascriptのAudioAPIはブラウザ毎に大きな仕様の違いがある話

2022年5月13日

Javascript テクノロジー

eyecatch 芸能人は歯が命、ゲームはデザインと音とコントロールが命、と考える、ユゲタです。 誰が言っていた言葉ですかって??? 「芸能人は歯が命」は、歯ブラシだったか歯磨き粉のテレビCMで、 「ゲームは・・・」は、そんな事誰も言ってませんよ、ユゲタが勝手に言ってみただけです。 最近仕事でゲームを作る機会があったので、このように思っただけです。 でも、デザインやコントロールは、ゲームを考える上で一番最初に考えて、一生懸命練り上げますが、 音というのは、最後の最後に、取ってつけるような感じで添え物のように扱われてしまいますが、 ここがしっかりしているととてもクオリティが上がると言うことは、事象ゲームメーカーコンサルティングのユゲタはよく理解しています。 そして、ユゲタが今取り掛かっているシステム開発がまさにインターネットブラウザで手軽に遊べるゲームコンテンツなんですが、 Javascriptで一番制限が厳しくされているのが、CookieとAudioAPIだと言う事を今回改めて理解する事ができました。

Javascriptの制限について

videoタグなどもjavascriptで操作する時に、いろいろな制限がされていますが、音声を再生しない動画はページを読み込んだタイミングで再生開始することはできますが、 音声がついている動画で、且つmuteになっていない動画は、自動再生することはできません。 これは、webページを開いた時に勝手にBGMを流すような個人サイトがかつてはたくさんあって、電車に乗っている時などに、そんなページをうかつに開こうもんなら、慌てふためいてしまう事は容易に想像できますよね。 特にスマートフォンでの制限はこうしたPCとは違った観点で制限されているので、普通にPCでできることがスマホでは当たり前のようにできないという事は多分にあります。 その中で特に制限がキツいAudioAPIってどんな制限がかかっているのかというと、 まずインターネットブラウザ上で音声を再生するには、「何かしらのユーザーイベント操作が行われないといけない」というかなり厳しい制限があります。 なんだそれぐらいか・・・と考えた人、もしあなたがゲーム制作会社の開発担当者だとしたら、このもどかしい制限に嘆き苦しむでしょう。 通常音声を再生するのは、audioタグをHTMLで記述すると、次のような表示(GoogleChromeの場合)がされて、再生ボタンをクリックする事で再生ができますが、読み込みと同時に自動再生はできません。 なのでゲームなどをブラウザのSPAのような感じで構築した場合、立ち上がった最初の画面では、ゲームスタートをクリックするか、そもそも意味のわからないクリック操作を要求するかのどちらかの仕様で搭載されるのがほとんどです。 これは、インターネットブラウザの仕様なのでいたしかたないんですよね。 これが嫌だと思ったら、ネイティブアプリで構築する以外にないですね。

インターネットブラウザ毎に違うAudioAPIの仕様

この間作ったゲームをGoogleChromeで確認していたんですが、その時にはBGMやSE音などが正常にでていたんですが、Safariブラウザ(Mac and iPhone)で表示してみると、 音が全く鳴りません・・・(具体的に言うと、最初の一回しか鳴りません) そこで、初めてブラウザ毎にAudioAPIの仕様が違っていることに気がつきました。 そこで調べてみると、 "AudioContext"というブラウザの音を出すこの機能を、通常は関数の中で次のように記述します。 function audio(){ const audioContext = new (window.AudioContext || window.webkitAudioContext)() ... } 他にもいろいろな記述の仕方があると思いますが、ユゲタはこの方式が一番簡易に設置できて好きですね。 そして、取得したaudioContextにmp3ファイルから取得したバッファデータを設置したり、Oscillatorという内部音源でビープ音などを再生することができます。 GoogleChrome(というか、safariブラウザ以外)の場合は、ページを読み込んだ後で、ユーザーのアクションイベント(クリックやタッチやキーダウン)が発生していれば、その後AudioAPIの取得は問題なくできるのですが、 Safariブラウザは、MacもiPhoneも、ユーザーのアクションイベント(クリックやタッチやキーダウン)から直接実行された処理(関数)じゃないと、AudioAPIが取得できないというなんとも厳しい制限になっています。 なんだかな〜も〜ってカンジです。

汎用的に対応したい場合は・・・

こうした仕様は、ブラウザのバージョンアップによって、大きく制限の内容が変わってくる可能性もあり、今後ももっと厳しい制限になる事も想定しておかなければいけません。 ちなみに、ライブラリなどでaudioインターフェイスを扱ってくれるものはたくさんあるようですが、ユゲタがやりたいのは、oscillatorを使った、内部MIDI的な音源管理をしたかったので、 今回テストとして、次のようなプログラムを作ってみました。 興味がある人は、改造して使ってみてください。 サンプルでは、スーパーマリオのコインの音がブラウザで鳴ります。 <!doctype html> <html lang="ja"> <head> <meta charset="UTF-8"/> <title>Document</title> <script src="audio.js"></script> <link rel="stylesheet" href="audio.css"> </head> <body> <button id='init'>初期設定(未処理)</button> <div class="controls"> <div class="left"> <div id="playButton" class="button">▶️ play</div> </div> <div class="right"> <span>Volume: </span> <input type="range" min="0.0" max="1.0" step="0.01" value="0.8" name="volume" id="volumeControl"> </div> </div> </body> </html> .button{ cursor:pointer; } .button:hover{ color:red; } class Chord{ constructor(datas){ if(!datas){return} for(let key in datas){ this[key] = datas[key] } this.data = this.parse(datas.chord) this.waon = this.get_waon_count(this.data) } parse(data){ if(!data){return} const frequencies = [] const reg = new RegExp("(\\[(.+?)\\]|[A-G~STOV]+?)([0-9\+\-]*)","gi") let T = __options.tempo, O = __options.octave, S = "", V = __options.volume, tempo = this.tdur(T , 4) let time = 0 let res while ((res = reg.exec(data)) !== null) { if(!res[1]){continue} const mode = res[1].toUpperCase() const value = res[3] let data = {} if(mode === "T"){ if(value){ T = value tempo = this.tdur(T , 4) } continue } else if(mode === "O"){ if(value){ O = value continue } } else if(mode === "V"){ if(value){ V = value continue } } else if(mode === "~" || mode === "S"){ data = { S : mode, num : null, tempo : tempo, freq : null, volume : V } } else if(["C","D","E","F","G","A","B"].indexOf(mode) !== -1){ S = value ? mode + value : mode let num = this.chord_octabe2num(S , O) let frequency = this.mtof(num); data = { S : S, num : num, tempo : tempo, freq : frequency, volume : V } } else{ let num = this.chord_octabe2num(S , O) let freqs = this.getOtherCode(mode); data = { S : mode, num : num, tempo : tempo, freq : freqs, volume : V } } if(!data || !data.S){continue} time += data.tempo data.time = time frequencies.push(data) } return frequencies } // 音階を取得 tdur(tempo, length) { return (60 / tempo) * (4 / length) } // オクターブ番号を取得 chord_octabe2num(chord , octave){ const add = 12 if(chord.constructor === Array){ const arr = [] for(let i=0; i<chord.length; i++){ const singleNum = this.chord2singleNum(chord) arr.push((octave * add) + singleNum) } return arr } else{ const singleNum = this.chord2singleNum(chord) return (octave * add) + singleNum } } // 音階以外の指定 getOtherCode(mode){ if(!mode){return ""} const mtc = mode.match(/\[(.+?)\]/i) if(mode && mtc && mtc[1]){ const chords = mtc[1] const arr = [] const res = this.parse(chords) for(let i=0; i<res.length; i++){ arr.push(res[i].freq) } return arr } else{ return mode } } mtof(midi) { if(midi.constructor === Array){ const arr = [] for(let i=0; i<midi.length; i++){ arr.push(this.mtof(midi[i])) } return arr } else{ return 440 * Math.pow(2, (midi - 69) / 12) } } chord2singleNum(chord){ switch(chord){ case "B+": // ?? case "C": return 0 case "C+": case "D-": return 1 case "D": return 2 case "D+": case "E-": return 3 case "F-": // ?? case "E": return 4 case "E+": // ?? case "F": return 5 case "F+": case "G-": return 6 case "G": return 7 case "G+": case "A-": return 8 case "A": return 9 case "A+": case "B-": return 10 case "C-": // ?? case "B": return 11 case "S": case "~": return null default : return false } } // 和音数の取得 get_waon_count(data){ data = data || this.data let max_count = 1 for(let i=0; i<data.length; i++){ if(!data[i].freq || data[i].freq.constructor !== Array){continue} if(max_count < data[i].freq.length){ max_count = data[i].freq.length } } return max_count } } class Play{ constructor(data , init_flg){ this.status = 'setting' this.data = data this.setAudioContext() // this.setAnalyser() if(init_flg){ this.play(init_flg) } } setAudioContext(){ this.audioContext = new (window.AudioContext || window.webkitAudioContext)() } setAnalyser(){ this.analyser = this.audioContext.createAnalyser() this.analyser.fftSize = 2048 this.analyser.connect(this.audioContext.destination) } setFreq(){ this.time = 0 this.start_time = this.time this.oscillator = [] this.gain = this.audioContext.createGain(); for(let i=0; i<this.data.waon; i++){ this.oscillator[i] = this.audioContext.createOscillator() this.oscillator[i].type = __options.oscillator } for(let data of this.data.data){ const t = this.t0 + this.time if(data.freq){ this.setOscillator(data.freq , t) } else if(data.S === "S"){ this.setOscillator(0 , t) } else if(data.S === "~"){ this.setGain(0 , t + data.tempo) } else{ continue } this.time += data.tempo } } setOscillator(freq , time){ const freqs = Array(this.data.waon).fill(0) if(freq.constructor === Array){ for(let i=0; i<this.data.waon; i++){ freqs[i] = freq[i] || 0 } } else{ freqs[0] = freq } for(let i=0; i<this.data.waon; i++){ this.oscillator[i].frequency.setValueAtTime(freqs[i], time) } } setGain(val , time){ this.gain.gain.linearRampToValueAtTime(val, time) } play(init_flg){ this.t0 = this.audioContext.currentTime || 0 this.setAnalyser() this.setFreq() this.play_sound() } play_init(){ for(let i=0; i<this.data.waon; i++){ this.data.oscillator[i].start(0) this.data.oscillator[i].stop(0) } } play_sound(){console.log(this.t0+"~"+ (this.t0+this.time)) for(let i=0; i<this.data.waon; i++){ this.oscillator[i].start(this.t0) this.oscillator[i].stop(this.t0 + this.time) this.oscillator[i].connect(this.gain) this.gain.gain.value = this.volume || __options.volume this.gain.connect(this.analyser) } } } class Setup{ constructor(){ this.data = new Chord(datas) this.setEvent() } setEvent(){ const playButton = this.elmPlayButton() playButton.addEventListener('click' , this.play.bind(this)) const volumeRange = this.elmVolumeRange() volumeRange.addEventListener('click' , this.changeVolume.bind(this)) volumeRange.value = datas.volume const initButton = this.elmInitButton() initButton.addEventListener('click' , this.clickInitButton.bind(this)) } elmPlayButton(){ return document.querySelector("#playButton") } elmVolumeRange(){ return document.querySelector("#volumeControl"); } elmInitButton(){ return document.getElementById('init') } play(e){ if(!this.init){return} if(!this.sound){ this.sound = new Play(this.data) } else{ this.sound.play() } } changeVolume(e){ if(!this.init){return} console.log(e.target.value) } clickInitButton(e){ const target = e.currentTarget target.disabled = true this.init = true this.sound = new Play(this.data) } } const datas = { chord : 'T450O7EGO8ECDG', volume : 0.5, } const __options = { oscillator : "square", fftsize : 2048, tempo : 120, octave : 5, volume : 0.5, playStyle : 'single', // ['single' , 'multi'] }; window.onload = function(){ new Setup() }

このブログを検索

ごあいさつ

このWebサイトは、独自思考で我が道を行くユゲタの少し尖った思考のTechブログです。 毎日興味がどんどん切り替わるので、テーマはマルチになっています。 もしかしたらアイデアに困っている人の助けになるかもしれません。