Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 52 additions & 8 deletions assets/audio/sequences/seq_0.prg.seq
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,54 @@
#define IO_PORT_SFX_INDEX_LOBITS IO_PORT_4
#define IO_PORT_SFX_INDEX_HIBITS IO_PORT_5

/**
* Load a dyntable up to 512 entries large and prepare the index into it with the sfx id
* using IO ports 4 (SFX_ID_LOBITS) and 5 (SFX_ID_HIBIT)
*/
.macro load_table tbl, tbl_size
.if \tbl_size <= 128
/* very short sfx bank */
ldio IO_PORT_SFX_INDEX_LOBITS
dyntbl \tbl
.elseif \tbl_size <= 256
/* short sfx bank */
ldio IO_PORT_SFX_INDEX_LOBITS
bgez 1f
and 0x7F
dyntbl \tbl + 2 * 1 * 128
rjump 2f
1:
dyntbl \tbl + 2 * 0 * 128
2:
.elseif \tbl_size <= 512
/* large sfx bank */
ldio IO_PORT_SFX_INDEX_HIBITS
sub 1
rbeqz 1f
ldio IO_PORT_SFX_INDEX_LOBITS
bgez 2f
/* 128-255 */
and 0x7F
dyntbl \tbl + 2 * 1 * 128
rjump 3f
1:
/* 256-383 */
dyntbl \tbl + 2 * 2 * 128
ldio IO_PORT_SFX_INDEX_LOBITS
bgez 3f
/* 384-511 */
and 0x7F
dyntbl \tbl + 2 * 3 * 128
rjump 3f
2:
/* 0-127 */
dyntbl \tbl + 2 * 0 * 128
3:
.else /* >512, driver doesn't support this so raise an error */
.error "\tbl is too large. Only up to 512 sfx ids can be registered per bank."
.endif
.endm

// Provide the sfx ids for use as constants
#define DEFINE_SFX(channel, sfxId, importance, distParam, randParam, flags) \
.internal sfxId; \
Expand Down Expand Up @@ -3426,14 +3474,10 @@ CHAN_169E:
/* 0x16A3 [0xDC 0x7F ] */ panweight 127
/* 0x16A5 [0xFC 0x00 0x7C ] */ call CHAN_007C
/* 0x16A8 [0x92 ] */ dellayer 2
/* 0x16A9 [0x64 ] */ ldio IO_PORT_SFX_INDEX_LOBITS
/* 0x16AA [0xF5 0x16 0xB4 ] */ bgez CHAN_16B4
/* 0x16AD [0xC9 0x7F ] */ and 127
/* 0x16AF [0xC2 0x17 0xD5 ] */ dyntbl environmentbank_table + 2 * 1 * 128
/* 0x16B2 [0xF4 0x03 ] */ rjump CHAN_16B7

CHAN_16B4:
/* 0x16B4 [0xC2 0x16 0xD5 ] */ dyntbl environmentbank_table + 2 * 0 * 128

/* extend environmentbank to 512 samples */
load_table environmentbank_table, ENVIRONMENTBANK_TABLE_SIZE

CHAN_16B7:
/* 0x16B7 [0xE4 ] */ dyncall
CHAN_16B8:
Expand Down
272 changes: 272 additions & 0 deletions new_sfx.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
#!/usr/bin/env python3
"""
Adds a new sound effect to the project by processing a .wav file,
updating necessary XML / build files and creating an enum that can
be called in game. Covers 80% of use cases where you just want to
add a new SFX and play it as a oneshot in code. Taken from Indigo
project, this script can be freely used/shared without attribution
"""

import os
import re
import sys
import shutil
import argparse
import logging
import subprocess
import textwrap
import xml.etree.ElementTree
import xml.dom.minidom
from pathlib import Path


LOG: logging.Logger = logging.getLogger(f"[{Path(__file__).name}]")
LOG_LEVEL: int = logging.INFO

EXTRACTED_PATH: Path = Path("extracted")
SAMPLEBANK_NAME: str = "SampleBank_0"
ASSETS_SAMPLES_PATH: Path = Path("assets") / "audio" / "samples" / SAMPLEBANK_NAME
ASSETS_SAMPLEBANK_XML: Path = Path("assets") / "audio" / "samplebanks" / (SAMPLEBANK_NAME + ".xml")
ASSETS_SOUNDFONT_XML: Path = Path("assets") / "audio" / "soundfonts" / "Soundfont_0.xml"
ASSETS_SEQUENCE_SEQ: Path = Path("assets") / "audio" / "sequences" / "seq_0.prg.seq"
REPLACEMENT_PATTERN: str = "[^0-9a-zA-Z]+"
SFX_BANK_HEADER: Path = Path("include") / "tables" / "sfx" / "environmentbank_table.h"
DEFINE_FORMATSTR: str = " DEFINE_SFX({0}, {1}, 0x80, 0, 0, 0)"


class FFMPEGOptions:
def __init__(self, volume:str=None, sample_rate:str="32000"):
self.volume:str = volume
self.sample_rate:str = sample_rate

