Skip to content

Commit

Permalink
Enable #AUDIO to create WAV files
Browse files Browse the repository at this point in the history
  • Loading branch information
skoolkid committed Jul 28, 2022
1 parent 0f3ecf6 commit 80fc2cf
Show file tree
Hide file tree
Showing 8 changed files with 217 additions and 37 deletions.
77 changes: 77 additions & 0 deletions skoolkit/audio.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Copyright 2013, 2014, 2022 Richard Dymond (rjdymond@gmail.com)
#
# This file is part of SkoolKit.
#
# SkoolKit is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# SkoolKit is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# SkoolKit. If not, see <http://www.gnu.org/licenses/>.

import math

CLOCK_SPEED = 3500000
MAX_AMPLITUDE = 65536
SAMPLE_RATE = 44100

class AudioWriter:
def write_audio(self, audio_file, delays, sample_rate=SAMPLE_RATE):
samples = self._delays_to_samples(delays, sample_rate)
self._write_wav(audio_file, samples, sample_rate)

def _delays_to_samples(self, delays, sample_rate, max_amplitude=MAX_AMPLITUDE):
sample_delay = CLOCK_SPEED / sample_rate
samples = []
direction = 1
i = 0
d0 = 0
d1 = delays[i]
t = 0
while 1:
while t >= d1:
i += 1
if i >= len(delays):
break
d0 = d1
d1 += delays[i]
direction *= -1
if i >= len(delays):
break
sample = direction * int(max_amplitude * math.sin(math.pi * (t - d0) / (d1 - d0)))
if sample > 32767:
sample = 32767
elif sample < -32768:
sample = 32768
elif sample < 0:
sample += 65536
samples.append(sample)
t += sample_delay
return samples

def _to_int32(self, num):
return (num & 255, (num >> 8) & 255, (num >> 16) & 255, num >> 24)

