Jump to content

FIFA 99 PC - Help With Commentary Extract


Go to solution Solved by zbirow,

Recommended Posts

Posted

Hello everyone,
I need some help converting .str audio files from the PC version of FIFA 99 into .wav format.
I managed to extract the files from PLAYERS.str, but I cannot get them to play. I tried using Foobar2000 with the latest vgmstream component; Foobar loads the files, but they are completely silent (muted).
The accompanying .off file stores the player IDs and their corresponding offsets for the .str file. Each player ID is linked to two audio files within the .str container, making a total of 5,700 files for 2,850 IDs. In the .off file, the IDs start at offset 0x8, while the audio file offsets begin at 0x2C90.
Could anyone help me convert these .str files to .wav, or explain how I can do it myself?
Here is the link to the files:
https://www.mediafire.com/file/hj74io6z657q3o1/FIFA+99+PC+-+Players+(str,off).rar/file
Thanks in advance

  • Members
  • Solution
Posted
import argparse
import math
import queue
import sys
import threading
import wave
from array import array
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Iterable


MAGIC_HEADER = b"SCHl"
MAGIC_CONTROL = b"SCCl"
MAGIC_DATA = b"SCDl"
MAGIC_END = b"SCEl"
DEFAULT_SAMPLE_RATE = 22050
EA_MT10_CODEC = 0x09

MASK_TABLE = (0x01, 0x03, 0x07, 0x0F, 0x1F, 0x3F, 0x7F, 0xFF)

UTK_RC_TABLE = (
    +0.000000, -0.996776, -0.990327, -0.983879,
    -0.977431, -0.970982, -0.964534, -0.958085,
    -0.951637, -0.930754, -0.904960, -0.879167,
    -0.853373, -0.827579, -0.801786, -0.775992,
    -0.750198, -0.724405, -0.698611, -0.670635,
    -0.619048, -0.567460, -0.515873, -0.464286,
    -0.412698, -0.361111, -0.309524, -0.257937,
    -0.206349, -0.154762, -0.103175, -0.051587,
    +0.000000, +0.051587, +0.103175, +0.154762,
    +0.206349, +0.257937, +0.309524, +0.361111,
    +0.412698, +0.464286, +0.515873, +0.567460,
    +0.619048, +0.670635, +0.698611, +0.724405,
    +0.750198, +0.775992, +0.801786, +0.827579,
    +0.853373, +0.879167, +0.904960, +0.930754,
    +0.951637, +0.958085, +0.964534, +0.970982,
    +0.977431, +0.983879, +0.990327, +0.996776,
)

UTK_CODEBOOKS = (
    (
        4, 6, 5, 9, 4, 6, 5, 13, 4, 6, 5, 10, 4, 6, 5, 17,
        4, 6, 5, 9, 4, 6, 5, 14, 4, 6, 5, 10, 4, 6, 5, 21,
        4, 6, 5, 9, 4, 6, 5, 13, 4, 6, 5, 10, 4, 6, 5, 18,
        4, 6, 5, 9, 4, 6, 5, 14, 4, 6, 5, 10, 4, 6, 5, 25,
        4, 6, 5, 9, 4, 6, 5, 13, 4, 6, 5, 10, 4, 6, 5, 17,
        4, 6, 5, 9, 4, 6, 5, 14, 4, 6, 5, 10, 4, 6, 5, 22,
        4, 6, 5, 9, 4, 6, 5, 13, 4, 6, 5, 10, 4, 6, 5, 18,
        4, 6, 5, 9, 4, 6, 5, 14, 4, 6, 5, 10, 4, 6, 5, 0,
        4, 6, 5, 9, 4, 6, 5, 13, 4, 6, 5, 10, 4, 6, 5, 17,
        4, 6, 5, 9, 4, 6, 5, 14, 4, 6, 5, 10, 4, 6, 5, 21,
        4, 6, 5, 9, 4, 6, 5, 13, 4, 6, 5, 10, 4, 6, 5, 18,
        4, 6, 5, 9, 4, 6, 5, 14, 4, 6, 5, 10, 4, 6, 5, 26,
        4, 6, 5, 9, 4, 6, 5, 13, 4, 6, 5, 10, 4, 6, 5, 17,
        4, 6, 5, 9, 4, 6, 5, 14, 4, 6, 5, 10, 4, 6, 5, 22,
        4, 6, 5, 9, 4, 6, 5, 13, 4, 6, 5, 10, 4, 6, 5, 18,
        4, 6, 5, 9, 4, 6, 5, 14, 4, 6, 5, 10, 4, 6, 5, 2,
    ),
    (
        4, 11, 7, 15, 4, 12, 8, 19, 4, 11, 7, 16, 4, 12, 8, 23,
        4, 11, 7, 15, 4, 12, 8, 20, 4, 11, 7, 16, 4, 12, 8, 27,
        4, 11, 7, 15, 4, 12, 8, 19, 4, 11, 7, 16, 4, 12, 8, 24,
        4, 11, 7, 15, 4, 12, 8, 20, 4, 11, 7, 16, 4, 12, 8, 1,
        4, 11, 7, 15, 4, 12, 8, 19, 4, 11, 7, 16, 4, 12, 8, 23,
        4, 11, 7, 15, 4, 12, 8, 20, 4, 11, 7, 16, 4, 12, 8, 28,
        4, 11, 7, 15, 4, 12, 8, 19, 4, 11, 7, 16, 4, 12, 8, 24,
        4, 11, 7, 15, 4, 12, 8, 20, 4, 11, 7, 16, 4, 12, 8, 3,
        4, 11, 7, 15, 4, 12, 8, 19, 4, 11, 7, 16, 4, 12, 8, 23,
        4, 11, 7, 15, 4, 12, 8, 20, 4, 11, 7, 16, 4, 12, 8, 27,
        4, 11, 7, 15, 4, 12, 8, 19, 4, 11, 7, 16, 4, 12, 8, 24,
        4, 11, 7, 15, 4, 12, 8, 20, 4, 11, 7, 16, 4, 12, 8, 1,
        4, 11, 7, 15, 4, 12, 8, 19, 4, 11, 7, 16, 4, 12, 8, 23,
        4, 11, 7, 15, 4, 12, 8, 20, 4, 11, 7, 16, 4, 12, 8, 28,
        4, 11, 7, 15, 4, 12, 8, 19, 4, 11, 7, 16, 4, 12, 8, 24,
        4, 11, 7, 15, 4, 12, 8, 20, 4, 11, 7, 16, 4, 12, 8, 3,
    ),
)

MDL_NORMAL = 0
MDL_LARGEPULSE = 1

UTK_COMMANDS = (
    (MDL_LARGEPULSE, 8, 0.0),
    (MDL_LARGEPULSE, 7, 0.0),
    (MDL_NORMAL, 8, 0.0),
    (MDL_NORMAL, 7, 0.0),
    (MDL_NORMAL, 2, 0.0),
    (MDL_NORMAL, 2, -1.0),
    (MDL_NORMAL, 2, +1.0),
    (MDL_NORMAL, 3, -1.0),
    (MDL_NORMAL, 3, +1.0),
    (MDL_LARGEPULSE, 4, -2.0),
    (MDL_LARGEPULSE, 4, +2.0),
    (MDL_LARGEPULSE, 3, -2.0),
    (MDL_LARGEPULSE, 3, +2.0),
    (MDL_LARGEPULSE, 5, -3.0),
    (MDL_LARGEPULSE, 5, +3.0),
    (MDL_LARGEPULSE, 4, -3.0),
    (MDL_LARGEPULSE, 4, +3.0),
    (MDL_LARGEPULSE, 6, -4.0),
    (MDL_LARGEPULSE, 6, +4.0),
    (MDL_LARGEPULSE, 5, -4.0),
    (MDL_LARGEPULSE, 5, +4.0),
    (MDL_LARGEPULSE, 7, -5.0),
    (MDL_LARGEPULSE, 7, +5.0),
    (MDL_LARGEPULSE, 6, -5.0),
    (MDL_LARGEPULSE, 6, +5.0),
    (MDL_LARGEPULSE, 8, -6.0),
    (MDL_LARGEPULSE, 8, +6.0),
    (MDL_LARGEPULSE, 7, -6.0),
    (MDL_LARGEPULSE, 7, +6.0),
)