class CustomSoundEffect:
def __init__(self, sample_path: Path):
self.source_path: Path = sample_path
self.dest_path: Path = CustomSoundEffect._safe_rename(sample_path)
id_base = self.dest_path.stem.upper()
self.sample_identifier: str = f"CUSTOM_SAMPLE_{id_base}"
self.effect_identifier: str = f"CUSTOM_EFFECT_{id_base}"
self.channel_identifier: str = f"CHAN_CUSTOM_{id_base}"
self.layer_identifier: str = f"LAYER_CUSTOM_{id_base}"
self.enum_identifier: str = f"NA_SE_EV_CUSTOM_{id_base}"

def _safe_rename(sample_path: Path) -> Path:
new_name: str = re.sub(REPLACEMENT_PATTERN, "_", sample_path.stem) + sample_path.suffix
if new_name != sample_path.name:
LOG.info(f"sanitizing name of sample, new name: '{new_name}'")

new_path = ASSETS_SAMPLES_PATH / new_name
if new_path.exists():
LOG.warning(f"filename already exists! sample will be overwritten! '{new_name}'")

return new_path


def _write_xml_pretty(tree: xml.etree.ElementTree, path: Path) -> None:
rough_string = xml.etree.ElementTree.tostring(tree, 'utf-8')
reparsed = xml.dom.minidom.parseString(rough_string)
pretty = reparsed.toprettyxml(indent=" ")
trimmed = os.linesep.join([s for s in pretty.splitlines() if s.strip()])
with open(path, "w") as fd:
fd.write(trimmed)
fd.write("\n")


def prepare_directories() -> None:
os.makedirs(ASSETS_SAMPLES_PATH, exist_ok=True)
os.makedirs(ASSETS_SAMPLEBANK_XML.parent, exist_ok=True)
os.makedirs(ASSETS_SOUNDFONT_XML.parent, exist_ok=True)

games: list[str] = os.listdir(EXTRACTED_PATH)
if len(games) == 0:
LOG.error("No assets found in extracted/, 'make setup' not yet run?")
sys.exit(1)
# just use the first one found, it's fine (probably)
game = games[0]
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is what i am referring to in my previous comment above. prolly wanna edit this part to be hackeroot-y

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah I think having a --version -v argument would be convenient for HackerOoT, people don't necessarily use ntsc-1.2 (which is the default currently)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right. Oh or probably reading it from the Makefile would be better

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think both would be neat actually, that said I don't think anyone would use several versions at the same time lol


if ASSETS_SAMPLEBANK_XML.exists():
LOG.info(f"existing {ASSETS_SAMPLEBANK_XML.name} found")
else:
samplebank_xml = EXTRACTED_PATH / game / ASSETS_SAMPLEBANK_XML
if not samplebank_xml.exists():
LOG.error(f"{samplebank_xml} does not exist")
sys.exit(1)

LOG.info(f"copying fresh {ASSETS_SAMPLEBANK_XML.name} to assets")
shutil.copy(samplebank_xml, ASSETS_SAMPLEBANK_XML)

if ASSETS_SOUNDFONT_XML.exists():
LOG.info(f"existing {ASSETS_SOUNDFONT_XML.name} found")
else:
soundfont_xml = EXTRACTED_PATH / game / ASSETS_SOUNDFONT_XML
if not soundfont_xml.exists():
LOG.error(f"{soundfont_xml} does not exist")
sys.exit(1)

LOG.info(f"copying fresh {ASSETS_SOUNDFONT_XML.name} to assets")
shutil.copy(soundfont_xml, ASSETS_SOUNDFONT_XML)


def process_wav(sfx: CustomSoundEffect, options: FFMPEGOptions) -> None:
LOG.info("processing sample with ffmpeg...")
args = ["ffmpeg", "-i", sfx.source_path, "-ac", "1", "-acodec", "pcm_s16le"]
if options.sample_rate is not None:
LOG.info(f" Changing sample rate to {options.sample_rate}")
args += ["-ar", options.sample_rate]
if options.volume is not None:
LOG.info(f" Changing volume to {options.volume}")
args += ["-af", f"volume={options.volume}"]
args += [sfx.dest_path, "-y"]
outstream = None if LOG_LEVEL == logging.DEBUG else subprocess.DEVNULL

try:
subprocess.run(args, check=True, stdout=outstream, stderr=outstream)
except Exception as ex:
LOG.error(f"ffmpeg failed to convert sample! {sfx.source_path.name}")
sys.exit(1)


def update_samplebank(sfx: CustomSoundEffect) -> None:
LOG.info(f"updating {ASSETS_SAMPLEBANK_XML}...")
et: xml.etree.ElementTree = xml.etree.ElementTree.parse(ASSETS_SAMPLEBANK_XML).getroot()
for it in et:
if it.attrib.get("Name", "") == sfx.sample_identifier:
LOG.warning(f"Sample entry {sfx.sample_identifier} already found in SampleBank!")
return

new_tag = xml.etree.ElementTree.SubElement(et, "Sample")
new_tag.attrib["Name"] = sfx.sample_identifier
new_tag.attrib["Path"] = str(Path("$(BUILD_DIR)") / str(sfx.dest_path).replace(".wav", ".aifc"))

