def read_imx(fp: BinaryFp) -> ImxImage: """read from an IMX image file and return an ImxImage :param fp: A file path. Or it can be an already-opened file, in which case: - it will read starting from the current file position - after returning, file position will be right after the IMX file data - the caller is responsible for closing the file afterwards :raises ImxImageError if IMX header is invalid :raises EndOfImxImageError if end of IMX data is reached unexpectedly :return: ImxImage instance """ with open_maybe(fp, "rb") as file: try: magic = readdata(file, 4) if magic != b"IMX\0": raise ImxImageError(f"Not an IMX file, unknown magic {magic!r}") width, height, pixfmtdata = readstruct(file, "<16x2I8s") pixfmt = _pixfmtdata_pixfmt.get(pixfmtdata) if pixfmt is None: raise ImxImageError(f"Unknown IMX pixel format data {pixfmtdata!r}") if width % 2: raise ImxImageError( "indexed-4 pixel format requires width be a multiple of 2, " f"but width={width}" ) if pixfmt in ("i4", "i8"): palette_size = readstruct(file, "<I") struct_fmt = f"{palette_size}B" palette = tuple(chunks(readstruct(file, struct_fmt), 4)) file.seek(4, SEEK_CUR) # always 2? else: palette = None pixels_size = readstruct(file, "<I") if pixfmt == "i4": struct_fmt = f"{pixels_size}B" pixels = tuple(from_nibbles(readstruct(file, struct_fmt))) elif pixfmt == "i8": struct_fmt = f"{pixels_size}B" pixels = readstruct(file, struct_fmt) elif pixfmt == "rgb24": struct_fmt = f"{pixels_size}B" pixels = tuple(chunks(readstruct(file, struct_fmt), 3)) elif pixfmt == "rgba32": struct_fmt = f"{pixels_size}B" pixels = tuple(chunks(readstruct(file, struct_fmt), 4)) # footer file.seek(8, SEEK_CUR) # always uint32s (3,0)? except EOFError as e: raise EndOfImxImageError(str(e)) return ImxImage(width, height, pixels, palette, pixfmt=pixfmt, alpha128=True)
def read_subwav16(file): """read a Subsong from a WAV file (16-bit signed linear PCM) file: A file path. Or it can be an already-opened file, in which case: - it will read starting from the current file position - after returning, file position will probably be right after the wav file, but I can't guarantee it since I didn't write the built-in `wave` module - the caller is responsible for closing the file afterwards raises: SubsongError if wav file's sample format is not 16-bit """ with wave.open(file, "rb") as wavfile: if wavfile.getsampwidth() != 2: # i.e. not 16-bit raise SubsongError( "Only 16-bit wav files are supported, this wav file is " f"{wavfile.getsampwidth() * 8}-bit.") num_channels = wavfile.getnchannels() sample_rate = wavfile.getframerate() num_frames = wavfile.getnframes() # deinterleave samples allsamples = struct.unpack(f"<{num_channels * num_frames}h", wavfile.readframes(num_frames)) # a wav frame is a single sample at the same time from all channels frames = chunks(allsamples, num_channels) del allsamples channels_samples = zip(*frames) channels = list() for ch_samples in channels_samples: ch = Pcm16Channel(ch_samples) channels.append(ch) return Subsong(channels, sample_rate)
def get_imcdata(self, entire=False): """interleaves and returns subsong's PS-ADPCM data, including header PS-ADPCM data is returned with a certain number of frames per block (fpb) and blocks per channel (bpc). There are 3 possibilities, in this order of priority: - use self.original_block_layout's saved fpb/bpc if it has them - if entire==True, use large fpb + just enough bpc to hold the data - otherwise, use 768 fpb + just enough bpc to hold the data "entire" is for sound effects but not music, "otherwise" is for both (sfx/music) """ # 1. decide block layout if self.original_block_layout is not None: frames_per_block, blocks_per_channel = self.original_block_layout elif entire: # some arbitrary maximum number of frames we'll allow in a single block: max_frames_per_block = 32767 # (higher values may work too, haven't tested) # if too many frames for a single block, we'll divide them evenly to fit divisor = ceil(self.num_frames / max_frames_per_block) frames_per_block, blocks_per_channel = ceil(self.num_frames / divisor), None else: frames_per_block, blocks_per_channel = 768, None # 2. create PS-ADPCM header num_blocks = self.num_channels * ( blocks_per_channel if blocks_per_channel is not None else ceil( self.num_frames / frames_per_block)) psadpcm_header = struct.pack("<4I", self.num_channels, self.sample_rate, frames_per_block, num_blocks) # 3. create PS-ADPCM data (divide channels into blocks & interleave them) bytes_per_block = PSFRAME_NUMBYTES * frames_per_block # a. start with each channel # [ch1data, ch2data] channel_datas = (ch.get_psadpcm() for ch in self.channels) # if necessary, pad each channel to reach blocks_per_channel if blocks_per_channel is not None: bytes_per_channel = bytes_per_block * blocks_per_channel channel_datas = (chdata + b"\0" * (bytes_per_channel - len(chdata)) for chdata in channel_datas) # b. chunk each channel into into a list of blocks: # [(ch1block, ch1block, ...), (ch2block, ch2block, ...)] # last block will be zero-padded to a full block channel_groups = (chunks(channel_data, bytes_per_block, fillseq=b"\0") for channel_data in channel_datas) # c. rearrange the lists of blocks into lists of interleaved blocks: # [(ch1block, ch2block), (ch1block, ch2block), ...] interleaved_groups = zip(*channel_groups) # d. flatten the lists of interleaved blocks into 1 list of interleaved_blocks: # [ch1block, ch2block, ch1block, ch2block ...] interleaved_blocks = chain.from_iterable(interleaved_groups) # e. join the interleaved blocks into psadpcm_data: # ch1block-ch2block-ch1block-ch2block-... psadpcm_data = b"".join(interleaved_blocks) return psadpcm_header + psadpcm_data
def get_pcm16(self): """return 16-bit signed linear PCM samples, converted from PS-ADPCM data returns: list of 16-bit signed ints (i.e. in the range -32768, 32767) raises: SubsongError on encountering a PS-ADPCM data frame with an invalid coef_idx, shift_factor, or flag """ decodedsamples = [] hist1 = hist2 = 0 for frame_idx, frame in enumerate( chunks(self._psadpcm_data, PSFRAME_NUMBYTES)): shift_factor, coef_idx = from_nibbles(frame[0]) flag = frame[1] # check for invalid values errors = [] if not coef_idx <= 0x4: errors.append(f"invalid coef_idx {coef_idx:#x}") if not shift_factor <= 0xC: errors.append(f"invalid shift_factor {shift_factor:#x}") if not flag <= 0x7: errors.append(f"invalid flag {flag:#x}") if errors: raise SubsongError(f"Frame {frame_idx} has " + " and ".join(errors)) for nibble in from_nibbles(frame[2:], signed=True): # To turn a nibble into a sample: # 1. multiply nibble by a biggish value sample = nibble * 2**(12 - shift_factor) # 2. adjust a little based on the previous sample sample += ps_adpcm_coefs[coef_idx][0] * hist1 # 3. and adjust again based on the sample before that sample += ps_adpcm_coefs[coef_idx][1] * hist2 # 4 clamp to 16-bit signed int if sample <= -32768: sample = -32768 elif sample >= 32767: sample = 32767 else: sample = int(sample) decodedsamples.append(sample) hist2 = hist1 hist1 = sample return decodedsamples
def __init__(self, psadpcm_data): """psadpcm_data: bytes representing one entire channel of PS-ADPCM frames Note that data returned by get_psadpcm() may differ from original psadpcm_data: - Padding frames will be removed. This means all frames after the end frame (the first frame having flag 0x7, which means "End + don't play this frame") or if that doesn't exist, all-zero padding frames at the end. - It will ensure the last two frames respectively have flag 0x1 ("End marker [last frame]") and flag 0x7, ("End + don't play this frame") - If the data didn't already have an end flag 0x7 frame, it will be given one. raises: SubsongError if the length of psadpcm_data does not represent a whole number of PS-ADPCM frames """ super().__init__() # verify length of psadpcm_data extrabytes = len(psadpcm_data) % PSFRAME_NUMBYTES if extrabytes: raise SubsongError( "psadpcm_data is not a whole number of frames, " f"last frame is only {extrabytes} bytes out of {PSFRAME_NUMBYTES}" ) # remove end/padding frames from the end of get_psadpcm discard_frame_idx = 0 for i, frame in enumerate(chunks(psadpcm_data, PSFRAME_NUMBYTES)): if frame[1] == 0x7: # discard the first frame having flag=0x7 and all frames after it # flag 0x7 means end + don't play this frame discard_frame_idx = i endframe = frame # save end frame to re-add later break if any(frame): # Or if no flag 0x7 frame, discard all after the last non-zero frame discard_frame_idx = i + 1 else: endframe = PSFRAME_ENDBLANK # no end frame, so let's create our own psadpcm_data = psadpcm_data[:discard_frame_idx * PSFRAME_NUMBYTES] # ensure the last audible frame has flag 0x1, meaning "End marker (last frame)" if psadpcm_data: psadpcm_data = psadpcm_data[:-15] + b"\x01" + psadpcm_data[-14:] self._psadpcm_data = psadpcm_data + endframe
def read_imc(file): """ read from a IMC audio container file and return an ImcContainer file: A file path. Or it can be an already-opened file, in which case: - it will read starting from the current file position, with no guarantee of file position after returning - the caller is responsible for closing the file afterwards raises: EOFError if end of file is reached unexpectedly """ with open_maybe(file, "rb") as file: start_offset = file.tell() # read number of subsongs num_subsongs = readstruct(file, "<I") # read raw ssinfo fmt, items_per_ssinfo_entry = ("<" + "16s4I" * num_subsongs), 5 raw_ssinfos = tuple( chunks(readstruct(file, fmt), items_per_ssinfo_entry)) next_ssoffsets = (x[1] for x in raw_ssinfos[1:]) # read subsongs, convert to ContainerSubsongs csubsongs = [] for raw_ssinfo, next_ssoffset in zip_longest(raw_ssinfos, next_ssoffsets, fillvalue=None): rawname, ssoffset, unk1, unk2, loadmode_raw = raw_ssinfo name = rawname.split(b"\0", 1)[0].decode(encoding="ascii") if next_ssoffset is not None: ss_knownsize = next_ssoffset - ssoffset else: ss_knownsize = None # read Subsong from within IMC container file file.seek(start_offset + ssoffset) subsong_ = subsong.read_subimc(file, knownsize=ss_knownsize) csubsong = ContainerSubsong(subsong_, name, loadmode_raw, rawname, unk1, unk2) csubsongs.append(csubsong) return ImcContainer(csubsongs)
def read_subimc(file, knownsize=None): """read from a IMC subsong file and return a Subsong file: A file path. Or it can be an already-opened file, in which case: - it will read starting from the current file position - after returning, file position will be right after the subsong file data - the caller is responsible for closing the file afterwards knownsize: Optional size of the IMC subsong file known in advance. It's only used for a quick sanity check (to anticipate reading past end of the subsong). Without this, it assumes this subsong ends when the file ends, so it's recommended to pass this if reading this subsong from inside a larger container file. raises: - SubsongError if IMC subsong header is invalid - EndOfSubsongError if end of subsong is reached unexpectedly """ with open_maybe(file, "rb") as file: # Read imc subsong header header = file.read(16) if len(header) != 16: raise EndOfSubsongError( "Expected 16 bytes for IMC subsong header, only got" f"{len(header)} bytes") num_channels, sample_rate, frames_per_block, num_blocks = struct.unpack( "<4I", header) # sanity checks, since the format is so simple if not 1 <= num_channels <= 8: raise SubsongError(f"invalid number of channels {num_channels}") if not 8000 <= sample_rate <= 48000: raise SubsongError(f"invalid sample rate {sample_rate}") if frames_per_block == 0: raise SubsongError("frames per block should not be 0") if num_blocks == 0: raise SubsongError("number of blocks should not be 0") if not num_blocks % num_channels == 0: raise SubsongError( f"number of channels ({num_channels}) does not divide evenly into" f"number of blocks ({num_blocks})") # quick check for premature end of file if knownsize is None: # assume subsong ends when the file ends oldtell = file.tell() file.seek(0, SEEK_END) real_datasize = file.tell() - oldtell file.seek(oldtell) else: real_datasize = knownsize - 0x10 # size - header predicted_datasize = PSFRAME_NUMBYTES * frames_per_block * num_blocks if predicted_datasize > real_datasize: raise EndOfSubsongError( f"IMC subsong header predicts {predicted_datasize} bytes of data, " f"but only {real_datasize} bytes remain in file") bytes_per_block = PSFRAME_NUMBYTES * frames_per_block # Deinterleave file data, separating the blocks into channels: # starting with one big lump of interleaved_data: # ch1block-ch2block-ch1block-ch2block-... interleaved_data = file.read(bytes_per_block * num_blocks) # chunk interleaved data into interleaved_blocks: # [ch1block, ch2block, ch1block, ch2block ...] interleaved_blocks = tuple(chunks(interleaved_data, bytes_per_block)) del interleaved_data # now in interleaved_blocks, no point in keeping # group interleaved blocks into interleaved_groups: # [(ch1block, ch2block), (ch1block, ch2block), ...] interleaved_groups = chunks(interleaved_blocks, num_channels) # deinterleave interleaved_groups into channel_groups: # [(ch1block, ch1block, ...), (ch2block, ch2block, ...)] channel_groups = zip(*interleaved_groups) # join each channel group into a one big lump of channel data: # [ch1block-ch1block-..., ch2block-ch2block-...] channel_datas = (b"".join(channel_group) for channel_group in channel_groups) # create Subsong from each channel_data channelobjs = [] for channel_data in channel_datas: chobj = PsAdpcmChannel(channel_data) channelobjs.append(chobj) blocks_per_channel = int(num_blocks / num_channels) return Subsong(channelobjs, sample_rate, frames_per_block, blocks_per_channel)
def get_psadpcm(self): """return PS-ADPCM data (bytes), converted from 16-bit PCM samples Note that, following the format of Gitaroo Man audio: - the last audible frame will have flag 0x1 (meaning "End marker (last frame)") - audible frames will be followed by a single blank frame with flag 0x7 (meaning "End + don't play this frame") """ num_audible_frames = self.num_psadpcm_frames - 1 if num_audible_frames <= 0: return PSFRAME_ENDBLANK # set up the stuff we'll be iterating through frames_samples = chunks(self._pcm_samples, PSFRAME_NUMSAMPLES, fillseq=[0]) # TODO if changing to numpy array.. flags = chain(repeat(0x0, num_audible_frames - 1), (0x1, )) # normal frames have flag 0x0, final audible frame has flag 0x1 i.e. "End marker # (last frame)" # Now, let's encode each frame one at a time and accumulate the encoded nibble # values in psadpcm_nibblevals prev_shift_factor, prev_coef_idx = 0, 0 psadpcm_nibblevals = [] hist1 = hist2 = 0 # for each frame (group of 28 samples): for frame_samples, flag in zip(frames_samples, flags): if not any(frame_samples): # a frame with all 0 samples is easy to encode frame_nibblevals = (0, 0, flag, 0) + (0, ) * PSFRAME_NUMSAMPLES psadpcm_nibblevals.extend(frame_nibblevals) hist1 = hist2 = 0 continue # Summary of the nested loop below: We make multiple attempts to encode # this frame and keep the best one. For the first attempt, since are no # previous attempts to compare to, we encode the whole frame and keep it. # For subsequent attempts, we encode each sample to a nibble, re-decode # it, and compare the re-decoded sample to the original. Depending on how # accurate it is compared to previous attempts, we may encode the whole # frame this way and keep it (replacing previous best attempts), # or we may give up partway through and move on to the next attempt. # shift_factors and coefs: For this frame, we'll test every combination # of shift_factor/coef_idx and see which one works the best (i.e. encodes # the frame most accurately). We could just use range(13) and range(5), # but it's much faster to test in an order based on the previous frame's # shift_factor/coef_idx. shift_factors = shift_factors_order[prev_shift_factor] coefs = coefs_order[prev_coef_idx] # bestdiff: an encoded frame's accuracy is measured by its one worst # sample (how much it differs from the original sample). This is the best # such value we've managed to get so far for this frame. bestdiff = None best_framevals = None best_hists = None for shift_factor in shift_factors: shiftmul = 2**(12 - shift_factor) for coef_idx, (coef1, coef2) in coefs: attempt_hist1, attempt_hist2 = hist1, hist2 attempt_worstdiff = None attempt_nibbles = [] # Let's encode the samples in this frame. for sample in frame_samples: coefval = (attempt_hist1 * coef1) + (attempt_hist2 * coef2) # encode to nibble using these parameters (I may need to add # 0.5 to compensate for integer truncation during decoding, # but I'm not sure) nibble = (sample + 0.5 - coefval) / shiftmul # clamp to nearest 4-bit signed int if nibble <= -8: nibble = -8 elif nibble >= 7: nibble = 7 else: nibble = round(nibble) # re-decode nibble desample = nibble * shiftmul + coefval # clamp to 16-bit signed int if desample <= -32768: desample = -32768 elif desample >= 32767: desample = 32767 else: desample = int(desample) # difference between original sample and the result of decoding # the encoded sample diff = abs(sample - desample) if bestdiff is not None and diff >= bestdiff: # diff is too big for these parameters to be an improvement break # give up on these parameters and move on to next if attempt_worstdiff is None or diff > attempt_worstdiff: attempt_worstdiff = diff attempt_nibbles.append(nibble) attempt_hist2 = attempt_hist1 attempt_hist1 = desample else: # We didn't hit the `break` and give up partway through # frame_samples, which means we now have an improvement over # previous attempts. (Note that due to bestdiff's initial # value, it will never give up partway through the first # attempt) bestdiff = attempt_worstdiff best_framevals = ( shift_factor, coef_idx, flag, 0, *attempt_nibbles, ) best_hists = (attempt_hist1, attempt_hist2) # At this point, we've tried every shift_factor/coef_idx, so now # best_framevals has what we want psadpcm_nibblevals.extend(best_framevals) hist1, hist2 = best_hists prev_shift_factor, prev_coef_idx = best_framevals[:2] # At this point, psadpcm_nibblevals contains all our nibbles values, # they need to be turned into bytes return to_nibbles(*psadpcm_nibblevals) + PSFRAME_ENDBLANK