@dataclass(frozen=True)
class ScdlBlock:
    offset: int
    size: int
    samples: int


@dataclass(frozen=True)
class AudioStream:
    index: int
    offset: int
    end_offset: int
    channels: int
    sample_rate: int
    num_samples: int
    codec: int
    blocks: tuple[ScdlBlock, ...]


@dataclass(frozen=True)
class ExtractStats:
    streams: int
    wavs: int
    samples: int
    output_dir: Path


class StrFormatError(RuntimeError):
    pass


class BitReader:
    def __init__(self) -> None:
        self.data = b""
        self.pos = 0
        self.bits_value = 0
        self.bits_count = 0

    def set_buffer(self, data: bytes) -> None:
        self.data = data
        self.pos = 0
        self.bits_value = 0
        self.bits_count = 0

    def read_byte(self) -> int:
        if self.pos < len(self.data):
            value = self.data[self.pos]
            self.pos += 1
            return value
        return 0

    def init_bits(self) -> None:
        if not self.bits_count:
            self.bits_value = self.read_byte()
            self.bits_count = 8

    def peek_bits(self, count: int) -> int:
        return self.bits_value & MASK_TABLE[count - 1]

    def read_bits(self, count: int) -> int:
        value = self.bits_value & MASK_TABLE[count - 1]
        self.bits_value >>= count
        self.bits_count -= count
        if self.bits_count < 8:
            self.bits_value |= self.read_byte() << self.bits_count
            self.bits_count += 8
        return value

    def consume_bits(self, count: int) -> None:
        self.read_bits(count)


class UtkDecoder:
    """EA MicroTalk / MT10 decoder."""

    def __init__(self) -> None:
        self.br = BitReader()
        self.adapt_cb = [0.0] * 324
        self.samples = [0.0] * 432
        self.reset()

    def reset(self) -> None:
        self.parsed_header = False
        self.reduced_bandwidth = False
        self.multipulse_threshold = 0
        self.fixed_gains = [0.0] * 64
        self.rc_data = [0.0] * 12
        self.synth_history = [0.0] * 12
        self.adapt_cb = [0.0] * 324
        self.samples = [0.0] * 432
        self.br.set_buffer(b"")

    def set_buffer(self, data: bytes) -> None:
        self.br.set_buffer(data)

    def decode_frame(self) -> list[float]:
        self._decode_frame_main()
        return self.samples

    def _parse_header(self) -> None:
        self.reduced_bandwidth = self.br.read_bits(1) == 1
        base_thre = self.br.read_bits(4)
        base_gain = self.br.read_bits(4)
        base_mult = self.br.read_bits(6)

        self.multipulse_threshold = 32 - base_thre
        self.fixed_gains[0] = 8.0 * (1 + base_gain)
        multiplier = 1.04 + base_mult * 0.001
        for i in range(1, 64):
            self.fixed_gains[i] = self.fixed_gains[i - 1] * multiplier

    def _decode_excitation(self, use_multipulse: bool, out: list[float], start: int, stride: int) -> None:
        i = 0
        if use_multipulse:
            model = 0
            while i < 108:
                huffman_code = self.br.peek_bits(8)
                cmd = UTK_CODEBOOKS[model][huffman_code]
                model, code_size, pulse_value = UTK_COMMANDS[cmd]
                self.br.consume_bits(code_size)

                if cmd > 3:
                    out[start + i] = pulse_value
                    i += stride
                elif cmd > 1:
                    count = 7 + self.br.read_bits(6)
                    if i + count * stride > 108:
                        count = (108 - i) // stride
                    while count > 0:
                        out[start + i] = 0.0
                        i += stride
                        count -= 1
                else:
                    x = 7
                    while self.br.read_bits(1):
                        x += 1
                    if not self.br.read_bits(1):
                        x *= -1
                    out[start + i] = float(x)
                    i += stride
        else:
            while i < 108:
                huffman_code = self.br.peek_bits(2)
                if huffman_code in (0, 2):
                    val = 0.0
                    bits = 1
                elif huffman_code == 1:
                    val = -2.0
                    bits = 2
                else:
                    val = 2.0
                    bits = 2
                self.br.consume_bits(bits)
                out[start + i] = val
                i += stride

    def _rc_to_lpc(self) -> list[float]:
        tmp1 = [0.0] * 12
        tmp2 = [0.0] * 12
        lpc = [0.0] * 12

        for i in range(10, -1, -1):
            tmp2[i + 1] = self.rc_data[i]
        tmp2[0] = 1.0

        for i in range(12):
            x = -(self.rc_data[11] * tmp2[11])
            for j in range(10, -1, -1):
                x -= self.rc_data[j] * tmp2[j]
                tmp2[j + 1] = x * self.rc_data[j] + tmp2[j]

            tmp2[0] = x
            tmp1[i] = x

            for j in range(i):
                x -= tmp1[i - 1 - j] * lpc[j]
            lpc[i] = x

        return lpc

    def _lp_synthesis_filter(self, offset: int, blocks: int) -> None:
        lpc = self._rc_to_lpc()
        ptr = offset
        for _ in range(blocks):
            for j in range(12):
                x = self.samples[ptr]
                for k in range(j):
                    x += lpc[k] * self.synth_history[k - j + 12]
                for k in range(j, 12):
                    x += lpc[k] * self.synth_history[k - j]
                self.synth_history[11 - j] = x
                self.samples[ptr] = x
                ptr += 1

    @staticmethod
    def _interpolate_rest(excitation: list[float], start: int) -> None:
        for i in range(0, 108, 2):
            tmp1 = (excitation[start + i - 5] + excitation[start + i + 5]) * 0.01803268
            tmp2 = (excitation[start + i - 3] + excitation[start + i + 3]) * 0.11459156
            tmp3 = (excitation[start + i - 1] + excitation[start + i + 1]) * 0.59738597
            excitation[start + i] = tmp1 - tmp2 + tmp3

    def _decode_frame_main(self) -> None:
        self.br.init_bits()
        if not self.parsed_header:
            self._parse_header()
            self.parsed_header = True

        use_multipulse = False
        rc_delta = [0.0] * 12

        for i in range(12):
            if i == 0:
                idx = self.br.read_bits(6)
                if idx < self.multipulse_threshold:
                    use_multipulse = True
            elif i < 4:
                idx = self.br.read_bits(6)
            else:
                idx = 16 + self.br.read_bits(5)
            rc_delta[i] = (UTK_RC_TABLE[idx] - self.rc_data[i]) * 0.25

        excitation = [0.0] * (5 + 108 + 5)
        for i in range(4):
            pitch_lag = self.br.read_bits(8)
            pitch_value = self.br.read_bits(4)
            gain_index = self.br.read_bits(6)

            pitch_gain = float(pitch_value) / 15.0
            fixed_gain = self.fixed_gains[gain_index]

            if not self.reduced_bandwidth:
                self._decode_excitation(use_multipulse, excitation, 5, 1)
            else:
                align = self.br.read_bits(1)
                zero_flag = self.br.read_bits(1)
                self._decode_excitation(use_multipulse, excitation, 5 + align, 2)

                if zero_flag:
                    for j in range(54):
                        excitation[5 + (1 - align) + 2 * j] = 0.0
                else:
                    for j in range(5):
                        excitation[j] = 0.0
                        excitation[5 + 108 + j] = 0.0
                    self._interpolate_rest(excitation, 5 + (1 - align))
                    fixed_gain *= 0.5

            for j in range(108):
                idx = 108 * i + 216 - pitch_lag + j
                if idx < 0:
                    idx = 0

                if idx < 324:
                    adaptive = self.adapt_cb[idx]
                else:
                    adaptive = self.samples[idx - 324]
                self.samples[108 * i + j] = fixed_gain * excitation[5 + j] + pitch_gain * adaptive

        self.adapt_cb[:] = self.samples[108:432]

        for i in range(4):
            for j in range(12):
                self.rc_data[j] += rc_delta[j]
            self._lp_synthesis_filter(12 * i, 1 if i < 3 else 33)


