diff --git a/assets/js/replay.js b/assets/js/replay.js index 73636d8..300d76d 100644 --- a/assets/js/replay.js +++ b/assets/js/replay.js @@ -1,36 +1,85 @@ +function isSoundLoaded(audio) { + return audio["data"] != undefined; +} + +function getAudioDurationSeconds(audio) { + return audio["buffer"].duration; +} + +async function playAudio(audio, stereo_balance) { + // If this is the very first time we're playing a sound, create + // the context. This must be done after the user clicked somewhere. + if (audioContext == undefined) { + audioContext = new AudioContext(); + } + + if (!isSoundLoaded(audio)) { + // We haven't started loading this sound yet. + return audio; + } + + // If we loaded the sound but haven't decoded it yet, do that now. + // TODO: This can cause race conditions if the same sound is played twice. + if (audio["buffer"] == undefined) { + audio["buffer"] = await audioContext.decodeAudioData(audio["data"]); + } -function playAudio(audio) { - audio.volume = 0.5; if (!recalculating) { - audio.play(); + const source = audioContext.createBufferSource(); + source.buffer = audio["buffer"]; + + stereoPanner = audioContext.createStereoPanner(); + stereoPanner.pan.value = stereo_balance; + + const gainNode = audioContext.createGain(); + gainNode.gain.value = 0.5; + + source.connect(gainNode); + gainNode.connect(stereoPanner); + stereoPanner.connect(audioContext.destination); + + source.start(); + audio["source"] = source; } + return audio; } -function playDownedAudio() { - // audio is random between Scream.0.wav Scream.1.wav Scream.2.wav Shot.0.wav Shot.1.wav - sfx = Math.floor(Math.random() * 5); - return playAudio(downed_audio[sfx]); +function stopAudio(audio) { + if (audio["source"] != undefined) { + audio["source"].stop(); + audio["source"] = undefined; + } } +// We are not allowed to create an AudioContext until there is a user +// interaction. +audioContext = undefined; + +// Keep the loading screen up until all these sounds have been loaded. +missing_sounds = []; -start_audio = [new Audio("/assets/sm5/audio/Start.0.wav"), new Audio("/assets/sm5/audio/Start.1.wav"), new Audio("/assets/sm5/audio/Start.2.wav"), new Audio("/assets/sm5/audio/Start.3.wav")]; -alarm_start_audio = new Audio("/assets/sm5/audio/Effect/General Quarters.wav"); -resupply_audio = [new Audio("/assets/sm5/audio/Effect/Resupply.0.wav"), new Audio("/assets/sm5/audio/Effect/Resupply.1.wav"), new Audio("/assets/sm5/audio/Effect/Resupply.2.wav"), new Audio("/assets/sm5/audio/Effect/Resupply.3.wav"), new Audio("/assets/sm5/audio/Effect/Resupply.4.wav")]; -downed_audio = [new Audio("/assets/sm5/audio/Effect/Scream.0.wav"), new Audio("/assets/sm5/audio/Effect/Scream.1.wav"), new Audio("/assets/sm5/audio/Effect/Scream.2.wav"), new Audio("/assets/sm5/audio/Effect/Shot.0.wav"), new Audio("/assets/sm5/audio/Effect/Shot.1.wav")]; -base_destroyed_audio = new Audio("/assets/sm5/audio/Effect/Boom.wav"); +// Is everything we need loaded? +assets_loaded = false; current_starting_sound_playing = undefined; started = false; + +// This value increases with every restart or start skip so we know +// whether or not the current start sound is relevant. +playback_key = 1 + cancelled_starting_sound = false; -restarted = false; -play = false; +restarted = false; // True if the playback had been restarted +play = false; // Playback is currently in progress scrub = false; // for when going back in time recalculating = false; // for when we're mass recalculating and don't want to play audio playback_speed = 1.0; +intro_sound = undefined; +start_sound = undefined; columns = []; team_names = []; @@ -60,10 +109,7 @@ function getCurrentGameTimeMillis() { return base_game_time_millis + (now - base_timestamp) * get_playback_speed(); } -function finishedPlayingIntro() { - if (current_starting_sound_playing != audio || restarted || cancelled_starting_sound) { - return; - } +function beginPlayback() { play = true; restarted = false; playButton.innerHTML = "Pause"; @@ -72,12 +118,14 @@ function finishedPlayingIntro() { current_starting_sound_playing = undefined; // play the game start sfx - playAudio(alarm_start_audio); + if (start_sound != undefined) { + playSound(start_sound, 0.0); + } playEvents(); } -function playPause() { +async function playPause() { if (play) { // pause the game // Lock the game time at what it currently is. base_game_time_millis = getCurrentGameTimeMillis(); @@ -90,46 +138,50 @@ function playPause() { restarted = false; if (current_starting_sound_playing != undefined) { + playback_key++; // cancel the callback for the starting sound + // Cancel the intro sound. + stopAudio(current_starting_sound_playing); + current_starting_sound_playing = undefined; + resetGame(); - restarted = false; - finishedPlayingIntro(); - cancelled_starting_sound = true; // cancel the callback for the starting sound + beginPlayback(); } else if (!started) { base_game_time_millis = 0 // starting the game for the first time - // choose a random sfx 0-3 - - sfx = Math.floor(Math.random() * 4); + if (intro_sound != undefined) { + audio = await playSound(intro_sound, 0.0); + current_starting_sound_playing = audio; + playback_key++; - audio = new Audio(`/assets/sm5/audio/Start.${sfx}.wav`); - audio.volume = 0.5; - current_starting_sound_playing = audio; - audio.play(); - - audio.addEventListener("loadeddata", () => { // wait for the sfx to finish - setTimeout(function() { - finishedPlayingIntro(); - }, audio.duration*1000); - }); + setTimeout(function(key) { + // The intro sound is over, start the actual playback - but only + // if nothing has happened in the meantime (restart or pause). + if (key == playback_key) { + beginPlayback(); + } + }, getAudioDurationSeconds(audio)*1000, playback_key); + } else { + // No intro sound, start playing right away. + beginPlayback(); + } return; } play = true; playButton.innerHTML = "Pause"; - started = true; restarted = false; playEvents(); } } -function add_column(column_name) { +function addColumn(column_name) { columns.push(column_name); } -function add_team(team_name, team_id, team_css_class) { +function addTeam(team_name, team_id, team_css_class) { let team_div = document.createElement("div"); team_div.id = team_id; @@ -161,25 +213,51 @@ function add_team(team_name, team_id, team_css_class) { teams.appendChild(team_div); } -function register_sound(sound_id, asset_urls) { +function registerSound(sound_id, asset_urls, priority, required) { sound_objects = []; asset_urls.forEach((asset_url) => { - sound_objects.push(new Audio(asset_url)); + sound_objects.push({ + "asset_url": asset_url}); }); sounds[sound_id] = sound_objects; + + if (required) { + missing_sounds.push(sound_id); + } } -function play_sound(sound_id) { - sound_assets = sounds[sound_id]; - index = Math.floor(Math.random() * sound_assets.length); - audio = sound_assets[index]; +async function loadAudioBuffer(audio) { + const response = await fetch(audio["asset_url"]); + if (!response.ok) { + console.log(`Failed to fetch audio file: ${url}`); + } - audio.volume = 0.5; - if (!recalculating) { - audio.play(); - } + audio["data"] = await response.arrayBuffer(); + + // Once we have loaded the sound, see if that was the last one missing + // and we can get rid of the loading screen. + checkPendingAssets(); +} + +// Loads all sounds that were previously registered with registerSound. +function loadSound(sound_id) { + sound_objects = sounds[sound_id]; + sounds_left = sound_objects.length; + + sound_objects.forEach((sound) => { + loadAudioBuffer(sound); + }); +} + +async function playSound(sound_id, stereo_balance) { + const sound_assets = sounds[sound_id]; + + const index = Math.floor(Math.random() * sound_assets.length); + const audio = sound_assets[index]; + + return await playAudio(audio, stereo_balance); } function add_player(team_id, row_id, cells) { @@ -279,15 +357,50 @@ function playEvents() { document.getElementById(`${team_ids[index]}_score`).innerHTML = `${team_names[index]}: ${team_score}`; }); - event[5].forEach((sound_id) => { - play_sound(sound_id); + const stereo_balance = event[5]; + + event[6].forEach((sound_id) => { + playSound(sound_id, stereo_balance); }); eventBox.scrollTop = eventBox.scrollHeight; } } -function startReplay() { +// See if there are any pending assets, otherwise start the main UI. +function checkPendingAssets() { + if (assets_loaded) { + // We're already all done. + return; + } + + missing_assets = false; + + while (missing_sounds.length > 0) { + const sound_id = missing_sounds[0]; + sounds[sound_id].forEach((sound) => { + if (!isSoundLoaded(sound)) { + // Not done yet. + missing_assets = true; + return; + } + }); + + if (missing_assets) { + // Something still missing. Let's wait. + return; + } + + // Everything in this sound is loaded, we can remove it. + missing_sounds.shift(); + }; + + // Everything is loaded. We're good. + assets_loaded = true; + enableReplayUi(); +} + +function enableReplayUi() { teamsLoadingPlaceholder.style.display = "none"; timeSlider.style.display = "block"; replayViewer.style.display = "flex"; @@ -297,19 +410,21 @@ function restartReplay() { console.log("Restarting replay"); if (current_starting_sound_playing != undefined) { - current_starting_sound_playing.pause(); + stopAudio(current_starting_sound_playing); + current_starting_sound_playing = undefined; } play = false; started = false; restarted = true; + playback_key++; // If true, we're not playing back, but we're paused and just want to evaluate the game at a different time. scrub = false; resetGame(); playButton.innerHTML = "Play"; - startReplay(); + enableReplayUi(); } function resetGame() { @@ -373,6 +488,14 @@ function setTimeLabel(seconds) { time_label_seconds = totalSeconds; } +function setIntroSound(soundId) { + intro_sound = soundId; +} + +function setStartSound(soundId) { + start_sound = soundId; +} + document.addEventListener("DOMContentLoaded", function() { onLoad(); }); diff --git a/helpers/replay.py b/helpers/replay.py index b36f6c9..7c5b080 100644 --- a/helpers/replay.py +++ b/helpers/replay.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import List +from typing import List, Optional def _escape_string(text: str) -> str: @@ -38,7 +38,13 @@ class ReplaySound: asset_urls: List[str] # ID to identify this sound. - id: int = 0 + id: int + + # Preload priority. Sounds with the highest priority preload first. + priority: int = 0 + + # If true, this sound must be loaded before playback can begin. + required: bool = False @dataclass @@ -58,6 +64,9 @@ class ReplayEvent: sounds: List[ReplaySound] + # If there are sounds, their stereo balance should be here (-1=left, 0=center, 1=right). + sound_stereo_balance: float = 0.0 + @dataclass class ReplayPlayer: @@ -91,6 +100,12 @@ class Replay: sounds: List[ReplaySound] + # Sound to play before the replay starts + intro_sound: Optional[ReplaySound] + + # Sound to play when the actual game starts, after the intro sound + start_sound: Optional[ReplaySound] + # Names of the columns in each team table. column_headers: List[str] @@ -99,13 +114,27 @@ def export_to_js(self) -> str: result = "" for column in self.column_headers: - result += f"add_column('{column}');\n" + result += f"addColumn('{column}');\n" for sound in self.sounds: - result += f"register_sound({sound.id}, {sound.asset_urls});\n" + result += f"registerSound({sound.id}, {sound.asset_urls}, {sound.priority}, {str(sound.required).lower()});\n" + + # Load the sounds in order of priority. + highest_priority = max([sound.priority for sound in self.sounds]) + + result += "let sound_promise = Promise.resolve();\n" + + while highest_priority >= 0: + sounds = [sound for sound in self.sounds if sound.priority == highest_priority] + + if sounds: + for sound in sounds: + result += f"loadSound({sound.id});\n" + + highest_priority -= 1 for team in self.teams: - result += f"add_team('{team.name}', '{team.id}', '{team.css_class}');\n" + result += f"addTeam('{team.name}', '{team.id}', '{team.css_class}');\n" for player in team.players: cells = [_escape_string(cell) for cell in player.cells] @@ -126,18 +155,25 @@ def export_to_js(self) -> str: } """ + if self.intro_sound: + result += f"setIntroSound({self.intro_sound.id});\n" + + if self.start_sound: + result += f"setStartSound({self.start_sound.id});\n" + result += "events = [\n" for event in self.events: cell_changes = [cell_change.to_js_string() for cell_change in event.cell_changes] row_changes = [row_change.to_js_string() for row_change in event.row_changes] sound_ids = [sound.id for sound in event.sounds] - result += f" [{event.timestamp_millis},'{_escape_string(event.message)}',[{','.join(cell_changes)}],[{','.join(row_changes)}],{event.team_scores},{sound_ids}],\n" + result += (f" [{event.timestamp_millis},'{_escape_string(event.message)}',[{','.join(cell_changes)}]," + f"[{','.join(row_changes)}],{event.team_scores},{event.sound_stereo_balance},{sound_ids}],\n") result += "];\n\n" result += """ document.addEventListener("DOMContentLoaded", function() { - startReplay(); + checkPendingAssets(); }); """ diff --git a/helpers/replay_sm5.py b/helpers/replay_sm5.py index 26a8a88..e0a359d 100644 --- a/helpers/replay_sm5.py +++ b/helpers/replay_sm5.py @@ -116,15 +116,23 @@ async def create_sm5_replay(game: SM5Game) -> Replay: teams = {} team_scores = {} + team_sound_balance = {} entity_id_to_nonplayer_name = { entity.entity_id: entity.name for entity in entity_starts if entity.entity_id[0] == "@" } + sound_balance = -0.5 + for team, players in team_rosters.items(): replay_player_list = [] players_in_team = [] team_scores[team] = 0 + team_sound_balance[team] = sound_balance + sound_balance += 1.0 + + if sound_balance > 1.0: + sound_balance -= 2.0 for player_info in players: if player_info.entity_start.role not in SM5_ROLE_DETAILS: @@ -160,8 +168,8 @@ async def create_sm5_replay(game: SM5Game) -> Replay: start_audio = ReplaySound( [f"{_AUDIO_PREFIX}Start.0.wav", f"{_AUDIO_PREFIX}Start.1.wav", f"{_AUDIO_PREFIX}Start.2.wav", - f"{_AUDIO_PREFIX}Start.3.wav"], _START_AUDIO) - alarm_start_audio = ReplaySound([f"{_AUDIO_PREFIX}Effect/General Quarters.wav"], _ALARM_START_AUDIO) + f"{_AUDIO_PREFIX}Start.3.wav"], _START_AUDIO, priority=2, required=True) + alarm_start_audio = ReplaySound([f"{_AUDIO_PREFIX}Effect/General Quarters.wav"], _ALARM_START_AUDIO, priority=1) resupply_audio = ReplaySound([f"{_AUDIO_PREFIX}Effect/Resupply.0.wav", f"{_AUDIO_PREFIX}Effect/Resupply.1.wav", f"{_AUDIO_PREFIX}Effect/Resupply.2.wav", f"{_AUDIO_PREFIX}Effect/Resupply.3.wav", f"{_AUDIO_PREFIX}Effect/Resupply.4.wav"], _RESUPPLY_AUDIO) @@ -184,6 +192,7 @@ async def create_sm5_replay(game: SM5Game) -> Replay: timestamp = event.time old_team_scores = team_scores.copy() sounds = [] + stereo_balance = 0.0 # Before we process the event, let's see if there's a player coming back up. Look up all the timestamps when # someone is coming back up. @@ -266,13 +275,17 @@ async def create_sm5_replay(game: SM5Game) -> Replay: case EventType.RESUPPLY_LIVES: _add_lives(player2, player2.role_details.lives_resupply, cell_changes, row_changes, player_reup_times) sounds.append(resupply_audio) + stereo_balance = team_sound_balance[player2.team] case EventType.RESUPPLY_AMMO: _add_shots(player2, player2.role_details.shots_resupply, cell_changes) sounds.append(resupply_audio) + stereo_balance = team_sound_balance[player2.team] case EventType.ACTIVATE_RAPID_FIRE: + player1.rapid_fire = True _add_special_points(player1, -10, cell_changes) + stereo_balance = team_sound_balance[player1.team] case EventType.DESTROY_BASE | EventType.MISISLE_BASE_DESTROY: _add_score(player1, 1001, cell_changes, team_scores) @@ -280,6 +293,7 @@ async def create_sm5_replay(game: SM5Game) -> Replay: if player1.role != IntRole.HEAVY and not player1.rapid_fire: _add_special_points(player1, 5, cell_changes) sounds.append(base_destroyed_audio) + stereo_balance = team_sound_balance[player1.team] case EventType.DAMAGED_OPPONENT | EventType.DOWNED_OPPONENT: if player1.role != IntRole.HEAVY and not player1.rapid_fire: @@ -289,6 +303,7 @@ async def create_sm5_replay(game: SM5Game) -> Replay: _add_score(player2, -20, cell_changes, team_scores) _increase_times_shot_others(player1, cell_changes) _increase_times_got_shot(player2, cell_changes) + stereo_balance = team_sound_balance[player2.team] sounds.append(downed_audio) case EventType.DAMANGED_TEAM | EventType.DOWNED_TEAM: @@ -296,23 +311,22 @@ async def create_sm5_replay(game: SM5Game) -> Replay: _add_score(player2, -20, cell_changes, team_scores) _increase_times_shot_others(player1, cell_changes) _increase_times_got_shot(player2, cell_changes) + stereo_balance = team_sound_balance[player2.team] sounds.append(downed_audio) - case EventType.ACTIVATE_RAPID_FIRE: - player1.rapid_fire = True - _add_special_points(player1, -10, cell_changes) - case EventType.DEACTIVATE_RAPID_FIRE: player1.rapid_fire = False case EventType.ACTIVATE_NUKE: _add_special_points(player1, -20, cell_changes) + stereo_balance = team_sound_balance[player1.team] case EventType.AMMO_BOOST: for player in teams[player1.team]: if not player.downed: _add_shots(player, player.role_details.shots_resupply, cell_changes) sounds.append(resupply_audio) + stereo_balance = team_sound_balance[player1.team] case EventType.LIFE_BOOST: for player in teams[player1.team]: @@ -320,6 +334,7 @@ async def create_sm5_replay(game: SM5Game) -> Replay: _add_lives(player, player.role_details.lives_resupply, cell_changes, row_changes, player_reup_times) sounds.append(resupply_audio) + stereo_balance = team_sound_balance[player1.team] case EventType.PENALTY: _add_score(player1, -1000, cell_changes, team_scores) @@ -336,13 +351,16 @@ async def create_sm5_replay(game: SM5Game) -> Replay: ] events.append(ReplayEvent(timestamp_millis=timestamp, message=message, cell_changes=cell_changes, - row_changes=row_changes, team_scores=new_team_scores, sounds=sounds)) + row_changes=row_changes, team_scores=new_team_scores, sounds=sounds, + sound_stereo_balance=stereo_balance)) return Replay( events=events, teams=replay_teams, column_headers=column_headers, sounds=sound_assets, + intro_sound=start_audio, + start_sound=alarm_start_audio, ) diff --git a/tests/helpers/replay_sm5_test.py b/tests/helpers/replay_sm5_test.py index 7614fd7..9993b1a 100644 --- a/tests/helpers/replay_sm5_test.py +++ b/tests/helpers/replay_sm5_test.py @@ -49,7 +49,8 @@ async def test_create_sm5_replay(self): '/assets/sm5/audio/Effect/Scream.2.wav', '/assets/sm5/audio/Effect/Shot.0.wav', '/assets/sm5/audio/Effect/Shot.1.wav'], - id=3)]), + id=3, priority=0, required=False)], + sound_stereo_balance=0.5), ReplayEvent(timestamp_millis=2500, message='LoggedIn resupplies Indy', team_scores=[], cell_changes=[ReplayCellChange(row_id='r1', column=3, new_value='19')], row_changes=[ReplayRowChange(row_id='r1', new_css_class='fire-team-dim')], @@ -58,7 +59,8 @@ async def test_create_sm5_replay(self): '/assets/sm5/audio/Effect/Resupply.2.wav', '/assets/sm5/audio/Effect/Resupply.3.wav', '/assets/sm5/audio/Effect/Resupply.4.wav'], - id=2)])], teams=[ + id=2, priority=0, required=False)], + sound_stereo_balance=-0.5)], teams=[ ReplayTeam(name='Fire Team', css_class='fire-team', id='fire_team', players=[ReplayPlayer( cells=['Commander', 'Indy', '0', '15', '5', '0', '0', '', ''], row_id='r1'), ReplayPlayer( @@ -68,15 +70,22 @@ async def test_create_sm5_replay(self): cells=['Scout', 'Miles', '0', '15', '0', '0', '0', '', ''], row_id='r3')])], sounds=[ReplaySound( asset_urls=['/assets/sm5/audio/Start.0.wav', '/assets/sm5/audio/Start.1.wav', - '/assets/sm5/audio/Start.2.wav', '/assets/sm5/audio/Start.3.wav'], id=0), ReplaySound( - asset_urls=['/assets/sm5/audio/Effect/General Quarters.wav'], id=1), ReplaySound( + '/assets/sm5/audio/Start.2.wav', '/assets/sm5/audio/Start.3.wav'], id=0, priority=2, + required=True), ReplaySound(asset_urls=['/assets/sm5/audio/Effect/General Quarters.wav'], id=1, priority=1, + required=False), ReplaySound( asset_urls=['/assets/sm5/audio/Effect/Resupply.0.wav', '/assets/sm5/audio/Effect/Resupply.1.wav', '/assets/sm5/audio/Effect/Resupply.2.wav', '/assets/sm5/audio/Effect/Resupply.3.wav', - '/assets/sm5/audio/Effect/Resupply.4.wav'], id=2), ReplaySound( + '/assets/sm5/audio/Effect/Resupply.4.wav'], id=2, priority=0, required=False), ReplaySound( asset_urls=['/assets/sm5/audio/Effect/Scream.0.wav', '/assets/sm5/audio/Effect/Scream.1.wav', '/assets/sm5/audio/Effect/Scream.2.wav', '/assets/sm5/audio/Effect/Shot.0.wav', - '/assets/sm5/audio/Effect/Shot.1.wav'], id=3), ReplaySound( - asset_urls=['/assets/sm5/audio/Effect/Boom.wav'], id=4)], + '/assets/sm5/audio/Effect/Shot.1.wav'], id=3, priority=0, required=False), ReplaySound( + asset_urls=['/assets/sm5/audio/Effect/Boom.wav'], id=4, priority=0, required=False)], + intro_sound=ReplaySound( + asset_urls=['/assets/sm5/audio/Start.0.wav', '/assets/sm5/audio/Start.1.wav', + '/assets/sm5/audio/Start.2.wav', '/assets/sm5/audio/Start.3.wav'], id=0, + priority=2, required=True), + start_sound=ReplaySound(asset_urls=['/assets/sm5/audio/Effect/General Quarters.wav'], id=1, + priority=1, required=False), column_headers=['Role', 'Codename', 'Score', 'Lives', 'Shots', 'Missiles', 'Spec', 'Accuracy', 'K/D'])