diff --git a/.gitignore b/.gitignore index 846149a..e972830 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,6 @@ spctools/it spctools/spc spctools/sample +FM Harmonics/ + .DS_Store diff --git a/common/riffwriter.py b/common/riffwriter.py index 66b1fa7..26c2baf 100644 --- a/common/riffwriter.py +++ b/common/riffwriter.py @@ -1,3 +1,6 @@ +# riffwriter.py -- Generic RIFF writing framework +# (C) 2023 a dinosaur (zlib) + from abc import ABC, abstractmethod from typing import BinaryIO, List diff --git a/common/wavesampler.py b/common/wavesampler.py index af7976b..9656d4d 100644 --- a/common/wavesampler.py +++ b/common/wavesampler.py @@ -1,7 +1,10 @@ +# wavesampler.py -- Support for the non-standard "Sampler" WAVE chunk +# (C) 2023 a dinosaur (zlib) + import struct from enum import Enum -from riffwriter import AbstractRiffChunk from typing import BinaryIO, List +from common.riffwriter import AbstractRiffChunk class WaveSamplerSMPTEOffset: diff --git a/common/waveserum.py b/common/waveserum.py new file mode 100644 index 0000000..8cc6b65 --- /dev/null +++ b/common/waveserum.py @@ -0,0 +1,18 @@ +# waveserum.py -- Serum comment chunk support +# (C) 2023 a dinosaur (zlib) + +from enum import Enum +from common.wavewriter import WaveCommentChunk + + +class SerumWavetableInterpolation(Enum): + NONE = 0 + LINEAR_XFADE = 1 + SPECTRAL_MORPH = 2 + + +class WaveSerumCommentChunk(WaveCommentChunk): + def __init__(self, size: int, mode: SerumWavetableInterpolation, factory=False): + comment = f"{size: <4} {mode.value}{'1' if factory else '0'}000000" + comment += " wavetable (www.xferrecords.com)" + super().__init__(comment.encode("ascii")) diff --git a/common/wavewriter.py b/common/wavewriter.py index edb530a..169ea2d 100644 --- a/common/wavewriter.py +++ b/common/wavewriter.py @@ -1,8 +1,11 @@ +# wavewriter.py -- Extensible WAVE writing framework +# (C) 2023 a dinosaur (zlib) + import struct from abc import abstractmethod from enum import Enum from typing import BinaryIO, List -from riffwriter import RiffFile, AbstractRiffChunk +from common.riffwriter import RiffFile, AbstractRiffChunk class WaveSampleFormat(Enum): @@ -46,6 +49,11 @@ class WaveAbstractFormatChunk(AbstractRiffChunk): self.bitdepth())) +class WaveFile(RiffFile): + def __init__(self, format: WaveAbstractFormatChunk, chunks: List[AbstractRiffChunk]): + super().__init__(b"WAVE", [format] + chunks) + + class WavePcmFormatChunk(WaveAbstractFormatChunk): def sampleformat(self) -> WaveSampleFormat: return WaveSampleFormat.PCM @@ -88,8 +96,3 @@ class WaveCommentChunk(AbstractRiffChunk): def __init__(self, comment: bytes): self._comment = comment - - -class WaveFile(RiffFile): - def __init__(self, format: WaveAbstractFormatChunk, chunks: List[AbstractRiffChunk]): - super().__init__(b"WAVE", [format] + chunks) diff --git a/sinharmonicswt.py b/sinharmonicswt.py new file mode 100644 index 0000000..074c4b3 --- /dev/null +++ b/sinharmonicswt.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +# Name: sinharmonicswt.py +# Copyright: © 2023 a dinosaur +# Homepage: https://github.com/ScrelliCopter/VGM-Tools +# License: Zlib (https://opensource.org/licenses/Zlib) +# Description: Generate Serum format wavetables of the harmonic series +# for a handful of common FM waveforms, intended for improving +# the workflow of creating FM sounds in Vital. + +import math +from pathlib import Path +from typing import NamedTuple + +from common.wavewriter import WaveFile, WavePcmFormatChunk, WaveDataChunk +from common.waveserum import WaveSerumCommentChunk, SerumWavetableInterpolation + + +def write_wavetable(name: str, generator, + mode: SerumWavetableInterpolation = SerumWavetableInterpolation.NONE, + num: int = 64, size: int = 2048): + def sweep_table() -> bytes: + for i in range(num - 1): + yield b"".join(generator(size, i + 1)) + + with open(name, "wb") as f: + WaveFile(WavePcmFormatChunk(1, 44100, 16), [ + WaveSerumCommentChunk(size, mode), + WaveDataChunk(b"".join(sweep_table())) + ]).write(f) + + +def main(): + clip = lambda x, a, b: max(a, min(b, x)) + def clamp2short(a: float) -> int: return clip(int(a * 0x7FFF), -0x8000, 0x7FFF) + + def sinetable16(size: int, harmonic: int): + # Generate a simple sine wave + for i in range(size): + sample = clamp2short(math.sin(i / size * math.tau * harmonic)) + yield sample.to_bytes(2, byteorder="little", signed=True) + + #TODO: probably make bandlimited versions of the nonlinear waves + def hsinetable16(size: int, harmonic: int): + # Generate one half of a sine wave with the negative pole hard clipped off + for i in range(size): + sample = clamp2short(max(0.0, math.sin(i / size * math.tau * harmonic))) + yield sample.to_bytes(2, byteorder="little", signed=True) + + #TODO: probably make bandlimited versions of the nonlinear waves + def asinetable16(size: int, harmonic: int): + # Generate a sine wave with the negative pole mirrored positively + for i in range(size): + sample = clamp2short(math.fabs(math.sin(i / size * math.pi * harmonic))) + yield sample.to_bytes(2, byteorder="little", signed=True) + + outfolder = Path("FM Harmonics") + outfolder.mkdir(exist_ok=True) + + # Build queue of files to generate + GenItem = NamedTuple("GenItem", generator=any, steps=int, mode=SerumWavetableInterpolation, name=str) + genqueue: list[GenItem] = list() + # All waveform types with 64 harmonic steps in stepped and linear versions + for mode in [("", SerumWavetableInterpolation.NONE), (" (XFade)", SerumWavetableInterpolation.LINEAR_XFADE)]: + for generator in [("Sine", sinetable16), ("Half Sine", hsinetable16), ("Abs Sine", asinetable16)]: + genqueue.append(GenItem(generator[1], 64, mode[1], f"{generator[0]} Harmonics{mode[0]}")) + # Shorter linear versions of hsine and asine + for steps in [8, 16, 32]: + spec = SerumWavetableInterpolation.LINEAR_XFADE + for generator in [("Half Sine", hsinetable16), ("Abs Sine", asinetable16)]: + genqueue.append(GenItem(generator[1], steps, spec, f"{generator[0]} (XFade {steps})")) + + # Generate & write wavetables + for i in genqueue: + write_wavetable(str(outfolder.joinpath(f"{i.name}.wav")), i.generator, i.mode, i.steps) + + +if __name__ == "__main__": + main() diff --git a/spctools/ripsamples.py b/spctools/ripsamples.py index 937bd49..84f3908 100755 --- a/spctools/ripsamples.py +++ b/spctools/ripsamples.py @@ -12,9 +12,9 @@ from typing import BinaryIO from io import BytesIO import sys -sys.path.append("../common") -from wavewriter import WaveFile, WavePcmFormatChunk, WaveDataChunk -from wavesampler import WaveSamplerChunk, WaveSamplerLoop +sys.path.append("..") +from common.wavewriter import WaveFile, WavePcmFormatChunk, WaveDataChunk +from common.wavesampler import WaveSamplerChunk, WaveSamplerLoop # Directory constants @@ -36,7 +36,6 @@ class Sample: def writesmp(smp: Sample, path: str): - print(path) with open(path, "wb") as wav: # Make sure sample rate is nonzero #TODO: figure out why this even happens... @@ -77,8 +76,8 @@ def readsmp(f: BinaryIO, ofs: int, idx: int): f.seek(ofs + 0x12) flags = int.from_bytes(f.read(1), byteorder="little", signed=False) - # Read flag values. - if not flags & 0b00000001: return None # Check sample data bit + # Read flag values + if not flags & 0b00000001: return None # Check sample data bit loopBit = True if flags & 0b00010000 else False smp = Sample() @@ -90,11 +89,11 @@ def readsmp(f: BinaryIO, ofs: int, idx: int): smp.loopBeg = int.from_bytes(f.read(4), byteorder="little", signed=False) smp.loopEnd = int.from_bytes(f.read(4), byteorder="little", signed=False) else: - f.seek(8, 1) # Skip over + f.seek(8, 1) # Skip over smp.loopBeg = 0 smp.loopEnd = 0 smp.rate = int.from_bytes(f.read(4), byteorder="little", signed=False) - f.seek(8, 1) # Skip over sustain shit + f.seek(8, 1) # Skip over sustain shit # Read sample data dataOfs = int.from_bytes(f.read(4), byteorder="little", signed=False)