def read_u32le(data: bytes, offset: int) -> int:
    return int.from_bytes(data[offset:offset + 4], "little")


def read_patch(data: bytes, offset: int) -> tuple[int, int]:
    if offset >= len(data):
        raise StrFormatError("Unexpected end of SCHl patch data")
    size = data[offset]
    offset += 1
    if offset + size > len(data):
        raise StrFormatError("Invalid SCHl patch length")
    value = 0
    for byte in data[offset:offset + size]:
        value = (value << 8) | byte
    return value, offset + size


def parse_schl_header(data: bytes, offset: int, size: int) -> dict[str, int]:
    chunk = data[offset:offset + size]
    if len(chunk) < 12:
        raise StrFormatError(f"Short SCHl header at 0x{offset:x}")

    info = {
        "channels": 1,
        "sample_rate": DEFAULT_SAMPLE_RATE,
        "num_samples": 0,
        "codec": EA_MT10_CODEC,
    }

    pos = 12
    value_patches = {
        0x00, 0x06, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89,
        0x8A, 0x8B, 0x8C, 0x8D, 0x8E, 0x91, 0x92, 0x93, 0x94, 0x95,
        0x98, 0x99, 0x9C, 0x9D, 0x9E, 0x9F, 0xA0, 0xA1, 0xA2, 0xA3,
        0xA6, 0xA7, 0xAB, 0xAC, 0xAD, 0x1A, 0x26, 0x27, 0x28, 0x29,
        0x2A,
    }

    while pos < len(chunk):
        patch_type = chunk[pos]
        pos += 1

        if patch_type in (0xFF, 0xFE):
            break
        if patch_type in (0xFC, 0xFD):
            continue

        if patch_type not in value_patches:
            # Most SCHl fields are length-prefixed patches. Try to skip unknown
            # fields the same way, so variants remain extractable.
            if pos >= len(chunk):
                break
        value, pos = read_patch(chunk, pos)

        if patch_type == 0x82:
            info["channels"] = max(1, value)
        elif patch_type == 0x83:
            info["codec"] = value
        elif patch_type == 0x84:
            info["sample_rate"] = value
        elif patch_type == 0x85:
            info["num_samples"] = value
        elif patch_type == 0xA0 and info.get("codec", EA_MT10_CODEC) == 0:
            info["codec"] = value

    return info


def parse_streams(data: bytes) -> list[AudioStream]:
    streams: list[AudioStream] = []
    pos = 0
    file_size = len(data)

    while pos < file_size:
        if pos + 8 > file_size:
            raise StrFormatError(f"Truncated chunk at 0x{pos:x}")

        if data[pos:pos + 4] != MAGIC_HEADER:
            raise StrFormatError(f"Expected SCHl at 0x{pos:x}, found {data[pos:pos + 4]!r}")

        start = pos
        header_size = read_u32le(data, pos + 4)
        if header_size < 8:
            raise StrFormatError(f"Invalid SCHl size at 0x{pos:x}")
        info = parse_schl_header(data, pos, header_size)
        pos += header_size

        if data[pos:pos + 4] == MAGIC_CONTROL:
            control_size = read_u32le(data, pos + 4)
            if control_size < 8:
                raise StrFormatError(f"Invalid SCCl size at 0x{pos:x}")
            pos += control_size

        blocks: list[ScdlBlock] = []
        while pos < file_size and data[pos:pos + 4] == MAGIC_DATA:
            block_size = read_u32le(data, pos + 4)
            if block_size < 13:
                raise StrFormatError(f"Invalid SCDl size at 0x{pos:x}")
            block_samples = read_u32le(data, pos + 8)
            blocks.append(ScdlBlock(pos, block_size, block_samples))
            pos += block_size

        if pos + 8 > file_size or data[pos:pos + 4] != MAGIC_END:
            raise StrFormatError(f"Expected SCEl after stream {len(streams)} at 0x{pos:x}")
        end_size = read_u32le(data, pos + 4)
        if end_size < 8:
            raise StrFormatError(f"Invalid SCEl size at 0x{pos:x}")
        pos += end_size

        num_samples = info["num_samples"] or sum(block.samples for block in blocks)
        streams.append(AudioStream(
            index=len(streams),
            offset=start,
            end_offset=pos,
            channels=info["channels"],
            sample_rate=info["sample_rate"] or DEFAULT_SAMPLE_RATE,
            num_samples=num_samples,
            codec=info["codec"],
            blocks=tuple(blocks),
        ))

    return streams


def clamp_pcm16(value: float) -> int:
    pcm = int(value + 0.5) if value >= 0 else int(value - 0.5)
    if pcm < -32768:
        return -32768
    if pcm > 32767:
        return 32767
    return pcm


def decode_stream(data: bytes, stream: AudioStream) -> array:
    if stream.channels != 1:
        raise StrFormatError(f"Stream {stream.index} has {stream.channels} channels; only mono MT10 is supported")
    if stream.codec != EA_MT10_CODEC:
        raise StrFormatError(f"Stream {stream.index} uses codec 0x{stream.codec:02x}, expected MT10 codec 0x09")

    decoder = UtkDecoder()
    out = array("h")

    for block in stream.blocks:
        samples_left = block.samples
        block_start = block.offset + 0x0D
        block_end = block.offset + block.size
        decoder.set_buffer(data[block_start:block_end])

        while samples_left > 0:
            frame = decoder.decode_frame()
            take = min(samples_left, 432)
            out.extend(clamp_pcm16(frame[i]) for i in range(take))
            samples_left -= take

    if sys.byteorder != "little":
        out.byteswap()
    return out


def write_wav(path: Path, samples: array, sample_rate: int, channels: int = 1) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with wave.open(str(path), "wb") as wav:
        wav.setnchannels(channels)
        wav.setsampwidth(2)
        wav.setframerate(sample_rate)
        wav.writeframes(samples.tobytes())


def default_output_dir(input_path: Path) -> Path:
    return input_path.with_name(f"{input_path.stem}_wav")


ProgressCallback = Callable[[int, int, Path], None]


