wakeforge / src /audio.py
eoinedge's picture
Upload folder using huggingface_hub
5838d6a verified
Raw
History Blame Contribute Delete
8.27 kB
"""Audio helpers: WAV I/O, resampling, augmentation and synthetic noise.
Kept dependency-light: only numpy is required (no ffmpeg / scipy), so it
runs anywhere including a minimal Hugging Face Space.
"""
from __future__ import annotations
import hashlib
import io
import math
import random
import re
import wave
from pathlib import Path
from typing import Tuple
import numpy as np
# --------------------------------------------------------------------------- #
# Naming helpers
# --------------------------------------------------------------------------- #
def slugify(text: str, max_len: int = 80) -> str:
text = str(text).strip().lower()
text = re.sub(r"[^a-z0-9]+", "_", text)
text = re.sub(r"_+", "_", text).strip("_")
return (text or "item")[:max_len]
def stable_hash(text: str, length: int = 12) -> str:
return hashlib.sha1(text.encode("utf-8")).hexdigest()[:length]
# --------------------------------------------------------------------------- #
# WAV read / write / resample
# --------------------------------------------------------------------------- #
def read_wav_bytes(wav_bytes: bytes) -> Tuple[np.ndarray, int]:
"""Decode 16-bit PCM WAV bytes into mono float32 samples and sample rate."""
with wave.open(io.BytesIO(wav_bytes), "rb") as wf:
channels = wf.getnchannels()
sample_width = wf.getsampwidth()
sample_rate = wf.getframerate()
frames = wf.readframes(wf.getnframes())
if sample_width != 2:
raise ValueError(f"Expected 16-bit PCM WAV, got sample width {sample_width}")
audio = np.frombuffer(frames, dtype=np.int16).astype(np.float32)
if channels > 1:
audio = audio.reshape(-1, channels).mean(axis=1)
return audio, sample_rate
def read_wav_file(path: Path) -> Tuple[np.ndarray, int]:
return read_wav_bytes(Path(path).read_bytes())
def write_wav_file(path: Path, audio: np.ndarray, sample_rate_hz: int) -> None:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
audio_i16 = np.clip(audio, -32768, 32767).astype(np.int16)
with wave.open(str(path), "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(sample_rate_hz)
wf.writeframes(audio_i16.tobytes())
def resample(audio: np.ndarray, src_rate: int, dst_rate: int) -> np.ndarray:
"""Linear-interpolation resample. Good enough for a bootstrap dataset."""
if src_rate == dst_rate or len(audio) == 0:
return audio.astype(np.float32)
duration = len(audio) / float(src_rate)
dst_len = max(1, int(round(duration * dst_rate)))
src_idx = np.linspace(0.0, len(audio) - 1, num=dst_len)
return np.interp(src_idx, np.arange(len(audio)), audio).astype(np.float32)
# --------------------------------------------------------------------------- #
# Shaping
# --------------------------------------------------------------------------- #
def normalize(audio: np.ndarray, peak: float = 28000.0) -> np.ndarray:
if len(audio) == 0:
return audio
m = float(np.max(np.abs(audio)))
if m < 1.0:
return audio
return (audio * (peak / m)).astype(np.float32)
def pad_or_trim(audio: np.ndarray, target_samples: int, random_crop: bool = False) -> np.ndarray:
current = len(audio)
if current == target_samples:
return audio
if current > target_samples:
start = (
random.randint(0, current - target_samples)
if random_crop
else (current - target_samples) // 2
)
return audio[start:start + target_samples]
pad_total = target_samples - current
pad_left = pad_total // 2
pad_right = pad_total - pad_left
return np.pad(audio, (pad_left, pad_right), mode="constant")
# --------------------------------------------------------------------------- #
# Augmentation
# --------------------------------------------------------------------------- #
def _gain(audio: np.ndarray, gain_db: float) -> np.ndarray:
return audio * (10.0 ** (gain_db / 20.0))
def _time_shift(audio: np.ndarray, max_shift: int) -> np.ndarray:
return np.roll(audio, random.randint(-max_shift, max_shift))
def _add_noise(audio: np.ndarray, snr_db: float) -> np.ndarray:
noise = np.random.normal(0.0, 1.0, len(audio)).astype(np.float32)
clean_power = float(np.mean(audio ** 2))
noise_power = float(np.mean(noise ** 2))
if clean_power < 1.0 or noise_power < 1e-9:
return audio
target_noise_power = clean_power / (10.0 ** (snr_db / 10.0))
noise *= math.sqrt(target_noise_power / noise_power)
return audio + noise
def _echo(audio: np.ndarray, sr: int) -> np.ndarray:
delay = random.randint(int(0.03 * sr), int(0.12 * sr))
decay = random.uniform(0.08, 0.25)
out = audio.copy()
if 0 < delay < len(audio):
out[delay:] += audio[:-delay] * decay
return out
def augment(audio: np.ndarray, sr: int) -> np.ndarray:
out = audio.copy()
out = _gain(out, random.uniform(-6.0, 3.0))
out = _time_shift(out, max_shift=int(0.12 * sr))
if random.random() < 0.75:
out = _add_noise(out, random.choice([30, 25, 20, 15, 10]))
if random.random() < 0.35:
out = _echo(out, sr)
return normalize(out, 28000.0)
# --------------------------------------------------------------------------- #
# Synthetic background noise
# --------------------------------------------------------------------------- #
def _white(n: int) -> np.ndarray:
return np.random.normal(0.0, 1.0, n).astype(np.float32)
def _pink(n: int) -> np.ndarray:
white = _white(n)
out = np.zeros_like(white)
alpha = 0.985
for i in range(1, n):
out[i] = alpha * out[i - 1] + (1.0 - alpha) * white[i]
return out.astype(np.float32)
def _brown(n: int) -> np.ndarray:
brown = np.cumsum(_white(n))
brown = brown - np.mean(brown)
return normalize(brown.astype(np.float32), 1.0)
def _hum(n: int, sr: int) -> np.ndarray:
t = np.arange(n, dtype=np.float32) / float(sr)
hum = (
np.sin(2.0 * math.pi * 50.0 * t)
+ 0.5 * np.sin(2.0 * math.pi * 100.0 * t)
+ 0.25 * np.sin(2.0 * math.pi * 150.0 * t)
)
hum += 0.04 * _white(n)
return hum.astype(np.float32)
def _fan(n: int, sr: int) -> np.ndarray:
base = _pink(n)
t = np.arange(n, dtype=np.float32) / float(sr)
blade_rate = random.uniform(18.0, 45.0)
modulation = 0.65 + 0.35 * np.sin(2.0 * math.pi * blade_rate * t)
return (base * modulation).astype(np.float32)
def _cafe(n: int, sr: int) -> np.ndarray:
base = 0.55 * _pink(n) + 0.45 * _white(n)
transient_count = max(1, int((n / sr) * random.uniform(2.0, 6.0)))
for _ in range(transient_count):
pos = random.randint(0, max(0, n - 1))
length = random.randint(max(1, int(0.008 * sr)), max(2, int(0.05 * sr)))
end = min(n, pos + length)
if end <= pos:
continue
click = np.hanning(end - pos).astype(np.float32)
base[pos:end] += click * random.uniform(0.5, 2.0)
return base.astype(np.float32)
def _street(n: int, sr: int) -> np.ndarray:
base = 0.7 * _brown(n) + 0.3 * _white(n)
t = np.arange(n, dtype=np.float32) / float(sr)
for _ in range(random.randint(1, 3)):
center = random.uniform(0.2, max(0.21, t[-1] - 0.2))
width = random.uniform(0.2, 0.8)
envelope = np.exp(-0.5 * ((t - center) / width) ** 2)
freq = random.uniform(70.0, 180.0)
base += 0.35 * envelope * np.sin(2.0 * math.pi * freq * t)
return base.astype(np.float32)
def make_background_noise(noise_type: str, num_samples: int, sr: int) -> np.ndarray:
if noise_type == "white":
noise = _white(num_samples)
elif noise_type == "pink":
noise = _pink(num_samples)
elif noise_type == "brown":
noise = _brown(num_samples)
elif noise_type == "hum":
noise = _hum(num_samples, sr)
elif noise_type == "fan":
noise = _fan(num_samples, sr)
elif noise_type == "cafe":
noise = _cafe(num_samples, sr)
elif noise_type == "street":
noise = _street(num_samples, sr)
else:
raise ValueError(f"Unknown noise type: {noise_type}")
return normalize(noise, random.uniform(6000, 22000))