from __future__ import annotations
import argparse
import wave
from pathlib import Path
import matplotlib.pyplot as plt
def _clamp(value: float, min_value: float, max_value: float) -> float:
return max(min_value, min(value, max_value))
def _float_to_u8(sample: float) -> int:
return int(round((_clamp(sample, -1.0, 1.0) + 1.0) * 127.5))
def _read_wav_samples(path: Path) -> list[int]:
with wave.open(str(path), "rb") as wav:
channels = wav.getnchannels()
sample_width = wav.getsampwidth()
frame_count = wav.getnframes()
frames = wav.readframes(frame_count)
if channels < 1:
raise SystemExit("WAV file has no channels")
if sample_width not in {1, 2, 3, 4}:
raise SystemExit(f"Unsupported WAV sample width: {sample_width}")
bytes_per_frame = sample_width * channels
if len(frames) % bytes_per_frame != 0:
raise SystemExit("WAV data size does not align with frame size")
max_int = float(2 ** (sample_width * 8 - 1))
samples: list[int] = []
for frame_start in range(0, len(frames), bytes_per_frame):
frame = frames[frame_start : frame_start + bytes_per_frame]
accum = 0.0
for channel_index in range(channels):
start = channel_index * sample_width
chunk = frame[start : start + sample_width]
if sample_width == 1:
value = (chunk[0] - 128) / 128.0
else:
if sample_width == 3:
raw = int.from_bytes(chunk, byteorder="little", signed=False)
if raw & 0x800000:
raw -= 1 << 24
value = raw / max_int
else:
raw = int.from_bytes(chunk, byteorder="little", signed=True)
value = raw / max_int
accum += value
mono = accum / channels
samples.append(_float_to_u8(mono))
return samples
def load_samples(path: Path) -> list[int]:
if path.suffix.lower() == ".wav":
return _read_wav_samples(path)
data = path.read_bytes()
return list(data)
def normalize_unsigned_8bit(samples: list[int]) -> list[float]:
if not samples:
return []
return [((value - 128) / 128.0) for value in samples]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Plot raw 8-bit audio samples from a .raw or .wav file."
)
parser.add_argument(
"path",
nargs="?",
default="samples.raw",
help="Path to raw or wav sample file (default: samples.raw)",
)
parser.add_argument(
"--max-samples",
type=int,
default=20_000,
help="Maximum number of samples to plot (default: 20000)",
)
parser.add_argument(
"--start",
type=int,
default=0,
help="Start index for plotting (default: 0)",
)
parser.add_argument(
"--normalize",
action="store_true",
help="Normalize unsigned 8-bit samples to [-1, 1]",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
path = Path(args.path)
if not path.exists():
raise SystemExit(f"File not found: {path}")
samples = load_samples(path)
if args.start < 0 or args.start >= len(samples):
raise SystemExit("Start index out of range")
end = min(len(samples), args.start + args.max_samples)
window = samples[args.start:end]
if args.normalize:
values = normalize_unsigned_8bit(window)
ylabel = "Amplitude (normalized)"
else:
values = window
ylabel = "Amplitude (0-255)"
x_values = list(range(args.start, args.start + len(values)))
plt.figure(figsize=(12, 4))
plt.plot(x_values, values, linewidth=1.0)
plt.title(f"Audio samples: {path.name}")
plt.xlabel("Sample index")
plt.ylabel(ylabel)
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
if __name__ == "__main__":
main()