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=['', 'Indy',
'0', '15', '5', '0', '0', '', ''], row_id='r1'), ReplayPlayer(
@@ -68,15 +70,22 @@ async def test_create_sm5_replay(self):
cells=['', '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'])