def extract_str_file(
    input_path: Path,
    output_dir: Path | None = None,
    sample_rate: int | None = None,
    start_index: int = 0,
    limit: int | None = None,
    progress: ProgressCallback | None = None,
) -> ExtractStats:
    data = input_path.read_bytes()
    streams = parse_streams(data)
    output_dir = output_dir or default_output_dir(input_path)
    output_dir.mkdir(parents=True, exist_ok=True)

    selected = streams[start_index:]
    if limit is not None:
        selected = selected[:limit]

    total = len(selected)
    written = 0
    total_samples = 0
    for current, stream in enumerate(selected, 1):
        effective_stream = stream
        if sample_rate:
            effective_stream = AudioStream(
                stream.index,
                stream.offset,
                stream.end_offset,
                stream.channels,
                sample_rate,
                stream.num_samples,
                stream.codec,
                stream.blocks,
            )

        samples = decode_stream(data, effective_stream)
        total_samples += len(samples)
        wav_path = output_dir / f"{input_path.stem.lower()}_{stream.index:04d}.wav"
        write_wav(wav_path, samples, effective_stream.sample_rate, effective_stream.channels)
        written += 1
        if progress:
            progress(current, total, wav_path)

    return ExtractStats(len(streams), written, total_samples, output_dir)


def find_default_inputs() -> list[Path]:
    path = Path("PLAYERS.STR")
    return [path] if path.exists() else []


def run_cli(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description="Extract EA MT10 audio from PLAYERS.STR to WAV.")
    parser.add_argument("inputs", nargs="*", type=Path, help="STR file(s) to extract")
    parser.add_argument("-o", "--output", type=Path, help="Output folder")
    parser.add_argument("--sample-rate", type=int, help=f"Override sample rate (default: {DEFAULT_SAMPLE_RATE})")
    parser.add_argument("--start-index", type=int, default=0, help="First stream index to extract")
    parser.add_argument("--limit", type=int, help="Extract only this many streams")
    parser.add_argument("--gui", action="store_true", help="Open the graphical interface")
    parser.add_argument("--no-gui", action="store_true", help="Run from command line")
    args = parser.parse_args(argv)

    if args.gui or (not args.no_gui and not args.inputs):
        return run_gui()

    inputs = args.inputs or find_default_inputs()
    if not inputs:
        print("No input STR file was provided and PLAYERS.STR was not found.", file=sys.stderr)
        return 2

    for input_path in inputs:
        if not input_path.exists():
            print(f"Missing file: {input_path}", file=sys.stderr)
            return 2

    for input_path in inputs:
        if args.output and len(inputs) > 1:
            out_dir = args.output / input_path.stem
        else:
            out_dir = args.output

        def report(current: int, total: int, wav_path: Path) -> None:
            print(f"[{current}/{total}] {wav_path}")

        stats = extract_str_file(
            input_path,
            output_dir=out_dir,
            sample_rate=args.sample_rate,
            start_index=args.start_index,
            limit=args.limit,
            progress=report,
        )
        seconds = stats.samples / float(args.sample_rate or DEFAULT_SAMPLE_RATE)
        print(f"Done: {stats.wavs}/{stats.streams} WAV files, {seconds:.1f}s of audio -> {stats.output_dir}")

    return 0


def run_gui() -> int:
    import tkinter as tk
    from tkinter import filedialog, messagebox, ttk

    root = tk.Tk()
    root.title("PLAYERS.STR WAV Extractor")
    root.geometry("680x330")
    root.minsize(620, 300)

    input_var = tk.StringVar(value=str(Path.cwd() / "PLAYERS.STR") if (Path.cwd() / "PLAYERS.STR").exists() else "")
    output_var = tk.StringVar(value=str(default_output_dir(Path("PLAYERS.STR")).resolve()))
    sample_rate_var = tk.StringVar(value=str(DEFAULT_SAMPLE_RATE))
    status_var = tk.StringVar(value="Ready")
    progress_var = tk.DoubleVar(value=0.0)
    work_queue: queue.Queue[tuple[str, object]] = queue.Queue()

    main = ttk.Frame(root, padding=14)
    main.pack(fill="both", expand=True)
    main.columnconfigure(1, weight=1)

    ttk.Label(main, text="STR file").grid(row=0, column=0, sticky="w", padx=(0, 8), pady=6)
    ttk.Entry(main, textvariable=input_var).grid(row=0, column=1, sticky="ew", pady=6)

    def browse_input() -> None:
        filename = filedialog.askopenfilename(
            title="Select STR file",
            filetypes=[("STR audio container", "*.str"), ("All files", "*.*")],
        )
        if filename:
            input_var.set(filename)
            output_var.set(str(default_output_dir(Path(filename))))

    ttk.Button(main, text="Browse", command=browse_input).grid(row=0, column=2, padx=(8, 0), pady=6)

    ttk.Label(main, text="Output folder").grid(row=1, column=0, sticky="w", padx=(0, 8), pady=6)
    ttk.Entry(main, textvariable=output_var).grid(row=1, column=1, sticky="ew", pady=6)

    def browse_output() -> None:
        dirname = filedialog.askdirectory(title="Select output folder")
        if dirname:
            output_var.set(dirname)

    ttk.Button(main, text="Browse", command=browse_output).grid(row=1, column=2, padx=(8, 0), pady=6)

    ttk.Label(main, text="Sample rate").grid(row=2, column=0, sticky="w", padx=(0, 8), pady=6)
    ttk.Entry(main, textvariable=sample_rate_var, width=12).grid(row=2, column=1, sticky="w", pady=6)

    bar = ttk.Progressbar(main, variable=progress_var, maximum=100.0)
    bar.grid(row=3, column=0, columnspan=3, sticky="ew", pady=(18, 6))
    ttk.Label(main, textvariable=status_var).grid(row=4, column=0, columnspan=3, sticky="w", pady=6)

    buttons = ttk.Frame(main)
    buttons.grid(row=5, column=0, columnspan=3, sticky="e", pady=(18, 0))

    start_button = ttk.Button(buttons, text="Start")
    start_button.pack(side="right")

    def worker() -> None:
        try:
            input_path = Path(input_var.get()).expanduser()
            output_path = Path(output_var.get()).expanduser()
            sample_rate = int(sample_rate_var.get() or DEFAULT_SAMPLE_RATE)

            def report(current: int, total: int, wav_path: Path) -> None:
                work_queue.put(("progress", (current, total, wav_path)))

            stats = extract_str_file(input_path, output_path, sample_rate=sample_rate, progress=report)
            work_queue.put(("done", stats))
        except Exception as exc:  # GUI should show the real error instead of dying silently.
            work_queue.put(("error", exc))

    def start() -> None:
        if not input_var.get().strip():
            messagebox.showerror("Missing file", "Select a STR file first.")
            return
        start_button.config(state="disabled")
        progress_var.set(0.0)
        status_var.set("Extracting...")
        threading.Thread(target=worker, daemon=True).start()

    start_button.config(command=start)

    def poll_queue() -> None:
        try:
            while True:
                kind, payload = work_queue.get_nowait()
                if kind == "progress":
                    current, total, wav_path = payload  # type: ignore[misc]
                    progress_var.set((current / max(total, 1)) * 100.0)
                    status_var.set(f"{current}/{total}: {Path(wav_path).name}")
                elif kind == "done":
                    stats = payload  # type: ignore[assignment]
                    progress_var.set(100.0)
                    status_var.set(f"Done: {stats.wavs} WAV files written to {stats.output_dir}")
                    start_button.config(state="normal")
                    messagebox.showinfo("Done", f"Extracted {stats.wavs} WAV files.")
                elif kind == "error":
                    start_button.config(state="normal")
                    status_var.set("Error")
                    messagebox.showerror("Extraction failed", str(payload))
        except queue.Empty:
            pass
        root.after(100, poll_queue)

    poll_queue()
    root.mainloop()
    return 0


