import logging
from io import BytesIO
from itertools import chain
from struct import pack, unpack
from typing import List, Optional
from logutils import BraceMessage as _F
from rv.controller import Controller
from rv.modules import Behavior as B
from rv.modules import Module
from rv.modules.base.sampler import BaseSampler
from rv.note import NOTE
from rv.readers.reader import read_sunvox_file
from rv.synth import Synth
log = logging.getLogger(__name__)
[docs]class Sampler(BaseSampler, Module):
"""
.. note::
Radiant Voices only supports sampler modules in files that were
saved using newer versions of SunVox.
Files created using older versions of SunVox, such as some of the files
in the ``simple_examples`` included with SunVox, must first be
loaded into the latest version of SunVox and then saved.
"""
chnk = 0x109
options_chnm = 0x0101
behaviors = {B.receives_notes, B.sends_audio}
effect: Optional[Synth]
class NoteSampleMap(dict):
start_note = NOTE.C0
end_note = NOTE.a9
default_sample = 0
def __init__(self):
super(Sampler.NoteSampleMap, self).__init__(
(NOTE(note_value), self.default_sample)
for note_value in range(self.start_note.value, self.end_note.value + 1)
)
@property
def bytes(self):
return bytes(self.values())
@bytes.setter
def bytes(self, value):
for k, v in zip(self.keys(), value):
self[k] = v
class Envelope:
chnm = None
range = None
initial_points = None
initial_sustain_point = None
initial_loop_start_point = None
initial_loop_end_point = None
initial_enable = None
initial_sustain = None
initial_loop = None
initial_ctl_index = 0
initial_gain_pct = 100
initial_velocity = 0
initial_controller = 0
_legacy_point_bytes = None
_legacy_active_points = None
_legacy_sustain_point = None
_legacy_loop_start_point = None
_legacy_loop_end_point = None
_legacy_bitmask = None
def __init__(self):
self.points = self.initial_points[:]
self.sustain_point = self.initial_sustain_point
self.loop_start_point = self.initial_loop_start_point
self.loop_end_point = self.initial_loop_end_point
self.enable = self.initial_enable
self.sustain = self.initial_sustain
self.loop = self.initial_loop
self.ctl_index = self.initial_ctl_index
self.gain_pct = self.initial_gain_pct
self.velocity = self.initial_velocity
self.loaded = False
@property
def bitmask(self):
return self.enable | self.sustain * 2 | self.loop * 4
@bitmask.setter
def bitmask(self, value):
self.enable = bool(value & 1)
self.sustain = bool(value & 2)
self.loop = bool(value & 4)
@property
def point_bytes(self):
y_points = (y - self.range[0] // 0x200 for y in self._y_values)
values = list(chain.from_iterable(zip(self._x_values, y_points)))
return pack("<" + "H" * len(values), *values)
@property
def _x_values(self):
values = [x for x, y in self.points]
while len(values) < 12:
values.append(0)
return values[:12]
@property
def _y_values(self):
values = [y // 0x200 for x, y in self.points]
while len(values) < 12:
values.append(0)
return values[:12]
def chunks(self):
yield b"CHNM", pack("<I", self.chnm)
data = pack(
"<HBBB",
self.bitmask,
self.ctl_index,
self.gain_pct,
self.velocity,
)
data += b"\0\0\0"
data += pack(
"<HHHH",
len(self.points),
self.sustain_point,
self.loop_start_point,
self.loop_end_point,
)
data += b"\0\0\0\0"
for x, y in self.points:
y -= self.range[0]
data += pack("<HH", x, y)
yield b"CHDT", data
def load_chdt(self, chdt):
(
self.bitmask,
self.ctl_index,
self.gain_pct,
self.velocity,
_,
_,
_,
point_count,
self.sustain_point,
self.loop_start_point,
self.loop_end_point,
) = unpack("<HBBBBBBHHHH", chdt[0:0x10])
points = self.points = []
for i in range(point_count):
offset = 0x14 + i * 4
data = chdt[offset : offset + 4]
x, y = unpack("<HH", data)
min_y = self.range[0]
points.append((x, y + min_y))
self.loaded = True
class VolumeEnvelope(Envelope):
chnm = 0x102
range = (0, 0x8000)
initial_enable = True
initial_loop = False
initial_loop_start_point = 0
initial_loop_end_point = 0
initial_sustain = True
initial_sustain_point = 0
initial_points = [(0, 0x8000), (8, 0), (0x80, 0), (0x100, 0)]
class PanningEnvelope(Envelope):
chnm = 0x103
range = (-0x4000, 0x4000)
initial_enable = False
initial_loop = False
initial_loop_start_point = 0
initial_loop_end_point = 0
initial_sustain = False
initial_sustain_point = 0
initial_points = [(0, 0), (0x40, -0x2000), (0x80, 0x2000), (0xB4, 0)]
class PitchEnvelope(Envelope):
chnm = 0x104
range = (-0x4000, 0x4000)
initial_enable = False
initial_loop = False
initial_loop_start_point = 0
initial_loop_end_point = 0
initial_sustain = False
initial_sustain_point = 0
initial_points = [(0, 0), (0x40, 0)]
class EffectControlEnvelope(Envelope):
range = (0, 0x8000)
initial_enable = False
initial_loop = False
initial_loop_start_point = 0
initial_loop_end_point = 0
initial_sustain = False
initial_sustain_point = 0
initial_points = [(0, 0x8000), (0x40, 0x8000)]
def __init__(self, chnm):
super().__init__()
self.chnm = chnm
class Sample:
def __init__(self):
self.data = b""
self.loop_start = 0
self.loop_len = 0
self.volume = 64
self.finetune = 100
self.format = Sampler.Format.float32
self.channels = Sampler.Channels.stereo
self.rate = 44100
self.loop_type = Sampler.LoopType.off
self.loop_sustain = False
self.panning = 0
self.relative_note = 16
self.unknown6 = b"\0" * 23
@property
def frame_size(self):
size = {
Sampler.Format.int8: 1,
Sampler.Format.int16: 2,
Sampler.Format.float32: 4,
}
multiplier = {Sampler.Channels.mono: 1, Sampler.Channels.stereo: 2}
return size[self.format] * multiplier[self.channels]
@property
def frames(self):
return len(self.data) // self.frame_size
vibrato_type = Controller(
BaseSampler.VibratoType, BaseSampler.VibratoType.sin, attached=False
)
vibrato_attack = Controller((0, 255), 0, attached=False)
vibrato_depth = Controller((0, 255), 0, attached=False)
vibrato_rate = Controller((0, 63), 0, attached=False)
volume_fadeout = Controller((0, 8192), 0, attached=False)
samples: List[Optional[Sample]]
def __init__(self, **kwargs):
super(Sampler, self).__init__(**kwargs)
self.volume_envelope = self.VolumeEnvelope()
self.panning_envelope = self.PanningEnvelope()
self.pitch_envelope = self.PitchEnvelope()
self.effect_control_envelopes = [
self.EffectControlEnvelope(0x105),
self.EffectControlEnvelope(0x106),
self.EffectControlEnvelope(0x107),
self.EffectControlEnvelope(0x108),
]
self.note_samples = self.NoteSampleMap()
self.samples = [None] * 128
self.unknown1 = b"\0" * 28
self.unknown2 = b"\0" * 4
self.unknown3 = b"\x40\x00\x80\x00\x00\x00\x00\x00"
self.unknown4 = b"\x04\x00\x00\x00"
self.unknown5 = b"\0" * 9
self.effect = None
def specialized_iff_chunks(self):
iters = [
self.global_config_chunks(),
self.envelope_config_chunks(),
self.volume_envelope.chunks(),
self.panning_envelope.chunks(),
self.pitch_envelope.chunks(),
self.effect_control_envelopes[0].chunks(),
self.effect_control_envelopes[1].chunks(),
self.effect_control_envelopes[2].chunks(),
self.effect_control_envelopes[3].chunks(),
]
for iter in iters:
yield from iter
if self.effect:
f = BytesIO()
self.effect.write_to(f)
yield b"CHNM", b"\x0a\1\0\0"
yield b"CHDT", f.getvalue()
yield from super(Sampler, self).specialized_iff_chunks()
for i, sample in enumerate(self.samples):
if sample is not None:
yield from self.sample_chunks(i, sample)
def global_config_chunks(self):
def b(v):
return pack("<B", v)
f = BytesIO()
w = f.write
w(self.unknown1)
compacted_samples = self.samples.copy()
while compacted_samples and compacted_samples[-1] is None:
compacted_samples.pop()
w(pack("<I", len(compacted_samples)))
w(self.unknown2)
w(self.note_samples.bytes[:96])
vol = self.volume_envelope
pan = self.panning_envelope
w(vol.point_bytes)
w(pan.point_bytes)
w(b(len(vol.points)))
w(b(len(pan.points)))
w(b(vol.sustain_point))
w(b(vol.loop_start_point))
w(b(vol.loop_end_point))
w(b(pan.sustain_point))
w(b(pan.loop_start_point))
w(b(pan.loop_end_point))
w(b(vol.bitmask))
w(b(pan.bitmask))
w(b(self.vibrato_type.value))
w(b(self.vibrato_attack))
w(b(self.vibrato_depth))
w(b(self.vibrato_rate))
w(pack("<H", self.volume_fadeout))
w(self.unknown3)
w(b"PMAS")
w(self.unknown4)
w(self.note_samples.bytes)
w(self.unknown5)
yield b"CHNM", pack("<I", 0)
yield b"CHDT", f.getvalue()
f.close()
def envelope_config_chunks(self):
yield b"CHNM", pack("<I", 0x101)
yield b"CHDT", b"\x00\x00\x00\x00\x00\x00"
def sample_chunks(self, i, sample):
f = BytesIO()
w = f.write
w(pack("<I", sample.frames))
w(pack("<I", sample.loop_start))
w(pack("<I", sample.loop_len))
w(pack("<B", sample.volume))
w(pack("<b", sample.finetune))
sustain_flag = 4 if sample.loop_sustain else 0
format_flag = {
self.Format.int8: 0x00,
self.Format.int16: 0x10,
self.Format.float32: 0x20,
}[sample.format]
channels_flag = {self.Channels.mono: 0x00, self.Channels.stereo: 0x40}[
sample.channels
]
loop_format_flags = (
sample.loop_type.value | format_flag | channels_flag | sustain_flag
)
w(pack("<B", loop_format_flags))
w(pack("<B", sample.panning + 0x80))
w(pack("<b", sample.relative_note))
w(sample.unknown6)
yield b"CHNM", pack("<I", i * 2 + 1)
yield b"CHDT", f.getvalue()
f.close()
yield b"CHNM", pack("<I", i * 2 + 2)
yield b"CHDT", sample.data
yield b"CHFF", pack("<I", sample.format.value | sample.channels.value)
if sample.rate != 44100:
yield b"CHFR", pack("<I", sample.rate)
def load_chunk(self, chunk):
chnm = chunk.chnm
chdt = chunk.chdt
if chnm == self.options_chnm:
self.load_options(chunk)
elif chnm == 0:
self.load_envelopes(chunk)
elif chnm < 0x101 and chnm % 2 == 1:
self.load_sample_meta(chunk)
elif chnm < 0x101 and chnm % 2 == 0:
self.load_sample_data(chunk)
elif chnm == 0x101:
self._unknown_0x101 = chdt
elif chnm == 0x102:
self.volume_envelope.load_chdt(chdt)
elif chnm == 0x103:
self.panning_envelope.load_chdt(chdt)
elif chnm == 0x104:
self.pitch_envelope.load_chdt(chdt)
elif 0x105 <= chnm <= 0x108:
self.effect_control_envelopes[chnm - 0x105].load_chdt(chdt)
elif chnm == 0x10A:
self.effect = read_sunvox_file(BytesIO(chdt))
def load_envelopes(self, chunk):
data = chunk.chdt
vol = self.volume_envelope
pan = self.panning_envelope
vol._legacy_point_bytes = data[0x84:0xB4]
pan._legacy_point_bytes = data[0xB4:0xE4]
vol._legacy_active_points = data[0xE4]
pan._legacy_active_points = data[0xE5]
vol._legacy_sustain_point = data[0xE6]
vol._legacy_loop_start_point = data[0xE7]
vol._legacy_loop_end_point = data[0xE8]
pan._legacy_sustain_point = data[0xE9]
pan._legacy_loop_start_point = data[0xEA]
pan._legacy_loop_end_point = data[0xEB]
vol._legacy_bitmask = data[0xEC]
pan._legacy_bitmask = data[0xED]
self.vibrato_type = self.VibratoType(data[0xEE])
self.vibrato_attack = data[0xEF]
self.vibrato_depth = data[0xF0]
self.vibrato_rate = data[0xF1]
(self.volume_fadeout,) = unpack("<H", data[0xF2:0xF4])
self.note_samples.bytes = data[0x104:0x17B]
self.unknown1 = data[0x00:0x1C]
self.unknown2 = data[0x20:0x24]
self.unknown3 = data[0xF4:0xFC]
self.unknown4 = data[0x100:0x104]
self.unknown5 = data[0x17B:0x184]
def load_sample_meta(self, chunk):
index = (chunk.chnm - 1) // 2
sample = self.samples[index] = self.Sample()
data = chunk.chdt
(sample.loop_start,) = unpack("<I", data[0x04:0x08])
(sample.loop_len,) = unpack("<I", data[0x08:0x0C])
sample.volume = data[0x0C]
(sample.finetune,) = unpack("<b", data[0x0D:0x0E])
loop_format_flags = data[0x0E]
loop = loop_format_flags & (0 | 1 | 2)
sample.loop_type = self.LoopType(loop)
format = loop_format_flags & (0x00 | 0x10 | 0x20)
sample.format = {
0x00: self.Format.int8,
0x10: self.Format.int16,
0x20: self.Format.float32,
}[format]
if loop_format_flags & 0x40:
sample.channels = self.Channels.stereo
else:
sample.channels = self.Channels.mono
sample.loop_sustain = bool(loop_format_flags & 4)
sample.panning = data[0x0F] - 0x80
(sample.relative_note,) = unpack("<b", data[0x10:0x11])
sample.unknown6 = data[0x11:0x28]
def load_sample_data(self, chunk):
index = (chunk.chnm - 2) // 2
sample = self.samples[index]
sample.data = chunk.chdt
format = chunk.chff & 0x07 or 1
sample.format = self.Format(format)
if sample.format is None:
sample.format = self.Format.int8
sample.channels = self.Channels(chunk.chff & 0x08)
sample.rate = chunk.chfr
def finalize_load(self):
if not self.volume_envelope.loaded:
self._upgrade_envelopes()
def _upgrade_envelopes(self):
log.info(
_F(
"Upgrading Sampler{} to infinite envelope format",
"[{}]".format(self.index) if self.index is not None else "",
)
)
vol = self.volume_envelope
pan = self.panning_envelope
vol.bitmask = vol._legacy_bitmask
vol.sustain_point = vol._legacy_sustain_point
vol.loop_start_point = vol._legacy_loop_start_point
vol.loop_end_point = vol._legacy_loop_end_point
pan.bitmask = pan._legacy_bitmask
pan.sustain_point = pan._legacy_sustain_point
pan.loop_start_point = pan._legacy_loop_start_point
pan.loop_end_point = pan._legacy_loop_end_point
vol_x_points = [
unpack("<H", vol._legacy_point_bytes[4 * i : 4 * i + 2])[0]
for i in range(vol._legacy_active_points)
]
pan_x_points = [
unpack("<H", pan._legacy_point_bytes[4 * i : 4 * i + 2])[0]
for i in range(pan._legacy_active_points)
]
vol_y_points = [
unpack("<H", vol._legacy_point_bytes[4 * i + 2 : 4 * i + 4])[0] * 0x200
for i in range(vol._legacy_active_points)
]
pan_y_points = [
unpack("<H", pan._legacy_point_bytes[4 * i + 2 : 4 * i + 4])[0] * 0x200
- pan.range[0]
for i in range(pan._legacy_active_points)
]
vol.points = [
(vol_x_points[i], vol_y_points[i]) for i in range(vol._legacy_active_points)
]
pan.points = [
(pan_x_points[i], pan_y_points[i]) for i in range(pan._legacy_active_points)
]