実現したいこと
このアプリにおいて、以下の問題を解決したいです。
- パートがずれることがある問題(すべての音源の再生位置を同期させたい)
- 初期状態で再生位置の操作を行うと表示が乱れる問題(スライダーの位置からできるようにする)
- (リピートオフ時)再生終了後に一時停止ボタンが再生ボタンに戻らない、再生位置が0に戻らない問題
アプリの概要
合唱の音源を再生するアプリです。パート(ソプラノ・アルト・テノール)ごとに鳴らす鳴らさないを切り替えできます。すべてのパートが鳴っているときは、全体の音源を流します。
p.mp3 → ピアノ音源(all再生中以外は再生)
all.mp3 → すべてのパートの音源(すべて選択されているときはこれだけ再生)
1.mp3 → Alto音源
s.mp3 → ソプラノ音源
a.mp3 → アルト音源
t.mp3 → テノール音源
該当のソースコード
かなり省略しています。
HTML
1 <button id="play">再生</button> 2 <button id="stop">てい止</button><br> 3 <label><input type="checkbox" id="repeat">リピート</label> 4 5 <h3>再生パート選択</h3> 6 <label><input type="checkbox" id="cb1" checked>4~12 Alt.</label>・・・(すべてのパート) 7 8 <h3>音量</h3> 9 <div class="slider-container"> 10 <span id="volume-text">100%</span> 11 <input type="range" id="volume" min="0" max="100" step="1" value="100"> 12 </div> 13 14 <h3>再生位置</h3> 15 <div class="slider-container"> 16 <span id="time-text">0:00 / 0:00</span> 17 <input type="range" id="seek" min="0" max="100" step="1" value="0"> 18 </div> 19</div> 20 21<audio id="p" src="p.mp3"></audio>…(すべてのMP3) 22 23<script> 24document.addEventListener("DOMContentLoaded", function() { 25 // グローバル変数 26 let audioCtx = new (window.AudioContext || window.webkitAudioContext)(); 27 let gainNode = audioCtx.createGain(); 28 gainNode.connect(audioCtx.destination); 29 // 初期音量をスライダーに合わせる 30 gainNode.gain.value = document.getElementById("volume").value / 100; 31 32 // 各ファイルのパスマッピング 33 const fileList = { 34 "p": "p.mp3",…(すべてのMP3) 35 }; 36 37 // デコード済みAudioBufferを格納する辞書 38 const buffers = {}; 39 let filesToLoad = Object.keys(fileList).length; 40 let errorOccurred = false; 41 const loadDiv = document.getElementById("load"); 42 loadDiv.style.display = "block"; 43 44 // ファイルを読み込み、デコードする関数 45 function loadBuffer(key, url) { 46 return fetch(url) 47 .then(response => { 48 if (!response.ok) throw new Error("Network response was not ok"); 49 return response.arrayBuffer(); 50 }) 51 .then(arrayBuffer => audioCtx.decodeAudioData(arrayBuffer)) 52 .then(audioBuffer => { 53 buffers[key] = audioBuffer; 54 filesToLoad--; 55 if (filesToLoad === 0 && !errorOccurred) { 56 loadDiv.style.display = "none"; 57 console.log("全オーディオファイルの読み込み完了 (Web Audio API)"); 58 } 59 }) 60 .catch(err => { 61 errorOccurred = true; 62 loadDiv.style.display = "none"; 63 alert("オーディオファイルの読み込みに失敗しました: " + url); 64 }); 65 } 66 67 Object.keys(fileList).forEach(key => { 68 loadBuffer(key, fileList[key]); 69 }); 70 71 // 再生状態管理用グローバル変数 72 let isPlaying = false; 73 let playStartTime = 0; // 再生開始時のaudioCtx.currentTime 74 let pauseOffset = 0; // 再生済みの秒数(再生中止時に蓄積) 75 let activeSources = []; // 現在再生中のAudioBufferSourceNode群 76 let selectedTrackIds = []; // チェックボックスから選択された音源IDの配列 77 let trackDuration = 0; // 選択したトラックの再生時間(最大値) 78 79 // チェックボックスの状態により再生する音源を決定 80 function updateSelectedTracks() { 81 // チェック対象:cb1,cb2,cb3,cb4, cbs, cba, cbt 82 const checkboxIds = ["cb1", "cb2", "cb3", "cb4", "cbs", "cba", "cbt"]; 83 let allChecked = true; 84 checkboxIds.forEach(id => { 85 if (!document.getElementById(id).checked) { 86 allChecked = false; 87 } 88 }); 89 if (allChecked) { 90 selectedTrackIds = ["all"]; 91 } else { 92 // 常に「p」を追加 93 let tracks = ["p"]; 94 const mapping = { 95 "cb1": "1", 96 "cb2": "2", 97 "cb3": "3", 98 "cb4": "4", 99 "cbs": "s", 100 "cba": "a", 101 "cbt": "t" 102 }; 103 Object.keys(mapping).forEach(cbId => { 104 if (document.getElementById(cbId).checked) { 105 tracks.push(mapping[cbId]); 106 } 107 }); 108 selectedTrackIds = tracks; 109 } 110 } 111 112 // 選択した各音源の再生を開始(pauseOffsetから再生) 113 function startPlayback() { 114 updateSelectedTracks(); 115 116 // 再生時間の決定:全チェックの場合は「all」それ以外は各トラックの最大値 117 if (selectedTrackIds.length === 1 && selectedTrackIds[0] === "all") { 118 trackDuration = buffers["all"].duration; 119 } else { 120 trackDuration = Math.max(...selectedTrackIds.map(id => buffers[id].duration)); 121 } 122 123 activeSources = []; 124 selectedTrackIds.forEach(id => { 125 let source = audioCtx.createBufferSource(); 126 source.buffer = buffers[id]; 127 source.loop = document.getElementById("repeat").checked; 128 source.connect(gainNode); 129 // ※ 各ソースにonendedを設定すると、早く終了したものが先に発火する可能性があるため、 130 // 終了判定はupdateSeekBar内で行います。 131 if (pauseOffset < source.buffer.duration) { 132 source.start(0, pauseOffset); 133 } 134 activeSources.push(source); 135 }); 136 playStartTime = audioCtx.currentTime; 137 isPlaying = true; 138 document.getElementById("play").innerHTML = '<span class="material-symbols-outlined">pause</span> 一時停止'; 139 document.getElementById("stop").style.display = "inline"; 140 updateSeekBar(); 141 } 142 143 // 再生/一時停止の切り替え 144 function togglePlayPause() { 145 if (!isPlaying) { 146 if (audioCtx.state === "suspended") { 147 audioCtx.resume(); 148 } 149 startPlayback(); 150 } else { 151 pauseAudio(); 152 } 153 } 154 155 // 一時停止処理:現在再生中のソースを停止し、経過時間を記録 156 function pauseAudio() { 157 if (isPlaying) { 158 pauseOffset += audioCtx.currentTime - playStartTime; 159 activeSources.forEach(source => { 160 try { source.stop(); } catch(e) {} 161 }); 162 activeSources = []; 163 isPlaying = false; 164 document.getElementById("play").innerHTML = '<span class="material-symbols-outlined">play_arrow</span> 再生'; 165 } 166 } 167 168 // 停止処理:再生停止&再生位置リセット 169 function stopAudio() { 170 if (isPlaying) { 171 activeSources.forEach(source => { 172 try { source.stop(); } catch(e) {} 173 }); 174 } 175 activeSources = []; 176 isPlaying = false; 177 pauseOffset = 0; 178 document.getElementById("play").innerHTML = '<span class="material-symbols-outlined">play_arrow</span> 再生'; 179 document.getElementById("stop").style.display = "none"; 180 document.getElementById("seek").value = 0; 181 document.getElementById("time-text").textContent = formatTime(0) + " / " + formatTime(trackDuration); 182 } 183 184 // リピートチェックボックスの変更(再生中のソースに対してループ設定を反映) 185 document.getElementById("repeat").addEventListener("change", function() { 186 activeSources.forEach(source => { 187 source.loop = document.getElementById("repeat").checked; 188 }); 189 }); 190 191 // 音量調整:GainNodeのgain値を変更 192 document.getElementById("volume").addEventListener("input", function() { 193 let vol = document.getElementById("volume").value; 194 gainNode.gain.value = vol / 100; 195 document.getElementById("volume-text").textContent = vol + "%"; 196 }); 197 198 // シークバー操作:ユーザー操作時は再生中なら再スタート、停止中なら再生位置更新 199 document.getElementById("seek").addEventListener("input", function() { 200 let seekValue = parseFloat(document.getElementById("seek").value); 201 pauseOffset = seekValue; 202 if (isPlaying) { 203 activeSources.forEach(source => { 204 try { source.stop(); } catch(e) {} 205 }); 206 activeSources = []; 207 playStartTime = audioCtx.currentTime; 208 startPlayback(); 209 } 210 updateSeekBar(); 211 }); 212 213// チェックボックス(パート選択)の変更で再生中なら再スタート(修正版) 214const checkboxIds = ["cb1", "cb2", "cb3", "cb4", "cbs", "cba", "cbt"]; 215checkboxIds.forEach(id => { 216 document.getElementById(id).addEventListener("change", function() { 217 if (isPlaying) { 218 // 現在の再生位置を計算して更新(再生位置の同期を実現) 219 let currentPosition = pauseOffset + (audioCtx.currentTime - playStartTime); 220 pauseOffset = currentPosition; 221 activeSources.forEach(source => { 222 try { source.stop(); } catch(e) {} 223 }); 224 activeSources = []; 225 playStartTime = audioCtx.currentTime; 226 startPlayback(); 227 } 228 }); 229}); 230 231 232 // シークバーの更新(再生中はrequestAnimationFrameで更新) 233 function updateSeekBar() { 234 let currentTime = isPlaying ? pauseOffset + (audioCtx.currentTime - playStartTime) : pauseOffset; 235 if (currentTime > trackDuration) { 236 currentTime = trackDuration; 237 } 238 document.getElementById("seek").max = trackDuration; 239 document.getElementById("seek").value = currentTime; 240 document.getElementById("time-text").textContent = formatTime(currentTime) + " / " + formatTime(trackDuration); 241 // リピートがOFFかつ再生が終了していたら自動停止 242 if (isPlaying && !document.getElementById("repeat").checked && currentTime >= trackDuration) { 243 stopAudio(); 244 return; 245 } 246 if (isPlaying) { 247 requestAnimationFrame(updateSeekBar); 248 } 249 } 250 251 // 秒数を m:ss 表記に変換 252 function formatTime(seconds) { 253 let min = Math.floor(seconds / 60); 254 let sec = Math.floor(seconds % 60); 255 return min + ":" + sec.toString().padStart(2, "0"); 256 } 257 258 // テーマ切替(ダークモード) 259 // キーボードショートカット 260(省略) 261 262 // 再生/停止ボタンのイベントリスナー設定 263 document.getElementById("play").addEventListener("click", togglePlayPause); 264 document.getElementById("stop").addEventListener("click", stopAudio); 265 266 // ページロード時の初期化(firstLoadingを短時間表示) 267(省略) 268</script>
よろしくお願いいたします。