if __name__ == "__main__":
    raise SystemExit(run_cli())

 

Posted
On 5/25/2026 at 4:17 PM, zbirow said:
import argparse
import math
import queue
import sys
import threading
import wave
from array import array
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Iterable


MAGIC_HEADER = b"SCHl"
MAGIC_CONTROL = b"SCCl"
MAGIC_DATA = b"SCDl"
MAGIC_END = b"SCEl"
DEFAULT_SAMPLE_RATE = 22050
EA_MT10_CODEC = 0x09

MASK_TABLE = (0x01, 0x03, 0x07, 0x0F, 0x1F, 0x3F, 0x7F, 0xFF)

UTK_RC_TABLE = (
    +0.000000, -0.996776, -0.990327, -0.983879,
    -0.977431, -0.970982, -0.964534, -0.958085,
    -0.951637, -0.930754, -0.904960, -0.879167,
    -0.853373, -0.827579, -0.801786, -0.775992,
    -0.750198, -0.724405, -0.698611, -0.670635,
    -0.619048, -0.567460, -0.515873, -0.464286,
    -0.412698, -0.361111, -0.309524, -0.257937,
    -0.206349, -0.154762, -0.103175, -0.051587,
    +0.000000, +0.051587, +0.103175, +0.154762,
    +0.206349, +0.257937, +0.309524, +0.361111,
    +0.412698, +0.464286, +0.515873, +0.567460,
    +0.619048, +0.670635, +0.698611, +0.724405,
    +0.750198, +0.775992, +0.801786, +0.827579,
    +0.853373, +0.879167, +0.904960, +0.930754,
    +0.951637, +0.958085, +0.964534, +0.970982,
    +0.977431, +0.983879, +0.990327, +0.996776,
)

UTK_CODEBOOKS = (
    (
        4, 6, 5, 9, 4, 6, 5, 13, 4, 6, 5, 10, 4, 6, 5, 17,
        4, 6, 5, 9, 4, 6, 5, 14, 4, 6, 5, 10, 4, 6, 5, 21,
        4, 6, 5, 9, 4, 6, 5, 13, 4, 6, 5, 10, 4, 6, 5, 18,
        4, 6, 5, 9, 4, 6, 5, 14, 4, 6, 5, 10, 4, 6, 5, 25,
        4, 6, 5, 9, 4, 6, 5, 13, 4, 6, 5, 10, 4, 6, 5, 17,
        4, 6, 5, 9, 4, 6, 5, 14, 4, 6, 5, 10, 4, 6, 5, 22,
        4, 6, 5, 9, 4, 6, 5, 13, 4, 6, 5, 10, 4, 6, 5, 18,
        4, 6, 5, 9, 4, 6, 5, 14, 4, 6, 5, 10, 4, 6, 5, 0,
        4, 6, 5, 9, 4, 6, 5, 13, 4, 6, 5, 10, 4, 6, 5, 17,
        4, 6, 5, 9, 4, 6, 5, 14, 4, 6, 5, 10, 4, 6, 5, 21,
        4, 6, 5, 9, 4, 6, 5, 13, 4, 6, 5, 10, 4, 6, 5, 18,
        4, 6, 5, 9, 4, 6, 5, 14, 4, 6, 5, 10, 4, 6, 5, 26,
        4, 6, 5, 9, 4, 6, 5, 13, 4, 6, 5, 10, 4, 6, 5, 17,
        4, 6, 5, 9, 4, 6, 5, 14, 4, 6, 5, 10, 4, 6, 5, 22,
        4, 6, 5, 9, 4, 6, 5, 13, 4, 6, 5, 10, 4, 6, 5, 18,
        4, 6, 5, 9, 4, 6, 5, 14, 4, 6, 5, 10, 4, 6, 5, 2,
    ),
    (
        4, 11, 7, 15, 4, 12, 8, 19, 4, 11, 7, 16, 4, 12, 8, 23,
        4, 11, 7, 15, 4, 12, 8, 20, 4, 11, 7, 16, 4, 12, 8, 27,
        4, 11, 7, 15, 4, 12, 8, 19, 4, 11, 7, 16, 4, 12, 8, 24,
        4, 11, 7, 15, 4, 12, 8, 20, 4, 11, 7, 16, 4, 12, 8, 1,
        4, 11, 7, 15, 4, 12, 8, 19, 4, 11, 7, 16, 4, 12, 8, 23,
        4, 11, 7, 15, 4, 12, 8, 20, 4, 11, 7, 16, 4, 12, 8, 28,
        4, 11, 7, 15, 4, 12, 8, 19, 4, 11, 7, 16, 4, 12, 8, 24,
        4, 11, 7, 15, 4, 12, 8, 20, 4, 11, 7, 16, 4, 12, 8, 3,
        4, 11, 7, 15, 4, 12, 8, 19, 4, 11, 7, 16, 4, 12, 8, 23,
        4, 11, 7, 15, 4, 12, 8, 20, 4, 11, 7, 16, 4, 12, 8, 27,
        4, 11, 7, 15, 4, 12, 8, 19, 4, 11, 7, 16, 4, 12, 8, 24,
        4, 11, 7, 15, 4, 12, 8, 20, 4, 11, 7, 16, 4, 12, 8, 1,
        4, 11, 7, 15, 4, 12, 8, 19, 4, 11, 7, 16, 4, 12, 8, 23,
        4, 11, 7, 15, 4, 12, 8, 20, 4, 11, 7, 16, 4, 12, 8, 28,
        4, 11, 7, 15, 4, 12, 8, 19, 4, 11, 7, 16, 4, 12, 8, 24,
        4, 11, 7, 15, 4, 12, 8, 20, 4, 11, 7, 16, 4, 12, 8, 3,
    ),
)

MDL_NORMAL = 0
MDL_LARGEPULSE = 1

UTK_COMMANDS = (
    (MDL_LARGEPULSE, 8, 0.0),
    (MDL_LARGEPULSE, 7, 0.0),
    (MDL_NORMAL, 8, 0.0),
    (MDL_NORMAL, 7, 0.0),
    (MDL_NORMAL, 2, 0.0),
    (MDL_NORMAL, 2, -1.0),
    (MDL_NORMAL, 2, +1.0),
    (MDL_NORMAL, 3, -1.0),
    (MDL_NORMAL, 3, +1.0),
    (MDL_LARGEPULSE, 4, -2.0),
    (MDL_LARGEPULSE, 4, +2.0),
    (MDL_LARGEPULSE, 3, -2.0),
    (MDL_LARGEPULSE, 3, +2.0),
    (MDL_LARGEPULSE, 5, -3.0),
    (MDL_LARGEPULSE, 5, +3.0),
    (MDL_LARGEPULSE, 4, -3.0),
    (MDL_LARGEPULSE, 4, +3.0),
    (MDL_LARGEPULSE, 6, -4.0),
    (MDL_LARGEPULSE, 6, +4.0),
    (MDL_LARGEPULSE, 5, -4.0),
    (MDL_LARGEPULSE, 5, +4.0),
    (MDL_LARGEPULSE, 7, -5.0),
    (MDL_LARGEPULSE, 7, +5.0),
    (MDL_LARGEPULSE, 6, -5.0),
    (MDL_LARGEPULSE, 6, +5.0),
    (MDL_LARGEPULSE, 8, -6.0),
    (MDL_LARGEPULSE, 8, +6.0),
    (MDL_LARGEPULSE, 7, -6.0),
    (MDL_LARGEPULSE, 7, +6.0),
)


