diff --git a/.gitignore b/.gitignore index f8d225d..41705b0 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ .DS_Store # Сырые AI-листы (перегенерируются npm run aiart gen по фиксированному seed) apps/game/tools/aiart/sheets/ +# Сырые AI-клипы аудио (перегенерируются gen_audioldm2.py по seed) +apps/game/tools/aiaudio/review/ diff --git a/apps/game/tools/aiaudio/gen_audioldm2.py b/apps/game/tools/aiaudio/gen_audioldm2.py new file mode 100644 index 0000000..37e131e --- /dev/null +++ b/apps/game/tools/aiaudio/gen_audioldm2.py @@ -0,0 +1,105 @@ +"""PoC (работа A, этап A0): text-to-audio через AudioLDM2 на CPU. + +Главный кандидат плана — Stable Audio Open Small — gated (нужен HF-токен), +поэтому PoC делаем на фолбэке AudioLDM2 (cvssp/audioldm2, CC-BY-NC-SA — +в ОСНОВНОЙ пайплайн с некоммерческими весами не пойдёт, см. docs/plan.md). + +Запуск: + ~/.cache/rpg-ai/venv-pxgpt/bin/python tools/aiaudio/gen_audioldm2.py [seed] + +Выход: tools/aiaudio/review/_.wav (16000 Гц — ресэмпл делает post). +Детерминизм: seed + num_inferences фиксированы, промпт в манифесте. +""" +import sys +from pathlib import Path + +import numpy as np +import torch + +HERE = Path(__file__).resolve().parent +REVIEW = HERE / "review" + +# Промпты PoC: амбиент прудов (туман, вода) против текущего процедурного +# gen.mjs (band-noise ветер + капли, 8 с) — критерий «лучше на слух». +PROMPTS = { + "amb_ponds": { + "positive": ( + "field recording, calm misty pond at dusk, gentle water lapping, " + "soft wind through reeds, occasional distant croaking, eerie " + "quiet melancholic atmosphere, seamless ambience" + ), + "negative": ( + "music, melody, rhythm, drums, speech, voices, sudden loud " + "noises, harsh noise, distortion" + ), + }, + "amb_zvenets": { + "positive": ( + "field recording, quiet rural settlement at dusk, low distant " + "metallic bell toll, soft wind, faint rustling, somber " + "melancholic atmosphere, seamless ambience" + ), + "negative": ( + "music, melody, song, rhythm, drums, speech, voices, sirens, " + "harsh noise, distortion" + ), + }, +} + +DURATION = 10.0 # с — заметно длиннее процедурного лупа (8 с), до crossfade +NUM_INFERENCE_STEPS = 100 # 200 — дефолт качества; на CPU берём 100 для PoC +GUIDANCE = 3.0 + + +def main() -> None: + if len(sys.argv) < 2 or sys.argv[1] not in PROMPTS: + print(f"использование: gen_audioldm2.py <{'|'.join(PROMPTS)}> [seed]") + sys.exit(1) + name = sys.argv[1] + seed = int(sys.argv[2]) if len(sys.argv) > 2 else 1 + spec = PROMPTS[name] + + import diffusers # noqa: F401 (после разбора аргументов — чтобы --help был быстрым) + + torch.manual_seed(seed) + np.random.seed(seed) + + from diffusers import AudioLDM2Pipeline + + # GPU (RTX 3090) при наличии: fp16, иначе CPU fp32. Seed-детерминизм — + # по-устройству: fp16-GPU и fp32-CPU дают разные реализации одного seed. + use_gpu = torch.cuda.is_available() + dtype = torch.float16 if use_gpu else torch.float32 + pipe = AudioLDM2Pipeline.from_pretrained("cvssp/audioldm2", torch_dtype=dtype) + pipe.to("cuda" if use_gpu else "cpu") + print(f"устройство: {'cuda fp16' if use_gpu else 'cpu fp32 (12 потоков)'}") + if not use_gpu: + torch.set_num_threads(12) + + REVIEW.mkdir(parents=True, exist_ok=True) + out = REVIEW / f"{name}_{seed}.wav" + print(f"генерация {name} (seed={seed}, {DURATION} с, шаги {NUM_INFERENCE_STEPS})...") + + audio = pipe( + spec["positive"], + negative_prompt=spec["negative"], + num_inference_steps=NUM_INFERENCE_STEPS, + guidance_scale=GUIDANCE, + audio_length_in_s=DURATION, + ).audios[0] + + # AudioLDM2 отдаёт float [-1..1] 16000 Гц моно; пишем PCM16 без клипа. + audio = np.clip(audio, -1.0, 1.0) + pcm = (audio * 32767.0).astype(np.int16) + import wave + + with wave.open(str(out), "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(16000) + w.writeframes(pcm.tobytes()) + print(f"готово: {out.relative_to(HERE)} ({pcm.shape[0] / 16000:.1f} с, 16 кГц)") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/apps/game/tools/aiaudio/manifest.json b/apps/game/tools/aiaudio/manifest.json new file mode 100644 index 0000000..d7aef3f --- /dev/null +++ b/apps/game/tools/aiaudio/manifest.json @@ -0,0 +1,17 @@ +{ + "run": "a0-poc", + "date": "2026-09-09", + "model": "AudioLDM2 (cvssp/audioldm2), RTX 3090 fp16 (torch 2.14.0+cu130), diffusers 0.35.2 + transformers 4.49.0", + "env": "~/.cache/rpg-ai/venv-aiaudio (pinned; diffusers 0.40 несовместим с transformers 4.x — _update_model_kwargs_for_generation удалён в 5.x)", + "license_note": "веса CC-BY-NC-SA 4.0 — только PoC; основной пайплайн по плану — Stable Audio Open Small (gated: 401 анонимно, нужен HF-токен + принятие Stability Community License)", + "pipeline": "gen_audioldm2.py -> review/*.wav (16 кГц) -> post.py (ресэмпл 22050, нормализация 0.85, loopify: хвост кроссфейдом в голову, 1 с) -> review/*_loop.wav", + "timing": "10 с аудио, 100 шагов: GPU ~15 с; CPU fp32 12 ядер ~3.5 мин", + "loop_check": "пост.py --check: скачок на шве против максимума межсэмплового diff в середине записи", + "criterion": "амбиент прудов лучше процедурного gen.mjs на слух и формат сходится (22050 моно 16-bit, луп без щелчка)", + "items": [ + { "file": "amb_ponds_1.wav", "prompt": "ponds.positive", "seed": 1, "steps": 100, "gate": "pending", "match": "?", "note": "GPU fp16" }, + { "file": "amb_ponds_2.wav", "prompt": "ponds.positive", "seed": 2, "steps": 100, "gate": "pending", "match": "?", "note": "GPU fp16" }, + { "file": "amb_zvenets_1.wav", "prompt": "zvenets.positive", "seed": 1, "steps": 100, "gate": "pending", "match": "?", "note": "GPU fp16" }, + { "file": "amb_zvenets_2.wav", "prompt": "zvenets.positive", "seed": 2, "steps": 100, "gate": "pending", "match": "?", "note": "GPU fp16" } + ] +} \ No newline at end of file diff --git a/apps/game/tools/aiaudio/post.py b/apps/game/tools/aiaudio/post.py new file mode 100644 index 0000000..493f7e4 --- /dev/null +++ b/apps/game/tools/aiaudio/post.py @@ -0,0 +1,80 @@ +"""PoC (работа A, этап A0): пост-обработка сгенерированного WAV под формат игры. + +Вход: review/_.wav — AudioLDM2, 16000 Гц, моно, 16-бит. +Выход: review/__loop.wav — 22050 Гц, моно 16-бит, нормализованный, + бесшовный луп (хвост кроссфейдом вплетается в начало — стык без щелчка). + +Запуск: ~/.cache/rpg-ai/venv-pxgpt/bin/python tools/aiaudio/post.py review/amb_ponds_1.wav +Проверка стыка: --check прогоняет сшивку twice и ищет скачок на границе. +""" +import sys +import wave +from pathlib import Path + +import numpy as np +from scipy.signal import resample_poly + +HERE = Path(__file__).resolve().parent +TARGET_RATE = 22050 # формат игры: моно, 22050 Гц, 16-бит (см. audio/gen.mjs) +FADE = 1.0 # длительность кроссфейда лупа, с +PEAK = 0.85 # целевой пик нормализации + + +def read_wav(path: Path) -> tuple[np.ndarray, int]: + with wave.open(str(path), "rb") as w: + rate = w.getframerate() + data = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16) + return data.astype(np.float64) / 32768.0, rate + + +def write_wav(path: Path, samples: np.ndarray) -> None: + pcm = np.clip(samples * 32767.0, -32768, 32767).astype(np.int16) + with wave.open(str(path), "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(TARGET_RATE) + w.writeframes(pcm.tobytes()) + + +def loopify(s: np.ndarray, rate: int, fade: float) -> np.ndarray: + """Хвост (последние fade с) кроссфейдом вплетается в начало — луп без шва. + + B[0..fade] = tail·(1-w) + head·w (хвост затухает, голова нарастает), + дальше — средина записи; шов (конец -> начало) соседние сэмплы A. + Длина выхода = len - fade с. + """ + n = int(fade * rate) + w = np.linspace(0.0, 1.0, n) + tail, head, body = s[-n:], s[:n], s[n:-n] + return np.concatenate([tail * (1 - w) + head * w, body]) + + +def main() -> None: + args = [a for a in sys.argv[1:] if not a.startswith("--")] + check = "--check" in sys.argv + if len(args) != 1: + print("использование: post.py [--check]") + sys.exit(1) + src = Path(args[0]).resolve() + s, rate = read_wav(src) + s = resample_poly(s, TARGET_RATE, rate) # 16000 -> 22050 (целые множители: 441/320) + peak = np.abs(s).max() + if peak > 1e-9: + s = s * (PEAK / peak) + s = loopify(s, TARGET_RATE, FADE) + out = src.with_name(src.stem + "_loop.wav") + write_wav(out, s) + dur = s.shape[0] / TARGET_RATE + print(f"готово: {out.relative_to(HERE)} ({dur:.1f} с, {TARGET_RATE} Гц, пик {PEAK})") + + if check: + # Стык без щелчка: максимальный межсэмпловый скачок на шве против + # такого же скачка в середине записи — шов не должен выделяться. + mid = np.abs(np.diff(s[len(s) // 4 : len(s) // 4 + TARGET_RATE])).max() + seam = np.abs(s[-1] - s[0]) + print(f"стык: скачок на шве {seam:.4f}, максимум в середине {mid:.4f}") + assert seam <= max(mid, 0.01), "шов лупа подозрительно громкий" + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/docs/plan.md b/docs/plan.md index 52f9815..06db384 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -50,12 +50,11 @@ ## Ограничения (читать первым) -- **Машина разработки: Intel HD 620 (iGPU, нет CUDA), 15 ГБ RAM, 4 ядра CPU.** - Только CPU-инференс (подтверждено пробой ускорителей, см. B3). Модели класса - SDXL/FLUX/SANA/DreamLite отпадают либо как «медленный офлайн-пакет» (десятки - минут на картинку). Целевой класс — компактные модели (< 1.5B параметров), - CPU-рантаймы; главный бесплатный рычаг скорости — меньше шагов диффузии, - а не квантизация и не iGPU. +- **Машина разработки (обновлено 2026-09-09): RTX 3090 24 ГБ VRAM (CUDA 13.3), + 12 ядер CPU, 62 ГБ RAM.** Прежняя запись про Intel HD 620 / 4 ядра / 15 ГБ + относилась к старому ПК и устарела вместе с B3. GPU-инференс — основной путь + (замер A0: 10 с аудио за ~15 с; SD-листы тоже ускоряются кратно). Квантизация + и компактные модели больше не обязательны, ограничение — VRAM 24 ГБ и диск. - **Стиль игры жёсткий**: палитра 32 цветов (`tools/pixelart/palette.mjs`), размеры спрайтов по `docs/art-style.md`, pixel-perfect. Любая AI-генерация — только как **сырьё** с обязательной пост-обработкой: даунскейл nearest, @@ -251,6 +250,26 @@ | MusicGen small (AudioCraft) | 300M | CPU ~4 ГБ RAM | ~30 с | MIT/CC-BY-NC веса | фолбэк, мелодия-условность | | Stable Audio Open 1.0 | 1.2B | GPU ~2.5–8 ГБ | 47 с | Stability Community | зрелый, но на нашей машине безнадёжно медленный; пропускаем | +### Разведка A0 (2026-09-09, новая машина) + +- **Железо не CPU-only, как предполагалось: RTX 3090 24 ГБ + 12 ядер + 62 ГБ RAM.** + Генерация 10 с аудио (100 шагов): **GPU fp16 ~15 с** (CPU fp32 — 3.5 мин). + Это снимает оговорки про «терпимо» у ACE-Step и делает GPU-инференс + основным путём для всех работ AI-контента (и аудио, и изображений). +- **Stable Audio Open Small закрыт**: `gated: auto`, анонимно 401 — нужен + HF-токен + принятие Stability Community License (весов Small-SFX/Small-Music + как отдельных репо нет; доступен общий stable-audio-open-small). +- **PoC сделан на фолбэке AudioLDM2** (`cvssp/audioldm2`, CC-BY-NC-SA — + в основной пайплайн с некоммерческими весами не идёт, как и договаривались). + Стек: `~/.cache/rpg-ai/venv-aiaudio`, torch 2.14.0+cu130, **diffusers 0.35.2 + + transformers 4.49.0** (diffusers 0.40 требует transformers 5.x и падает на + AudioLDM2: `_update_model_kwargs_for_generation` удалён в 5.x). +- Пайплайн: `apps/game/tools/aiaudio/gen_audioldm2.py` → `post.py` + (ресэмпл 22050 моно 16-bit, нормализация 0.85, loopify — хвост кроссфейдом + в голову за 1 с, проверка стыка `--check`). Сырые клипы — `review/` + (в gitignore, перегенерируются по seed). Seed-детерминизм — по-устройству + (fp16 GPU ≠ fp32 CPU). + ### Пайплайн интеграции (проект решения) ``` @@ -265,9 +284,10 @@ ### Этапы -- **A0 (PoC)**: Stable Audio 3 Small-SFX локально на CPU; сгенерировать 5 - SFX-замен (step, ui_click, bell_hit) + 1 амбиент-луп прудов. Критерий: звук - лучше процедурного на слух и формат сходится. +- **A0 (PoC)**: локальный text-to-audio; амбиент-луп прудов. Критерий: звук + лучше процедурного на слух и формат сходится. *(SFX-замены выпали: после + E4 файловых SFX нет — все на спек-синтезе `data/sfxSpecs.ts`.)* + ✅ **сделано 2026-09-09** (PoC на фолбэке AudioLDM2, слушание — у пользователя). - **A1**: пайплайн aiaudio + манифест; замена SFX, амбиент-лупы областей. - **A2**: музыкальные лупы (Small-Music), сравнение с gen.mjs-треками. - **A3**: ACE-Step офлайн-пакет для катсцен акта 2 (1–2 трека под моменты).