diff --git a/.gitignore b/.gitignore index ecfde15..846149a 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,8 @@ neotools/**/*.pcm neotools/**/*.vgm neotools/**/*.vgz +spctools/it +spctools/spc +spctools/sample + .DS_Store diff --git a/common/riffwriter.py b/common/riffwriter.py new file mode 100644 index 0000000..66b1fa7 --- /dev/null +++ b/common/riffwriter.py @@ -0,0 +1,37 @@ +from abc import ABC, abstractmethod +from typing import BinaryIO, List + + +class AbstractRiffChunk(ABC): + @abstractmethod + def fourcc(self) -> bytes: raise NotImplementedError + + @abstractmethod + def size(self) -> int: raise NotImplementedError + + @abstractmethod + def write(self, f: BinaryIO): raise NotImplementedError + + +class RiffFile(AbstractRiffChunk): + def fourcc(self) -> bytes: return b"RIFF" + + def size(self) -> int: return 4 + sum(8 + c.size() for c in self._chunks) + + def __init__(self, type: bytes, chunks: List[AbstractRiffChunk]): + self._chunks = chunks + if len(type) != 4: raise ValueError + self._type = type + + def write(self, f: BinaryIO): + f.writelines([ + self.fourcc(), + self.size().to_bytes(4, "little", signed=False), + self._type]) + for chunk in self._chunks: + size = chunk.size() + if size & 0x3: raise AssertionError("Unaligned chunks will produce malformed riff files") + f.writelines([ + chunk.fourcc(), + size.to_bytes(4, "little", signed=False)]) + chunk.write(f) diff --git a/common/wavesampler.py b/common/wavesampler.py new file mode 100644 index 0000000..af7976b --- /dev/null +++ b/common/wavesampler.py @@ -0,0 +1,107 @@ +import struct +from enum import Enum +from riffwriter import AbstractRiffChunk +from typing import BinaryIO, List + + +class WaveSamplerSMPTEOffset: + def __init__(self, hours: int=0, minutes: int=0, seconds: int=0, frames: int=0): + if -23 > hours > 23: raise ValueError("Hours out of range") + if 0 > minutes > 59: raise ValueError("Minutes out of range") + if 0 > seconds > 59: raise ValueError("Seconds out of range") + if 0 > frames > 0xFF: raise ValueError("Frames out of range") + self._hours = hours + self._minutes = minutes + self._seconds = seconds + self._frames = frames + + def hours(self) -> int: return self._hours + def minutes(self) -> int: return self._minutes + def seconds(self) -> int: return self._seconds + def frames(self) -> int: return self._frames + + def pack(self) -> bytes: + #FIXME: endianess?? + return struct.pack(" bytes: + return struct.pack(" bytes: return b"smpl" + + def loopsSize(self) -> int: return len(self._loops) * 24 + + def size(self) -> int: return 36 + self.loopsSize() + + def write(self, f: BinaryIO): + #TODO: unused data dummied out for now + f.write(struct.pack("<4sIiiii4sII", + self._manufacturer, # MMA Manufacturer code + self._product, # Product + self._period, # Playback period (ns) + self._unityNote, # MIDI unity note + self._fineTune, # MIDI pitch fraction + self._smpteFormat, # SMPTE format + self._smpteOffset.pack(), # SMPTE offset + + len(self._loops), # Number of loops + self.loopsSize())) # Loop data length + f.writelines(loop.pack() for loop in self._loops) + + def __init__(self, + manufacturer: bytes|None=None, + product: int=0, + period: int=0, + midiUnityNote: int=0, + midiPitchFraction: int=0, + smpteFormat: int=0, + smpteOffset: WaveSamplerSMPTEOffset|None=None, + loops: List[WaveSamplerLoop]=None): + if manufacturer is not None: + if len(manufacturer) not in [1, 3]: raise ValueError("Malformed MIDI manufacturer code") + self._manufacturer = len(manufacturer).to_bytes(1, byteorder="little", signed=False) + self._manufacturer += manufacturer.rjust(3, b"\x00") + else: + self._manufacturer = b"\x00" * 4 + + if 0 > product > 0xFFFF: raise ValueError("Product code out of range") + self._product = product # Arbitrary vendor specific product code, dunno if this should be signed or unsigned + self._period = period # Sample period in ns, (1 / samplerate) * 10^9, who cares + if 0 > midiUnityNote > 127: raise ValueError("MIDI Unity note out of range") + self._unityNote = midiUnityNote # MIDI note that plays the sample unpitched, middle C=60 + self._fineTune = midiPitchFraction # Finetune fraction, 256 == 100 cents + if smpteFormat not in [0, 24, 25, 29, 30]: raise ValueError("Invalid SMPTE format") + self._smpteFormat = smpteFormat + self._smpteOffset = smpteOffset if (smpteOffset and smpteFormat > 0) else WaveSamplerSMPTEOffset() + if self._smpteOffset.frames() > self._smpteFormat: raise ValueError("SMPTE frame offset can't exceed SMPTE format") + self._loops = loops if loops is not None else list() diff --git a/common/wavewriter.py b/common/wavewriter.py new file mode 100644 index 0000000..edb530a --- /dev/null +++ b/common/wavewriter.py @@ -0,0 +1,95 @@ +import struct +from abc import abstractmethod +from enum import Enum +from typing import BinaryIO, List +from riffwriter import RiffFile, AbstractRiffChunk + + +class WaveSampleFormat(Enum): + PCM = 0x0001 + IEEE_FLOAT = 0x0003 + ALAW = 0x0006 + MULAW = 0x0007 + EXTENSIBLE = 0xFFFE + + +class WaveAbstractFormatChunk(AbstractRiffChunk): + def fourcc(self) -> bytes: return b"fmt " + + def size(self) -> int: return 16 + + @abstractmethod + def sampleformat(self) -> WaveSampleFormat: raise NotImplementedError + + @abstractmethod + def channels(self) -> int: raise NotImplementedError + + @abstractmethod + def samplerate(self) -> int: raise NotImplementedError + + @abstractmethod + def byterate(self) -> int: raise NotImplementedError + + @abstractmethod + def align(self) -> int: raise NotImplementedError + + @abstractmethod + def bitdepth(self) -> int: raise NotImplementedError + + def write(self, f: BinaryIO): + f.write(struct.pack(" WaveSampleFormat: return WaveSampleFormat.PCM + + def channels(self) -> int: return self._channels + + def samplerate(self) -> int: return self._samplerate + + def byterate(self) -> int: return self._samplerate * self._channels * self._bytedepth + + def align(self) -> int: return self._channels * self._bytedepth + + def bitdepth(self) -> int: return self._bytedepth * 8 + + def __init__(self, channels: int, samplerate: int, bitdepth: int): + if channels < 0 or channels >= 256: raise ValueError + if samplerate < 1 or samplerate > 0xFFFFFFFF: raise ValueError + if bitdepth not in [8, 16, 32]: raise ValueError + self._channels = channels + self._samplerate = samplerate + self._bytedepth = bitdepth // 8 + + +class WaveDataChunk(AbstractRiffChunk): + def fourcc(self) -> bytes: return b"data" + + def size(self) -> int: return len(self._data) + + def write(self, f: BinaryIO): f.write(self._data) + + def __init__(self, data: bytes): + self._data = data + + +class WaveCommentChunk(AbstractRiffChunk): + def fourcc(self) -> bytes: return b"clm " + + def size(self) -> int: return len(self._comment) + + def write(self, f: BinaryIO): f.write(self._comment) + + 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/spctools/ripsamples.py b/spctools/ripsamples.py index 5249b09..937bd49 100755 --- a/spctools/ripsamples.py +++ b/spctools/ripsamples.py @@ -1,21 +1,29 @@ #!/usr/bin/env python3 # ripsamples.py -- a python script for mass extracting samples from SPC files. -# (C) 2018 neoadpcmextract.c (C) 2018 a dinosaur (zlib) +# (C) 2018, 2023 a dinosaur (zlib) import os import subprocess import pathlib import struct import hashlib +from typing import BinaryIO +from io import BytesIO -# Directory constants. +import sys +sys.path.append("../common") +from wavewriter import WaveFile, WavePcmFormatChunk, WaveDataChunk +from wavesampler import WaveSamplerChunk, WaveSamplerLoop + + +# Directory constants SPCDIR = "./spc" ITDIR = "./it" SMPDIR = "./sample" -# External programs used by this script. -SPC2IT = "spc2it" +# External programs used by this script +SPC2IT = "spc2it/spc2it" class Sample: @@ -24,63 +32,35 @@ class Sample: loopEnd = 0 rate = 0 - data = None + data: bytes = None -def writesmp(smp, path): + +def writesmp(smp: Sample, path: str): + print(path) with open(path, "wb") as wav: - - # Make sure sample rate is nonzero. + # Make sure sample rate is nonzero #TODO: figure out why this even happens... if smp.rate == 0: smp.rate = 32000 - #print(path + " may be corrupted...") + #print(path + " may be corrupted") - writeLoop = True if smp.loopEnd > smp.loopBeg else False + fmtChunk = WavePcmFormatChunk( # Audio format (uncompressed) + 1, # Channel count (mono) + smp.rate, # Samplerate + 16) # Bits per sample (16 bit) + dataChunk = WaveDataChunk(smp.data) + loopChunk = None + if smp.loopEnd > smp.loopBeg: + loopChunk = WaveSamplerChunk(loops=[WaveSamplerLoop( + start=smp.loopBeg, # Loop start + end=smp.loopEnd)]) # Loop end - # Write RIFF chunk. - wav.write(b"RIFF") - # Size of entire file following - riffSize = 104 if writeLoop else 36 - wav.write(struct.pack("