@dataclass(frozen=True)
class ScdlBlock:
    offset: int
    size: int
    samples: int


@dataclass(frozen=True)
class AudioStream:
    index: int
    offset: int
    end_offset: int
    channels: int
    sample_rate: int
    num_samples: int
    codec: int
    blocks: tuple[ScdlBlock, ...]


@dataclass(frozen=True)
class ExtractStats:
    streams: int
    wavs: int
    samples: int
    output_dir: Path


class StrFormatError(RuntimeError):
    pass


class BitReader:
    def __init__(self) -> None:
        self.data = b""
        self.pos = 0
        self.bits_value = 0
        self.bits_count = 0

    def set_buffer(self, data: bytes) -> None:
        self.data = data
        self.pos = 0
        self.bits_value = 0
        self.bits_count = 0

    def read_byte(self) -> int:
        if self.pos < len(self.data):
            value = self.data[self.pos]
            self.pos += 1
            return value
        return 0

    def init_bits(self) -> None:
        if not self.bits_count:
            self.bits_value = self.read_byte()
            self.bits_count = 8

    def peek_bits(self, count: int) -> int:
        return self.bits_value & MASK_TABLE[count - 1]

    def read_bits(self, count: int) -> int:
        value = self.bits_value & MASK_TABLE[count - 1]
        self.bits_value >>= count
        self.bits_count -= count
        if self.bits_count < 8:
            self.bits_value |= self.read_byte() << self.bits_count
            self.bits_count += 8
        return value

    def consume_bits(self, count: int) -> None:
        self.read_bits(count)


class UtkDecoder:
    """EA MicroTalk / MT10 decoder."""

    def __init__(self) -> None:
        self.br = BitReader()
        self.adapt_cb = [0.0] * 324
        self.samples = [0.0] * 432
        self.reset()

    def reset(self) -> None:
        self.parsed_header = False
        self.reduced_bandwidth = False
        self.multipulse_threshold = 0
        self.fixed_gains = [0.0] * 64
        self.rc_data = [0.0] * 12
        self.synth_history = [0.0] * 12
        self.adapt_cb = [0.0] * 324
        self.samples = [0.0] * 432
        self.br.set_buffer(b"")

    def set_buffer(self, data: bytes) -> None:
        self.br.set_buffer(data)

    def decode_frame(self) -> list[float]:
        self._decode_frame_main()
        return self.samples

    def _parse_header(self) -> None:
        self.reduced_bandwidth = self.br.read_bits(1) == 1
        base_thre = self.br.read_bits(4)
        base_gain = self.br.read_bits(4)
        base_mult = self.br.read_bits(6)

        self.multipulse_threshold = 32 - base_thre
        self.fixed_gains[0] = 8.0 * (1 + base_gain)
        multiplier = 1.04 + base_mult * 0.001
        for i in range(1, 64):
            self.fixed_gains[i] = self.fixed_gains[i - 1] * multiplier

    def _decode_excitation(self, use_multipulse: bool, out: list[float], start: int, stride: int) -> None:
        i = 0
        if use_multipulse:
            model = 0
            while i < 108:
                huffman_code = self.br.peek_bits(8)
                cmd = UTK_CODEBOOKS[model][huffman_code]
                model, code_size, pulse_value = UTK_COMMANDS[cmd]
                self.br.consume_bits(code_size)

                if cmd > 3:
                    out[start + i] = pulse_value
                    i += stride
                elif cmd > 1:
                    count = 7 + self.br.read_bits(6)
                    if i + count * stride > 108:
                        count = (108 - i) // stride
                    while count > 0:
                        out[start + i] = 0.0
                        i += stride
                        count -= 1
                else:
                    x = 7
                    while self.br.read_bits(1):
                        x += 1
                    if not self.br.read_bits(1):
                        x *= -1
                    out[start + i] = float(x)
                    i += stride
        else:
            while i < 108:
                huffman_code = self.br.peek_bits(2)
                if huffman_code in (0, 2):
                    val = 0.0
                    bits = 1
                elif huffman_code == 1:
                    val = -2.0
                    bits = 2
                else:
                    val = 2.0
                    bits = 2
                self.br.consume_bits(bits)
                out[start + i] = val
                i += stride

    def _rc_to_lpc(self) -> list[float]:
        tmp1 = [0.0] * 12
        tmp2 = [0.0] * 12
        lpc = [0.0] * 12

        for i in range(10, -1, -1):
            tmp2[i + 1] = self.rc_data[i]
        tmp2[0] = 1.0

        for i in range(12):
            x = -(self.rc_data[11] * tmp2[11])
            for j in range(10, -1, -1):
                x -= self.rc_data[j] * tmp2[j]
                tmp2[j + 1] = x * self.rc_data[j] + tmp2[j]

            tmp2[0] = x
            tmp1[i] = x

            for j in range(i):
                x -= tmp1[i - 1 - j] * lpc[j]
            lpc[i] = x

        return lpc

    def _lp_synthesis_filter(self, offset: int, blocks: int) -> None:
        lpc = self._rc_to_lpc()
        ptr = offset
        for _ in range(blocks):
            for j in range(12):
                x = self.samples[ptr]
                for k in range(j):
                    x += lpc[k] * self.synth_history[k - j + 12]
                for k in range(j, 12):
                    x += lpc[k] * self.synth_history[k - j]
                self.synth_history[11 - j] = x
                self.samples[ptr] = x
                ptr += 1

    @staticmethod
    def _interpolate_rest(excitation: list[float], start: int) -> None:
        for i in range(0, 108, 2):
            tmp1 = (excitation[start + i - 5] + excitation[start + i + 5]) * 0.01803268
            tmp2 = (excitation[start + i - 3] + excitation[start + i + 3]) * 0.11459156
            tmp3 = (excitation[start + i - 1] + excitation[start + i + 1]) * 0.59738597
            excitation[start + i] = tmp1 - tmp2 + tmp3

    def _decode_frame_main(self) -> None:
        self.br.init_bits()
        if not self.parsed_header:
            self._parse_header()
            self.parsed_header = True

        use_multipulse = False
        rc_delta = [0.0] * 12

        for i in range(12):
            if i == 0:
                idx = self.br.read_bits(6)
                if idx < self.multipulse_threshold:
                    use_multipulse = True
            elif i < 4:
                idx = self.br.read_bits(6)
            else:
                idx = 16 + self.br.read_bits(5)
            rc_delta[i] = (UTK_RC_TABLE[idx] - self.rc_data[i]) * 0.25

        excitation = [0.0] * (5 + 108 + 5)
        for i in range(4):
            pitch_lag = self.br.read_bits(8)
            pitch_value = self.br.read_bits(4)
            gain_index = self.br.read_bits(6)

            pitch_gain = float(pitch_value) / 15.0
            fixed_gain = self.fixed_gains[gain_index]

            if not self.reduced_bandwidth:
                self._decode_excitation(use_multipulse, excitation, 5, 1)
            else:
                align = self.br.read_bits(1)
                zero_flag = self.br.read_bits(1)
                self._decode_excitation(use_multipulse, excitation, 5 + align, 2)

                if zero_flag:
                    for j in range(54):
                        excitation[5 + (1 - align) + 2 * j] = 0.0
                else:
                    for j in range(5):
                        excitation[j] = 0.0
                        excitation[5 + 108 + j] = 0.0
                    self._interpolate_rest(excitation, 5 + (1 - align))
                    fixed_gain *= 0.5

            for j in range(108):
                idx = 108 * i + 216 - pitch_lag + j
                if idx < 0:
                    idx = 0

                if idx < 324:
                    adaptive = self.adapt_cb[idx]
                else:
                    adaptive = self.samples[idx - 324]
                self.samples[108 * i + j] = fixed_gain * excitation[5 + j] + pitch_gain * adaptive

        self.adapt_cb[:] = self.samples[108:432]

        for i in range(4):
            for j in range(12):
                self.rc_data[j] += rc_delta[j]
            self._lp_synthesis_filter(12 * i, 1 if i < 3 else 33)