_write_xml_pretty(et, ASSETS_SAMPLEBANK_XML)


def update_bank_header(sfx: CustomSoundEffect) -> None:
LOG.info(f"updating bank header {SFX_BANK_HEADER}...")
with open(SFX_BANK_HEADER) as fd:
lines = fd.readlines()

for line in lines:
if sfx.enum_identifier in line:
LOG.warning(f"Enum already defined in sfx bank header! '{sfx.enum_identifier}'")
return

with open(SFX_BANK_HEADER, "a") as fd:
fd.write(DEFINE_FORMATSTR.format(sfx.channel_identifier, sfx.enum_identifier))
fd.write("\n")


def update_soundfont(sfx: CustomSoundEffect) -> None:
LOG.info(f"updating {ASSETS_SOUNDFONT_XML}...")
et: xml.etree.ElementTree = xml.etree.ElementTree.parse(ASSETS_SOUNDFONT_XML).getroot()

def _check_element_exists(group: str, name: str):
for it in et.find(group):
if it.attrib.get("Name", "") == name:
LOG.warning(f"{group} entry {name} already found in Soundfont!")
return True
return False

# update the <Samples> list
if not _check_element_exists("Samples", sfx.sample_identifier):
new_tag = xml.etree.ElementTree.SubElement(et.find("Samples"), "Sample")
new_tag.attrib["Name"] = sfx.sample_identifier

# update the <Effects> list
if not _check_element_exists("Effects", sfx.effect_identifier):
new_tag = xml.etree.ElementTree.SubElement(et.find("Effects"), "Effect")
new_tag.attrib["Name"] = sfx.effect_identifier
new_tag.attrib["Sample"] = sfx.sample_identifier

_write_xml_pretty(et, ASSETS_SOUNDFONT_XML)


def update_sequence(sfx: CustomSoundEffect) -> None:
LOG.info(f"updating {ASSETS_SEQUENCE_SEQ}...")
with open(ASSETS_SEQUENCE_SEQ) as fd:
# this is totally safe
head, sep, tail = fd.read().partition("SEQ_0_END:\n")

if sfx.channel_identifier in head or sfx.channel_identifier in tail:
LOG.warning(f"Assembly for {sfx.channel_identifier} already found in sequence!")
return

# transposes down to [0, 63] to fit into 6 bits for notedv
playsfx_define = textwrap.dedent("""
#define PLAYSFX(effect) \\
.if (effect >= 64); \\
transpose (effect/64); \\
.endif; \\
instr FONTANY_INSTR_SFX; \\
notedv (effect - (effect/64) * 64), 0, 127; \\

""")
assembly = textwrap.dedent(f"""
.channel {sfx.channel_identifier}
ldlayer 0, {sfx.layer_identifier}
end

.layer {sfx.layer_identifier}
PLAYSFX(SF0_{sfx.effect_identifier})
end

""")

with open(ASSETS_SEQUENCE_SEQ, "w") as fd:
fd.write(head)
if "#define PLAYSFX(effect)" not in head:
fd.write(playsfx_define)
fd.write(assembly)
fd.write(sep)
fd.write(tail)


def main(sample_path: Path, options: FFMPEGOptions) -> None:
prepare_directories()
sfx = CustomSoundEffect(sample_path)
process_wav(sfx, options)
update_samplebank(sfx)
update_bank_header(sfx)
update_soundfont(sfx)
update_sequence(sfx)
LOG.info(f"Success! {sfx.enum_identifier} has been added!")


if __name__ == "__main__":
parser = argparse.ArgumentParser(description=textwrap.dedent(
"""Add a new sound effect.
This script can be safely re-run multiple times to tweak parameters
like volume and sample rate of the .wav file."""))
parser.add_argument("sample", help = "Path to the sample.wav file to add")
parser.add_argument("--debug", help="Print extra debug info while converting sample.", action="store_true")
parser.add_argument("--volume", required=False, help="Change volume of sample. Provide a decimal, e.g. 1.0", default=None)
parser.add_argument("--sample_rate", required=False, help="Change sample rate. Provide an integer. By default will be set to 32000", default="32000")
args = parser.parse_args()

if args.debug:
logging.basicConfig(level=logging.DEBUG)
else:
logging.basicConfig(level=LOG_LEVEL)

sample_path = Path(args.sample)

if not shutil.which("ffmpeg"):
LOG.error("ffmpeg not found, aborting")
sys.exit(1)

# jank but check some expected files as a minor safety net that the CWD is correct
if not Path("src").exists() or not Path("spec").exists():
LOG.error(f"{Path(__file__).name} does not seem to be in the repo root directory.")
sys.exit(1)

if sample_path.suffix != ".wav":
LOG.error("expected sample to be a .wav file extension")
sys.exit(1)

if not sample_path.exists():
LOG.error(f"file not found '{sample_path}'")
sys.exit(1)

options = FFMPEGOptions(volume=args.volume, sample_rate=args.sample_rate)

main(sample_path, options)