diff --git a/apps/game/assets/audio/ambience/old_siding.wav b/apps/game/assets/audio/ambience/old_siding.wav new file mode 100644 index 0000000..f590253 --- /dev/null +++ b/apps/game/assets/audio/ambience/old_siding.wav Binary files differ diff --git a/apps/game/assets/audio/ambience/rust_grove.wav b/apps/game/assets/audio/ambience/rust_grove.wav new file mode 100644 index 0000000..dba7d52 --- /dev/null +++ b/apps/game/assets/audio/ambience/rust_grove.wav Binary files differ diff --git a/apps/game/assets/audio/ambience/wind.wav b/apps/game/assets/audio/ambience/wind.wav index 330a1f7..9983afd 100644 --- a/apps/game/assets/audio/ambience/wind.wav +++ b/apps/game/assets/audio/ambience/wind.wav Binary files differ diff --git a/apps/game/src/data/audio.ts b/apps/game/src/data/audio.ts index 90e7b09..6bec064 100644 --- a/apps/game/src/data/audio.ts +++ b/apps/game/src/data/audio.ts @@ -16,7 +16,9 @@ 'ambience/ponds', 'ambience/zvenets', 'ambience/ponds_night', - 'ambience/zvenets_night' + 'ambience/zvenets_night', + 'ambience/rust_grove', + 'ambience/old_siding' ] as const; /** Локальные амбиент-слои (вода, гул, дома) и глобальные (ветер). */ diff --git a/apps/game/src/data/locations.ts b/apps/game/src/data/locations.ts index b45b16d..d56d352 100644 --- a/apps/game/src/data/locations.ts +++ b/apps/game/src/data/locations.ts @@ -347,7 +347,7 @@ rust_grove: { id: 'rust_grove', name: 'Ржавая роща', - ambience: 'ambience/ponds', + ambience: 'ambience/rust_grove', spawn: { x: 2, y: 20 }, npcs: [], enemies: [ @@ -376,7 +376,7 @@ old_siding: { id: 'old_siding', name: 'Старый разъезд', - ambience: 'ambience/ponds', + ambience: 'ambience/old_siding', spawn: { x: 2, y: 20 }, npcs: [], enemies: [ diff --git a/apps/game/tools/aiaudio/analyze.py b/apps/game/tools/aiaudio/analyze.py new file mode 100644 index 0000000..d35e3bc --- /dev/null +++ b/apps/game/tools/aiaudio/analyze.py @@ -0,0 +1,68 @@ +"""Численный отбор AI-клипов (работа A1): метрики вместо слушания. + +Для review/__loop.wav считает: RMS, диапазон огибающей (дыхание +лупа — std скользящего RMS), долю высокочастотной энергии (резкость/жёсткость), +центроид спектра, число клиппингов. Кандидаты против эталона сравниваются +по этим цифрам; финальный отбор — таблицей в консоль. + +Запуск: ~/.cache/rpg-ai/venv-aiaudio/bin/python tools/aiaudio/analyze.py +""" +import sys +import wave +from pathlib import Path + +import numpy as np + +HERE = Path(__file__).resolve().parent + + +def read_wav(path: Path) -> np.ndarray: + with wave.open(str(path), "rb") as w: + data = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16) + return data.astype(np.float64) / 32768.0 + + +def metrics(s: np.ndarray, rate: int) -> dict: + win = rate // 10 # окно огибающей 100 мс + n = len(s) // win + env = np.sqrt((s[: n * win].reshape(n, win) ** 2).mean(axis=1)) + spec = np.abs(np.fft.rfft(s * np.hanning(len(s)))) + freqs = np.fft.rfftfreq(len(s), 1 / rate) + total = spec.sum() + 1e-12 + hi = spec[freqs > 4000].sum() / total # резкость: доля >4 кГц + centroid = (spec * freqs).sum() / total + clips = int((np.abs(s) > 0.99).sum()) + return { + "rms": float(np.sqrt((s**2).mean())), + "dyn": float(env.std() / (env.mean() + 1e-12)), # дыхание: неровность огибающей + "hi": float(hi), + "cent": float(centroid), + "clip": clips, + } + + +def main() -> None: + import glob + + files: list[Path] = [] + for pat in sys.argv[1:]: + # Шаблон относительный: сначала от cwd, при пустом результате — от tools/aiaudio. + for base in (Path.cwd(), HERE): + found = sorted(glob.glob(str(base / pat))) + if found: + files.extend(Path(p) for p in found) + break + if not files: + print("нет файлов под шаблонами") + sys.exit(1) + print(f"{'файл':44} {'rms':>6} {'dyn':>6} {'hi':>6} {'cent':>6} {'clip':>5}") + for f in files: + m = metrics(*((lambda r, rate: (r, rate))(read_wav(f), 22050))) + print( + f"{f.name:44} {m['rms']:6.3f} {m['dyn']:6.3f} {m['hi']:6.3f} " + f"{m['cent']:6.0f} {m['clip']:5d}" + ) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/apps/game/tools/aiaudio/gen_audioldm2.py b/apps/game/tools/aiaudio/gen_audioldm2.py index 485b679..90dd70e 100644 --- a/apps/game/tools/aiaudio/gen_audioldm2.py +++ b/apps/game/tools/aiaudio/gen_audioldm2.py @@ -1,17 +1,19 @@ -"""PoC (работа A, этап A0): text-to-audio через AudioLDM2. +"""Генерация AI-аудио (работа A, этап A1): text-to-audio через AudioLDM2 по манифесту. -Главный кандидат плана — Stable Audio Open Small — gated (нужен HF-токен), -поэтому PoC делаем на фолбэке AudioLDM2 (cvssp/audioldm2, CC-BY-NC-SA — -в ОСНОВНОЙ пайплайн с некоммерческими весами не пойдёт, см. docs/plan.md). +Манифест — apps/game/tools/aiaudio/manifest.json: промпты, сиды, длительности, +gate — единственный источник истины пайплайна (аналог atlas-manifest.json в +aiart). Модель — AudioLDM2 (cvssp/audioldm2, CC-BY-NC-SA; решение A1 2026-09-09: +игра некоммерческая. Смена модели = новый gen-скрипт с тем же CLI). -Запуск: - gen_audioldm2.py # все клипы из LIST с seed по умолчанию - gen_audioldm2.py [id[:seed]...] # один/несколько клипов -Клип = запись LIST (промпт, длительность, шаги); выход — review/_.wav. +Запуск (обычно через runner: npm run aiaudio gen [id[:seed,seed]...]): + ~/.cache/rpg-ai/venv-aiaudio/bin/python gen_audioldm2.py [id[:seed]...] +Без аргументов — все сиды всех позиций манифеста. Выход — review/_.wav +(16000 Гц, моно); луп/нормализация — post.py (runner запускает сам). Детерминизм: seed + шаги фиксированы, промпт в манифесте; детерминизм по-устройству (fp16 GPU ≠ fp32 CPU). GPU (RTX 3090) при наличии: fp16. """ +import json import sys from pathlib import Path @@ -20,122 +22,22 @@ HERE = Path(__file__).resolve().parent REVIEW = HERE / "review" +MANIFEST = HERE / "manifest.json" NEG_NOISE = ( "music, melody, rhythm, drums, speech, voices, sudden loud noises, " "harsh noise, distortion" ) -# Клипы PoC: амбиенты (длинные, лупуются пост-обработкой) и SFX (короткие, -# без лупа). Эталоны процедурных — tools/audio/gen.mjs и data/sfxSpecs.ts. -LIST = { - # --- амбиенты (10 с, луп) --- - "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": NEG_NOISE, - "duration": 10.0, - }, - "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": NEG_NOISE, - "duration": 10.0, - }, - "amb_meadows": { - "positive": ( - "field recording, desolate burned field, cold dry wind blowing " - "over dead grass, occasional faint debris rattling, bleak " - "post-fire emptiness, seamless ambience" - ), - "negative": NEG_NOISE, - "duration": 10.0, - }, - "amb_ponds_night": { - "positive": ( - "field recording, pond at night, deeper water lapping, night " - "insects, distant eerie echoes, dark oppressive quiet, " - "seamless ambience" - ), - "negative": NEG_NOISE, - "duration": 10.0, - }, - "amb_tower_hum": { - "positive": ( - "deep metallic resonance hum, giant bell tower vibrating, low " - "sustained drone with slow shimmer, ominous and ancient" - ), - "negative": NEG_NOISE, - "duration": 10.0, - }, - # --- SFX (короткие, без лупа) --- - "sfx_step": { - "positive": ( - "single soft footstep on dry crumbly ash and dead grass, short " - "subtle crunch, close up, dry foley" - ), - "negative": NEG_NOISE, - "duration": 2.5, - }, - "sfx_click": { - "positive": ( - "single short UI click, soft wooden tap, clean and dry, " - "interface sound" - ), - "negative": NEG_NOISE, - "duration": 1.5, - }, - "sfx_bell": { - "positive": ( - "single bronze bell strike, resonant metallic ring with long " - "natural decay, solemn, temple bell, one hit only" - ), - "negative": NEG_NOISE, - "duration": 5.0, - }, - "sfx_whoosh": { - "positive": ( - "single quick air whoosh, fast swing passing by, short swoosh " - "transition" - ), - "negative": NEG_NOISE, - "duration": 2.0, - }, - "sfx_hit": { - "positive": ( - "single dull impact hit, heavy blunt thud with brief echo, " - "combat punch sound, one hit only" - ), - "negative": NEG_NOISE, - "duration": 2.0, - }, - "sfx_pickup": { - "positive": ( - "single gentle collect chime, soft bright pling, short " - "delicate pickup sound, one note only" - ), - "negative": NEG_NOISE, - "duration": 2.0, - }, - "sfx_drop": { - "positive": ( - "single water drop into calm pond, soft plop with tiny ripple, " - "close up, one drop only" - ), - "negative": NEG_NOISE, - "duration": 2.0, - }, -} - DEFAULT_SEED = 1 +def load_items() -> dict[str, dict]: + """Позиции манифеста по id (промпт/длительность/сиды — источник истины).""" + data = json.loads(MANIFEST.read_text(encoding="utf-8")) + return {it["id"]: it for it in data["items"]} + + def load_pipeline(): from diffusers import AudioLDM2Pipeline @@ -149,16 +51,19 @@ def main() -> None: - # Аргументы — id[:seed]; без аргументов — весь LIST с DEFAULT_SEED. + items = load_items() + + # Аргументы — id или id:seed (один сид на аргумент, runner разворачивает сам). jobs: list[tuple[str, int]] = [] for arg in sys.argv[1:]: ident, _, seed = arg.partition(":") - if ident not in LIST: - print(f"неизвестный клип {ident!r}; доступно: {', '.join(LIST)}") + if ident not in items: + print(f"неизвестный id {ident!r}; доступно: {', '.join(items)}") sys.exit(1) jobs.append((ident, int(seed) if seed else DEFAULT_SEED)) if not jobs: - jobs = [(name, DEFAULT_SEED) for name in LIST] + for ident, it in items.items(): + jobs.extend((ident, s) for s in it.get("seeds", [DEFAULT_SEED])) import diffusers # noqa: F401 (после разбора аргументов — чтобы --help был быстрым) @@ -167,13 +72,13 @@ REVIEW.mkdir(parents=True, exist_ok=True) for ident, seed in jobs: - spec = LIST[ident] + spec = items[ident] torch.manual_seed(seed) np.random.seed(seed) print(f"генерация {ident} (seed={seed}, {spec['duration']} с, {dev})...") audio = pipe( spec["positive"], - negative_prompt=spec["negative"], + negative_prompt=spec.get("negative", NEG_NOISE), num_inference_steps=100, guidance_scale=3.0, audio_length_in_s=spec["duration"], diff --git a/apps/game/tools/aiaudio/index.mjs b/apps/game/tools/aiaudio/index.mjs new file mode 100644 index 0000000..1dd07a3 --- /dev/null +++ b/apps/game/tools/aiaudio/index.mjs @@ -0,0 +1,105 @@ +/** + * Пайплайн AI-аудио (работа A1) — манифест-driven, аналог `npm run aiart`. + * + * Команды: + * npm run aiaudio list — таблица позиций манифеста (gate/сиды/promote) + * npm run aiaudio gen [id[:seed,seed]...] — генерация AudioLDM2 + пост-обработка + * (без аргументов — все сиды манифеста; sfx — без лупа, остальное — луп + --check) + * npm run aiaudio post [...] — только пост-обработка (pass-through post.py) + * npm run aiaudio promote — апрув: review/__loop.wav → + * assets/audio/.wav + gate=approved в манифесте (sfx не promote-ится — + * SFX_KEYS пуст, спек-синтез; нужен файловый SFX-путь в движке) + * + * Модель/окружение — см. шапку manifest.json; python — venv ~/.cache/rpg-ai/venv-aiaudio. + */ +import { spawnSync } from 'node:child_process'; +import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; + +const require = createRequire(import.meta.url); +const HERE = path.dirname(require.resolve('./manifest.json')); +const ASSETS = path.resolve(HERE, '../../assets'); // apps/game/assets +const PY = path.join(process.env.HOME ?? '', '.cache/rpg-ai/venv-aiaudio/bin/python'); +const USAGE = 'npm run aiaudio ...|promote >'; + +function fail(msg) { + console.error(msg); + process.exit(1); +} + +function readManifest() { + return JSON.parse(readFileSync(path.join(HERE, 'manifest.json'), 'utf8')); +} + +/** Цели генерации: без аргументов — все сиды манифеста, иначе id[:seed,seed]. */ +function parseTargets(args, manifest) { + if (args.length === 0) { + return manifest.items.flatMap((it) => (it.seeds ?? [1]).map((seed) => ({ it, seed }))); + } + const out = []; + for (const arg of args) { + const [id, seeds] = arg.split(':'); + const it = manifest.items.find((x) => x.id === id); + if (!it) fail(`неизвестный id ${id}; доступно: ${manifest.items.map((x) => x.id).join(', ')}`); + for (const seed of (seeds ? seeds.split(',') : it.seeds ?? [1]).map(Number)) { + out.push({ it, seed }); + } + } + return out; +} + +function run(cmd, pyArgs) { + if (!existsSync(PY)) fail(`нет venv-питона: ${PY} (см. шапку manifest.json)`); + const r = spawnSync(PY, [cmd, ...pyArgs], { cwd: HERE, stdio: 'inherit' }); + if (r.status !== 0) fail(`${cmd} завершился с ошибкой`); +} + +function cmdGen(args) { + const manifest = readManifest(); + const targets = parseTargets(args, manifest); + console.log(`целей: ${targets.length} (${targets.map((t) => `${t.it.id}_${t.seed}`).join(', ')})`); + run('gen_audioldm2.py', targets.map((t) => `${t.it.id}:${t.seed}`)); + // Пост-обработка сразу: sfx — без лупа (_fx), остальное — луп + проверка шва. + for (const t of targets) { + const flags = t.it.kind === 'sfx' ? ['--noloop'] : ['--check']; + if (t.it.peak) flags.push('--peak', String(t.it.peak)); + run('post.py', [`review/${t.it.id}_${t.seed}.wav`, ...flags]); + } +} + +function cmdPromote(args) { + const [id, seedStr] = args; + if (!id || !seedStr) fail(USAGE); + const manifest = readManifest(); + const it = manifest.items.find((x) => x.id === id); + if (!it) fail(`неизвестный id ${id}`); + if (!it.promote) fail(`${id}: позиция без promote (SFX_KEYS пуст — файловых sfx пока нет)`); + const src = path.join(HERE, 'review', `${id}_${seedStr}_loop.wav`); + if (!existsSync(src)) fail(`нет файла ${src} (сначала npm run aiaudio gen ${id}:${seedStr})`); + // Промотируем только ключ, известный реестрам игры (audio.ts). + const audioTs = readFileSync(path.resolve(HERE, '../../src/data/audio.ts'), 'utf8'); + if (!audioTs.includes(`'${it.promote}'`)) fail(`ключ '${it.promote}' не объявлен в data/audio.ts — добавь сначала`); + const dst = path.join(ASSETS, 'audio', `${it.promote}.wav`); + copyFileSync(src, dst); + it.gate = 'approved'; + it.approved_seed = Number(seedStr); + it.date = new Date().toISOString().slice(0, 10); + writeFileSync(path.join(HERE, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`); + console.log(`промотировано: ${it.promote}.wav (seed ${seedStr}, gate=approved)`); +} + +function cmdList() { + const manifest = readManifest(); + for (const it of manifest.items) { + const gate = it.gate === 'approved' ? `approved(seed ${it.approved_seed})` : it.gate ?? '—'; + console.log(`${it.id.padEnd(20)} ${it.kind.padEnd(8)} сиды ${(it.seeds ?? [1]).join(',').padEnd(8)} ${gate.padEnd(20)} ${it.promote ?? ''}`); + } +} + +const [cmd, ...args] = process.argv.slice(2); +if (cmd === 'list') cmdList(); +else if (cmd === 'gen') cmdGen(args); +else if (cmd === 'post') run('post.py', args); +else if (cmd === 'promote') cmdPromote(args); +else fail(`использование: ${USAGE}`); \ No newline at end of file diff --git a/apps/game/tools/aiaudio/manifest.json b/apps/game/tools/aiaudio/manifest.json index 6c4ae1b..0c9739f 100644 --- a/apps/game/tools/aiaudio/manifest.json +++ b/apps/game/tools/aiaudio/manifest.json @@ -1,30 +1,253 @@ { - "run": "a0-poc", + "run": "a1-pipeline", "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", + "license_note": "веса CC-BY-NC-SA 4.0 — решение A1 (2026-09-09, пользователь): строим пайплайн на AudioLDM2, игра некоммерческая; главный путь плана (Stable Audio Open Small) остаётся опцией — пайплайн манифест-driven, смена модели = новый gen-скрипт с тем же CLI", + "pipeline": "npm run aiaudio gen [id[:seed]...] -> post.py (ресэмпл 22050, нормализация 0.85, loopify 1 с) -> npm run aiaudio promote -> assets/audio/.wav; sfx — без лупа (_fx) и без promote (SFX_KEYS пуст, спек-синтез E4)", "timing": "10 с аудио, 100 шагов: GPU ~15 с; CPU fp32 12 ядер ~3.5 мин", - "loop_check": "пост.py --check: скачок на шве против максимума межсэмплового diff в середине записи", - "criterion": "амбиент лучше процедурного на слух и формат сходится — АПРУВ 2026-09-09 (в игру: ponds, ponds_night, zvenets, tower_hum — seed 1; луга молчат по дизайну; SFX остаются спек-синтезом E4)", + "loop_check": "post.py --check: скачок на шве против максимума межсэмплового diff в середине записи", + "criterion": "ambience/layer — лучше процедурного эталона (tools/audio/gen.mjs) на слух; sfx — лучше спек-синтеза (data/sfxSpecs.ts); апрув — пользователь на слух", "items": [ - { "file": "amb_ponds_1.wav", "prompt": "amb_ponds.positive", "seed": 1, "steps": 100, "gate": "accept", "match": "?", "note": "луп; эталон gen.mjs ambiencePonds" }, - { "file": "amb_ponds_2.wav", "prompt": "amb_ponds.positive", "seed": 2, "steps": 100, "gate": "accept", "match": "?", "note": "луп" }, - { "file": "amb_zvenets_1.wav", "prompt": "amb_zvenets.positive", "seed": 1, "steps": 100, "gate": "accept", "match": "?", "note": "луп; эталон ambienceZvenets" }, - { "file": "amb_zvenets_2.wav", "prompt": "amb_zvenets.positive", "seed": 2, "steps": 100, "gate": "accept", "match": "?", "note": "луп" }, - { "file": "amb_meadows_1.wav", "prompt": "amb_meadows.positive", "seed": 1, "steps": 100, "gate": "accept", "match": "?", "note": "луп; в игре луга без амбиента (тишина пепла)" }, - { "file": "amb_meadows_2.wav", "prompt": "amb_meadows.positive", "seed": 2, "steps": 100, "gate": "accept", "match": "?", "note": "луп" }, - { "file": "amb_ponds_night_1.wav", "prompt": "amb_ponds_night.positive", "seed": 1, "steps": 100, "gate": "accept", "match": "?", "note": "луп; эталон ambiencePondsNight" }, - { "file": "amb_ponds_night_2.wav", "prompt": "amb_ponds_night.positive", "seed": 2, "steps": 100, "gate": "accept", "match": "?", "note": "луп" }, - { "file": "amb_tower_hum_1.wav", "prompt": "amb_tower_hum.positive", "seed": 1, "steps": 100, "gate": "accept", "match": "?", "note": "луп; слой layerTowerHum" }, - { "file": "sfx_step_1.wav", "prompt": "sfx_step.positive", "seed": 1, "steps": 100, "gate": "accept", "match": "?", "note": "SFX; в игре спек-синтез" }, - { "file": "sfx_click_1.wav", "prompt": "sfx_click.positive", "seed": 1, "steps": 100, "gate": "accept", "match": "?", "note": "SFX" }, - { "file": "sfx_bell_1.wav", "prompt": "sfx_bell.positive", "seed": 1, "steps": 100, "gate": "accept", "match": "?", "note": "SFX; эталон bellHit" }, - { "file": "sfx_bell_2.wav", "prompt": "sfx_bell.positive", "seed": 2, "steps": 100, "gate": "accept", "match": "?", "note": "SFX" }, - { "file": "sfx_whoosh_1.wav", "prompt": "sfx_whoosh.positive", "seed": 1, "steps": 100, "gate": "accept", "match": "?", "note": "SFX; эталон whoosh" }, - { "file": "sfx_hit_1.wav", "prompt": "sfx_hit.positive", "seed": 1, "steps": 100, "gate": "accept", "match": "?", "note": "SFX" }, - { "file": "sfx_pickup_1.wav", "prompt": "sfx_pickup.positive", "seed": 1, "steps": 100, "gate": "accept", "match": "?", "note": "SFX" }, - { "file": "sfx_drop_1.wav", "prompt": "sfx_drop.positive", "seed": 1, "steps": 100, "gate": "accept", "match": "?", "note": "SFX; эталон капли ambiencePonds" } + { + "id": "amb_ponds", + "kind": "ambience", + "promote": "ambience/ponds", + "seeds": [ + 1, + 2 + ], + "duration": 10, + "gate": "approved", + "approved_seed": 1, + "date": "2026-09-09", + "positive": "field recording, calm misty pond at dusk, gentle water lapping, soft wind through reeds, occasional distant croaking, eerie quiet melancholic atmosphere, seamless ambience", + "note": "эталон ambiencePonds; луга молчат по дизайну, луп прудов апрувнут A0" + }, + { + "id": "amb_zvenets", + "kind": "ambience", + "promote": "ambience/zvenets", + "seeds": [ + 1, + 2 + ], + "duration": 10, + "gate": "approved", + "approved_seed": 1, + "date": "2026-09-09", + "positive": "field recording, quiet rural settlement at dusk, low distant metallic bell toll, soft wind, faint rustling, somber melancholic atmosphere, seamless ambience", + "note": "эталон ambienceZvenets; апрувнут A0" + }, + { + "id": "amb_meadows", + "kind": "ambience", + "seeds": [ + 1, + 2 + ], + "duration": 10, + "gate": "hold", + "date": "2026-09-09", + "positive": "field recording, desolate burned field, cold dry wind blowing over dead grass, occasional faint debris rattling, bleak post-fire emptiness, seamless ambience", + "note": "в игре луга без амбиента (тишина пепла) — клипы держим как запас" + }, + { + "id": "amb_ponds_night", + "kind": "ambience", + "promote": "ambience/ponds_night", + "seeds": [ + 1, + 2 + ], + "duration": 10, + "gate": "approved", + "approved_seed": 1, + "date": "2026-09-09", + "positive": "field recording, pond at night, deeper water lapping, night insects, distant eerie echoes, dark oppressive quiet, seamless ambience", + "note": "эталон ambiencePondsNight; апрувнут A0" + }, + { + "id": "amb_tower_hum", + "kind": "layer", + "promote": "ambience/tower_hum", + "seeds": [ + 1 + ], + "duration": 10, + "gate": "approved", + "approved_seed": 1, + "date": "2026-09-09", + "positive": "deep metallic resonance hum, giant bell tower vibrating, low sustained drone with slow shimmer, ominous and ancient", + "note": "слой layerTowerHum; апрувнут A0" + }, + { + "id": "amb_rust_grove", + "kind": "ambience", + "promote": "ambience/rust_grove", + "seeds": [ + 1, + 2, + 3 + ], + "duration": 10, + "gate": "approved", + "positive": "field recording, dead forest of rusted metal trees wrapped in black cables, wind through cable strands causing faint creaks and rare distant metallic chimes, low rustle of ash, somber mysterious atmosphere, seamless ambience", + "note": "новое A1: роще свой луп (seed 2 — тихий, редкие яркие скрипы); заменил подстановку прудов", + "approved_seed": 2, + "date": "2026-09-09" + }, + { + "id": "amb_old_siding", + "kind": "ambience", + "promote": "ambience/old_siding", + "seeds": [ + 1, + 2, + 3 + ], + "duration": 10, + "gate": "approved", + "positive": "field recording, abandoned railway siding in ash wastes at dusk, faint metallic squeak of old rails, cold wind over ash ground, very distant low machinery pulse, desolate quiet, seamless ambience", + "note": "новое A1: разъезду свой луп (seed 3 — событийная огибающая, скрипы рельсов); заменил подстановку прудов", + "approved_seed": 3, + "date": "2026-09-09" + }, + { + "id": "amb_zvenets_night", + "kind": "ambience", + "promote": "ambience/zvenets_night", + "seeds": [ + 1, + 2, + 3 + ], + "duration": 10, + "gate": "hold", + "positive": "field recording, quiet rural settlement at night, very low distant bell resonance, cold night wind, sparse faint insect chirps, deep somber quiet, seamless ambience", + "note": "оставляем процедурный эталон: у него огибающая живее (dyn 0.46 против 0.09–0.18) — отбор по метрикам, пользователь делегировал решение" + }, + { + "id": "layer_houses", + "kind": "layer", + "promote": "ambience/houses", + "seeds": [ + 1, + 2 + ], + "duration": 10, + "gate": "hold", + "positive": "quiet interior murmur of old wooden settlement houses, faint wood creaks, muffled domestic sounds behind walls, hearth crackling far away, close perspective, seamless ambience", + "note": "оставляем процедурный эталон: тихий с событиями треска (dyn 2.28, rms 0.03) против ровных AI-клипов" + }, + { + "id": "layer_wind", + "kind": "layer", + "promote": "ambience/wind", + "seeds": [ + 1, + 2, + 3, + 4 + ], + "duration": 10, + "peak": 0.6, + "gate": "approved", + "positive": "field recording, cold wind howling across a vast burned field, long slow gusts that swell and fade away, faint low mournful whistle, occasional light debris rattling far away, quiet distant perspective, seamless ambience", + "note": "новое A1 (фидбек: завывание, периодическое затухание, тихий): пик 0.6 поверх игрового гейна слоя 0.25–0.4", + "approved_seed": 3, + "date": "2026-09-09" + }, + { + "id": "layer_ponds_water", + "kind": "layer", + "promote": "ambience/ponds_water", + "seeds": [ + 1, + 2 + ], + "duration": 10, + "gate": "hold", + "positive": "gentle pond water lapping softly against a shore, calm small ripples, quiet close-up foley, seamless ambience", + "note": "оставляем процедурный эталон: dyn 0.67 против 0.15–0.26 у AI" + }, + { + "id": "sfx_step", + "kind": "sfx", + "seeds": [ + 1 + ], + "duration": 2.5, + "gate": "candidate", + "positive": "single soft footstep on dry crumbly ash and dead grass, short subtle crunch, close up, dry foley", + "note": "SFX-кандидаты A0/A1: в игре спек-синтез E4; promote после сравнения на слух (нужен файловый SFX-путь)" + }, + { + "id": "sfx_click", + "kind": "sfx", + "seeds": [ + 1 + ], + "duration": 1.5, + "gate": "candidate", + "positive": "single short UI click, soft wooden tap, clean and dry, interface sound", + "note": "SFX-кандидат" + }, + { + "id": "sfx_bell", + "kind": "sfx", + "seeds": [ + 1, + 2 + ], + "duration": 5, + "gate": "candidate", + "positive": "single bronze bell strike, resonant metallic ring with long natural decay, solemn, temple bell, one hit only", + "note": "SFX-кандидат; эталон bellHit" + }, + { + "id": "sfx_whoosh", + "kind": "sfx", + "seeds": [ + 1 + ], + "duration": 2, + "gate": "candidate", + "positive": "single quick air whoosh, fast swing passing by, short swoosh transition", + "note": "SFX-кандидат; эталон whoosh" + }, + { + "id": "sfx_hit", + "kind": "sfx", + "seeds": [ + 1 + ], + "duration": 2, + "gate": "candidate", + "positive": "single dull impact hit, heavy blunt thud with brief echo, combat punch sound, one hit only", + "note": "SFX-кандидат" + }, + { + "id": "sfx_pickup", + "kind": "sfx", + "seeds": [ + 1 + ], + "duration": 2, + "gate": "candidate", + "positive": "single gentle collect chime, soft bright pling, short delicate pickup sound, one note only", + "note": "SFX-кандидат; эталон motepick" + }, + { + "id": "sfx_drop", + "kind": "sfx", + "seeds": [ + 1 + ], + "duration": 2, + "gate": "candidate", + "positive": "single water drop into calm pond, soft plop with tiny ripple, close up, one drop only", + "note": "SFX-кандидат; эталон капли ambiencePonds" + } ] -} \ No newline at end of file +} diff --git a/apps/game/tools/aiaudio/post.py b/apps/game/tools/aiaudio/post.py index 75c4cd3..e1de11d 100644 --- a/apps/game/tools/aiaudio/post.py +++ b/apps/game/tools/aiaudio/post.py @@ -51,11 +51,25 @@ def main() -> None: - args = [a for a in sys.argv[1:] if not a.startswith("--")] - check = "--check" in sys.argv - noloop = "--noloop" in sys.argv + # Разбор аргументов вручную: --peak <значение> — цель нормализации + # (манифест может задавать тише для слоёв; по умолчанию PEAK). + peak_target = PEAK + args: list[str] = [] + raw = sys.argv[1:] + i = 0 + while i < len(raw): + if raw[i] == "--peak": + peak_target = float(raw[i + 1]) + i += 2 + elif not raw[i].startswith("--"): + args.append(raw[i]) + i += 1 + else: + i += 1 + check = "--check" in raw + noloop = "--noloop" in raw if len(args) != 1: - print("использование: post.py [--check] [--noloop]") + print("использование: post.py [--check] [--noloop] [--peak 0.85]") sys.exit(1) src = Path(args[0]).resolve() s, rate = read_wav(src) @@ -65,11 +79,11 @@ # которые луп вплетает/выбрасывает — иначе фактический пик ниже цели. peak = np.abs(s).max() if peak > 1e-9: - s = s * (PEAK / peak) + s = s * (peak_target / peak) out = src.with_name(src.stem + ("_fx.wav" if noloop else "_loop.wav")) write_wav(out, s) dur = s.shape[0] / TARGET_RATE - print(f"готово: {out.relative_to(HERE)} ({dur:.1f} с, {TARGET_RATE} Гц, пик {PEAK})") + print(f"готово: {out.relative_to(HERE)} ({dur:.1f} с, {TARGET_RATE} Гц, пик {peak_target})") if check: # Стык без щелчка: максимальный межсэмпловый скачок на шве против diff --git a/docs/demo.md b/docs/demo.md index b844bd4..3bbdc6e 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -23,7 +23,7 @@ | GameState (флаги/вары) | прогресс квеста, hp/kills/motes/deaths; сериализация в сейвы | готово | | SaveManager (слоты) | autosave (Esc) + 3 слота, `SaveSlotsScene` | готово | | Settings + onChange | громкости master/ambience/sfx, `SettingsScene` | готово | -| AudioManager (шины) | амбиент локаций с кроссфейдом (луга — без амбиента: тишина выжженных земель, только музыка и шаги; пруды, Звенец и гул башни — AI-генерация AudioLDM2, работа A0: бесшовные лупы 9 с, `tools/aiaudio`), sfx боя/шагов/UI — спек-синтез | готово | +| AudioManager (шины) | амбиент локаций с кроссфейдом (луга — без амбиента: тишина выжженных земель, только музыка и шаги; пруды, Звенец, роща, разъезд и гул башни — AI-генерация AudioLDM2, работа A0/A1: бесшовные лупы 9 с, манифест-пайплайн `npm run aiaudio`), sfx боя/шагов/UI — спек-синтез | готово | | Tween + easing | кат-сцены, лерпы освещения; `backOut` для pop-эффектов | готово | | FxLayer + пресеты (`render/fx/`) | кольца резонанса, всплывающие тосты, растворения трупов/интерактивов, хит-флэши (герой и враги), мотыли над пеплом (`emberPreset`), частицы ударов/смертей | готово | | Particles (атмосфера + burst) | пепел лугов, туман прудов, удары/смерти сгустков | готово | @@ -93,6 +93,7 @@ - `systems/` — PlayerController (путь + прямое движение), DialogueSystem, `combat/` (CombatWorld, EnemyBrain, PlayerCombat, CombatViews, HealthBar, stats). - Инструменты: `apps/game/tools/pixelart` (PNG-атласы), `apps/game/tools/audio` (WAV-генератор), + `apps/game/tools/aiaudio` (AI-лупы AudioLDM2: манифест + gen/post/promote, апрув-гейт), `apps/game/tools/maps` (карты-файлы), `apps/game/tools/agent.mjs` + `apps/game/tools/lib.mjs` + `apps/game/tools/checks/` (агентный мост: check/snapshot/сценарии; браузерный каркас — `packages/engine/tools/agent-lib.mjs`), `apps/game/tools/smoke*.mjs` diff --git a/docs/plan.md b/docs/plan.md index 34239e1..bc2872a 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -295,17 +295,23 @@ (в gitignore, перегенерируются по seed). Seed-детерминизм — по-устройству (fp16 GPU ≠ fp32 CPU). -### Пайплайн интеграции (проект решения) +### Пайплайн (как сделано, A1) ``` -tools/aiaudio/gen.mjs # вызов модели, seed, промпт из манифеста (аналог aiart) - → post: ffmpeg → 22050 моно 16-bit → нормализация → crossfade-луп для ambience/sfx - → review-каталог → apps/game/assets/audio/{sfx,ambience}/*.wav +npm run aiaudio gen [id[:seed,seed]...] # AudioLDM2 + post.py (ресэмпл 22050 моно + # 16-bit, нормализация, loopify 1 с, --check + # шва; sfx — без лупа) +npm run aiaudio list | promote # апрув-гейт: review/ → assets/audio/, + # gate=approved в манифесте ``` -Принципы те же: манифест, апрув-гейт, seed-детерминизм, формат игры не меняется -(шины master/music/sfx/ambience в движке уже готовы). Лупы проверять скриптом -(стык начала/конца без щелчка). +Манифест — `apps/game/tools/aiaudio/manifest.json` (промпты, сиды, duration, +`peak` — цель нормализации, gate, заметки). Принципы aiart: манифест, апрув-гейт +(ключ promote проверяется на объявление в `data/audio.ts`), seed-детерминизм, +формат игры не меняется (шины master/music/sfx/ambience готовы). Лупы проверяются +скриптом (стык начала/конца без щелчка) и метриками `analyze.py` (огибающая/ +спектр — отбор при делегированном апруве). SFX остаются на спек-синтезе — +промотировать некуда (SFX_KEYS пуст), файлового SFX-пути в движке нет. ### Этапы @@ -313,7 +319,15 @@ лучше процедурного на слух и формат сходится. *(SFX-замены выпали: после E4 файловых SFX нет — все на спек-синтезе `data/sfxSpecs.ts`.)* ✅ **сделано 2026-09-09** (PoC на фолбэке AudioLDM2, слушание — у пользователя). -- **A1**: пайплайн aiaudio + манифест; замена SFX, амбиент-лупы областей. +- **A1**: пайплайн aiaudio + манифест; амбиент-лупы областей. + ✅ **сделано 2026-09-09** (решение A1: AudioLDM2, игра некоммерческая): + манифест-driven раннер `npm run aiaudio` (gen/post/promote/list, python из + `~/.cache/rpg-ai/venv-aiaudio`), пост-обработка с пер-позиционным `peak`, + метрики `analyze.py`. Промотированы: **роща** (свой луп вместо подстановки + прудов, seed 2), **разъезд** (seed 3), **ветер** (фидбек: завывание + + периодическое затухание + тише — пик 0.6, seed 3). Ночью Звенца / дома / вода — + оставлены процедурные (огибающая живее у эталонов, кандидаты hold в манифесте). + SFX — остались спек-синтезом (см. выше). - **A2**: музыкальные лупы (Small-Music), сравнение с gen.mjs-треками. - **A3**: ACE-Step офлайн-пакет для катсцен акта 2 (1–2 трека под моменты). diff --git a/package.json b/package.json index b792896..d9901db 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "aiart": "node apps/game/tools/aiart/atlas.mjs", "art:lint": "node apps/game/tools/aiart/lint-art.mjs $(find apps/game/assets -name '*.png')", "audio": "node apps/game/tools/audio/gen.mjs", + "aiaudio": "node apps/game/tools/aiaudio/index.mjs", "maps": "RUN_MAPS_GEN=1 vitest run apps/game/tools/maps/gen.test.ts", "dialogues:dry": "vite-node apps/game/tools/dialogues/dry-run.ts", "dialogues": "node apps/game/tools/dialogue-editor/server.mjs",