def read_u32le(data: bytes, offset: int) -> int:
    return int.from_bytes(data[offset:offset + 4], "little")


def read_patch(data: bytes, offset: int) -> tuple[int, int]:
    if offset >= len(data):
        raise StrFormatError("Unexpected end of SCHl patch data")
    size = data[offset]
    offset += 1
    if offset + size > len(data):
        raise StrFormatError("Invalid SCHl patch length")
    value = 0
    for byte in data[offset:offset + size]:
        value = (value << 8) | byte
    return value, offset + size


def parse_schl_header(data: bytes, offset: int, size: int) -> dict[str, int]:
    chunk = data[offset:offset + size]
    if len(chunk) < 12:
        raise StrFormatError(f"Short SCHl header at 0x{offset:x}")

    info = {
        "channels": 1,
        "sample_rate": DEFAULT_SAMPLE_RATE,
        "num_samples": 0,
        "codec": EA_MT10_CODEC,
    }

    pos = 12
    value_patches = {
        0x00, 0x06, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89,
        0x8A, 0x8B, 0x8C, 0x8D, 0x8E, 0x91, 0x92, 0x93, 0x94, 0x95,
        0x98, 0x99, 0x9C, 0x9D, 0x9E, 0x9F, 0xA0, 0xA1, 0xA2, 0xA3,
        0xA6, 0xA7, 0xAB, 0xAC, 0xAD, 0x1A, 0x26, 0x27, 0x28, 0x29,
        0x2A,
    }

    while pos < len(chunk):
        patch_type = chunk[pos]
        pos += 1

        if patch_type in (0xFF, 0xFE):
            break
        if patch_type in (0xFC, 0xFD):
            continue

        if patch_type not in value_patches:
            # Most SCHl fields are length-prefixed patches. Try to skip unknown
            # fields the same way, so variants remain extractable.
            if pos >= len(chunk):
                break
        value, pos = read_patch(chunk, pos)

        if patch_type == 0x82:
            info["channels"] = max(1, value)
        elif patch_type == 0x83:
            info["codec"] = value
        elif patch_type == 0x84:
            info["sample_rate"] = value
        elif patch_type == 0x85:
            info["num_samples"] = value
        elif patch_type == 0xA0 and info.get("codec", EA_MT10_CODEC) == 0:
            info["codec"] = value

    return info


def parse_streams(data: bytes) -> list[AudioStream]:
    streams: list[AudioStream] = []
    pos = 0
    file_size = len(data)

    while pos < file_size:
        if pos + 8 > file_size:
            raise StrFormatError(f"Truncated chunk at 0x{pos:x}")

        if data[pos:pos + 4] != MAGIC_HEADER:
            raise StrFormatError(f"Expected SCHl at 0x{pos:x}, found {data[pos:pos + 4]!r}")

        start = pos
        header_size = read_u32le(data, pos + 4)
        if header_size < 8:
            raise StrFormatError(f"Invalid SCHl size at 0x{pos:x}")
        info = parse_schl_header(data, pos, header_size)
        pos += header_size

        if data[pos:pos + 4] == MAGIC_CONTROL:
            control_size = read_u32le(data, pos + 4)
            if control_size < 8:
                raise StrFormatError(f"Invalid SCCl size at 0x{pos:x}")
            pos += control_size

        blocks: list[ScdlBlock] = []
        while pos < file_size and data[pos:pos + 4] == MAGIC_DATA:
            block_size = read_u32le(data, pos + 4)
            if block_size < 13:
                raise StrFormatError(f"Invalid SCDl size at 0x{pos:x}")
            block_samples = read_u32le(data, pos + 8)
            blocks.append(ScdlBlock(pos, block_size, block_samples))
            pos += block_size

        if pos + 8 > file_size or data[pos:pos + 4] != MAGIC_END:
            raise StrFormatError(f"Expected SCEl after stream {len(streams)} at 0x{pos:x}")
        end_size = read_u32le(data, pos + 4)
        if end_size < 8:
            raise StrFormatError(f"Invalid SCEl size at 0x{pos:x}")
        pos += end_size

        num_samples = info["num_samples"] or sum(block.samples for block in blocks)
        streams.append(AudioStream(
            index=len(streams),
            offset=start,
            end_offset=pos,
            channels=info["channels"],
            sample_rate=info["sample_rate"] or DEFAULT_SAMPLE_RATE,
            num_samples=num_samples,
            codec=info["codec"],
            blocks=tuple(blocks),
        ))

    return streams


def clamp_pcm16(value: float) -> int:
    pcm = int(value + 0.5) if value >= 0 else int(value - 0.5)
    if pcm < -32768:
        return -32768
    if pcm > 32767:
        return 32767
    return pcm


def decode_stream(data: bytes, stream: AudioStream) -> array:
    if stream.channels != 1:
        raise StrFormatError(f"Stream {stream.index} has {stream.channels} channels; only mono MT10 is supported")
    if stream.codec != EA_MT10_CODEC:
        raise StrFormatError(f"Stream {stream.index} uses codec 0x{stream.codec:02x}, expected MT10 codec 0x09")

    decoder = UtkDecoder()
    out = array("h")

    for block in stream.blocks:
        samples_left = block.samples
        block_start = block.offset + 0x0D
        block_end = block.offset + block.size
        decoder.set_buffer(data[block_start:block_end])

        while samples_left > 0:
            frame = decoder.decode_frame()
            take = min(samples_left, 432)
            out.extend(clamp_pcm16(frame[i]) for i in range(take))
            samples_left -= take

    if sys.byteorder != "little":
        out.byteswap()
    return out


def write_wav(path: Path, samples: array, sample_rate: int, channels: int = 1) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with wave.open(str(path), "wb") as wav:
        wav.setnchannels(channels)
        wav.setsampwidth(2)
        wav.setframerate(sample_rate)
        wav.writeframes(samples.tobytes())


def default_output_dir(input_path: Path) -> Path:
    return input_path.with_name(f"{input_path.stem}_wav")


ProgressCallback = Callable[[int, int, Path], None]


def extract_str_file(
    input_path: Path,
    output_dir: Path | None = None,
    sample_rate: int | None = None,
    start_index: int = 0,
    limit: int | None = None,
    progress: ProgressCallback | None = None,
) -> ExtractStats:
    data = input_path.read_bytes()
    streams = parse_streams(data)
    output_dir = output_dir or default_output_dir(input_path)
    output_dir.mkdir(parents=True, exist_ok=True)

    selected = streams[start_index:]
    if limit is not None:
        selected = selected[:limit]

    total = len(selected)
    written = 0
    total_samples = 0
    for current, stream in enumerate(selected, 1):
        effective_stream = stream
        if sample_rate:
            effective_stream = AudioStream(
                stream.index,
                stream.offset,
                stream.end_offset,
                stream.channels,
                sample_rate,
                stream.num_samples,
                stream.codec,
                stream.blocks,
            )

        samples = decode_stream(data, effective_stream)
        total_samples += len(samples)
        wav_path = output_dir / f"{input_path.stem.lower()}_{stream.index:04d}.wav"
        write_wav(wav_path, samples, effective_stream.sample_rate, effective_stream.channels)
        written += 1
        if progress:
            progress(current, total, wav_path)

    return ExtractStats(len(streams), written, total_samples, output_dir)