def _write_wav(self, audio_file, samples, sample_rate):
data_length = 2 * len(samples)
header = bytearray()
header.extend(b'RIFF')
header.extend(self._to_int32(36 + data_length))
header.extend(b'WAVEfmt ')
header.extend(self._to_int32(16)) # length of fmt chunk
header.extend((1, 0)) # format (1=PCM)
header.extend((1, 0)) # channels
header.extend(self._to_int32(sample_rate)) # sample rate
header.extend(self._to_int32(sample_rate * 2)) # byte rate
header.extend((2, 0)) # bytes per sample
header.extend((16, 0)) # bits per sample
header.extend(b'data')
header.extend(self._to_int32(data_length)) # length of data chunk
audio_file.write(header)
for sample in samples:
audio_file.write(bytes((sample & 255, sample // 256)))
12 changes: 11 additions & 1 deletion skoolkit/skoolhtml.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from io import StringIO

from skoolkit import skoolmacro, SkoolKitError, SkoolParsingError, evaluate, format_template, parse_int, warn
from skoolkit.audio import AudioWriter
from skoolkit.components import get_component, get_image_writer
from skoolkit.defaults import REF_FILE
from skoolkit.graphics import Frame, adjust_udgs, build_udg, font_udgs, scr_udgs
Expand Down Expand Up @@ -73,6 +74,8 @@ def __init__(self, skool_parser, ref_parser, file_info=None, code_id=MAIN_CODE_I
self.image_writer = get_image_writer(iw_config, colours)
self.frames = {}

self.audio_writer = AudioWriter()

self.snapshot = self.parser.snapshot
self._snapshots = [(self.snapshot, '')]
self.asm_entry_dicts = {}
Expand Down Expand Up @@ -1021,12 +1024,19 @@ def _expand_image_path(self, path):
prev_path = path
return path

def _write_audio(self, audio_path, delays):
f = self.file_info.open_file(audio_path, mode='wb')
self.audio_writer.write_audio(f, delays)
f.close()

def expand_audio(self, text, index, cwd):
end, fname = skoolmacro.parse_audio(text, index)
end, fname, delays = skoolmacro.parse_audio(text, index)
if fname.startswith('/'):
fname = fname.lstrip('/')
else:
fname = join(self.paths['AudioPath'], fname)
if delays:
self._write_audio(fname, delays)
return end, self.format_template('audio', {'src': self.relpath(cwd, fname)})

def expand_copy(self, text, index, cwd):
Expand Down
22 changes: 20 additions & 2 deletions skoolkit/skoolmacro.py
Original file line number Diff line number Diff line change
Expand Up @@ -506,12 +506,30 @@ def expand_macros(writer, text, *cwd):

return text

def _flatten(elements):
f = []
for e in elements:
if isinstance(e, list):
f.extend(_flatten(e))
else:
f.append(e)
return f

def _eval_delays(spec):
if set(spec) <= frozenset(' 0123456789,*[]'):
return _flatten(eval(f'[{spec}]'))

def parse_audio(text, index):
# #AUDIO(fname)
# #AUDIO(fname)[(delays)]
end, fname = parse_brackets(text, index)
if not fname:
raise MacroParsingError('Missing filename: #AUDIO{}'.format(text[index:end]))
return end, fname
if len(text) > end and text[end] == '(':
end, spec = parse_brackets(text, end)
delays = _eval_delays(spec)
else:
delays = None
return end, fname, delays

def parse_call(writer, text, index, *cwd):
# #CALL:methodName(args)
Expand Down
6 changes: 4 additions & 2 deletions sphinx/source/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ Changelog
8.7b1
-----
* Dropped support for Python 3.6
* Added the :ref:`AUDIO` macro (for creating HTML5 ``<audio>`` elements)
* Added the :ref:`AUDIO` macro (for creating HTML5 ``<audio>`` elements, and
optionally creating audio files in WAV format)
* Added the ``AudioPath`` parameter to the :ref:`paths` section (for specifying
where the :ref:`AUDIO` macro should look for audio files by default)
where the :ref:`AUDIO` macro should look for or create audio files by
default)
* Added the :ref:`t_audio` template (for formatting the ``<audio>`` element
produced by the :ref:`AUDIO` macro)

Expand Down
29 changes: 24 additions & 5 deletions sphinx/source/skool-macros.rst
Original file line number Diff line number Diff line change
Expand Up @@ -850,19 +850,38 @@ General macros
------
In HTML mode, the ``#AUDIO`` macro expands to an HTML5 ``<audio>`` element. ::

#AUDIO(fname)
#AUDIO(fname)[(delays)]

* ``fname`` is the name of the audio file
* ``delays`` is a comma-separated list of interval lengths (in T-states)
between speaker state changes

If ``fname`` starts with a '/', the filename is taken to be relative to the
root of the HTML disassembly. Otherwise the filename is taken to be relative to
the audio directory (as defined by the ``AudioPath`` parameter in the
:ref:`paths` section).

The audio file must already exist in the specified location, otherwise the
``<audio>`` element controls will not work. To make sure that a pre-built audio
file is copied into the desired location when :ref:`skool2html.py` is run, it
can be declared in the :ref:`resources` section.
If ``delays`` is specified, a corresponding audio file in WAV format is
created. Each element in ``delays`` can be an integer, a list of integers, or a
list of lists of integers etc. nested to arbitrary depth, expressed as Python
literals. For example::

1000, [1500]*100, [[800, 1200]*2, 900]*200

This would be flattened into a list of integers, as follows:

* a single instance of '1000'
* 100 instances of '1500'
* 200 instances of the sequence '800, 1200, 800, 1200, 900'

The sum of this list of integers being 1131000, this would result in an audio
file of duration 1131000 / 3500000 = 0.323s.

If ``delays`` is not specified, the named audio file must already exist in the
specified location, otherwise the ``<audio>`` element controls will not work.
To make sure that a pre-built audio file is copied into the desired location
when :ref:`skool2html.py` is run, it can be declared in the :ref:`resources`
section.

The :ref:`t_audio` template is used to format the ``<audio>`` element.

Expand Down
38 changes: 38 additions & 0 deletions tests/test_audio.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from io import BytesIO

from skoolkittest import SkoolKitTestCase
from skoolkit.audio import AudioWriter

def _int32(num):
return bytes((num & 255, (num >> 8) & 255, (num >> 16) & 255, num >> 24))

class AudioWriterTest(SkoolKitTestCase):
def _get_audio_data(self, audio_writer, delays):
audio_stream = BytesIO()
audio_writer.write_audio(audio_stream, delays)
audio_bytes = bytearray(audio_stream.getvalue())
audio_stream.close()
return audio_bytes

def _check_header(self, audio_bytes):
length = len(audio_bytes)
self.assertEqual(audio_bytes[:4], b'RIFF')
self.assertEqual(audio_bytes[4:8], _int32(length - 8))
self.assertEqual(audio_bytes[8:12], b'WAVE')
self.assertEqual(audio_bytes[12:16], b'fmt ')
self.assertEqual(audio_bytes[16:20], _int32(16)) # fmt chunk length
self.assertEqual(audio_bytes[20:22], bytes((1, 0))) # format
self.assertEqual(audio_bytes[22:24], bytes((1, 0))) # channels
self.assertEqual(audio_bytes[24:28], _int32(44100)) # sample rate
self.assertEqual(audio_bytes[28:32], _int32(88200)) # byte rate
self.assertEqual(audio_bytes[32:34], bytes((2, 0))) # bytes per sample
self.assertEqual(audio_bytes[34:36], bytes((16, 0))) # bits per sample
self.assertEqual(audio_bytes[36:40], b'data')
self.assertEqual(audio_bytes[40:44], _int32(length - 44))
return audio_bytes[44:]

def test_samples(self):
audio_writer = AudioWriter()
audio_bytes = self._get_audio_data(audio_writer, [100] * 4)
samples = self._check_header(audio_bytes)
self.assertEqual(samples, b'\x00\x00\xff\x7f\x00\x80\xff\x7f\x00\x80\x83\xe6')
66 changes: 41 additions & 25 deletions tests/test_skoolhtml.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,12 @@ def write_image(self, frames, img_file):
self.alpha = frame1.alpha
img_file.write(b'a')

class TestAudioWriter:
delays = None

def write_audio(self, audio_file, delays):
self.delays = delays

class HtmlWriterTestCase(SkoolKitTestCase):
def setUp(self):
super().setUp()
Expand All @@ -260,7 +266,8 @@ def _mock_write_file(self, fname, contents):
def _get_writer(self, ref=None, snapshot=(), case=0, base=0, skool=None,
create_labels=False, asm_labels=False, variables=(),
mock_file_info=False, mock_write_file=True,
mock_image_writer=True, warn=False):
mock_image_writer=True, mock_audio_writer=True,
warn=False):
self.skoolfile = None
ref_parser = RefParser()
if ref is not None:
Expand All @@ -279,6 +286,8 @@ def _get_writer(self, ref=None, snapshot=(), case=0, base=0, skool=None,
file_info = FileInfo(self.odir, GAMEDIR, False)
if mock_image_writer:
patch.object(skoolhtml, 'get_image_writer', TestImageWriter).start()
if mock_audio_writer:
patch.object(skoolhtml, 'AudioWriter', TestAudioWriter).start()
self.addCleanup(patch.stopall)
writer = HtmlWriter(skool_parser, ref_parser, file_info)
if mock_write_file:
Expand Down Expand Up @@ -1418,6 +1427,18 @@ def _test_image_macro(self, snapshot, macros, path, udgs=None, scale=2, mask=0,
if udgs:
self._check_image(writer, udgs, scale, mask, tindex, alpha, x, y, width, height, path)

def _test_audio_macro(self, writer, macro, src, path=None, delays=None):
exp_html = f"""
<audio controls src="{src}">
<p>Your browser doesn't support HTML5 audio. Here is a <a href="{src}">link to the audio</a> instead.</p>
</audio>
"""
output = writer.expand(macro, ASMDIR)
self.assertEqual(dedent(exp_html).strip(), output)
if path:
self.assertEqual(writer.file_info.fname, path)
self.assertEqual(delays, writer.audio_writer.delays)

def _test_udgarray_macro(self, snapshot, prefix, udg_specs, suffix, path, udgs=None, scale=2, mask=0, tindex=0,
alpha=-1, x=0, y=0, width=None, height=None, ref=None, alt=None, base=0):
self._test_image_macro(snapshot, f'{prefix};{udg_specs}{suffix}', path, udgs, scale, mask, tindex, alpha, x, y, width, height, ref, alt, base)
Expand Down Expand Up @@ -1467,40 +1488,35 @@ def _unsupported_macro(self, *args):
def test_macro_audio(self):
writer = self._get_writer(skool='')
fname = 'sound.wav'
src = f'../audio/{fname}'
exp_html = f"""
<audio controls src="{src}">
<p>Your browser doesn't support HTML5 audio. Here is a <a href="{src}">link to the audio</a> instead.</p>
</audio>
"""
output = writer.expand(f'#AUDIO({fname})', ASMDIR)
self.assertEqual(dedent(exp_html).strip(), output)
macro = f'#AUDIO({fname})'
exp_src = f'../audio/{fname}'
self._test_audio_macro(writer, macro, exp_src)

def test_macro_audio_with_absolute_path(self):
writer = self._get_writer(skool='')
fname = '/audio/sound.wav'
src = '..' + fname
exp_html = f"""
<audio controls src="{src}">
<p>Your browser doesn't support HTML5 audio. Here is a <a href="{src}">link to the audio</a> instead.</p>
</audio>
"""
output = writer.expand(f'#AUDIO({fname})', ASMDIR)
self.assertEqual(dedent(exp_html).strip(), output)
macro = f'#AUDIO({fname})'
exp_src = '..' + fname
self._test_audio_macro(writer, macro, exp_src)

def test_macro_audio_with_custom_audio_path(self):
audio_path = 'sounds'
ref = f'[Paths]\nAudioPath={audio_path}'
writer = self._get_writer(skool='', ref=ref)
fname = 'sound.wav'
src = f'../{audio_path}/{fname}'
exp_html = f"""
<audio controls src="{src}">
<p>Your browser doesn't support HTML5 audio. Here is a <a href="{src}">link to the audio</a> instead.</p>
</audio>
"""
output = writer.expand(f'#AUDIO({fname})', ASMDIR)
self.assertEqual(dedent(exp_html).strip(), output)
macro = f'#AUDIO({fname})'
exp_src = f'../{audio_path}/{fname}'
self._test_audio_macro(writer, macro, exp_src)

def test_macro_audio_with_delays(self):
writer = self._get_writer(skool='', mock_file_info=True)
fname = 'sound.wav'
delays = '[500]*10'
macro = f'#AUDIO({fname})({delays})'
exp_src = f'../audio/{fname}'
exp_path = f'audio/{fname}'
exp_delays = [500] * 10
self._test_audio_macro(writer, macro, exp_src, exp_path, exp_delays)

def test_macro_chr(self):
writer = self._get_writer(skool='', variables=[('foo', 66)])
Expand Down
4 changes: 2 additions & 2 deletions tools/mksktarball
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,10 @@ rm -rf ${ABSDIR}

rsync -aR \
{bin2{sna,tap},skool2{asm,bin,ctl,html},sna2{ctl,img,skool},snap{info,mod},tap{2sna,info}}.py \
skoolkit/{__init__,basic,bin2{sna,tap},components,config,ctlparser,defaults,disassembler,graphics,image,opcodes,pngwriter,refparser,skool{{,2}{asm,ctl,html},2bin,macro,parser},sna2img,sna{,2}{ctl,skool},snap{info,mod,shot},tap{2sna,info},textutils,z80}.py \
skoolkit/{__init__,audio,basic,bin2{sna,tap},components,config,ctlparser,defaults,disassembler,graphics,image,opcodes,pngwriter,refparser,skool{{,2}{asm,ctl,html},2bin,macro,parser},sna2img,sna{,2}{ctl,skool},snap{info,mod,shot},tap{2sna,info},textutils,z80}.py \
examples/hungry_horace.{ctl,ref,t2s} \
skoolkit/resources/skoolkit{,-dark,-green,-plum,-wide}.css \
tests/{{macro,skoolkit}test,test_{basic,bin2{sna,tap},ctlparser,disassembler,graphics,image,skool{{,2}{asm,ctl,html},2bin,macro,parser},refparser,skoolkit,sna2{ctl,img},sna{2,}skool,snap{info,mod,shot},tap{2sna,info},textutils,z80}}.py \
tests/{{macro,skoolkit}test,test_{audio,basic,bin2{sna,tap},ctlparser,disassembler,graphics,image,skool{{,2}{asm,ctl,html},2bin,macro,parser},refparser,skoolkit,sna2{ctl,img},sna{2,}skool,snap{info,mod,shot},tap{2sna,info},textutils,z80}}.py \
COPYING MANIFEST.in long_description.rst pyproject.toml setup.cfg \
$ABSDIR

Expand Down

0 comments on commit 80fc2cf

Please sign in to comment.