初始提交:3 款纯音频游戏 + 共享引擎 + 完整文档与资产管线

- 共享引擎(app/src/main):GestureEngine 6 手势识别、AudioEngine 三层音频
  (ExoPlayer BGM 交叉淡入 / 对白 ducking / SoundPool 低延迟)、Game/GameEngine
  状态机接口、MainActivity 全屏封面 + TalkBack
- 《声命线》storyline:末世互动广播剧,3 幕 8 抉择点 4 结局
- 《声东击西》reflex:听声辨位反应,12 波战斗 + BGM 三档 BPM 变速
- 《暗室》escape:3x3 网格密室逃脱,钥匙→铁柜→保险丝→电箱→大门谜题链
- 文档:setup(从零搭建)/ architecture(架构)/ engine-api(引擎 API)/
  asset-pipeline(资产流水线)/ games/*(三款游戏设计文档)
- 资产管线:Qwen3-TTS 对白 + 程序合成音效/BGM + Mureka BGM 的 gen 脚本与台账,
  全部 ogg 资产入库可复现
- env.d 环境脚本:android/tts 入库;sfx/mureka 含密钥不入库,仅提交 .example.sh 模板
This commit is contained in:
SoundGame Dev
2026-08-29 13:00:34 +08:00
commit f0f0a9b50e
446 changed files with 2627836 additions and 0 deletions
@@ -0,0 +1,510 @@
import numpy as np
import json
import os
import dill
import random
import time
import torch
import torchaudio
from os import path
from torchaudio import transforms as T
from typing import Optional, Callable, List
from .utils import Stereo, Mono, PhaseFlipper, PadCrop_Normalized_T, VolumeNorm, strip_trailing_silence
AUDIO_KEYS = ("flac", "wav", "mp3", "m4a", "ogg", "opus")
# fast_scandir implementation by Scott Hawley originally in https://github.com/zqevans/audio-diffusion/blob/main/dataset/dataset.py
def fast_scandir(
dir:str, # top-level directory at which to begin scanning
ext:list, # list of allowed file extensions,
#max_size = 1 * 1000 * 1000 * 1000 # Only files < 1 GB
):
"very fast `glob` alternative. from https://stackoverflow.com/a/59803793/4259243"
subfolders, files = [], []
ext = ['.'+x if x[0]!='.' else x for x in ext] # add starting period to extensions if needed
try: # hope to avoid 'permission denied' by this try
for f in os.scandir(dir):
try: # 'hope to avoid too many levels of symbolic links' error
if f.is_dir():
subfolders.append(f.path)
elif f.is_file():
file_ext = os.path.splitext(f.name)[1].lower()
is_hidden = os.path.basename(f.path).startswith(".")
if file_ext in ext and not is_hidden:
files.append(f.path)
except:
pass
except:
pass
for dir in list(subfolders):
sf, f = fast_scandir(dir, ext)
subfolders.extend(sf)
files.extend(f)
return subfolders, files
def keyword_scandir(
dir: str, # top-level directory at which to begin scanning
ext: list, # list of allowed file extensions
keywords: list, # list of keywords to search for in the file name
):
"very fast `glob` alternative. from https://stackoverflow.com/a/59803793/4259243"
subfolders, files = [], []
# make keywords case insensitive
keywords = [keyword.lower() for keyword in keywords]
# add starting period to extensions if needed
ext = ['.'+x if x[0] != '.' else x for x in ext]
banned_words = ["paxheader", "__macosx"]
try: # hope to avoid 'permission denied' by this try
for f in os.scandir(dir):
try: # 'hope to avoid too many levels of symbolic links' error
if f.is_dir():
subfolders.append(f.path)
elif f.is_file():
is_hidden = f.name.split("/")[-1][0] == '.'
has_ext = os.path.splitext(f.name)[1].lower() in ext
name_lower = f.name.lower()
has_keyword = any(
[keyword in name_lower for keyword in keywords])
has_banned = any(
[banned_word in name_lower for banned_word in banned_words])
if has_ext and has_keyword and not has_banned and not is_hidden and not os.path.basename(f.path).startswith("._"):
files.append(f.path)
except:
pass
except:
pass
for dir in list(subfolders):
sf, f = keyword_scandir(dir, ext, keywords)
subfolders.extend(sf)
files.extend(f)
return subfolders, files
def get_audio_filenames(
paths: list, # directories in which to search
keywords=None,
exts=['.wav', '.mp3', '.flac', '.ogg', '.aif', '.opus'],
filelist_path=None
):
"recursively get a list of audio filenames"
filenames = []
if type(paths) is str:
paths = [paths]
for path in paths: # get a list of relevant filenames
if filelist_path is None:
# Check for filelist.txt at the root of the directory
filelist_path = os.path.join(path, "filelist.txt")
if os.path.exists(filelist_path):
with open(filelist_path, "r") as f:
files = f.readlines()
files = [os.path.join(path, file.strip()) for file in files]
filenames.extend(files)
continue
if keywords is not None:
subfolders, files = keyword_scandir(path, exts, keywords)
else:
subfolders, files = fast_scandir(path, exts)
filenames.extend(files)
return filenames
def get_latent_filenames(
paths, # directories in which to search
extension='npy',
filelist_path=None
):
"recursively get a list of pre-encoded filenames"
filenames = []
if type(paths) is str:
paths = [paths]
for path in paths: # get a list of relevant filenames
if filelist_path is None:
# Check for filelist.txt at the root of the directory
filelist_path = os.path.join(path, "filelist.txt")
if os.path.exists(filelist_path):
with open(filelist_path, "r") as f:
files = f.readlines()
files = [os.path.join(path, file.strip()) for file in files]
filenames.extend(files)
continue
_, files = fast_scandir(path, [extension])
filenames.extend(files)
# Filter out silence.npy (used for silence latent padding, not a data sample)
filenames = [f for f in filenames if os.path.basename(f) != "silence.npy"]
# Add metadata paths
filenames = [(filename, filename.replace(f".{extension}", ".json")) for filename in filenames]
return filenames
class LocalDatasetConfig:
def __init__(
self,
id: str,
path: str,
keywords: Optional[List[str]]=None,
custom_metadata_fn: Optional[Callable[[str], str]] = None,
filelist_path = None,
weight: float = 1.0,
):
self.id = id
self.path = path
self.custom_metadata_fn = custom_metadata_fn
self.keywords = keywords
self.filelist_path = filelist_path
self.weight = weight
class LatentDatasetConfig(LocalDatasetConfig):
def __init__(
self,
latent_extension: str = "npy",
filelist_path = None,
*args,
**kwargs,
):
super().__init__(*args, **kwargs)
self.latent_extension = latent_extension
self.filelist_path = filelist_path
# weight is inherited from LocalDatasetConfig via **kwargs
class SampleDataset(torch.utils.data.Dataset):
def __init__(
self,
configs,
sample_size=65536,
sample_rate=48000,
random_crop=True,
force_channels="stereo",
volume_norm=False,
volume_norm_param=(-16, 2),
strip_silence=False,
pad=True,
):
super().__init__()
self.filenames = []
self.sample_weights = []
self.augs = torch.nn.Sequential(
PhaseFlipper(),
#nn.Identity()
)
self.root_paths = []
self.pad_crop = PadCrop_Normalized_T(sample_size, sample_rate, randomize=random_crop, pad=pad)
self.strip_silence = strip_silence
self.force_channels = force_channels
self.encoding = torch.nn.Sequential(
Stereo() if self.force_channels == "stereo" else torch.nn.Identity(),
Mono() if self.force_channels == "mono" else torch.nn.Identity()
)
self.sr = sample_rate
self.volume_norm = VolumeNorm(volume_norm_param, self.sr) if volume_norm else torch.nn.Identity()
self.custom_metadata_fns = {}
for config in configs:
self.root_paths.append(config.path)
new_files = get_audio_filenames(config.path, config.keywords, filelist_path=config.filelist_path)
self.filenames.extend(new_files)
self.sample_weights.extend([config.weight] * len(new_files))
if config.custom_metadata_fn is not None:
self.custom_metadata_fns[config.path] = dill.dumps(config.custom_metadata_fn)
print(f'Found {len(self.filenames)} files')
def load_file(self, filename):
ext = filename.split(".")[-1]
audio, in_sr = torchaudio.load(filename, format=ext)
if in_sr != self.sr:
resample_tf = T.Resample(in_sr, self.sr)
audio = resample_tf(audio)
return audio
def __len__(self):
return len(self.filenames)
def __getitem__(self, idx):
audio_filename = self.filenames[idx]
try:
start_time = time.time()
audio = self.load_file(audio_filename)
audio = self.volume_norm(audio)
if self.strip_silence:
audio = strip_trailing_silence(audio, self.sr)
audio, t_start, t_end, seconds_start, seconds_total, padding_mask = self.pad_crop(audio)
# Check for silence
if is_silence(audio):
return self[random.randrange(len(self))]
# Run augmentations on this sample (including random crop)
if self.augs is not None:
audio = self.augs(audio)
audio = audio.clamp(-1, 1)
# Encode the file to assist in prediction
if self.encoding is not None:
audio = self.encoding(audio)
info = {}
info["path"] = audio_filename
for root_path in self.root_paths:
if root_path in audio_filename:
info["relpath"] = path.relpath(audio_filename, root_path)
info["timestamps"] = (t_start, t_end)
info["seconds_start"] = seconds_start
info["seconds_total"] = seconds_total
info["padding_mask"] = [padding_mask]
info["sample_rate"] = self.sr
end_time = time.time()
info["load_time"] = end_time - start_time
for custom_md_path in self.custom_metadata_fns.keys():
if custom_md_path in audio_filename:
custom_metadata_fn = dill.loads(self.custom_metadata_fns[custom_md_path])
custom_metadata = custom_metadata_fn(info, audio)
info.update(custom_metadata)
if "__reject__" in info and info["__reject__"]:
return self[random.randrange(len(self))]
# Provide audio inputs as their own dictionary to be merged into info, each audio element will be normalized in the same way as the main audio
if "__audio__" in info:
for audio_key, audio_value in info["__audio__"].items():
# Process the audio_value tensor, which should be a torch tensor
audio_value, _, _, _, _, _ = self.pad_crop(audio_value)
audio_value = audio_value.clamp(-1, 1)
if self.encoding is not None:
audio_value = self.encoding(audio_value)
info[audio_key] = audio_value
del info["__audio__"]
return (audio, info)
except Exception as e:
print(f'Couldn\'t load file {audio_filename}: {e}')
return self[random.randrange(len(self))]
class PreEncodedDataset(torch.utils.data.Dataset):
def __init__(
self,
configs: List[LatentDatasetConfig],
latent_crop_length=None,
min_length_sec=None,
max_length_sec=None,
random_crop=False,
tokenizers: Optional[dict] = None,
):
super().__init__()
self.filenames = []
self.sample_weights = []
self.custom_metadata_fns = {}
self.silence_latents = {}
for config in configs:
new_files = get_latent_filenames(config.path, config.latent_extension, config.filelist_path)
self.filenames.extend(new_files)
self.sample_weights.extend([config.weight] * len(new_files))
if config.custom_metadata_fn is not None:
self.custom_metadata_fns[config.path] = dill.dumps(config.custom_metadata_fn)
# Load silence latent if available (for variable-length padding)
paths = config.path if isinstance(config.path, list) else [config.path]
for path in paths:
silence_path = os.path.join(path, "silence.npy")
if os.path.exists(silence_path):
self.silence_latents[path] = np.load(silence_path).squeeze(0) # [C, N]
print(f'Loaded silence latent from {silence_path}')
self.latent_crop_length = latent_crop_length
self.random_crop = random_crop
self.min_length_sec = min_length_sec
self.max_length_sec = max_length_sec
# tokenizers: dict mapping metadata key -> (tokenizer, max_length)
# If provided, text fields will be pre-tokenized in DataLoader workers
self.tokenizers = tokenizers
print(f'Found {len(self.filenames)} files')
def __len__(self):
return len(self.filenames)
def _get_silence_for_file(self, latent_filename):
"""Return the silence latent for the dataset that contains this file, or None."""
for path, silence in self.silence_latents.items():
if path in latent_filename:
return silence
return None
def __getitem__(self, idx):
latent_filename, md_filename = self.filenames[idx]
try:
latents = torch.from_numpy(np.load(latent_filename)) # [C, N]
with open(md_filename, "r") as f:
try:
info = json.load(f)
except:
raise Exception(f"Couldn't load metadata file {md_filename}")
info["latent_filename"] = latent_filename
if self.latent_crop_length is not None:
stored_length = latents.shape[1]
if stored_length > self.latent_crop_length:
# Crop to latent_crop_length (existing logic)
# Get the last index from the padding mask, the index of the last 1 in the sequence
last_ix = len(info["padding_mask"]) - 1 - info["padding_mask"][::-1].index(1)
if self.random_crop and last_ix > self.latent_crop_length:
start = random.randint(0, last_ix - self.latent_crop_length)
else:
start = 0
latents = latents[:, start:start+self.latent_crop_length]
info["padding_mask"] = info["padding_mask"][start:start+self.latent_crop_length]
info["latent_crop_start"] = start
elif stored_length < self.latent_crop_length:
# Pad with silence latent to reach latent_crop_length
pad_needed = self.latent_crop_length - stored_length
silence = self._get_silence_for_file(latent_filename)
if silence is not None:
# Slice or tile silence latent to cover pad_needed frames
if silence.shape[1] >= pad_needed:
silence_pad = silence[:, :pad_needed]
else:
silence_pad = np.tile(silence, (1, (pad_needed // silence.shape[1]) + 1))[:, :pad_needed]
latents = torch.cat([latents, torch.from_numpy(silence_pad)], dim=1)
else:
# No silence latent available — zero-pad as fallback
latents = torch.nn.functional.pad(latents, (0, pad_needed))
# Build padding_mask: valid frames from stored mask, zeros for padding
info["padding_mask"] = info["padding_mask"][:stored_length] + [0] * pad_needed
info["latent_crop_start"] = 0
else:
# Exact match
info["latent_crop_start"] = 0
info["latent_crop_length"] = self.latent_crop_length
info["padding_mask"] = [torch.tensor(info["padding_mask"])]
seconds_total = info["seconds_total"]
if self.min_length_sec is not None and seconds_total < self.min_length_sec:
return self[random.randrange(len(self))]
if self.max_length_sec is not None and seconds_total > self.max_length_sec:
return self[random.randrange(len(self))]
for custom_md_path in self.custom_metadata_fns.keys():
if custom_md_path in latent_filename:
custom_metadata_fn = dill.loads(self.custom_metadata_fns[custom_md_path])
custom_metadata = custom_metadata_fn(info, latents)
info.update(custom_metadata)
if "__reject__" in info and info["__reject__"]:
return self[random.randrange(len(self))]
if "__replace__" in info and info["__replace__"] is not None:
# Replace the latents with the new latents if the custom metadata function returns a new set of latents
latents = info["__replace__"]
info["audio"] = latents
# Pre-tokenize text fields in DataLoader workers to avoid
# CPU contention with the main training thread
if self.tokenizers is not None:
for key, (tokenizer, max_length) in self.tokenizers.items():
if key in info and isinstance(info[key], str):
# Save raw text before replacing with tokens (needed by CLAP and other text-based losses)
info[f"{key}_text"] = info[key]
encoded = tokenizer(
info[key],
truncation=True,
max_length=max_length,
padding="max_length",
return_tensors="pt",
)
info[key] = {
"input_ids": encoded["input_ids"].squeeze(0),
"attention_mask": encoded["attention_mask"].squeeze(0),
}
return (latents, info)
except Exception as e:
print(f'Couldn\'t load file {latent_filename}: {e}')
return self[random.randrange(len(self))]
# get_dbmax and is_silence copied from https://github.com/drscotthawley/aeiou/blob/main/aeiou/core.py under Apache 2.0 License
# License can be found in LICENSES/LICENSE_AEIOU.txt
def get_dbmax(
audio, # torch tensor of (multichannel) audio
):
"finds the loudest value in the entire clip and puts that into dB (full scale)"
return 20*torch.log10(torch.flatten(audio.abs()).max()).cpu().numpy()
def is_silence(
audio, # torch tensor of (multichannel) audio
thresh=-60, # threshold in dB below which we declare to be silence
):
"checks if entire clip is 'silence' below some dB threshold"
dBmax = get_dbmax(audio)
return dBmax < thresh
def collation_fn(samples):
batched = list(zip(*samples))
result = []
for b in batched:
if isinstance(b[0], (int, float)):
b = np.array(b)
elif isinstance(b[0], torch.Tensor):
b = torch.stack(b)
elif isinstance(b[0], np.ndarray):
b = np.array(b)
else:
b = b
result.append(b)
return result
@@ -0,0 +1,257 @@
import math
import random
import torch
from torch import nn
from typing import Optional, Tuple
from torchaudio import transforms as T
class PadCrop(nn.Module):
def __init__(self, n_samples, randomize=True):
super().__init__()
self.n_samples = n_samples
self.randomize = randomize
def __call__(self, signal):
n, s = signal.shape
start = 0 if (not self.randomize) else torch.randint(0, max(0, s - self.n_samples) + 1, []).item()
end = start + self.n_samples
output = signal.new_zeros([n, self.n_samples])
output[:, :min(s, self.n_samples)] = signal[:, start:end]
return output
class PadCrop_Normalized_T(nn.Module):
def __init__(self, n_samples: int, sample_rate: int, randomize: bool = True, pad: bool = True):
super().__init__()
self.n_samples = n_samples
self.sample_rate = sample_rate
self.randomize = randomize
self.pad = pad
def __call__(self, source: torch.Tensor) -> Tuple[torch.Tensor, float, float, int, int, torch.Tensor]:
n_channels, n_samples = source.shape
# Calculate bounds and offset
upper_bound = max(0, n_samples - self.n_samples)
offset = 0
if self.randomize and n_samples > self.n_samples:
offset = random.randint(0, upper_bound)
# Calculate normalized times
norm_denom = upper_bound + self.n_samples
t_start = offset / norm_denom
t_end = (offset + self.n_samples) / norm_denom
# Calculate timing info
seconds_start = math.floor(offset / self.sample_rate)
seconds_total = math.ceil(n_samples / self.sample_rate)
# Optimize for different cases
if n_samples >= self.n_samples:
# No padding needed - use view (zero-copy)
chunk = source[:, offset:offset + self.n_samples]
# Create full mask efficiently
padding_mask = torch.ones(self.n_samples, dtype=source.dtype, device=source.device)
elif not self.pad:
# No padding mode - return audio at natural length
chunk = source
padding_mask = torch.ones(n_samples, dtype=source.dtype, device=source.device)
else:
# Padding needed - create chunk and fill in-place
chunk = torch.zeros(n_channels, self.n_samples, dtype=source.dtype, device=source.device)
chunk[:, :n_samples] = source # Use in-place assignment
# Create padding mask in-place
padding_mask = torch.zeros(self.n_samples, dtype=source.dtype, device=source.device)
padding_mask[:n_samples] = 1
return (
chunk,
t_start,
t_end,
seconds_start,
seconds_total,
padding_mask
)
def strip_trailing_silence(audio, sample_rate, threshold_db=-60, min_silence_duration=0.1):
"""Strip silence from the end of an audio tensor.
Args:
audio: tensor [channels, samples]
sample_rate: audio sample rate
threshold_db: dB threshold below which audio is considered silent
min_silence_duration: minimum trailing silence duration in seconds to strip
Returns:
Truncated audio tensor [channels, trimmed_samples], or original if no significant trailing silence
"""
n_samples = audio.shape[-1]
hop_length = max(1, int(sample_rate * 0.01)) # 10ms frames
min_silence_samples = int(sample_rate * min_silence_duration)
n_frames = n_samples // hop_length
if n_frames == 0:
return audio
# Work in float32 for precision
audio_f = audio.float()
# Reshape into frames and compute max absolute amplitude per frame across channels
trimmed = audio_f[:, :n_frames * hop_length]
frames = trimmed.reshape(audio_f.shape[0], n_frames, hop_length)
frame_peak = frames.abs().amax(dim=(0, 2)) # [n_frames] - max across channels and samples
frame_db = 20 * torch.log10(frame_peak + 1e-10)
# Find last frame above threshold
above_thresh = (frame_db > threshold_db).nonzero(as_tuple=True)[0]
if len(above_thresh) == 0:
# Entire audio is silent
return audio[:, :0]
last_active_frame = above_thresh[-1].item()
content_end = min((last_active_frame + 1) * hop_length, n_samples)
# Only strip if trailing silence is long enough
if (n_samples - content_end) < min_silence_samples:
return audio
return audio[:, :content_end]
class PhaseFlipper(nn.Module):
"Randomly invert the phase of a signal"
def __init__(self, p=0.5):
super().__init__()
self.p = p
def __call__(self, signal):
return -signal if (random.random() < self.p) else signal
class Mono(nn.Module):
def __call__(self, signal):
return torch.mean(signal, dim=0, keepdims=True) if len(signal.shape) > 1 else signal
class Stereo(nn.Module):
def __call__(self, signal):
signal_shape = signal.shape
# Check if it's mono
if len(signal_shape) == 1: # s -> 2, s
signal = signal.unsqueeze(0).repeat(2, 1)
elif len(signal_shape) == 2:
if signal_shape[0] == 1: #1, s -> 2, s
signal = signal.repeat(2, 1)
elif signal_shape[0] > 2: #?, s -> 2,s
signal = signal[:2, :]
return signal
class VolumeNorm(nn.Module):
"Volume normalization and augmentation of a signal [LUFS standard]"
def __init__(self, params=[-16, 2], sample_rate=16000, energy_threshold=1e-6):
super().__init__()
self.loudness = T.Loudness(sample_rate)
self.value = params[0]
self.gain_range = [-params[1], params[1]]
self.energy_threshold = energy_threshold
def __call__(self, signal):
"""
signal: torch.Tensor [channels, time]
"""
# avoid do normalisation for silence
energy = torch.mean(signal**2)
if energy < self.energy_threshold:
return signal
input_loudness = self.loudness(signal)
# Generate a random target loudness within the specified range
target_loudness = self.value + (torch.rand(1).item() * (self.gain_range[1] - self.gain_range[0]) + self.gain_range[0])
delta_loudness = target_loudness - input_loudness
gain = torch.pow(10.0, delta_loudness / 20.0)
output = gain * signal
# Check for potentially clipped samples
if torch.max(torch.abs(output)) >= 1.0:
output = self.declip(output)
return output
def declip(self, signal):
"""
Declip the signal by scaling down if any samples are clipped
"""
max_val = torch.max(torch.abs(signal))
if max_val > 1.0:
signal = signal / max_val
signal *= 0.95
return signal
def create_padding_mask_from_lengths(
valid_lengths: torch.Tensor,
total_seq_len: int,
) -> torch.Tensor:
"""
Create a boolean padding mask from per-batch valid sequence lengths.
Args:
valid_lengths: Tensor of shape (batch_size,) with valid length per sample
total_seq_len: Total sequence length of the latent
Returns:
Boolean tensor of shape (batch_size, total_seq_len) where True = valid, False = padding
"""
device = valid_lengths.device
positions = torch.arange(total_seq_len, device=device).unsqueeze(0) # (1, T)
padding_mask = positions < valid_lengths.unsqueeze(1) # (B, T)
return padding_mask
def compute_effective_seq_len_from_conditioning(
conditioning: list,
sample_rate: int,
downsampling_ratio: int = 1,
device: str = "cuda"
) -> Optional[torch.Tensor]:
"""
Compute effective sequence lengths from seconds_total in conditioning dicts.
Args:
conditioning: List of conditioning dicts, one per batch element
sample_rate: Audio sample rate
downsampling_ratio: Pretransform downsampling ratio (1 if no pretransform)
device: Device to place the tensor on
Returns:
Tensor of shape (batch_size,) with effective sequence lengths in latent space,
or None if seconds_total is not present in conditioning
"""
if conditioning is None:
return None
# Check if seconds_total is present in any conditioning dict
if not any("seconds_total" in c for c in conditioning):
return None
effective_lengths = []
for cond_dict in conditioning:
if "seconds_total" in cond_dict:
seconds = cond_dict["seconds_total"]
# Convert seconds to latent sequence length
audio_samples = int(seconds * sample_rate)
latent_length = math.ceil(audio_samples / downsampling_ratio)
effective_lengths.append(latent_length)
else:
# If seconds_total not present for this item, use None as marker
effective_lengths.append(None)
# If any item is missing seconds_total, return None to fall back to full length
if any(l is None for l in effective_lengths):
return None
return torch.tensor(effective_lengths, dtype=torch.float32, device=device)