def find_default_inputs() -> list[Path]:
    path = Path("PLAYERS.STR")
    return [path] if path.exists() else []


def run_cli(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description="Extract EA MT10 audio from PLAYERS.STR to WAV.")
    parser.add_argument("inputs", nargs="*", type=Path, help="STR file(s) to extract")
    parser.add_argument("-o", "--output", type=Path, help="Output folder")
    parser.add_argument("--sample-rate", type=int, help=f"Override sample rate (default: {DEFAULT_SAMPLE_RATE})")
    parser.add_argument("--start-index", type=int, default=0, help="First stream index to extract")
    parser.add_argument("--limit", type=int, help="Extract only this many streams")
    parser.add_argument("--gui", action="store_true", help="Open the graphical interface")
    parser.add_argument("--no-gui", action="store_true", help="Run from command line")
    args = parser.parse_args(argv)

    if args.gui or (not args.no_gui and not args.inputs):
        return run_gui()

    inputs = args.inputs or find_default_inputs()
    if not inputs:
        print("No input STR file was provided and PLAYERS.STR was not found.", file=sys.stderr)
        return 2

    for input_path in inputs:
        if not input_path.exists():
            print(f"Missing file: {input_path}", file=sys.stderr)
            return 2

    for input_path in inputs:
        if args.output and len(inputs) > 1:
            out_dir = args.output / input_path.stem
        else:
            out_dir = args.output

        def report(current: int, total: int, wav_path: Path) -> None:
            print(f"[{current}/{total}] {wav_path}")

        stats = extract_str_file(
            input_path,
            output_dir=out_dir,
            sample_rate=args.sample_rate,
            start_index=args.start_index,
            limit=args.limit,
            progress=report,
        )
        seconds = stats.samples / float(args.sample_rate or DEFAULT_SAMPLE_RATE)
        print(f"Done: {stats.wavs}/{stats.streams} WAV files, {seconds:.1f}s of audio -> {stats.output_dir}")

    return 0


def run_gui() -> int:
    import tkinter as tk
    from tkinter import filedialog, messagebox, ttk

    root = tk.Tk()
    root.title("PLAYERS.STR WAV Extractor")
    root.geometry("680x330")
    root.minsize(620, 300)

    input_var = tk.StringVar(value=str(Path.cwd() / "PLAYERS.STR") if (Path.cwd() / "PLAYERS.STR").exists() else "")
    output_var = tk.StringVar(value=str(default_output_dir(Path("PLAYERS.STR")).resolve()))
    sample_rate_var = tk.StringVar(value=str(DEFAULT_SAMPLE_RATE))
    status_var = tk.StringVar(value="Ready")
    progress_var = tk.DoubleVar(value=0.0)
    work_queue: queue.Queue[tuple[str, object]] = queue.Queue()

    main = ttk.Frame(root, padding=14)
    main.pack(fill="both", expand=True)
    main.columnconfigure(1, weight=1)

    ttk.Label(main, text="STR file").grid(row=0, column=0, sticky="w", padx=(0, 8), pady=6)
    ttk.Entry(main, textvariable=input_var).grid(row=0, column=1, sticky="ew", pady=6)

    def browse_input() -> None:
        filename = filedialog.askopenfilename(
            title="Select STR file",
            filetypes=[("STR audio container", "*.str"), ("All files", "*.*")],
        )
        if filename:
            input_var.set(filename)
            output_var.set(str(default_output_dir(Path(filename))))

    ttk.Button(main, text="Browse", command=browse_input).grid(row=0, column=2, padx=(8, 0), pady=6)

    ttk.Label(main, text="Output folder").grid(row=1, column=0, sticky="w", padx=(0, 8), pady=6)
    ttk.Entry(main, textvariable=output_var).grid(row=1, column=1, sticky="ew", pady=6)

    def browse_output() -> None:
        dirname = filedialog.askdirectory(title="Select output folder")
        if dirname:
            output_var.set(dirname)

    ttk.Button(main, text="Browse", command=browse_output).grid(row=1, column=2, padx=(8, 0), pady=6)

    ttk.Label(main, text="Sample rate").grid(row=2, column=0, sticky="w", padx=(0, 8), pady=6)
    ttk.Entry(main, textvariable=sample_rate_var, width=12).grid(row=2, column=1, sticky="w", pady=6)

    bar = ttk.Progressbar(main, variable=progress_var, maximum=100.0)
    bar.grid(row=3, column=0, columnspan=3, sticky="ew", pady=(18, 6))
    ttk.Label(main, textvariable=status_var).grid(row=4, column=0, columnspan=3, sticky="w", pady=6)

    buttons = ttk.Frame(main)
    buttons.grid(row=5, column=0, columnspan=3, sticky="e", pady=(18, 0))

    start_button = ttk.Button(buttons, text="Start")
    start_button.pack(side="right")

    def worker() -> None:
        try:
            input_path = Path(input_var.get()).expanduser()
            output_path = Path(output_var.get()).expanduser()
            sample_rate = int(sample_rate_var.get() or DEFAULT_SAMPLE_RATE)

            def report(current: int, total: int, wav_path: Path) -> None:
                work_queue.put(("progress", (current, total, wav_path)))

            stats = extract_str_file(input_path, output_path, sample_rate=sample_rate, progress=report)
            work_queue.put(("done", stats))
        except Exception as exc:  # GUI should show the real error instead of dying silently.
            work_queue.put(("error", exc))

    def start() -> None:
        if not input_var.get().strip():
            messagebox.showerror("Missing file", "Select a STR file first.")
            return
        start_button.config(state="disabled")
        progress_var.set(0.0)
        status_var.set("Extracting...")
        threading.Thread(target=worker, daemon=True).start()

    start_button.config(command=start)

    def poll_queue() -> None:
        try:
            while True:
                kind, payload = work_queue.get_nowait()
                if kind == "progress":
                    current, total, wav_path = payload  # type: ignore[misc]
                    progress_var.set((current / max(total, 1)) * 100.0)
                    status_var.set(f"{current}/{total}: {Path(wav_path).name}")
                elif kind == "done":
                    stats = payload  # type: ignore[assignment]
                    progress_var.set(100.0)
                    status_var.set(f"Done: {stats.wavs} WAV files written to {stats.output_dir}")
                    start_button.config(state="normal")
                    messagebox.showinfo("Done", f"Extracted {stats.wavs} WAV files.")
                elif kind == "error":
                    start_button.config(state="normal")
                    status_var.set("Error")
                    messagebox.showerror("Extraction failed", str(payload))
        except queue.Empty:
            pass
        root.after(100, poll_queue)

    poll_queue()
    root.mainloop()
    return 0


if __name__ == "__main__":
    raise SystemExit(run_cli())

 

Thank you very much friend. its work 🙂

Create an account or sign in to comment

You need to be a member in order to leave a comment

Create an account

Sign up for a new account in our community. It's easy!

Register a new account

Sign in

Already have an account? Sign in here.

Sign In Now
×
×
  • Create New...