adams06 Posted May 20 Posted May 20 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 zbirow Posted May 25 Members Solution Posted May 25 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())
adams06 Posted May 27 Author Posted May 27 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 🙂
Recommended Posts
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 accountSign in
Already have an account? Sign in here.
Sign In Now