diff --git a/apps/game/tools/aiaudio/index.mjs b/apps/game/tools/aiaudio/index.mjs index 1dd07a3..4934dc5 100644 --- a/apps/game/tools/aiaudio/index.mjs +++ b/apps/game/tools/aiaudio/index.mjs @@ -9,6 +9,8 @@ * npm run aiaudio promote — апрув: review/__loop.wav → * assets/audio/.wav + gate=approved в манифесте (sfx не promote-ится — * SFX_KEYS пуст, спек-синтез; нужен файловый SFX-путь в движке) + * npm run aiaudio view [seed...] — спектро-лист кандидата в /tmp + * (движковая тулза packages/engine/tools/spectro.mjs: взгляд агента на звук) * * Модель/окружение — см. шапку manifest.json; python — venv ~/.cache/rpg-ai/venv-aiaudio. */ @@ -89,6 +91,26 @@ console.log(`промотировано: ${it.promote}.wav (seed ${seedStr}, gate=approved)`); } +/** Лист спектрограмм кандидата (взгляд ИИ-агента): view [seed...] → /tmp/*.png. */ +function cmdView(args) { + const [id, ...seeds] = args; + if (!id) fail('использование: npm run aiaudio view [seed,seed...]'); + const manifest = readManifest(); + const it = manifest.items.find((x) => x.id === id); + if (!it) fail(`неизвестный id ${id}`); + const files = (seeds.length ? seeds.map(Number) : it.seeds ?? [1]).map((s) => { + const loop = it.kind !== 'sfx'; + const suffix = loop ? '_loop.wav' : '_fx.wav'; + const p = path.join(HERE, 'review', `${id}_${s}${suffix}`); + if (!existsSync(p)) fail(`нет файла ${p} (сначала npm run aiaudio gen ${id}:${s})`); + return `${p}${loop ? ':loop' : ''}`; + }); + const script = path.resolve(HERE, '../../../../packages/engine/tools/spectro.mjs'); + const out = `/tmp/aiaudio_${id}_spec.png`; + const r = spawnSync('node', [script, out, ...files], { stdio: 'inherit' }); + if (r.status !== 0) fail('spectro завершился с ошибкой'); +} + function cmdList() { const manifest = readManifest(); for (const it of manifest.items) { @@ -102,4 +124,5 @@ else if (cmd === 'gen') cmdGen(args); else if (cmd === 'post') run('post.py', args); else if (cmd === 'promote') cmdPromote(args); +else if (cmd === 'view') cmdView(args); else fail(`использование: ${USAGE}`); \ No newline at end of file diff --git a/docs/engine/assets-audio-save.md b/docs/engine/assets-audio-save.md index 590846e..cb48de8 100644 --- a/docs/engine/assets-audio-save.md +++ b/docs/engine/assets-audio-save.md @@ -108,7 +108,13 @@ `AudioBuffer` (null, пока контекст не создан — после первого `play`/`unlock`/`prepare` работает). Чистые примитивы синтеза (bandNoise, synth, decay, normalize, makeRng) идут с движком в -`@rpg/engine/tools/synth.mjs` — без node-импортов, готовы для браузера: +`@rpg/engine/tools/synth.mjs` — без node-импортов, готовы для браузера. +Обратное направление — разбор WAV-файлов — `decodeWav` из +`@rpg/engine/tools/wav.mjs` (PCM 16-бит, моно/стерео → Float32), а +`@rpg/engine/tools/spectro.mjs` строит по сэмплам спектро-лист PNG +(спектрограмма log-частоты + огибающая RMS + маркер шва лупа) — «взгляд» +ИИ-агента на сгенерированный звук без слушания (гейты работ AI-аудио, +`npm run aiaudio view `): ```ts import { bandNoise, decay, normalize, synth } from '@rpg/engine/tools/synth.mjs'; diff --git a/docs/engine/practices.md b/docs/engine/practices.md index 8c40b10..a3780c8 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -265,7 +265,12 @@ `@rpg/engine/tools/synth.mjs` — bandNoise, bellPartial, synth, decay, loopify; сиды фиксированы — звук детерминирован) + запись в словарь `AMBIENCE`; `npm run audio` перегенерирует WAV в - `apps/game/assets/audio/ambience/`. + `apps/game/assets/audio/ambience/`. AI-лупы (AudioLDM2) — через + `npm run aiaudio` (манифест `tools/aiaudio/manifest.json`: gen → post → + promote по апруву). Отбор кандидата без слушания — спектро-лист + `packages/engine/tools/spectro.mjs` (`npm run aiaudio view ` → PNG в + /tmp: спектрограмма log-частоты, огибающая RMS, маркер шва лупа) — агент + «смотрит» на порывы/события/артефакты, числа `analyze.py` дополняют картину. 3. Ключ лупа — в реестр `apps/game/src/data/audio.ts` (`AMBIENCE_KEYS` / `AMBIENCE_LAYER_KEYS`): BootScene префетчит оттуда (спек-ключи не префетчатся — их компилирует движок на месте). diff --git a/docs/llms.txt b/docs/llms.txt index bcdf3f7..a3fdcbb 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -31,7 +31,7 @@ - [Практики](engine/practices.md): живой документ приёмов по ситуациям агента; пополняется в каждом коммите. - `apps/game/tools/agent.mjs`: CLI check/run/snapshot/screenshot/dev (JSON по умолчанию). -- `packages/engine/tools/`: жанронезависимые тулзы (png/canvas/imaging/wav, agent-lib, boundary) — импорт через `@rpg/engine/tools/*`; тулзы, знающие контент игры, остаются в `apps/game/tools/` как сценарии применения. +- `packages/engine/tools/`: жанронезависимые тулзы (png/canvas/imaging/wav/spectro, agent-lib, boundary) — импорт через `@rpg/engine/tools/*`; тулзы, знающие контент игры, остаются в `apps/game/tools/` как сценарии применения. spectro.mjs — спектро-лист PNG («взгляд» агента на WAV, гейты AI-аудио). - `apps/game/tools/checks/*.mjs`: сценарии проверки геймплейных систем через мост. - `apps/game/tools/dialogues/`: dry-run CLI (`npm run dialogues:dry`) и probe/registries для редактора; визуальный редактор графов — `npm run dialogues` (порт 5299). - `apps/game/tools/guards/boundary.mjs` (`npm run guard`): сценарий запуска гварда; правила — `packages/engine/tools/boundary.mjs`. diff --git a/docs/plan.md b/docs/plan.md index bc2872a..84bcc6a 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -312,6 +312,9 @@ скриптом (стык начала/конца без щелчка) и метриками `analyze.py` (огибающая/ спектр — отбор при делегированном апруве). SFX остаются на спек-синтезе — промотировать некуда (SFX_KEYS пуст), файлового SFX-пути в движке нет. +«Взгляд» агента на звук: движковая тулза `packages/engine/tools/spectro.mjs` +(спектрограмма log-частоты + огибающая RMS + маркер шва → PNG; `npm run aiaudio +view `) — замена числового отбора визуальной оценкой на гейтах A2/A3. ### Этапы diff --git a/packages/engine/tools/__tests__/spectro.test.mjs b/packages/engine/tools/__tests__/spectro.test.mjs new file mode 100644 index 0000000..61939a1 --- /dev/null +++ b/packages/engine/tools/__tests__/spectro.test.mjs @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { writeFileSync, readFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { encodeWav, decodeWav } from '../wav.mjs'; +import { spectrogram, envelope, spectroSheet } from '../spectro.mjs'; + +/** Синус 440 Гц амплитуды 0.5 (2 с — за пределами окна анализа). */ +const tone = (rate = 22050) => { + const n = rate * 2; + const s = new Float32Array(n); + for (let i = 0; i < n; i++) s[i] = 0.5 * Math.sin((2 * Math.PI * 440 * i) / rate); + return s; +}; + +describe('decodeWav', () => { + it('читает обратно то, что написал encodeWav', () => { + const src = tone(); + const { samples, rate } = decodeWav(encodeWav(src, 22050)); + expect(rate).toBe(22050); + expect(samples.length).toBe(src.length); + // 16-бит квантование: расхождение с оригиналом микроскопическое. + let err = 0; + for (let i = 0; i < samples.length; i++) err = Math.max(err, Math.abs(samples[i] - src[i])); + expect(err).toBeLessThan(1e-4); + }); + + it('падает с внятной ошибкой на не-WAV', () => { + expect(() => decodeWav(Buffer.from('not a wav file at all'))).toThrow(/не WAV/); + }); +}); + +describe('spectrogram/envelope', () => { + it('пик спектрограммы тона попадает на его частоту', () => { + const rate = 22050; + const spec = spectrogram(tone(rate), { win: 1024, hop: 512 }); + expect(spec.cols).toBeGreaterThan(0); + expect(Number.isFinite(spec.max)).toBe(true); + // 440 Гц → бин 440/22050*1024 ≈ 20 (из 512). + const bin = Math.round((440 / rate) * 1024); + let best = -Infinity; + let bestRow = 0; + for (let r = 0; r < spec.rows; r++) { + if (spec.db[r] > best) { + best = spec.db[r]; + bestRow = r; + } + } + expect(Math.abs(bestRow - bin)).toBeLessThanOrEqual(2); + }); + + it('огибающая нормализована в 0..1', () => { + const env = envelope(tone(), 22050); + for (const v of env) { + expect(v).toBeGreaterThanOrEqual(0); + expect(v).toBeLessThanOrEqual(1); + } + expect(env.some((v) => v > 0.9)).toBe(true); // максимум достижим + }); +}); + +describe('spectroSheet', () => { + it('пишет PNG-лист по нескольким клипам', () => { + const dir = mkdtempSync(join(tmpdir(), 'spectro-')); + try { + const out = join(dir, 'sheet.png'); + const s = tone(); + spectroSheet( + [ + { name: 'a', samples: s, rate: 22050, loop: true }, + { name: 'b', samples: s, rate: 22050, loop: false } + ], + out + ); + const buf = readFileSync(out); + expect(buf.subarray(1, 4).toString()).toBe('PNG'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); \ No newline at end of file diff --git a/packages/engine/tools/spectro.mjs b/packages/engine/tools/spectro.mjs new file mode 100644 index 0000000..a15c7f2 --- /dev/null +++ b/packages/engine/tools/spectro.mjs @@ -0,0 +1,227 @@ +/** + * Спектро-лист: «взгляд» ИИ-агента на WAV без слушания (аудио-гейты работ + * AI-контента). Жанронезависимо: вход — сэмплы, выход — PNG. На каждый клип — + * спектрограмма (лог-частота, dB-палитра), огибающая RMS и метки времени; + * для лупов — маркер шва (стык конца в начало). Модуль не знает контента игры. + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; +import { encodePng } from './png.mjs'; +import { decodeWav } from './wav.mjs'; + +const EPS = 1e-10; +/** Порог динамики спектрограммы, dB ниже максимума записи. */ +const DB_FLOOR = 66; + +/** Палитра dB→цвет: чёрный → синева → бирюза → золото → белый (жар). */ +const HEAT = [ + [12, 14, 20], + [26, 34, 62], + [38, 92, 108], + [70, 160, 130], + [158, 202, 96], + [222, 198, 74], + [252, 246, 220] +]; + +/** Итеративный radix-2 FFT (in-place, re/im длиной в степень двойки). */ +function fft(re, im) { + const n = re.length; + for (let i = 1, j = 0; i < n; i++) { + let bit = n >> 1; + for (; j & bit; bit >>= 1) j ^= bit; + j ^= bit; + if (i < j) { + [re[i], re[j]] = [re[j], re[i]]; + [im[i], im[j]] = [im[j], im[i]]; + } + } + for (let len = 2; len <= n; len <<= 1) { + const ang = (-2 * Math.PI) / len; + const wr = Math.cos(ang); + const wi = Math.sin(ang); + for (let i = 0; i < n; i += len) { + let cr = 1; + let ci = 0; + for (let k = 0; k < len / 2; k++) { + const ur = re[i + k]; + const ui = im[i + k]; + const vr = re[i + k + len / 2] * cr - im[i + k + len / 2] * ci; + const vi = re[i + k + len / 2] * ci + im[i + k + len / 2] * cr; + re[i + k] = ur + vr; + im[i + k] = ui + vi; + re[i + k + len / 2] = ur - vr; + im[i + k + len / 2] = ui - vi; + const ncr = cr * wr - ci * wi; + ci = cr * wi + ci * wr; + cr = ncr; + } + } + } +} + +/** + * Спектрограмма: окна Ханна win с шагом hop, |FFT| в dB. Возвращает + * { cols, rows, db: Float32Array (cols*rows, частота: строка 0 — верх/высокая) }. + */ +export function spectrogram(samples, { win = 1024, hop = 512 } = {}) { + const cols = Math.max(1, Math.floor((samples.length - win) / hop) + 1); + const rows = win / 2; + const db = new Float32Array(cols * rows); + const re = new Float32Array(win); + const im = new Float32Array(win); + let max = -Infinity; + for (let c = 0; c < cols; c++) { + for (let i = 0; i < win; i++) { + const w = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / win); + re[i] = (samples[c * hop + i] ?? 0) * w; + im[i] = 0; + } + fft(re, im); + for (let r = 0; r < rows; r++) { + const mag = Math.hypot(re[r], im[r]); // нижняя половина спектра + const v = 20 * Math.log10(mag + EPS); + db[c * rows + r] = v; + if (v > max) max = v; + } + } + return { cols, rows, db, max }; +} + +/** Огибающая RMS (0..1): окно winMs, шаг окно/2 — для полосы «дыхания». */ +export function envelope(samples, rate, winMs = 60) { + const win = Math.max(1, Math.round((winMs / 1000) * rate)); + const cols = Math.max(1, Math.floor(samples.length / win)); + const out = new Float32Array(cols); + let max = 0; + for (let c = 0; c < cols; c++) { + let sum = 0; + for (let i = 0; i < win; i++) { + const s = samples[c * win + i] ?? 0; + sum += s * s; + } + out[c] = Math.sqrt(sum / win); + if (out[c] > max) max = out[c]; + } + if (max > 0) for (let c = 0; c < cols; c++) out[c] /= max; + return out; +} + +/** dB→цвет палитры (0 = тише DB_FLOOR ниже максимума записи). */ +function heatIndex(db, max) { + const t = Math.max(0, Math.min(1, (db - (max - DB_FLOOR)) / DB_FLOOR)); + const seg = t * (HEAT.length - 1); + const i = Math.min(HEAT.length - 2, Math.floor(seg)); + const f = seg - i; + const mix = (c) => Math.round(HEAT[i][c] + (HEAT[i + 1][c] - HEAT[i][c]) * f); + return [mix(0), mix(1), mix(2)]; +} + +/** Крошечный шрифт цифр 3×5 (для номеров строк листа). */ +const DIGITS = ['111101101101111', '010110010010111', '111001111100111', '111001111001111', '101101111001001', '111100111001111', '111100111101111', '111001010100100', '111101111101111', '111101111001111']; + +/** Нарисовать цифру (индекс строки) в 3×5 с масштабом. */ +function drawNumber(px, x, y, n, scale = 2, color = [160, 168, 180]) { + for (const ch of String(n)) { + const g = DIGITS[Number(ch)]; + for (let i = 0; i < 15; i++) { + if (g[i] === '1') { + const gx = i % 3; + const gy = (i / 3) | 0; + for (let dy = 0; dy < scale; dy++) { + for (let dx = 0; dx < scale; dx++) { + const px0 = (y + gy * scale + dy) * px.width + x + gx * scale + dx; + px.data.set(color, px0 * 4); + } + } + } + } + x += 4 * scale; + } +} + +/** + * Лист клипов: спектро (log-частота) + огибающая + ось времени на строку. + * clips: { name, samples, rate, loop? }. loop=true — красный маркер шва + * (конец→начало) на правом краю спектро. Пишет PNG в path, возвращает путь. + */ +export function spectroSheet(clips, path, { width = 720, specH = 140, envH = 22 } = {}) { + const labelH = 12; + const rowH = labelH + specH + envH + 10; + const W = width; + const H = clips.length * rowH + 8; + const px = { width: W, height: H, data: Buffer.alloc(W * H * 4, 255) }; + const bg = [235, 236, 240]; + for (let i = 0; i < W * H; i++) px.data.set(bg, i * 4); + + clips.forEach((clip, row) => { + const y0 = row * rowH + 4; + const spec = spectrogram(clip.samples, { win: 1024, hop: Math.max(1, Math.floor(clip.samples.length / (W - 16))) }); + const fmin = 40; + const fmax = clip.rate / 2; + const x0 = 20; + const gw = W - x0 - 12; + for (let x = 0; x < gw; x++) { + const col = Math.min(spec.cols - 1, Math.floor((x / gw) * spec.cols)); + for (let y = 0; y < specH; y++) { + const f = fmax * Math.pow(fmin / fmax, y / specH); // лог-частота сверху вниз + const binIdx = Math.min(spec.rows - 1, Math.round((f / fmax) * spec.rows)); + const [r, g, b] = heatIndex(spec.db[col * spec.rows + binIdx], spec.max); + const pi = ((y0 + labelH + y) * W + x0 + x) * 4; + px.data.set([r, g, b], pi); + } + } + // Огибающая RMS: заполненная полоса под спектро. + const env = envelope(clip.samples, clip.rate); + const ey = y0 + labelH + specH + 4; + for (let x = 0; x < gw; x++) { + const v = env[Math.min(env.length - 1, Math.floor((x / gw) * env.length))] ?? 0; + const h = Math.round(v * (envH - 4)); + for (let y = 0; y < h; y++) { + const pi = ((ey + envH - 4 - y) * W + x0 + x) * 4; + px.data.set([70, 110, 140], pi); + } + } + // Секундные тики времени под огибающей. + const totalSec = clip.samples.length / clip.rate; + for (let s = 0; s <= totalSec; s++) { + const x = Math.round((s / totalSec) * (gw - 1)); + for (let y = 0; y < 4; y++) { + const pi = ((ey + envH + y) * W + x0 + x) * 4; + px.data.set([120, 126, 138], pi); + } + } + // Маркер шва лупа: красные кромки на стыке конец→начало. + if (clip.loop) { + for (let y = 0; y < specH; y++) { + const pl = ((y0 + labelH + y) * W + x0) * 4; + const pr = ((y0 + labelH + y) * W + x0 + gw - 1) * 4; + px.data.set([200, 60, 50], pl); + px.data.set([200, 60, 50], pr); + } + } + drawNumber(px, 6, y0 + labelH / 2 - 4, row + 1); + }); + + writeFileSync(path, encodePng(W, H, px.data)); + return path; +} + +// CLI: node spectro.mjs [clip.wav[:loop]...] +// :loop — пометить шов; порядок файлов = номера строк на листе. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const [out, ...files] = process.argv.slice(2); + if (!out || files.length === 0) { + console.error('использование: node spectro.mjs [...]'); + process.exit(1); + } + const clips = files.map((f, i) => { + const loop = f.endsWith(':loop'); + const file = f.replace(/:loop$/, ''); + const buf = readFileSync(file); + const { samples, rate } = decodeWav(buf); + console.log(`${i + 1}: ${file} (${(samples.length / rate).toFixed(1)} с, ${rate} Гц)${loop ? ' [луп]' : ''}`); + return { name: file, samples, rate, loop }; + }); + console.log(`лист: ${spectroSheet(clips, out)}`); +} \ No newline at end of file diff --git a/packages/engine/tools/wav.mjs b/packages/engine/tools/wav.mjs index 4e62d30..d3e8519 100644 --- a/packages/engine/tools/wav.mjs +++ b/packages/engine/tools/wav.mjs @@ -43,3 +43,37 @@ } return Buffer.from(data); } + +/** + * Разобрать WAV в сэмплы: PCM 16-бит, моно или стерео (даунмикс в моно + * усреднением). Возвращает { samples: Float32Array (-1..1), rate }. Чанки + * (fmt/data) ищем по ID — файлы с LIST-примечаниями читаются корректно. + */ +export function decodeWav(buf) { + const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); + const tag = (offset, n) => String.fromCharCode(...Array.from({ length: n }, (_, i) => view.getUint8(offset + i))); + if (tag(0, 4) !== 'RIFF' || tag(8, 4) !== 'WAVE') throw new Error('не WAV-файл'); + let rate = 0, bits = 0, channels = 0, data = null; + let off = 12; + while (off + 8 <= buf.length) { + const id = tag(off, 4); + const size = view.getUint32(off + 4, true); + if (id === 'fmt ') { + channels = view.getUint16(off + 10, true); + rate = view.getUint32(off + 12, true); + bits = view.getUint16(off + 22, true); + } else if (id === 'data') { + data = { off: off + 8, frames: Math.floor(size / 2 / channels) }; + } + off += 8 + size + (size % 2); // чанк выравнивается по слову + } + if (!data || !rate) throw new Error('WAV без fmt/data или не PCM'); + if (bits !== 16) throw new Error(`поддержан только PCM 16-бит (в файле ${bits})`); + const samples = new Float32Array(data.frames); + for (let i = 0; i < data.frames; i++) { + let sum = 0; + for (let c = 0; c < channels; c++) sum += view.getInt16(data.off + (i * channels + c) * 2, true) / 32768; + samples[i] = sum / channels; + } + return { samples, rate }; +}