Skip to content

Commit

Permalink
add support for audio format convertion
Browse files Browse the repository at this point in the history
  • Loading branch information
BLooperZ committed Oct 18, 2019
1 parent 9ddd68a commit 4047a35
Show file tree
Hide file tree
Showing 5 changed files with 140 additions and 25 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,6 @@ venv.bak/

# mypy
.mypy_cache/

# Editor config
.vscode/
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,21 @@ You can find a ready-to-use build in the [Releases page](https://github.com/BLoo

4. Put `monster.so3` in same directory with the classic game.

Make sure there are no other `monster` files (`sou`, `sog` or `sof`) there.
Make sure there are no other `monster` files (`sof`, `sog`, `so3` or `sou`) there.

### *NOTE*:
It is also possible to convert sounds to `ogg` or `flac` format.

This can be done by providing format argument to the script:

`remonster.exe ogg` -> `ogg` format, creates `monster.sog`.
(much smaller file than `mp3`)

`remonster.exe flac` -> `flac` format, creates `monster.sof`. (no reason to use `flac` here as source files are already compressed with lossy compression).

This feature requires [ffmpeg binaries](https://ffmpeg.zeranoe.com/builds/) to be installed or to be put in script directory.

When using convertion output may vary depending on ffmpeg version.

## Thanks

Expand Down
46 changes: 46 additions & 0 deletions convert.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import io
import sys
import warnings
import concurrent.futures
from functools import partial

with warnings.catch_warnings():
warnings.simplefilter("ignore")
import pydub

def convert_sound(src_ext, target_ext, snd_data):
if src_ext == target_ext:
return snd_data
with io.BytesIO(snd_data) as in_snd:
snd = pydub.AudioSegment.from_file(in_snd, format=src_ext)
with io.BytesIO() as out_snd:
snd.export(out_snd, format=target_ext)
return out_snd.getvalue()

def convert_streams(streams, src_ext, target_ext):
offs, tags_info, sounds = zip(*streams)
convert = partial(convert_sound, src_ext, target_ext)

with concurrent.futures.ProcessPoolExecutor() as executor:
try:
converted = executor.map(convert, sounds)
for offset, tags, sound in zip(offs, tags_info, converted):
yield offset, tags, sound
except KeyboardInterrupt as kbi:
executor.shutdown(wait=False)
raise kbi

def test_converter(target_ext):
try:
with io.BytesIO() as stream:
pydub.AudioSegment.empty().export(stream, format=target_ext)
except OSError:
print('ERROR: ffmpeg not available.')
print('To convert audio, please make sure ffmpeg binaries can be found in PATH.')
sys.exit(1)

def format_streams(streams, src_ext, target_ext):
if src_ext == target_ext:
return streams
test_converter(target_ext)
return convert_streams(streams, src_ext, target_ext)
99 changes: 75 additions & 24 deletions remonster.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,86 @@
#!/usr/bin/env python

import sys
import io
import binascii
import tempfile
import struct
from functools import partial
import itertools
import functools

import fsb5

from convert import format_streams

def print_progress(total, iterable, size=50):
print(f'\r0 of {total}', end='\r')
for idx, _ in enumerate(iterable):
if total:
prefix = '=' * ((idx * size) // total)
suffix = '-' * (((total - idx - 1) * size) // total)
completed = (idx + 1) / total
print(f'\r|{prefix}>{suffix}| Completed: {100 * completed:0.2f}%', end='\r')
print()

output_exts = {
'ogg': 'sog',
'flac': 'sof',
'mp3': 'so3'
}

def collect_streams(output_idx, audio_stream, streams):
for offset, tags, stream in streams:
output_idx.write(offset)
output_idx.write(struct.pack('>I', audio_stream.tell()))
output_idx.write(struct.pack('>I', len(tags)))

audio_stream.write(tags)
audio_stream.write(stream)
output_idx.write(struct.pack('>I', len(stream)))

yield offset, tags, stream

def build_monster(streams, output_file, progress):
with io.BytesIO() as output_idx, \
tempfile.TemporaryFile() as audio_stream:

print('Collecting audio streams...')
progress(collect_streams(output_idx, audio_stream, streams))

print('Writing output file...')
total = calculate_stream_size(output_idx.tell(), audio_stream.tell())
progress = functools.partial(print_progress, total)
with open(output_file, 'wb') as output:
output.write(struct.pack('>I', output_idx.tell()))
output_idx.seek(0, io.SEEK_SET)
audio_stream.seek(0, io.SEEK_SET)
progress(itertools.chain(
copy_stream_buffered(output_idx, output),
copy_stream_buffered(audio_stream, output)
))

def read_index(monster_table, tags_table):
for sound, tags in zip(monster_table, tags_table):
sound, tags = sound[:-1], tags[:-1]
offset, fname = sound[:8], sound[8:]
yield binascii.unhexlify(offset.encode()), binascii.unhexlify(tags.encode()), fname

def calculate_stream_size(*sizes):
return sum(((size + io.DEFAULT_BUFFER_SIZE - 1) // io.DEFAULT_BUFFER_SIZE) for size in sizes)

def copy_stream_buffered(in_stream, out_stream):
for buffer in iter(functools.partial(in_stream.read, io.DEFAULT_BUFFER_SIZE), b''):
out_stream.write(buffer)
yield len(buffer)

def read_streams(sfx, speech, index):
for offset, tags, fname in index:
stream = sfx[fname] if fname in sfx else speech[f'EN_{fname}']
yield offset, tags, stream

if __name__ == '__main__':
import multiprocessing as mp

mp.freeze_support()

try:
with open('monster.tbl', 'r') as monster_table, \
Expand All @@ -39,27 +99,18 @@ def read_index(monster_table, tags_table):
except OSError as e:
print(f'ERROR: Failed to load file: {e.filename}.')
print('Please make sure this file is available in current working directory.')
exit(1)

output_ext = output_exts[ext]
sys.exit(1)

with io.BytesIO() as output_idx, \
tempfile.TemporaryFile() as audio_stream:
for offset, tags, fname in index:
output_idx.write(offset)
output_idx.write(struct.pack('>I', audio_stream.tell()))
output_idx.write(struct.pack('>I', len(tags)))

audio_stream.write(tags)
stream = sfx[fname] if fname in sfx else speech[f'EN_{fname}']
audio_stream.write(stream)
output_idx.write(struct.pack('>I', len(stream)))
target_ext = ext
if len(sys.argv) > 1:
target_ext = sys.argv[1]
if target_ext not in output_exts:
available = '|'.join(output_exts)
print(f'ERROR: Unsupported audio format: {target_ext}.')
print(f'Available options are <{available}>.')
sys.exit(1)

with open(f'monster.{output_ext}', 'wb') as output:
output.write(struct.pack('>I', output_idx.tell()))
output_idx.seek(0, io.SEEK_SET)
audio_stream.seek(0, io.SEEK_SET)
for buffer in iter(partial(output_idx.read, io.DEFAULT_BUFFER_SIZE), b''):
output.write(buffer)
for buffer in iter(partial(audio_stream.read, io.DEFAULT_BUFFER_SIZE), b''):
output.write(buffer)
output_ext = output_exts[target_ext]
streams = format_streams(read_streams(sfx, speech, index), ext, target_ext)
build = build_monster(streams, f'monster.{output_ext}', progress=functools.partial(print_progress, len(index)))
print('Done!')
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
fsb5==1.0
pydub==0.23.1
PyInstaller==3.5

0 comments on commit 4047a35

Please sign in to comment.